2026-10-10 17:20:00 +08:00
|
|
|
import {
|
|
|
|
|
ApiOutlined,
|
|
|
|
|
ClearOutlined,
|
|
|
|
|
RobotOutlined,
|
|
|
|
|
SendOutlined,
|
|
|
|
|
SettingOutlined,
|
|
|
|
|
StopOutlined,
|
|
|
|
|
UserOutlined,
|
|
|
|
|
} from '@ant-design/icons';
|
|
|
|
|
import {
|
|
|
|
|
Alert,
|
|
|
|
|
Avatar,
|
|
|
|
|
Button,
|
|
|
|
|
Drawer,
|
|
|
|
|
Empty,
|
|
|
|
|
Form,
|
|
|
|
|
Input,
|
|
|
|
|
Space,
|
|
|
|
|
Spin,
|
|
|
|
|
Tag,
|
|
|
|
|
Tooltip,
|
|
|
|
|
message,
|
|
|
|
|
} from 'antd';
|
|
|
|
|
import { type FC, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
|
import './index.less';
|
|
|
|
|
|
|
|
|
|
interface ChatMessage {
|
|
|
|
|
id: string;
|
|
|
|
|
role: 'system' | 'user' | 'assistant';
|
|
|
|
|
content: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AiSettings {
|
|
|
|
|
baseUrl: string;
|
|
|
|
|
apiKey: string;
|
|
|
|
|
model: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// v2:服务商切换为 TokenFree,旧版本地缓存不再复用,避免过期默认值残留
|
|
|
|
|
const SETTINGS_KEY = 'parking-ai-settings-v2';
|
|
|
|
|
const LEGACY_SETTINGS_KEY = 'parking-ai-settings';
|
|
|
|
|
const MESSAGES_KEY = 'parking-ai-messages';
|
|
|
|
|
|
|
|
|
|
const PROVIDER_HOME = 'https://www.tokenfree.com/';
|
|
|
|
|
|
|
|
|
|
const DEFAULT_SETTINGS: AiSettings = {
|
|
|
|
|
baseUrl: 'https://www.tokenfree.com/v1',
|
|
|
|
|
apiKey: '',
|
|
|
|
|
model: 'kimi-k2.6',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadSettings = (): AiSettings => {
|
|
|
|
|
try {
|
|
|
|
|
const raw = localStorage.getItem(SETTINGS_KEY);
|
|
|
|
|
if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
|
|
|
|
// 仅迁移用户在旧服务商下已填写的 API Key,地址/模型强制使用新默认值
|
|
|
|
|
const legacyRaw = localStorage.getItem(LEGACY_SETTINGS_KEY);
|
|
|
|
|
if (legacyRaw) {
|
|
|
|
|
const legacy = JSON.parse(legacyRaw) as Partial<AiSettings>;
|
|
|
|
|
const migrated = {
|
|
|
|
|
...DEFAULT_SETTINGS,
|
|
|
|
|
apiKey: typeof legacy.apiKey === 'string' ? legacy.apiKey : '',
|
|
|
|
|
};
|
|
|
|
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(migrated));
|
|
|
|
|
return migrated;
|
|
|
|
|
}
|
|
|
|
|
return DEFAULT_SETTINGS;
|
|
|
|
|
} catch {
|
|
|
|
|
return DEFAULT_SETTINGS;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const createMessage = (
|
|
|
|
|
role: ChatMessage['role'],
|
|
|
|
|
content: string,
|
|
|
|
|
): ChatMessage => ({
|
|
|
|
|
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
|
|
|
role,
|
|
|
|
|
content,
|
|
|
|
|
createdAt: new Date().toLocaleString(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const AiChat: FC = () => {
|
|
|
|
|
const [settings, setSettings] = useState<AiSettings>(loadSettings);
|
|
|
|
|
const [messages, setMessages] = useState<ChatMessage[]>(() => {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(localStorage.getItem(MESSAGES_KEY) || '[]');
|
|
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const [input, setInput] = useState('');
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
|
|
|
const [settingsForm] = Form.useForm<AiSettings>();
|
|
|
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
|
|
|
const listRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
|
|
|
|
const normalizedBaseUrl = useMemo(
|
|
|
|
|
() => settings.baseUrl.replace(/\/+$/, ''),
|
|
|
|
|
[settings.baseUrl],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
document.title = 'AI对话 - 智慧停车管理平台';
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
localStorage.setItem(MESSAGES_KEY, JSON.stringify(messages));
|
|
|
|
|
listRef.current?.scrollTo({
|
|
|
|
|
top: listRef.current.scrollHeight,
|
|
|
|
|
behavior: 'smooth',
|
|
|
|
|
});
|
|
|
|
|
}, [messages]);
|
|
|
|
|
|
|
|
|
|
const saveSettings = async () => {
|
|
|
|
|
const values = await settingsForm.validateFields();
|
|
|
|
|
const next = {
|
|
|
|
|
...values,
|
|
|
|
|
baseUrl: values.baseUrl.replace(/\/+$/, ''),
|
|
|
|
|
};
|
|
|
|
|
setSettings(next);
|
|
|
|
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(next));
|
|
|
|
|
setSettingsOpen(false);
|
|
|
|
|
message.success('AI 接口配置已保存');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const testConnection = async () => {
|
|
|
|
|
const values = settingsForm.getFieldsValue();
|
|
|
|
|
const baseUrl = values.baseUrl.replace(/\/+$/, '');
|
|
|
|
|
if (!baseUrl) {
|
|
|
|
|
message.warning('请先填写 API Base URL');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${baseUrl}/models`, {
|
|
|
|
|
headers: { Authorization: `Bearer ${values.apiKey}` },
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error(res.statusText);
|
|
|
|
|
message.success('连接测试成功');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
message.error(`连接失败:${String(error)}`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const stopGenerating = () => {
|
|
|
|
|
abortRef.current?.abort();
|
|
|
|
|
setLoading(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sendMessage = async () => {
|
|
|
|
|
const content = input.trim();
|
|
|
|
|
if (!content) return;
|
|
|
|
|
if (!settings.apiKey) {
|
|
|
|
|
setSettingsOpen(true);
|
|
|
|
|
message.warning('请先配置 API Key');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const userMessage = createMessage('user', content);
|
|
|
|
|
const assistantMessage = createMessage('assistant', '');
|
|
|
|
|
const nextMessages = [...messages, userMessage, assistantMessage];
|
|
|
|
|
setMessages(nextMessages);
|
|
|
|
|
setInput('');
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
abortRef.current = controller;
|
|
|
|
|
|
|
|
|
|
const payload = {
|
|
|
|
|
model: settings.model,
|
|
|
|
|
stream: true,
|
|
|
|
|
messages: [
|
|
|
|
|
{
|
|
|
|
|
role: 'system',
|
|
|
|
|
content:
|
|
|
|
|
'你是智慧停车管理平台AI助手,可解答停车运营、设备状态、异常报警、园区监控和数据分析相关问题。',
|
|
|
|
|
},
|
|
|
|
|
...messages
|
|
|
|
|
.filter((item) => item.role !== 'system')
|
|
|
|
|
.map((item) => ({ role: item.role, content: item.content })),
|
|
|
|
|
{ role: 'user', content },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${normalizedBaseUrl}/chat/completions`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
headers: {
|
|
|
|
|
Authorization: `Bearer ${settings.apiKey}`,
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok || !response.body) {
|
|
|
|
|
throw new Error(response.statusText || 'AI 服务响应异常');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const reader = response.body.getReader();
|
|
|
|
|
const decoder = new TextDecoder('utf-8');
|
|
|
|
|
let buffer = '';
|
|
|
|
|
let answer = '';
|
|
|
|
|
|
|
|
|
|
const patchLastMessage = (text: string) => {
|
|
|
|
|
setMessages((current) =>
|
|
|
|
|
current.map((item, index) =>
|
|
|
|
|
index === current.length - 1 ? { ...item, content: text } : item,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
const { value, done } = await reader.read();
|
|
|
|
|
if (done) break;
|
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
|
const chunks = buffer.split('\n\n');
|
|
|
|
|
buffer = chunks.pop() || '';
|
|
|
|
|
|
|
|
|
|
chunks.forEach((chunk) => {
|
|
|
|
|
chunk
|
|
|
|
|
.split('\n')
|
|
|
|
|
.filter((line) => line.startsWith('data:'))
|
|
|
|
|
.forEach((line) => {
|
|
|
|
|
const data = line.replace(/^data:\s*/, '').trim();
|
|
|
|
|
if (!data || data === '[DONE]') return;
|
|
|
|
|
try {
|
|
|
|
|
const json = JSON.parse(data);
|
|
|
|
|
const delta = json.choices?.[0]?.delta?.content || '';
|
|
|
|
|
answer += delta;
|
|
|
|
|
patchLastMessage(answer);
|
|
|
|
|
} catch {
|
|
|
|
|
// 忽略心跳或非标准分片
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!answer) patchLastMessage('暂未收到有效回复,请稍后重试。');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const text =
|
|
|
|
|
error instanceof DOMException && error.name === 'AbortError'
|
|
|
|
|
? answerOrStopped()
|
|
|
|
|
: `AI 服务连接失败:${String(error)}`;
|
|
|
|
|
setMessages((current) =>
|
|
|
|
|
current.map((item, index) =>
|
|
|
|
|
index === current.length - 1 ? { ...item, content: text } : item,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
abortRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const answerOrStopped = () => '已停止生成。';
|
|
|
|
|
|
|
|
|
|
const clearHistory = () => {
|
|
|
|
|
setMessages([]);
|
|
|
|
|
localStorage.removeItem(MESSAGES_KEY);
|
|
|
|
|
message.success('对话记录已清空');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const openSettings = () => {
|
|
|
|
|
settingsForm.setFieldsValue(settings);
|
|
|
|
|
setSettingsOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="ai-chat-page">
|
|
|
|
|
<div className="ai-chat-shell panel-card">
|
|
|
|
|
<div className="ai-chat-header">
|
|
|
|
|
<div>
|
|
|
|
|
<Space>
|
|
|
|
|
<Avatar
|
|
|
|
|
className="ai-robot-avatar"
|
|
|
|
|
icon={<RobotOutlined />}
|
|
|
|
|
size="large"
|
|
|
|
|
/>
|
|
|
|
|
<div>
|
|
|
|
|
<h2>智慧停车 AI 助手</h2>
|
|
|
|
|
<p>
|
|
|
|
|
<Tag color="cyan">{settings.model}</Tag>
|
|
|
|
|
<span className="ai-status-dot" />
|
|
|
|
|
流式对话已就绪
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</Space>
|
|
|
|
|
</div>
|
|
|
|
|
<Space>
|
|
|
|
|
<Tooltip title="接口配置">
|
|
|
|
|
<Button
|
|
|
|
|
icon={<SettingOutlined />}
|
|
|
|
|
onClick={openSettings}
|
|
|
|
|
shape="circle"
|
|
|
|
|
/>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
<Tooltip title="清空对话">
|
|
|
|
|
<Button
|
|
|
|
|
danger
|
|
|
|
|
icon={<ClearOutlined />}
|
|
|
|
|
onClick={clearHistory}
|
|
|
|
|
shape="circle"
|
|
|
|
|
/>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</Space>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!settings.apiKey && (
|
|
|
|
|
<Alert
|
|
|
|
|
className="ai-config-alert"
|
|
|
|
|
message="尚未配置 API Key"
|
|
|
|
|
description="默认已接入 TokenFree(标准 Chat Completions 接口),请点击右上角设置按钮填写您的 API Key 后即可开始对话。"
|
|
|
|
|
type="warning"
|
|
|
|
|
showIcon
|
|
|
|
|
action={
|
|
|
|
|
<Button size="small" type="primary" onClick={openSettings}>
|
|
|
|
|
立即配置
|
|
|
|
|
</Button>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="ai-message-list" ref={listRef}>
|
|
|
|
|
{messages.length === 0 ? (
|
|
|
|
|
<Empty
|
|
|
|
|
description="开始询问停车运营、设备状态或异常报警问题"
|
|
|
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
messages.map((item) => (
|
|
|
|
|
<div
|
|
|
|
|
key={item.id}
|
|
|
|
|
className={`ai-message-row ${
|
|
|
|
|
item.role === 'user' ? 'is-user' : 'is-assistant'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<Avatar
|
|
|
|
|
className="ai-message-avatar"
|
|
|
|
|
icon={
|
|
|
|
|
item.role === 'user' ? <UserOutlined /> : <RobotOutlined />
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<div className="ai-bubble">
|
|
|
|
|
<div className="ai-bubble-content">
|
|
|
|
|
{item.content || (loading ? <Spin size="small" /> : '')}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="ai-bubble-time">{item.createdAt}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="ai-input-bar">
|
|
|
|
|
<Input.TextArea
|
|
|
|
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
|
|
|
|
className="ai-textarea"
|
|
|
|
|
placeholder="输入你的问题,Enter 发送,Shift + Enter 换行"
|
|
|
|
|
value={input}
|
|
|
|
|
onChange={(event) => setInput(event.target.value)}
|
|
|
|
|
onPressEnter={(event) => {
|
|
|
|
|
if (!event.shiftKey) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
void sendMessage();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
{loading ? (
|
|
|
|
|
<Button
|
|
|
|
|
danger
|
|
|
|
|
icon={<StopOutlined />}
|
|
|
|
|
onClick={stopGenerating}
|
|
|
|
|
size="large"
|
|
|
|
|
>
|
|
|
|
|
停止
|
|
|
|
|
</Button>
|
|
|
|
|
) : (
|
|
|
|
|
<Button
|
|
|
|
|
icon={<SendOutlined />}
|
|
|
|
|
onClick={() => void sendMessage()}
|
|
|
|
|
size="large"
|
|
|
|
|
type="primary"
|
|
|
|
|
>
|
|
|
|
|
发送
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Drawer
|
|
|
|
|
className="ai-settings-drawer"
|
|
|
|
|
title={
|
|
|
|
|
<Space>
|
|
|
|
|
<ApiOutlined />
|
|
|
|
|
AI 接口配置
|
|
|
|
|
</Space>
|
|
|
|
|
}
|
|
|
|
|
open={settingsOpen}
|
|
|
|
|
onClose={() => setSettingsOpen(false)}
|
|
|
|
|
width={380}
|
|
|
|
|
extra={
|
|
|
|
|
<Space>
|
|
|
|
|
<Button onClick={testConnection}>测试连接</Button>
|
|
|
|
|
<Button type="primary" onClick={() => void saveSettings()}>
|
|
|
|
|
保存
|
|
|
|
|
</Button>
|
|
|
|
|
</Space>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<div className="api-key-tip">
|
|
|
|
|
<p className="api-key-tip-title">
|
|
|
|
|
<strong>TokenFree 使用教程</strong>
|
|
|
|
|
</p>
|
|
|
|
|
<p className="api-key-tip-sub">
|
|
|
|
|
TokenFree 是统一 AI API 网关,支持标准 Chat Completions 接口协议
|
|
|
|
|
(流式输出 / Models 列表),注册后即可调用
|
|
|
|
|
Kimi、Qwen、Claude 等多家模型。
|
|
|
|
|
</p>
|
|
|
|
|
<ol className="api-key-steps">
|
|
|
|
|
<li>
|
|
|
|
|
打开{' '}
|
|
|
|
|
<a href={PROVIDER_HOME} target="_blank" rel="noreferrer">
|
|
|
|
|
<strong>https://www.tokenfree.com/</strong>
|
|
|
|
|
</a>
|
|
|
|
|
,点击注册并完成邮箱验证后登录。
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
登录后进入控制台「令牌 / API Keys」页面,点击「添加令牌」,
|
|
|
|
|
复制生成的 Key(以 <code>sk-</code> 开头)。
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
将 API Key 粘贴到下方表单;接口地址与默认模型已预填,
|
|
|
|
|
一般无需修改。
|
|
|
|
|
</li>
|
|
|
|
|
<li>
|
|
|
|
|
点击「测试连接」验证 Key 是否有效,通过后点击「保存」,
|
|
|
|
|
返回页面即可开始对话。
|
|
|
|
|
</li>
|
|
|
|
|
</ol>
|
|
|
|
|
<p className="api-key-tip-meta">
|
|
|
|
|
<span>接口地址:<code>https://www.tokenfree.com/v1</code></span>
|
|
|
|
|
<span>默认模型:<code>kimi-k2.6</code></span>
|
|
|
|
|
</p>
|
|
|
|
|
<p className="api-key-tip-note">
|
|
|
|
|
提示:也可在「模型 / Model」处填写 TokenFree 支持的其他模型名称;
|
|
|
|
|
Key 仅保存在本地浏览器,不会上传到本系统服务器。
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Form layout="vertical" form={settingsForm}>
|
|
|
|
|
<Form.Item
|
|
|
|
|
label="API Base URL"
|
|
|
|
|
name="baseUrl"
|
|
|
|
|
rules={[{ required: true, message: '请输入 API Base URL' }]}
|
|
|
|
|
extra="默认:https://www.tokenfree.com/v1(标准 Chat Completions 接口,一般无需修改)"
|
|
|
|
|
>
|
|
|
|
|
<Input placeholder="https://www.tokenfree.com/v1" />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
<Form.Item
|
|
|
|
|
label="API Key"
|
|
|
|
|
name="apiKey"
|
|
|
|
|
rules={[{ required: true, message: '请输入 API Key' }]}
|
|
|
|
|
extra="在 TokenFree 控制台「令牌」页面创建并复制"
|
|
|
|
|
>
|
|
|
|
|
<Input.Password placeholder="sk-..." />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
<Form.Item
|
|
|
|
|
label="Model"
|
|
|
|
|
name="model"
|
|
|
|
|
rules={[{ required: true, message: '请输入模型名称' }]}
|
|
|
|
|
extra="默认 kimi-k2.6,也可填写 TokenFree 支持的其他模型"
|
|
|
|
|
>
|
|
|
|
|
<Input placeholder="kimi-k2.6" />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
</Form>
|
|
|
|
|
</Drawer>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-10 18:47:30 +08:00
|
|
|
export default AiChat;
|