Skip to content

Commit eba42d2

Browse files
committed
feat: ai chat
1 parent 1d3f2bb commit eba42d2

File tree

3 files changed

+146
-2
lines changed

3 files changed

+146
-2
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import React from 'react';
2+
import { callAiModel, type ChatMessage } from '@services/AiService';
3+
4+
export const AiChatDemo: React.FC = () => {
5+
const [apiUrl, setApiUrl] = React.useState('');
6+
const [apiKey, setApiKey] = React.useState('');
7+
const [model, setModel] = React.useState('gpt-4o-mini');
8+
const [messages, setMessages] = React.useState<ChatMessage[]>([{ role: 'system', content: 'You are a helpful assistant.' }]);
9+
const [input, setInput] = React.useState('');
10+
const [loading, setLoading] = React.useState(false);
11+
const abortRef = React.useRef<AbortController | null>(null);
12+
13+
React.useEffect(() => {
14+
const url = import.meta.env.VITE_AI_API_URL || '';
15+
setApiUrl(url);
16+
}, []);
17+
18+
const onSend = async () => {
19+
if (!apiUrl || !apiKey || !input.trim() || loading) return;
20+
const nextMessages = [...messages, { role: 'user', content: input.trim() } as ChatMessage];
21+
setMessages(nextMessages);
22+
setInput('');
23+
setLoading(true);
24+
const controller = new AbortController();
25+
abortRef.current = controller;
26+
const assistantDraft: ChatMessage = { role: 'assistant', content: '' };
27+
setMessages(prev => [...prev, assistantDraft]);
28+
try {
29+
await callAiModel({
30+
apiUrl,
31+
apiKey,
32+
model,
33+
messages: nextMessages,
34+
signal: controller.signal,
35+
onChunk: (m) => {
36+
assistantDraft.content = m.content;
37+
setMessages(prev => {
38+
const copy = [...prev];
39+
copy[copy.length - 1] = { role: 'assistant', content: assistantDraft.content };
40+
return copy;
41+
});
42+
}
43+
});
44+
} catch (e) {
45+
// noop
46+
} finally {
47+
setLoading(false);
48+
abortRef.current = null;
49+
}
50+
};
51+
52+
const onAbort = () => {
53+
abortRef.current?.abort();
54+
};
55+
56+
return (
57+
<div className="bg-white border rounded-lg p-4 sm:p-6 space-y-4">
58+
<h2 className="text-base sm:text-lg font-semibold text-gray-900">AI 接口演示</h2>
59+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
60+
<input className="px-3 py-2 border rounded col-span-1 sm:col-span-1" placeholder="API URL"
61+
value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} />
62+
<input className="px-3 py-2 border rounded col-span-1 sm:col-span-1" placeholder="API Key"
63+
type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
64+
<input className="px-3 py-2 border rounded col-span-1 sm:col-span-1" placeholder="Model"
65+
value={model} onChange={(e) => setModel(e.target.value)} />
66+
</div>
67+
<div className="h-64 border rounded p-3 overflow-auto bg-gray-50">
68+
{messages.map((m, i) => (
69+
<div key={i} className="mb-2">
70+
<div className="text-xs text-gray-500">{m.role}</div>
71+
<div className="whitespace-pre-wrap text-sm">{m.content}</div>
72+
</div>
73+
))}
74+
</div>
75+
<div className="flex gap-2">
76+
<input className="flex-1 px-3 py-2 border rounded" placeholder="输入消息"
77+
value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') onSend(); }} />
78+
<button onClick={onSend} disabled={loading} className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50">发送</button>
79+
<button onClick={onAbort} disabled={!loading} className="bg-gray-200 px-4 py-2 rounded disabled:opacity-50">中止</button>
80+
</div>
81+
</div>
82+
);
83+
};
84+
85+

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import React, { useState, useEffect } from 'react';
22
import { useAuth } from '../hooks/useAuth';
33
import { trpc } from '../services/trpc';
4+
import { AiChatDemo } from './AiChatDemo';
45

