fotric-claw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -0
  3. package/backend/.env.example +26 -0
  4. package/backend/nest-cli.json +8 -0
  5. package/backend/package-lock.json +13239 -0
  6. package/backend/package.json +82 -0
  7. package/backend/src/agent/agent.module.ts +10 -0
  8. package/backend/src/agent/agent.service.ts +210 -0
  9. package/backend/src/agent/index.ts +4 -0
  10. package/backend/src/agent/llm.factory.ts +20 -0
  11. package/backend/src/agent/tools/fetch.tool.ts +128 -0
  12. package/backend/src/agent/tools/file-read.tool.ts +99 -0
  13. package/backend/src/agent/tools/index.ts +55 -0
  14. package/backend/src/agent/tools/node-repl.tool.ts +82 -0
  15. package/backend/src/agent/tools/rag.tool.ts +192 -0
  16. package/backend/src/agent/tools/shell.tool.ts +65 -0
  17. package/backend/src/app.module.ts +26 -0
  18. package/backend/src/chat/chat.controller.ts +34 -0
  19. package/backend/src/chat/chat.module.ts +12 -0
  20. package/backend/src/chat/chat.service.ts +52 -0
  21. package/backend/src/chat/dto/chat.dto.ts +12 -0
  22. package/backend/src/chat/dto/index.ts +1 -0
  23. package/backend/src/chat/index.ts +4 -0
  24. package/backend/src/config/config.controller.ts +92 -0
  25. package/backend/src/config/config.module.ts +7 -0
  26. package/backend/src/config/constants.ts +56 -0
  27. package/backend/src/config/index.ts +3 -0
  28. package/backend/src/files/files.controller.ts +87 -0
  29. package/backend/src/files/files.module.ts +7 -0
  30. package/backend/src/files/index.ts +2 -0
  31. package/backend/src/main.ts +21 -0
  32. package/backend/src/memory/index.ts +3 -0
  33. package/backend/src/memory/memory.module.ts +10 -0
  34. package/backend/src/memory/memory.service.ts +329 -0
  35. package/backend/src/memory/memory.types.ts +38 -0
  36. package/backend/src/sessions/default.json +7 -0
  37. package/backend/src/sessions/index.ts +2 -0
  38. package/backend/src/sessions/main_session.json +40 -0
  39. package/backend/src/sessions/sessions.controller.ts +25 -0
  40. package/backend/src/sessions/sessions.module.ts +9 -0
  41. package/backend/src/sessions/test.json +16 -0
  42. package/backend/src/skills/browser_search/SKILL.md +81 -0
  43. package/backend/src/skills/get_weather/SKILL.md +72 -0
  44. package/backend/src/skills/index.ts +3 -0
  45. package/backend/src/skills/skill.types.ts +27 -0
  46. package/backend/src/skills/skills.module.ts +8 -0
  47. package/backend/src/skills/skills.service.ts +139 -0
  48. package/backend/src/skills/web_search/SKILL.md +76 -0
  49. package/backend/src/workspace/AGENTS.md +47 -0
  50. package/backend/src/workspace/IDENTITY.md +32 -0
  51. package/backend/src/workspace/MEMORY.md +15 -0
  52. package/backend/src/workspace/SOUL.md +29 -0
  53. package/backend/src/workspace/USER.md +8 -0
  54. package/backend/tsconfig.build.json +4 -0
  55. package/backend/tsconfig.json +26 -0
  56. package/bin/fotric-claw.js +281 -0
  57. package/frontend/next.config.js +14 -0
  58. package/frontend/package-lock.json +5700 -0
  59. package/frontend/package.json +33 -0
  60. package/frontend/postcss.config.js +6 -0
  61. package/frontend/src/app/globals.css +41 -0
  62. package/frontend/src/app/layout.tsx +22 -0
  63. package/frontend/src/app/page.tsx +405 -0
  64. package/frontend/src/lib/api.ts +157 -0
  65. package/frontend/src/lib/utils.ts +3 -0
  66. package/frontend/tailwind.config.js +32 -0
  67. package/frontend/tsconfig.json +26 -0
  68. package/knowledge/README.md +21 -0
  69. package/package.json +49 -0
  70. package/scripts/init-skills.ts +95 -0
  71. package/storage/.gitkeep +5 -0
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "fotric-claw-frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint"
10
+ },
11
+ "dependencies": {
12
+ "@monaco-editor/react": "^4.6.0",
13
+ "class-variance-authority": "^0.7.0",
14
+ "clsx": "^2.1.0",
15
+ "lucide-react": "^0.312.0",
16
+ "next": "14.1.0",
17
+ "react": "^18.2.0",
18
+ "react-dom": "^18.2.0",
19
+ "tailwind-merge": "^2.2.0",
20
+ "tailwindcss-animate": "^1.0.7"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^20.11.0",
24
+ "@types/react": "^18.2.48",
25
+ "@types/react-dom": "^18.2.18",
26
+ "autoprefixer": "^10.4.17",
27
+ "eslint": "^8.56.0",
28
+ "eslint-config-next": "14.1.0",
29
+ "postcss": "^8.4.33",
30
+ "tailwindcss": "^3.4.1",
31
+ "typescript": "^5.3.3"
32
+ }
33
+ }
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
@@ -0,0 +1,41 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ --radius: 0.5rem;
7
+ }
8
+
9
+ body {
10
+ @apply bg-frost-50 text-gray-900;
11
+ }
12
+
13
+ .glass {
14
+ @apply bg-white/70 backdrop-blur-lg border border-white/20;
15
+ }
16
+
17
+ .glass-dark {
18
+ @apply bg-gray-900/70 backdrop-blur-lg border border-gray-700/20;
19
+ }
20
+
21
+ .scrollbar-thin {
22
+ scrollbar-width: thin;
23
+ scrollbar-color: theme('colors.frost.300') transparent;
24
+ }
25
+
26
+ .scrollbar-thin::-webkit-scrollbar {
27
+ width: 6px;
28
+ height: 6px;
29
+ }
30
+
31
+ .scrollbar-thin::-webkit-scrollbar-track {
32
+ background: transparent;
33
+ }
34
+
35
+ .scrollbar-thin::-webkit-scrollbar-thumb {
36
+ @apply bg-frost-300 rounded-full;
37
+ }
38
+
39
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
40
+ @apply bg-frost-400;
41
+ }
@@ -0,0 +1,22 @@
1
+ import type { Metadata } from 'next';
2
+ import { Inter } from 'next/font/google';
3
+ import './globals.css';
4
+
5
+ const inter = Inter({ subsets: ['latin'] });
6
+
7
+ export const metadata: Metadata = {
8
+ title: 'FotricCalw',
9
+ description: 'A lightweight, transparent AI Agent system',
10
+ };
11
+
12
+ export default function RootLayout({
13
+ children,
14
+ }: {
15
+ children: React.ReactNode;
16
+ }) {
17
+ return (
18
+ <html lang="en">
19
+ <body className={inter.className}>{children}</body>
20
+ </html>
21
+ );
22
+ }
@@ -0,0 +1,405 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+ import { Send, Loader2, Wrench, Brain, MessageSquare, Settings, X, Eye, EyeOff, GripVertical } from 'lucide-react';
5
+ import { cn } from '@/lib/utils';
6
+ import { chat, getSession, getConfig, updateConfig, type SessionMessage, type AppConfig } from '@/lib/api';
7
+
8
+ interface Message {
9
+ id: string;
10
+ role: 'user' | 'assistant';
11
+ content: string;
12
+ thoughts?: string[];
13
+ toolCalls?: Array<{ name: string; args: unknown }>;
14
+ }
15
+
16
+ export default function Home() {
17
+ const [messages, setMessages] = useState<Message[]>([]);
18
+ const [input, setInput] = useState('');
19
+ const [isLoading, setIsLoading] = useState(false);
20
+ const [sessionId] = useState('main_session');
21
+ const [isHistoryLoading, setIsHistoryLoading] = useState(true);
22
+ const [isSidebarOpen, setIsSidebarOpen] = useState(false);
23
+ const [config, setConfig] = useState<AppConfig>({ apiKey: '', apiBaseUrl: '', modelName: '' });
24
+ const [isSaving, setIsSaving] = useState(false);
25
+ const [showApiKey, setShowApiKey] = useState(false);
26
+ const [configMessage, setConfigMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
27
+ const [sidebarWidth, setSidebarWidth] = useState(384);
28
+ const [isResizing, setIsResizing] = useState(false);
29
+ const messagesEndRef = useRef<HTMLDivElement>(null);
30
+ const sidebarRef = useRef<HTMLDivElement>(null);
31
+ const startXRef = useRef<number>(384);
32
+
33
+ useEffect(() => {
34
+ const loadSessionHistory = async () => {
35
+ try {
36
+ const { session } = await getSession(sessionId);
37
+ if (session && session.messages && session.messages.length > 0) {
38
+ const historyMessages: Message[] = session.messages
39
+ .filter((msg: SessionMessage) => msg.role === 'user' || msg.role === 'assistant')
40
+ .map((msg: SessionMessage, index: number) => ({
41
+ id: `history-${index}`,
42
+ role: msg.role as 'user' | 'assistant',
43
+ content: msg.content,
44
+ }));
45
+ setMessages(historyMessages);
46
+ }
47
+ } catch (error) {
48
+ console.error('Failed to load session history:', error);
49
+ } finally {
50
+ setIsHistoryLoading(false);
51
+ }
52
+ };
53
+
54
+ loadSessionHistory();
55
+ }, [sessionId]);
56
+
57
+ useEffect(() => {
58
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
59
+ }, [messages]);
60
+
61
+ useEffect(() => {
62
+ const handleMouseMove = (e: MouseEvent) => {
63
+ if (!isResizing) return;
64
+ const newWidth = window.innerWidth - e.clientX;
65
+ const clampedWidth = Math.max(320, Math.min(800, newWidth));
66
+ setSidebarWidth(clampedWidth);
67
+ };
68
+
69
+ const handleMouseUp = () => {
70
+ setIsResizing(false);
71
+ document.body.style.cursor = '';
72
+ document.body.style.userSelect = '';
73
+ };
74
+
75
+ if (isResizing) {
76
+ document.addEventListener('mousemove', handleMouseMove);
77
+ document.addEventListener('mouseup', handleMouseUp);
78
+ document.body.style.cursor = 'col-resize';
79
+ document.body.style.userSelect = 'none';
80
+ }
81
+
82
+ return () => {
83
+ document.removeEventListener('mousemove', handleMouseMove);
84
+ document.removeEventListener('mouseup', handleMouseUp);
85
+ };
86
+ }, [isResizing]);
87
+
88
+ const handleResizeStart = (e: React.MouseEvent) => {
89
+ e.preventDefault();
90
+ setIsResizing(true);
91
+ startXRef.current = sidebarWidth;
92
+ };
93
+
94
+ const loadConfig = async () => {
95
+ try {
96
+ const data = await getConfig();
97
+ setConfig(data);
98
+ } catch (error) {
99
+ console.error('Failed to load config:', error);
100
+ }
101
+ };
102
+
103
+ const handleOpenSidebar = async () => {
104
+ setIsSidebarOpen(true);
105
+ await loadConfig();
106
+ };
107
+
108
+ const handleSaveConfig = async () => {
109
+ setIsSaving(true);
110
+ setConfigMessage(null);
111
+ try {
112
+ const result = await updateConfig(config);
113
+ if (result.success) {
114
+ setConfigMessage({ type: 'success', text: result.message });
115
+ } else {
116
+ setConfigMessage({ type: 'error', text: result.message });
117
+ }
118
+ } catch (error) {
119
+ setConfigMessage({ type: 'error', text: '保存配置失败' });
120
+ } finally {
121
+ setIsSaving(false);
122
+ }
123
+ };
124
+
125
+ const handleSubmit = async (e: React.FormEvent) => {
126
+ e.preventDefault();
127
+ if (!input.trim() || isLoading) return;
128
+
129
+ const userMessage: Message = {
130
+ id: Date.now().toString(),
131
+ role: 'user',
132
+ content: input.trim(),
133
+ };
134
+
135
+ setMessages(prev => [...prev, userMessage]);
136
+ setInput('');
137
+ setIsLoading(true);
138
+
139
+ const assistantMessage: Message = {
140
+ id: (Date.now() + 1).toString(),
141
+ role: 'assistant',
142
+ content: '',
143
+ thoughts: [],
144
+ toolCalls: [],
145
+ };
146
+
147
+ setMessages(prev => [...prev, assistantMessage]);
148
+
149
+ try {
150
+ const response = await chat({ message: userMessage.content, session_id: sessionId });
151
+
152
+ setMessages(prev => {
153
+ const updated = [...prev];
154
+ const lastMessage = updated[updated.length - 1];
155
+ if (lastMessage.role === 'assistant') {
156
+ lastMessage.content = response.message;
157
+ }
158
+ return updated;
159
+ });
160
+ } catch (error) {
161
+ console.error('Chat error:', error);
162
+ setMessages(prev => {
163
+ const updated = [...prev];
164
+ const lastMessage = updated[updated.length - 1];
165
+ if (lastMessage.role === 'assistant') {
166
+ lastMessage.content = 'Error: Failed to get response. Please check if the backend is running.';
167
+ }
168
+ return updated;
169
+ });
170
+ }
171
+
172
+ setIsLoading(false);
173
+ };
174
+
175
+ return (
176
+ <div className="flex h-screen flex-col">
177
+ {isSidebarOpen && (
178
+ <div
179
+ className="fixed inset-0 bg-black/50 z-40"
180
+ onClick={() => setIsSidebarOpen(false)}
181
+ />
182
+ )}
183
+
184
+ <div
185
+ ref={sidebarRef}
186
+ className={cn(
187
+ "fixed right-0 top-0 h-full bg-white shadow-xl z-50 transform transition-all duration-300",
188
+ isSidebarOpen ? "translate-x-0" : "translate-x-full"
189
+ )}
190
+ style={{ width: `${sidebarWidth}px` }}
191
+ >
192
+ <div className="flex flex-col h-full">
193
+ <div className="flex items-center justify-between p-4 border-b border-frost-200">
194
+ <h2 className="text-lg font-semibold text-klein flex items-center gap-2">
195
+ <Settings className="w-5 h-5" />
196
+ 系统设置
197
+ </h2>
198
+ <button
199
+ onClick={() => setIsSidebarOpen(false)}
200
+ className="text-frost-400 hover:text-klein transition-colors"
201
+ >
202
+ <X className="w-5 h-5" />
203
+ </button>
204
+ </div>
205
+
206
+ <div className="flex-1 overflow-y-auto p-4">
207
+ <div className="space-y-4">
208
+ <div>
209
+ <label className="block text-sm font-medium text-gray-700 mb-1">
210
+ API 密钥
211
+ </label>
212
+ <div className="relative">
213
+ <input
214
+ type={showApiKey ? "text" : "password"}
215
+ value={config.apiKey}
216
+ onChange={(e) => setConfig({ ...config, apiKey: e.target.value })}
217
+ placeholder="sk-..."
218
+ className="w-full rounded-xl border border-frost-200 bg-white px-4 py-3 pr-10 focus:outline-none focus:ring-2 focus:ring-klein focus:border-transparent"
219
+ />
220
+ <button
221
+ type="button"
222
+ onClick={() => setShowApiKey(!showApiKey)}
223
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-frost-400 hover:text-klein"
224
+ >
225
+ {showApiKey ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
226
+ </button>
227
+ </div>
228
+ <p className="text-xs text-frost-400 mt-1">
229
+ 点击眼睛图标显示/隐藏 API 密钥
230
+ </p>
231
+ </div>
232
+
233
+ <div>
234
+ <label className="block text-sm font-medium text-gray-700 mb-1">
235
+ API 基础地址
236
+ </label>
237
+ <input
238
+ type="text"
239
+ value={config.apiBaseUrl}
240
+ onChange={(e) => setConfig({ ...config, apiBaseUrl: e.target.value })}
241
+ placeholder="https://api.openai.com/v1"
242
+ className="w-full rounded-xl border border-frost-200 bg-white px-4 py-3 focus:outline-none focus:ring-2 focus:ring-klein focus:border-transparent"
243
+ />
244
+ <p className="text-xs text-frost-400 mt-1">
245
+ OpenAI 兼容的 API 端点地址
246
+ </p>
247
+ </div>
248
+
249
+ <div>
250
+ <label className="block text-sm font-medium text-gray-700 mb-1">
251
+ 模型名称
252
+ </label>
253
+ <input
254
+ type="text"
255
+ value={config.modelName}
256
+ onChange={(e) => setConfig({ ...config, modelName: e.target.value })}
257
+ placeholder="gpt-4o"
258
+ className="w-full rounded-xl border border-frost-200 bg-white px-4 py-3 focus:outline-none focus:ring-2 focus:ring-klein focus:border-transparent"
259
+ />
260
+ <p className="text-xs text-frost-400 mt-1">
261
+ 示例:gpt-4o, gpt-3.5-turbo, qwen-max
262
+ </p>
263
+ </div>
264
+ </div>
265
+
266
+ {configMessage && (
267
+ <div className={cn(
268
+ "flex items-center gap-2 p-4 rounded-xl mt-4",
269
+ configMessage.type === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
270
+ )}>
271
+ {configMessage.text}
272
+ </div>
273
+ )}
274
+ </div>
275
+
276
+ <div className="p-4 border-t border-frost-200">
277
+ <button
278
+ onClick={handleSaveConfig}
279
+ disabled={isSaving}
280
+ className="w-full rounded-xl bg-klein px-6 py-3 text-white font-medium hover:bg-klein-light transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
281
+ >
282
+ {isSaving ? (
283
+ <Loader2 className="w-5 h-5 animate-spin" />
284
+ ) : (
285
+ <Settings className="w-5 h-5" />
286
+ )}
287
+ 保存配置
288
+ </button>
289
+ </div>
290
+ </div>
291
+
292
+ <div
293
+ className="absolute left-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-klein/20 z-40 flex items-center justify-center group"
294
+ onMouseDown={handleResizeStart}
295
+ >
296
+ <GripVertical className="w-4 h-4 text-frost-300 group-hover:text-klein" />
297
+ </div>
298
+ </div>
299
+
300
+ <header className="glass border-b border-frost-200 px-6 py-4">
301
+ <div className="flex items-center justify-between">
302
+ <h1 className="text-xl font-semibold text-klein">FotricCalw</h1>
303
+ <div className="flex items-center gap-4">
304
+ <button
305
+ onClick={handleOpenSidebar}
306
+ className="text-frost-400 hover:text-klein transition-colors"
307
+ title="设置"
308
+ >
309
+ <Settings className="w-5 h-5" />
310
+ </button>
311
+ <a
312
+ href="https://www.fotric.cn/"
313
+ target="_blank"
314
+ rel="noopener noreferrer"
315
+ className="text-sm text-frost-400 hover:text-klein transition-colors"
316
+ >
317
+ FOTRIC
318
+ </a>
319
+ </div>
320
+ </div>
321
+ </header>
322
+
323
+ <main className="flex-1 overflow-y-auto p-6 scrollbar-thin">
324
+ <div className="mx-auto max-w-3xl space-y-6">
325
+ {isHistoryLoading ? (
326
+ <div className="flex flex-col items-center justify-center h-full text-center text-frost-400">
327
+ <Loader2 className="w-8 h-8 mb-4 animate-spin" />
328
+ <p className="text-lg">正在加载对话历史...</p>
329
+ </div>
330
+ ) : messages.length === 0 ? (
331
+ <div className="flex flex-col items-center justify-center h-full text-center text-frost-400">
332
+ <MessageSquare className="w-16 h-16 mb-4" />
333
+ <p className="text-lg">开始与 FotricCalw 对话</p>
334
+ <p className="text-sm mt-2">您的透明 AI 助手,具有基于文件的记忆功能</p>
335
+ </div>
336
+ ) : null}
337
+
338
+ {messages.map((message) => (
339
+ <div
340
+ key={message.id}
341
+ className={cn(
342
+ 'rounded-2xl p-4',
343
+ message.role === 'user'
344
+ ? 'bg-klein text-white ml-12'
345
+ : 'glass mr-12'
346
+ )}
347
+ >
348
+ {message.role === 'assistant' && message.thoughts && message.thoughts.length > 0 && (
349
+ <div className="mb-3 p-3 bg-frost-100 rounded-lg text-sm">
350
+ <div className="flex items-center gap-2 text-frost-400 mb-2">
351
+ <Brain className="w-4 h-4" />
352
+ <span className="font-medium">思考中...</span>
353
+ </div>
354
+ {message.thoughts.map((thought, i) => (
355
+ <p key={i} className="text-frost-400 text-xs mt-1">{thought}</p>
356
+ ))}
357
+ </div>
358
+ )}
359
+
360
+ {message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0 && (
361
+ <div className="mb-3 space-y-2">
362
+ {message.toolCalls.map((tool, i) => (
363
+ <div key={i} className="flex items-center gap-2 text-xs text-frost-400 bg-frost-100 rounded px-2 py-1">
364
+ <Wrench className="w-3 h-3" />
365
+ <span>{tool.name}</span>
366
+ </div>
367
+ ))}
368
+ </div>
369
+ )}
370
+
371
+ <p className="whitespace-pre-wrap">{message.content || (message.role === 'assistant' && isLoading ? '...' : '')}</p>
372
+ </div>
373
+ ))}
374
+ <div ref={messagesEndRef} />
375
+ </div>
376
+ </main>
377
+
378
+ <footer className="border-t border-frost-200 p-4 glass">
379
+ <form onSubmit={handleSubmit} className="mx-auto max-w-3xl">
380
+ <div className="flex gap-3">
381
+ <input
382
+ type="text"
383
+ value={input}
384
+ onChange={(e) => setInput(e.target.value)}
385
+ placeholder="输入您的消息..."
386
+ disabled={isLoading}
387
+ className="flex-1 rounded-xl border border-frost-200 bg-white px-4 py-3 focus:outline-none focus:ring-2 focus:ring-klein focus:border-transparent disabled:opacity-50"
388
+ />
389
+ <button
390
+ type="submit"
391
+ disabled={isLoading || !input.trim()}
392
+ className="rounded-xl bg-klein px-6 py-3 text-white font-medium hover:bg-klein-light transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
393
+ >
394
+ {isLoading ? (
395
+ <Loader2 className="w-5 h-5 animate-spin" />
396
+ ) : (
397
+ <Send className="w-5 h-5" />
398
+ )}
399
+ </button>
400
+ </div>
401
+ </form>
402
+ </footer>
403
+ </div>
404
+ );
405
+ }
@@ -0,0 +1,157 @@
1
+ const API_BASE = process.env.NEXT_PUBLIC_API_BASE || 'http://localhost:8002';
2
+
3
+ export interface ChatRequest {
4
+ message: string;
5
+ session_id?: string;
6
+ stream?: boolean;
7
+ }
8
+
9
+ export interface ChatResponse {
10
+ message: string;
11
+ }
12
+
13
+ export interface StreamEvent {
14
+ type: 'thought' | 'tool_call' | 'content' | 'done';
15
+ content: string;
16
+ toolName?: string;
17
+ toolArgs?: unknown;
18
+ }
19
+
20
+ export interface Session {
21
+ id: string;
22
+ name: string;
23
+ messages: SessionMessage[];
24
+ createdAt: string;
25
+ updatedAt: string;
26
+ }
27
+
28
+ export interface SessionMessage {
29
+ role: 'user' | 'assistant' | 'system' | 'tool';
30
+ content: string;
31
+ toolCallId?: string;
32
+ }
33
+
34
+ export interface FileInfo {
35
+ name: string;
36
+ isDirectory: boolean;
37
+ path: string;
38
+ }
39
+
40
+ export interface AppConfig {
41
+ apiKey: string;
42
+ apiBaseUrl: string;
43
+ modelName: string;
44
+ }
45
+
46
+ export async function getConfig(): Promise<AppConfig> {
47
+ const response = await fetch(`${API_BASE}/api/config`);
48
+ return response.json();
49
+ }
50
+
51
+ export async function updateConfig(config: AppConfig): Promise<{ success: boolean; message: string }> {
52
+ const response = await fetch(`${API_BASE}/api/config`, {
53
+ method: 'PUT',
54
+ headers: {
55
+ 'Content-Type': 'application/json',
56
+ },
57
+ body: JSON.stringify(config),
58
+ });
59
+ return response.json();
60
+ }
61
+
62
+ export async function chat(request: ChatRequest): Promise<ChatResponse> {
63
+ const response = await fetch(`${API_BASE}/api/chat`, {
64
+ method: 'POST',
65
+ headers: {
66
+ 'Content-Type': 'application/json',
67
+ },
68
+ body: JSON.stringify({ ...request, stream: false }),
69
+ });
70
+
71
+ if (!response.ok) {
72
+ throw new Error(`Chat failed: ${response.statusText}`);
73
+ }
74
+
75
+ return response.json();
76
+ }
77
+
78
+ export async function* streamChat(request: ChatRequest): AsyncGenerator<StreamEvent> {
79
+ const response = await fetch(`${API_BASE}/api/chat/stream`, {
80
+ method: 'POST',
81
+ headers: {
82
+ 'Content-Type': 'application/json',
83
+ },
84
+ body: JSON.stringify(request),
85
+ });
86
+
87
+ if (!response.ok) {
88
+ throw new Error(`Chat failed: ${response.statusText}`);
89
+ }
90
+
91
+ const reader = response.body?.getReader();
92
+ if (!reader) {
93
+ throw new Error('No response body');
94
+ }
95
+
96
+ const decoder = new TextDecoder();
97
+ let buffer = '';
98
+
99
+ while (true) {
100
+ const { done, value } = await reader.read();
101
+ if (done) break;
102
+
103
+ buffer += decoder.decode(value, { stream: true });
104
+ const lines = buffer.split('\n');
105
+ buffer = lines.pop() || '';
106
+
107
+ for (const line of lines) {
108
+ if (line.startsWith('data: ')) {
109
+ try {
110
+ const data = JSON.parse(line.slice(6));
111
+ yield data as StreamEvent;
112
+ } catch {
113
+ continue;
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ export async function getSessions(): Promise<{ sessions: Session[] }> {
121
+ const response = await fetch(`${API_BASE}/api/sessions`);
122
+ return response.json();
123
+ }
124
+
125
+ export async function getSession(id: string): Promise<{ session: Session | null }> {
126
+ const response = await fetch(`${API_BASE}/api/sessions/${id}`);
127
+ return response.json();
128
+ }
129
+
130
+ export async function deleteSession(id: string): Promise<{ success: boolean }> {
131
+ const response = await fetch(`${API_BASE}/api/sessions/${id}`, {
132
+ method: 'DELETE',
133
+ });
134
+ return response.json();
135
+ }
136
+
137
+ export async function getFile(path: string): Promise<{ content: string; path: string }> {
138
+ const response = await fetch(`${API_BASE}/api/files?path=${encodeURIComponent(path)}`);
139
+ return response.json();
140
+ }
141
+
142
+ export async function saveFile(path: string, content: string): Promise<{ success: boolean }> {
143
+ const response = await fetch(`${API_BASE}/api/files`, {
144
+ method: 'POST',
145
+ headers: {
146
+ 'Content-Type': 'application/json',
147
+ },
148
+ body: JSON.stringify({ path, content }),
149
+ });
150
+ return response.json();
151
+ }
152
+
153
+ export async function listFiles(dir?: string): Promise<{ files: FileInfo[]; dir: string }> {
154
+ const url = dir ? `${API_BASE}/api/files/list?dir=${encodeURIComponent(dir)}` : `${API_BASE}/api/files/list`;
155
+ const response = await fetch(url);
156
+ return response.json();
157
+ }
@@ -0,0 +1,3 @@
1
+ export function cn(...classes: (string | undefined | null | false)[]): string {
2
+ return classes.filter(Boolean).join(' ');
3
+ }