Skip to content

Commit de37905

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

File tree

4 files changed

+154
-13
lines changed

4 files changed

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

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

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import React, { useState, useEffect } from 'react';
2-
import { useAuth } from '../hooks/useAuth';
32
import { trpc } from '../services/trpc';
43
import { AiChatDemo } from './AiChatDemo';
4+
import { AiChatBackendDemo } from './AiChatBackendDemo';
55

6-
export const Dashboard: React.FC = () => {
7-
const { user, logout } = useAuth();
6+
type DashboardProps = {
7+
user: { userId: string };
8+
onLogout: () => void;
9+
};
10+
11+
export const Dashboard: React.FC<DashboardProps> = ({ user, onLogout }) => {
812
const [announcements, setAnnouncements] = useState<string[]>([]);
913
const [isLoading, setIsLoading] = useState(true);
10-
const [activeTab, setActiveTab] = useState<'overview' | 'user' | 'echo' | 'ai'>('overview');
14+
const [activeTab, setActiveTab] = useState<'overview' | 'user' | 'echo' | 'ai' | 'aiBackend'>('overview');
1115
const [me, setMe] = useState<{ userId: string } | null>(null);
1216
const [echoInput, setEchoInput] = useState('Hello Backend');
1317
const [echoResult, setEchoResult] = useState<string>('');
@@ -35,7 +39,14 @@ export const Dashboard: React.FC = () => {
3539
setMeError(null);
3640
try {
3741
const result = await trpc.user.getMe.query();
38-
setMe({ userId: result.userId });
42+
if (!result) {
43+
throw new Error('未登录或会话已失效');
44+
}
45+
const derivedUserId = (result as any).userId ?? (result as any).id;
46+
if (!derivedUserId) {
47+
throw new Error('后端未返回用户ID');
48+
}
49+
setMe({ userId: String(derivedUserId) });
3950
} catch (err) {
4051
setMeError(err instanceof Error ? err.message : '请求失败');
4152
} finally {
@@ -47,8 +58,6 @@ export const Dashboard: React.FC = () => {
4758
setEchoLoading(true);
4859
setEchoResult('');
4960
try {
50-
// Use a generic echo if available in backend contract, otherwise fallback to announcement text
51-
// @ts-expect-error: echo may not exist depending on backend version
5261
const res = await (trpc as any).utils?.echo?.query?.({ text: echoInput });
5362
if (res?.text) {
5463
setEchoResult(res.text);
@@ -71,8 +80,8 @@ export const Dashboard: React.FC = () => {
7180
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">欢迎回来!</h1>
7281
<p className="text-sm sm:text-base text-gray-600">用户ID: {user?.userId}</p>
7382
</div>
74-
<button
75-
onClick={logout}
83+
<button
84+
onClick={onLogout}
7685
className="bg-red-600 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-red-700 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 text-sm sm:text-base"
7786
>
7887
退出登录
@@ -84,7 +93,8 @@ export const Dashboard: React.FC = () => {
8493
<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>
8594
<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>
8695
<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>
96+
<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>
97+
<button className={`whitespace-nowrap py-2 border-b-2 font-medium text-sm ${activeTab === 'aiBackend' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`} onClick={() => setActiveTab('aiBackend')}>AI 后端代理</button>
8898
</nav>
8999
</div>
90100

@@ -142,6 +152,10 @@ export const Dashboard: React.FC = () => {
142152
{activeTab === 'ai' && (
143153
<AiChatDemo />
144154
)}
155+
156+
{activeTab === 'aiBackend' && (
157+
<AiChatBackendDemo />
158+
)}
145159
</div>
146160

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

apps/web/src/games/demo-with-backend/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useAuth } from './hooks/useAuth';
66
import { GameShell } from '@ui/GameShell';
77

88
export const DemoWithBackend: React.FC = () => {
9-
const { user, isLoading, verifyToken } = useAuth();
9+
const { user, isLoading, verifyToken, logout } = useAuth();
1010
const [verifying, setVerifying] = React.useState(false);
1111

1212
// 添加环境变量调试信息
@@ -51,9 +51,9 @@ export const DemoWithBackend: React.FC = () => {
5151
);
5252
}
5353

54-
return (
54+
return (
5555
<GameShell orientation="landscape">
56-
{user ? <Dashboard /> : <LoginScreen />}
56+
{user ? <Dashboard user={user} onLogout={logout} /> : <LoginScreen />}
5757
<DebugInfo />
5858
</GameShell>
5959
);

packages/services/src/AiService.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,57 @@ export async function callAiModel({ apiUrl, apiKey, model, messages, temperature
5050
return newMessage;
5151
}
5252

53+
const getBackendBaseUrl = () => {
54+
if (import.meta.env.VITE_BACKEND_URL) return import.meta.env.VITE_BACKEND_URL as string;
55+
if (import.meta.env.DEV) return 'http://localhost:3000';
56+
return typeof window !== 'undefined' ? window.location.origin : '';
57+
};
58+
59+
type BackendCallParams = {
60+
model: string;
61+
messages: ChatMessage[];
62+
signal?: AbortSignal;
63+
onChunk?: (m: { role: 'assistant'; content: string; reasoning_content: string; timestamp: string }) => void;
64+
};
65+
66+
export async function callBackendAi({ model, messages, signal, onChunk }: BackendCallParams) {
67+
const baseUrl = getBackendBaseUrl();
68+
const token = typeof window !== 'undefined' ? localStorage.getItem('sessionToken') : null;
69+
const response = await fetch(`${baseUrl}/api/ai/chat`, {
70+
method: 'POST',
71+
headers: {
72+
'Content-Type': 'application/json',
73+
...(token ? { Authorization: `Bearer ${token}` } : {})
74+
},
75+
signal,
76+
body: JSON.stringify({ model, messages, stream: true })
77+
});
78+
if (!response.ok) {
79+
const errorText = await response.text();
80+
throw new Error(`AI后端请求失败: ${response.status} - ${errorText}`);
81+
}
82+
const newMessage = { role: 'assistant' as const, content: '', reasoning_content: '', timestamp: new Date().toISOString() };
83+
const reader = response.body?.getReader();
84+
if (!reader) return newMessage;
85+
const decoder = new TextDecoder();
86+
while (true) {
87+
const { done, value } = await reader.read();
88+
if (done) break;
89+
const chunk = decoder.decode(value);
90+
const lines = chunk.split('\n').filter(l => l.trim());
91+
for (const line of lines) {
92+
if (line === 'data: [DONE]') continue;
93+
try {
94+
const jsonStr = line.replace('data: ', '');
95+
if (!jsonStr.trim()) continue;
96+
const data = JSON.parse(jsonStr);
97+
if (data.choices?.[0]?.delta?.reasoning_content !== undefined) newMessage.reasoning_content += data.choices[0].delta.reasoning_content || '';
98+
if (data.choices?.[0]?.delta?.content !== undefined) newMessage.content += data.choices[0].delta.content || '';
99+
if (onChunk) onChunk(newMessage);
100+
} catch {}
101+
}
102+
}
103+
return newMessage;
104+
}
105+
53106

0 commit comments

Comments
 (0)