Files
smart-parking-platform/frontend/src/pages/aiChat/index.tsx
T

485 lines
14 KiB
TypeScript
Raw Normal View History

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>
);
};
export default AiChat;