claude-world-studio 1.0.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 (46) hide show
  1. package/.env.example +30 -0
  2. package/.mcp.json +51 -0
  3. package/README.md +224 -0
  4. package/client/App.tsx +446 -0
  5. package/client/components/ChatWindow.tsx +790 -0
  6. package/client/components/FileExplorer.tsx +218 -0
  7. package/client/components/FilePreviewModal.tsx +179 -0
  8. package/client/components/PublishDialog.tsx +307 -0
  9. package/client/components/SettingsPage.tsx +452 -0
  10. package/client/components/Sidebar.tsx +198 -0
  11. package/client/components/ToolUseBlock.tsx +140 -0
  12. package/client/index.html +12 -0
  13. package/client/index.tsx +10 -0
  14. package/client/styles/globals.css +48 -0
  15. package/demo/01-welcome.png +0 -0
  16. package/demo/02-pipeline-cards.png +0 -0
  17. package/demo/03-custom-topic-fill.png +0 -0
  18. package/demo/04-topic-typed.png +0 -0
  19. package/demo/05-loading-state.png +0 -0
  20. package/demo/06-tool-calls.png +0 -0
  21. package/demo/07-history-rich.png +0 -0
  22. package/demo/09-en-cards.png +0 -0
  23. package/demo/10-ja-cards.png +0 -0
  24. package/demo/capture-remaining.mjs +73 -0
  25. package/demo/capture.mjs +110 -0
  26. package/demo/demo-walkthrough-2.webm +0 -0
  27. package/demo/demo-walkthrough.webm +0 -0
  28. package/package.json +48 -0
  29. package/postcss.config.js +6 -0
  30. package/scripts/threads_api.py +536 -0
  31. package/server/ai-client.ts +356 -0
  32. package/server/db.ts +299 -0
  33. package/server/mcp-config.ts +85 -0
  34. package/server/routes/accounts.ts +88 -0
  35. package/server/routes/files.ts +175 -0
  36. package/server/routes/publish.ts +77 -0
  37. package/server/routes/sessions.ts +59 -0
  38. package/server/routes/settings.ts +220 -0
  39. package/server/server.ts +261 -0
  40. package/server/services/social-publisher.ts +74 -0
  41. package/server/services/studio-mcp.ts +107 -0
  42. package/server/session.ts +167 -0
  43. package/server/types.ts +86 -0
  44. package/tailwind.config.js +8 -0
  45. package/tsconfig.json +16 -0
  46. package/vite.config.ts +19 -0
