Skip to content

Commit dbf5fb5

Browse files
committed
feat: AI fronted
1 parent 799d69f commit dbf5fb5

File tree

5 files changed

+239
-184
lines changed

5 files changed

+239
-184
lines changed

apps/web/src/games/demo-with-backend/components/AiChatBackendDemo.tsx

Lines changed: 0 additions & 87 deletions
This file was deleted.

apps/web/src/games/demo-with-backend/components/AiChatDemo.tsx

Lines changed: 0 additions & 85 deletions
This file was deleted.
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import React from 'react';
2+
import { callAiModel, callBackendAi, type ChatMessage } from '@services/AiService';
3+
4+
const MODEL_OPTIONS = [
5+
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', provider: 'google', inputPricePerMillionTokens: 1.25, outputPricePerMillionTokens: 10.00 },
6+
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', provider: 'google', inputPricePerMillionTokens: 0.30, outputPricePerMillionTokens: 2.50 },
7+
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash-Lite', provider: 'google', inputPricePerMillionTokens: 0.10, outputPricePerMillionTokens: 0.40 },
8+
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash', provider: 'google', inputPricePerMillionTokens: 0.10, outputPricePerMillionTokens: 0.40 },
9+
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash-Lite', provider: 'google', inputPricePerMillionTokens: 0.075, outputPricePerMillionTokens: 0.30 },
10+
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro', provider: 'google', inputPricePerMillionTokens: 1.25, outputPricePerMillionTokens: 5.00 },
11+
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash', provider: 'google', inputPricePerMillionTokens: 0.075, outputPricePerMillionTokens: 0.30 },
12+
{ value: 'gemini-1.5-flash-8b', label: 'Gemini 1.5 Flash-8B', provider: 'google', inputPricePerMillionTokens: 0.0375, outputPricePerMillionTokens: 0.15 },
13+
14+
{ value: 'deepseek-chat', label: 'DeepSeek Chat', provider: 'deepseek' },
15+
{ value: 'deepseek-reasoner', label: 'DeepSeek Reasoner', provider: 'deepseek' },
16+
17+
{ value: 'gpt-4o', label: 'GPT-4o', provider: 'openai', inputPricePerMillionTokens: 5.00, outputPricePerMillionTokens: 20.00 },
18+
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'openai', inputPricePerMillionTokens: 0.60, outputPricePerMillionTokens: 2.40 },
19+
{ value: 'gpt-5', label: 'GPT-5', provider: 'openai', inputPricePerMillionTokens: 1.250, outputPricePerMillionTokens: 10.000 },
20+
{ value: 'gpt-5-mini', label: 'GPT-5 Mini', provider: 'openai', inputPricePerMillionTokens: 0.250, outputPricePerMillionTokens: 2.000 },
21+
{ value: 'gpt-5-nano', label: 'GPT-5 Nano', provider: 'openai', inputPricePerMillionTokens: 0.050, outputPricePerMillionTokens: 0.400 },
22+
23+
{ value: 'claude-opus-4.1', label: 'Claude Opus 4.1', provider: 'anthropic', inputPricePerMillionTokens: 15.00, outputPricePerMillionTokens: 75.00 },
24+
{ value: 'claude-opus-4', label: 'Claude Opus 4', provider: 'anthropic', inputPricePerMillionTokens: 15.00, outputPricePerMillionTokens: 75.00 },
25+
{ value: 'claude-sonnet-4', label: 'Claude Sonnet 4', provider: 'anthropic', inputPricePerMillionTokens: 3.00, outputPricePerMillionTokens: 15.00 },
26+
{ value: 'claude-sonnet-3.7', label: 'Claude Sonnet 3.7', provider: 'anthropic', inputPricePerMillionTokens: 3.00, outputPricePerMillionTokens: 15.00 },
27+
{ value: 'claude-3.5-haiku', label: 'Claude 3.5 Haiku', provider: 'anthropic', inputPricePerMillionTokens: 0.80, outputPricePerMillionTokens: 4.00 },
28+
];
29+
30+
export const AiChatUnified: React.FC = () => {
31+
const [useBackend, setUseBackend] = React.useState(true);
32+
const [apiUrl, setApiUrl] = React.useState('');
33+
const [apiKey, setApiKey] = React.useState('');
34+
const [model, setModel] = React.useState('gemini-2.5-flash');
35+
const [messages, setMessages] = React.useState<ChatMessage[]>([{ role: 'system', content: 'You are a helpful assistant.' }]);
36+
const [input, setInput] = React.useState('');
37+
const [loading, setLoading] = React.useState(false);
38+
const [error, setError] = React.useState('');
39+
const [stream, setStream] = React.useState(true);
40+
const abortRef = React.useRef<AbortController | null>(null);
41+
const messagesEndRef = React.useRef<HTMLDivElement>(null);
42+
43+
const selectedModelOption = MODEL_OPTIONS.find(option => option.value === model);
44+
const isGeminiModel = selectedModelOption?.provider === 'google';
45+
const canUseStream = !isGeminiModel;
46+
47+
React.useEffect(() => {
48+
const url = import.meta.env.VITE_AI_API_URL || '';
49+
setApiUrl(url);
50+
}, []);
51+
52+
React.useEffect(() => {
53+
if (isGeminiModel && stream) {
54+
setStream(false);
55+
}
56+
}, [isGeminiModel, stream]);
57+
58+
React.useEffect(() => {
59+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
60+
}, [messages]);
61+
62+
const onSend = async () => {
63+
if (loading) return;
64+
if (!useBackend && (!apiUrl || !apiKey)) return;
65+
if (!input.trim()) return;
66+
67+
const nextMessages = [...messages, { role: 'user', content: input.trim() } as ChatMessage];
68+
setMessages(nextMessages);
69+
setInput('');
70+
setLoading(true);
71+
setError('');
72+
const controller = new AbortController();
73+
abortRef.current = controller;
74+
const assistantDraft: ChatMessage = { role: 'assistant', content: '' };
75+
setMessages(prev => [...prev, assistantDraft]);
76+
77+
try {
78+
if (useBackend) {
79+
const result = await callBackendAi({
80+
model,
81+
messages: nextMessages,
82+
signal: controller.signal,
83+
stream: canUseStream ? stream : false,
84+
onChunk: (m: { role: 'assistant'; content: string; reasoning_content: string; timestamp: string }) => {
85+
assistantDraft.content = m.content;
86+
setMessages(prev => {
87+
const copy = [...prev];
88+
copy[copy.length - 1] = { role: 'assistant', content: assistantDraft.content };
89+
return copy;
90+
});
91+
}
92+
});
93+
if (!(canUseStream && stream)) {
94+
setMessages(prev => {
95+
const copy = [...prev];
96+
copy[copy.length - 1] = { role: 'assistant', content: result.content };
97+
return copy;
98+
});
99+
}
100+
} else {
101+
await callAiModel({
102+
apiUrl,
103+
apiKey,
104+
model,
105+
messages: nextMessages,
106+
signal: controller.signal,
107+
stream: canUseStream ? stream : false,
108+
onChunk: (m: { role: 'assistant'; content: string; reasoning_content: string; timestamp: string }) => {
109+
assistantDraft.content = m.content;
110+
setMessages(prev => {
111+
const copy = [...prev];
112+
copy[copy.length - 1] = { role: 'assistant', content: assistantDraft.content };
113+
return copy;
114+
});
115+
}
116+
});
117+
}
118+
} catch (e) {
119+
setError(e instanceof Error ? e.message : '调用失败');
120+
} finally {
121+
setLoading(false);
122+
abortRef.current = null;
123+
}
124+
};
125+
126+
const onAbort = () => {
127+
abortRef.current?.abort();
128+
};
129+
130+
const onClearChat = () => {
131+
setMessages([{ role: 'system', content: 'You are a helpful assistant.' }]);
132+
setError('');
133+
};
134+
135+
const canSend = useBackend || (apiUrl && apiKey);
136+
137+
return (
138+
<div className="bg-white border rounded-lg p-4 sm:p-6 space-y-4">
139+
<div className="flex justify-between items-center">
140+
<h2 className="text-base sm:text-lg font-semibold text-gray-900">AI 聊天演示</h2>
141+
<div className="flex items-center gap-2">
142+
<span className="text-xs text-gray-500">{messages.length - 1} 条对话</span>
143+
<button onClick={onClearChat} disabled={loading}
144+
className="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
145+
清除
146+
</button>
147+
</div>
148+
</div>
149+
150+
<div className="space-y-3">
151+
<div className="flex items-center gap-4">
152+
<label className="flex items-center gap-2 text-sm">
153+
<input type="radio" name="mode" checked={useBackend} onChange={() => setUseBackend(true)} />
154+
<span>使用后端代理</span>
155+
</label>
156+
<label className="flex items-center gap-2 text-sm">
157+
<input type="radio" name="mode" checked={!useBackend} onChange={() => setUseBackend(false)} />
158+
<span>直接调用 API</span>
159+
</label>
160+
</div>
161+
162+
{!useBackend && (
163+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
164+
<input className="px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-primary" placeholder="API URL"
165+
value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} />
166+
<input className="px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-primary" placeholder="API Key"
167+
type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
168+
</div>
169+
)}
170+
171+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
172+
<select className="px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-primary bg-white"
173+
value={model} onChange={(e) => setModel(e.target.value)}>
174+
{MODEL_OPTIONS.map(option => (
175+
<option key={option.value} value={option.value}>
176+
{option.label}
177+
</option>
178+
))}
179+
</select>
180+
<label className="flex items-center gap-2 text-sm px-3 py-2">
181+
<input type="checkbox" checked={stream} onChange={(e) => setStream(e.target.checked)}
182+
disabled={!canUseStream} className="rounded" />
183+
<span className={!canUseStream ? 'text-gray-400' : ''}>流式</span>
184+
{!canUseStream && <span className="text-xs text-amber-600">(Gemini不支持)</span>}
185+
</label>
186+
</div>
187+
</div>
188+
189+
<div className="h-80 border rounded p-3 overflow-auto bg-gray-50 space-y-3">
190+
{messages.map((m, i) => (
191+
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
192+
<div className={`max-w-[80%] rounded-lg px-3 py-2 ${
193+
m.role === 'user'
194+
? 'bg-primary text-white'
195+
: m.role === 'assistant'
196+
? 'bg-white border shadow-sm'
197+
: 'bg-gray-200 text-gray-700'
198+
}`}>
199+
<div className="text-xs opacity-70 mb-1">{
200+
m.role === 'user' ? '用户' : m.role === 'assistant' ? 'AI助手' : '系统'
201+
}</div>
202+
<div className="whitespace-pre-wrap text-sm leading-relaxed">{m.content || (loading && i === messages.length - 1 ? '正在思考...' : '')}</div>
203+
</div>
204+
</div>
205+
))}
206+
<div ref={messagesEndRef} />
207+
</div>
208+
209+
<div className="flex gap-2">
210+
<input className="flex-1 px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入消息"
211+
value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') onSend(); }} />
212+
<button onClick={onSend} disabled={loading || !canSend}
213+
className="bg-primary hover:bg-primary-dark text-white px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
214+
{loading ? '发送中...' : '发送'}
215+
</button>
216+
<button onClick={onAbort} disabled={!loading}
217+
className="bg-gray-200 hover:bg-gray-300 px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
218+
中止
219+
</button>
220+
</div>
221+
222+
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded p-2">{error}</div>}
223+
</div>
224+
);
225+
};

0 commit comments

Comments
 (0)