56
export const Dashboard: React.FC = () => {
67
const { user, logout } = useAuth();
78
const [announcements, setAnnouncements] = useState<string[]>([]);
89
const [isLoading, setIsLoading] = useState(true);
9-
const [activeTab, setActiveTab] = useState<'overview' | 'user' | 'echo'>('overview');
10+
const [activeTab, setActiveTab] = useState<'overview' | 'user' | 'echo' | 'ai'>('overview');
1011
const [me, setMe] = useState<{ userId: string } | null>(null);
1112
const [echoInput, setEchoInput] = useState('Hello Backend');
1213
const [echoResult, setEchoResult] = useState<string>('');
@@ -82,7 +83,8 @@ export const Dashboard: React.FC = () => {
8283
<nav className="-mb-px flex space-x-6" aria-label="Tabs">
8384
<button className={`whitespace-nowrap py-2 border-b-2 font-medium text-sm ${activeTab === 'overview' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`} onClick={() => setActiveTab('overview')}>概览</button>
8485
<button className={`whitespace-nowrap py-2 border-b-2 font-medium text-sm ${activeTab === 'user' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`} onClick={() => setActiveTab('user')}>用户信息</button>
85-
<button className={`whitespace-nowrap py-2 border-b-2 font-medium text-sm ${activeTab === 'echo' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`} onClick={() => setActiveTab('echo')}>Echo 示例</button>
86+
<button className={`whitespace-nowrap py-2 border-b-2 font-medium text-sm ${activeTab === 'echo' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`} onClick={() => setActiveTab('echo')}>Echo 示例</button>
87+
<button className={`whitespace-nowrap py-2 border-b-2 font-medium text-sm ${activeTab === 'ai' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`} onClick={() => setActiveTab('ai')}>AI 示例</button>
8688
</nav>
8789
</div>
8890

@@ -136,6 +138,10 @@ export const Dashboard: React.FC = () => {
136138
{echoResult && <p className="mt-3 text-sm text-gray-800">结果:{echoResult}</p>}
137139
</div>
138140
)}
141+
142+
{activeTab === 'ai' && (
143+
<AiChatDemo />
144+
)}
139145
</div>
140146

141147
<div className="bg-white rounded-lg shadow-lg p-4 sm:p-6">

packages/services/src/AiService.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export type ChatMessage = {
2+
role: 'user' | 'assistant' | 'system';
3+
content: string;
4+
};
5+
6+
type CallParams = {
7+
apiUrl: string;
8+
apiKey: string;
9+
model: string;
10+
messages: ChatMessage[];
11+
temperature?: number;
12+
maxTokens?: number;
13+
signal?: AbortSignal;
14+
onChunk?: (m: { role: 'assistant'; content: string; reasoning_content: string; timestamp: string }) => void;
15+
};
16+
17+
export async function callAiModel({ apiUrl, apiKey, model, messages, temperature = 0.7, maxTokens = 4096, signal, onChunk }: CallParams) {
18+
const requestBody = { model, messages, stream: true, temperature, max_tokens: maxTokens };
19+
const response = await fetch(apiUrl, {
20+
method: 'POST',
21+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
22+
signal,
23+
body: JSON.stringify(requestBody)
24+
});
25+
if (!response.ok) {
26+
const errorText = await response.text();
27+
throw new Error(`API请求失败: ${response.status} - ${errorText}`);
28+
}
29+
const newMessage = { role: 'assistant' as const, content: '', reasoning_content: '', timestamp: new Date().toISOString() };
30+
const reader = response.body?.getReader();
31+
if (!reader) return newMessage;
32+
const decoder = new TextDecoder();
33+
while (true) {
34+
const { done, value } = await reader.read();
35+
if (done) break;
36+
const chunk = decoder.decode(value);
37+
const lines = chunk.split('\n').filter(l => l.trim());
38+
for (const line of lines) {
39+
if (line === 'data: [DONE]') continue;
40+
try {
41+
const jsonStr = line.replace('data: ', '');
42+
if (!jsonStr.trim()) continue;
43+
const data = JSON.parse(jsonStr);
44+
if (data.choices?.[0]?.delta?.reasoning_content !== undefined) newMessage.reasoning_content += data.choices[0].delta.reasoning_content || '';
45+
if (data.choices?.[0]?.delta?.content !== undefined) newMessage.content += data.choices[0].delta.content || '';
46+
if (onChunk) onChunk(newMessage);
47+
} catch {}
48+
}
49+
}
50+
return newMessage;
51+
}
52+
53+

0 commit comments

Comments
 (0)