package/client/App.tsx ADDED
@@ -0,0 +1,446 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import useWebSocket, { ReadyState } from "react-use-websocket";
3
+ import { Sidebar } from "./components/Sidebar";
4
+ import { ChatWindow } from "./components/ChatWindow";
5
+ import { FileExplorer } from "./components/FileExplorer";
6
+ import { FilePreviewModal } from "./components/FilePreviewModal";
7
+ import { PublishDialog } from "./components/PublishDialog";
8
+ import { SettingsPage } from "./components/SettingsPage";
9
+
10
+ export type Language = "zh-TW" | "en" | "ja";
11
+
12
+ interface Session {
13
+ id: string;
14
+ title: string;
15
+ workspace_path: string;
16
+ created_at: string;
17
+ updated_at: string;
18
+ }
19
+
20
+ interface Message {
21
+ id: string;
22
+ role: "user" | "assistant" | "tool_use" | "tool_result" | "result";
23
+ content: string | null;
24
+ created_at?: string;
25
+ timestamp?: string;
26
+ toolName?: string;
27
+ tool_name?: string;
28
+ toolInput?: Record<string, unknown>;
29
+ tool_input?: string;
30
+ toolId?: string;
31
+ tool_id?: string;
32
+ cost_usd?: number;
33
+ }
34
+
35
+ /** Discriminated union for server→client WebSocket messages */
36
+ type ServerWSMessage =
37
+ | { type: "connected" }
38
+ | { type: "history"; messages: Message[]; sessionId: string }
39
+ | { type: "user_message"; content: string; sessionId: string }
40
+ | { type: "assistant_message"; content: string; sessionId: string }
41
+ | { type: "tool_use"; toolName: string; toolId: string; toolInput: Record<string, unknown>; sessionId: string }
42
+ | { type: "tool_result"; toolId: string; content: string; sessionId: string }
43
+ | { type: "result"; success: boolean; cost?: number; duration?: number; sessionId: string }
44
+ | { type: "interrupted"; sessionId: string }
45
+ | { type: "error"; error: string; sessionId?: string };
46
+
47
+ const API_BASE = "/api";
48
+
49
+ function getWsUrl(): string {
50
+ const proto = window.location.protocol === "https:" ? "wss" : "ws";
51
+ return `${proto}://${window.location.host}/ws`;
52
+ }
53
+
54
+ export default function App() {
55
+ const [sessions, setSessions] = useState<Session[]>([]);
56
+ const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
57
+ const selectedSessionRef = useRef<string | null>(null);
58
+ const [messages, setMessages] = useState<Message[]>([]);
59
+ const [isLoading, setIsLoading] = useState(false);
60
+ const [showFiles, setShowFiles] = useState(false);
61
+ const [showSettings, setShowSettings] = useState(false);
62
+ const [showPublish, setShowPublish] = useState(false);
63
+ const [previewFile, setPreviewFile] = useState<{ relativePath: string; sessionId: string } | null>(null);
64
+ const [defaultWorkspace, setDefaultWorkspace] = useState("");
65
+ const [language, setLanguage] = useState<Language>("zh-TW");
66
+
67
+ // Keep ref in sync for use inside WS callback
68
+ useEffect(() => {
69
+ selectedSessionRef.current = selectedSessionId;
70
+ }, [selectedSessionId]);
71
+
72
+ const handleWSMessage = useCallback((message: ServerWSMessage) => {
73
+ // Ignore messages from sessions we're not currently viewing
74
+ const msgSessionId = "sessionId" in message ? message.sessionId : undefined;
75
+ if (msgSessionId && msgSessionId !== selectedSessionRef.current) {
76
+ return;
77
+ }
78
+
79
+ switch (message.type) {
80
+ case "connected":
81
+ break;
82
+
83
+ case "history": {
84
+ const history = message.messages || [];
85
+ // Merge: use history as base, append any optimistic local messages not in history
86
+ setMessages((prev) => {
87
+ if (prev.length === 0) return history;
88
+ // Find messages added locally after the last history entry
89
+ const historyIds = new Set(history.map((m: { id: string }) => m.id));
90
+ const localOnly = prev.filter((m) => !historyIds.has(m.id));
91
+ return [...history, ...localOnly];
92
+ });
93
+ break;
94
+ }
95
+
96
+ case "user_message":
97
+ // Render user messages from other tabs/subscribers
98
+ if (message.content) {
99
+ setMessages((prev) => {
100
+ // Skip if already present (optimistic local insert)
101
+ if (prev.some((m) => m.role === "user" && m.content === message.content &&
102
+ Date.now() - new Date(m.timestamp || 0).getTime() < 5000)) {
103
+ return prev;
104
+ }
105
+ return [...prev, {
106
+ id: crypto.randomUUID(),
107
+ role: "user",
108
+ content: message.content,
109
+ timestamp: new Date().toISOString(),
110
+ }];
111
+ });
112
+ }
113
+ break;
114
+
115
+ case "assistant_message":
116
+ setMessages((prev) => [
117
+ ...prev,
118
+ {
119
+ id: crypto.randomUUID(),
120
+ role: "assistant",
121
+ content: message.content,
122
+ timestamp: new Date().toISOString(),
123
+ },
124
+ ]);
125
+ break;
126
+
127
+ case "tool_use":
128
+ setMessages((prev) => [
129
+ ...prev,
130
+ {
131
+ id: `tu_${message.toolId || crypto.randomUUID()}`,
132
+ role: "tool_use",
133
+ content: null,
134
+ timestamp: new Date().toISOString(),
135
+ toolName: message.toolName,
136
+ toolInput: message.toolInput,
137
+ toolId: message.toolId,
138
+ },
139
+ ]);
140
+ break;
141
+
142
+ case "tool_result":
143
+ setMessages((prev) => [
144
+ ...prev,
145
+ {
146
+ id: `tr_${message.toolId || crypto.randomUUID()}`,
147
+ role: "tool_result",
148
+ content: message.content,
149
+ timestamp: new Date().toISOString(),
150
+ toolId: message.toolId,
151
+ },
152
+ ]);
153
+ break;
154
+
155
+ case "result":
156
+ setIsLoading(false);
157
+ setMessages((prev) => [
158
+ ...prev,
159
+ {
160
+ id: crypto.randomUUID(),
161
+ role: "result",
162
+ content: JSON.stringify({
163
+ success: message.success,
164
+ cost: message.cost,
165
+ duration: message.duration,
166
+ }),
167
+ timestamp: new Date().toISOString(),
168
+ },
169
+ ]);
170
+ fetchSessions();
171
+ break;
172
+
173
+ case "interrupted":
174
+ setIsLoading(false);
175
+ break;
176
+
177
+ case "error":
178
+ console.error("Server error:", message.error);
179
+ setIsLoading(false);
180
+ break;
181
+ }
182
+ }, []);
183
+
184
+ const { sendJsonMessage, readyState, lastJsonMessage } = useWebSocket(getWsUrl, {
185
+ shouldReconnect: () => true,
186
+ reconnectAttempts: 10,
187
+ reconnectInterval: 3000,
188
+ });
189
+
190
+ const isConnected = readyState === ReadyState.OPEN;
191
+
192
+ // Re-subscribe to the active session after reconnect
193
+ useEffect(() => {
194
+ if (isConnected && selectedSessionRef.current) {
195
+ setIsLoading(false); // reset stale loading state from before disconnect
196
+ sendJsonMessage({ type: "subscribe", sessionId: selectedSessionRef.current });
197
+ }
198
+ }, [isConnected, sendJsonMessage]);
199
+
200
+ useEffect(() => {
201
+ if (lastJsonMessage) {
202
+ handleWSMessage(lastJsonMessage as ServerWSMessage);
203
+ }
204
+ }, [lastJsonMessage, handleWSMessage]);
205
+
206
+ const fetchSessions = async () => {
207
+ try {
208
+ const res = await fetch(`${API_BASE}/sessions`);
209
+ if (!res.ok) return;
210
+ const data = await res.json();
211
+ setSessions(data);
212
+ } catch {
213
+ // Network error — ignore silently
214
+ }
215
+ };
216
+
217
+ const fetchSettings = async () => {
218
+ try {
219
+ const res = await fetch(`${API_BASE}/settings`);
220
+ if (!res.ok) return;
221
+ const data = await res.json();
222
+ setDefaultWorkspace(data.defaultWorkspace || "");
223
+ setLanguage(data.language || "zh-TW");
224
+ } catch {
225
+ // ignore
226
+ }
227
+ };
228
+
229
+ const handleLanguageChange = async (lang: Language) => {
230
+ const prev = language;
231
+ setLanguage(lang);
232
+
233
+ try {
234
+ const res = await fetch(`${API_BASE}/settings`, {
235
+ method: "PUT",
236
+ headers: { "Content-Type": "application/json" },
237
+ body: JSON.stringify({ language: lang }),
238
+ });
239
+ if (!res.ok) throw new Error("Save failed");
240
+ } catch {
241
+ setLanguage(prev);
242
+ return;
243
+ }
244
+
245
+ if (selectedSessionId && messages.length > 0 && isConnected) {
246
+ const SWITCH_MSG: Record<Language, string> = {
247
+ "zh-TW": "[系統] 使用者已將語言切換為繁體中文。從現在起,請用繁體中文(台灣用語)回覆所有訊息。",
248
+ "en": "[System] User switched language to English. From now on, please respond in English for all messages.",
249
+ "ja": "[システム] ユーザーが言語を日本語に切り替えました。これ以降、すべてのメッセージを日本語で回答してください。",
250
+ };
251
+ sendJsonMessage({
252
+ type: "chat",
253
+ content: SWITCH_MSG[lang],
254
+ sessionId: selectedSessionId,
255
+ });
256
+ }
257
+ };
258
+
259
+ const createSession = async () => {
260
+ try {
261
+ const res = await fetch(`${API_BASE}/sessions`, {
262
+ method: "POST",
263
+ headers: { "Content-Type": "application/json" },
264
+ body: JSON.stringify({ workspacePath: defaultWorkspace || undefined }),
265
+ });
266
+ if (!res.ok) return;
267
+ const session = await res.json();
268
+ setSessions((prev) => [session, ...prev]);
269
+ selectSession(session.id);
270
+ setShowSettings(false);
271
+ } catch (error) {
272
+ console.error("Failed to create session:", error);
273
+ }
274
+ };
275
+
276
+ const deleteSession = async (sessionId: string) => {
277
+ try {
278
+ const res = await fetch(`${API_BASE}/sessions/${sessionId}`, { method: "DELETE" });
279
+ if (!res.ok) return;
280
+ setSessions((prev) => prev.filter((s) => s.id !== sessionId));
281
+ if (selectedSessionId === sessionId) {
282
+ setSelectedSessionId(null);
283
+ setMessages([]);
284
+ }
285
+ } catch (error) {
286
+ console.error("Failed to delete session:", error);
287
+ }
288
+ };
289
+
290
+ const selectSession = (sessionId: string) => {
291
+ // Update ref BEFORE subscribe so WS message filter accepts the new session
292
+ selectedSessionRef.current = sessionId;
293
+ setSelectedSessionId(sessionId);
294
+ setMessages([]);
295
+ setIsLoading(false);
296
+ setShowSettings(false);
297
+ if (isConnected) {
298
+ sendJsonMessage({ type: "subscribe", sessionId });
299
+ }
300
+ };
301
+
302
+ const handleSendMessage = (content: string) => {
303
+ if (!selectedSessionId || !isConnected) return;
304
+
305
+ setMessages((prev) => {
306
+ // Auto-update session title from first user message
307
+ if (prev.length === 0) {
308
+ const title = content.slice(0, 80).replace(/\n/g, " ");
309
+ setSessions((ss) =>
310
+ ss.map((s) => s.id === selectedSessionId ? { ...s, title } : s)
311
+ );
312
+ // Persist title to server (fire-and-forget)
313
+ fetch(`${API_BASE}/sessions/${selectedSessionId}`, {
314
+ method: "PATCH",
315
+ headers: { "Content-Type": "application/json" },
316
+ body: JSON.stringify({ title }),
317
+ }).catch(() => {});
318
+ }
319
+ return [
320
+ ...prev,
321
+ {
322
+ id: crypto.randomUUID(),
323
+ role: "user",
324
+ content,
325
+ timestamp: new Date().toISOString(),
326
+ },
327
+ ];
328
+ });
329
+
330
+ setIsLoading(true);
331
+
332
+ sendJsonMessage({
333
+ type: "chat",
334
+ content,
335
+ sessionId: selectedSessionId,
336
+ });
337
+ };
338
+
339
+ const handleInterrupt = () => {
340
+ if (!selectedSessionId) return;
341
+ sendJsonMessage({
342
+ type: "interrupt",
343
+ sessionId: selectedSessionId,
344
+ });
345
+ setIsLoading(false);
346
+ };
347
+
348
+ // Preview a file — accepts absolute or relative path (must be within workspace)
349
+ const handlePreviewFile = (filePath: string) => {
350
+ if (!selectedSessionId) return;
351
+ const session = sessions.find((s) => s.id === selectedSessionId);
352
+ if (filePath.startsWith("/")) {
353
+ // Absolute path — must be within workspace, otherwise reject
354
+ if (!session) return;
355
+ const wsBase = session.workspace_path.replace(/\/$/, "");
356
+ if (!filePath.startsWith(wsBase + "/")) return; // outside workspace, ignore
357
+ const relativePath = filePath.slice(wsBase.length + 1);
358
+ setPreviewFile({ relativePath, sessionId: selectedSessionId });
359
+ } else {
360
+ // Already relative
361
+ setPreviewFile({ relativePath: filePath, sessionId: selectedSessionId });
362
+ }
363
+ };
364
+
365
+ // Preview from file explorer (already relative)
366
+ const handleExplorerPreview = (relativePath: string) => {
367
+ if (!selectedSessionId) return;
368
+ setPreviewFile({ relativePath, sessionId: selectedSessionId });
369
+ };
370
+
371
+ useEffect(() => {
372
+ fetchSessions();
373
+ fetchSettings();
374
+ }, []);
375
+
376
+ return (
377
+ <div className="flex h-screen bg-gray-50">
378
+ {/* Sidebar */}
379
+ <div className="w-64 shrink-0">
380
+ <Sidebar
381
+ sessions={sessions}
382
+ selectedSessionId={selectedSessionId}
383
+ onSelectSession={selectSession}
384
+ onNewSession={createSession}
385
+ onDeleteSession={deleteSession}
386
+ onShowSettings={() => setShowSettings(true)}
387
+ defaultWorkspace={defaultWorkspace}
388
+ isConnected={isConnected}
389
+ language={language}
390
+ onLanguageChange={handleLanguageChange}
391
+ />
392
+ </div>
393
+
394
+ {/* Main content */}
395
+ {showSettings ? (
396
+ <SettingsPage
397
+ isVisible={showSettings}
398
+ onClose={() => setShowSettings(false)}
399
+ language={language}
400
+ onLanguageChange={handleLanguageChange}
401
+ />
402
+ ) : (
403
+ <>
404
+ <ChatWindow
405
+ sessionId={selectedSessionId}
406
+ messages={messages}
407
+ isConnected={isConnected}
408
+ isLoading={isLoading}
409
+ onSendMessage={handleSendMessage}
410
+ onInterrupt={handleInterrupt}
411
+ onShowFiles={() => setShowFiles(!showFiles)}
412
+ onShowPublish={() => setShowPublish(true)}
413
+ onNewSession={createSession}
414
+ onPreviewFile={handlePreviewFile}
415
+ workspacePath={sessions.find((s) => s.id === selectedSessionId)?.workspace_path}
416
+ showFilesActive={showFiles}
417
+ language={language}
418
+ />
419
+
420
+ <FileExplorer
421
+ sessionId={selectedSessionId}
422
+ isVisible={showFiles}
423
+ onToggle={() => setShowFiles(!showFiles)}
424
+ onPreviewFile={handleExplorerPreview}
425
+ />
426
+ </>
427
+ )}
428
+
429
+ {/* File preview modal */}
430
+ {previewFile && (
431
+ <FilePreviewModal
432
+ sessionId={previewFile.sessionId}
433
+ filePath={previewFile.relativePath}
434
+ onClose={() => setPreviewFile(null)}
435
+ />
436
+ )}
437
+
438
+ {/* Publish dialog */}
439
+ <PublishDialog
440
+ isOpen={showPublish}
441
+ onClose={() => setShowPublish(false)}
442
+ sessionId={selectedSessionId}
443
+ />
444
+ </div>
445
+ );
446
+ }