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.
- package/.env.example +30 -0
- package/.mcp.json +51 -0
- package/README.md +224 -0
- package/client/App.tsx +446 -0
- package/client/components/ChatWindow.tsx +790 -0
- package/client/components/FileExplorer.tsx +218 -0
- package/client/components/FilePreviewModal.tsx +179 -0
- package/client/components/PublishDialog.tsx +307 -0
- package/client/components/SettingsPage.tsx +452 -0
- package/client/components/Sidebar.tsx +198 -0
- package/client/components/ToolUseBlock.tsx +140 -0
- package/client/index.html +12 -0
- package/client/index.tsx +10 -0
- package/client/styles/globals.css +48 -0
- package/demo/01-welcome.png +0 -0
- package/demo/02-pipeline-cards.png +0 -0
- package/demo/03-custom-topic-fill.png +0 -0
- package/demo/04-topic-typed.png +0 -0
- package/demo/05-loading-state.png +0 -0
- package/demo/06-tool-calls.png +0 -0
- package/demo/07-history-rich.png +0 -0
- package/demo/09-en-cards.png +0 -0
- package/demo/10-ja-cards.png +0 -0
- package/demo/capture-remaining.mjs +73 -0
- package/demo/capture.mjs +110 -0
- package/demo/demo-walkthrough-2.webm +0 -0
- package/demo/demo-walkthrough.webm +0 -0
- package/package.json +48 -0
- package/postcss.config.js +6 -0
- package/scripts/threads_api.py +536 -0
- package/server/ai-client.ts +356 -0
- package/server/db.ts +299 -0
- package/server/mcp-config.ts +85 -0
- package/server/routes/accounts.ts +88 -0
- package/server/routes/files.ts +175 -0
- package/server/routes/publish.ts +77 -0
- package/server/routes/sessions.ts +59 -0
- package/server/routes/settings.ts +220 -0
- package/server/server.ts +261 -0
- package/server/services/social-publisher.ts +74 -0
- package/server/services/studio-mcp.ts +107 -0
- package/server/session.ts +167 -0
- package/server/types.ts +86 -0
- package/tailwind.config.js +8 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +19 -0
package/server/server.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import cors from "cors";
|
|
4
|
+
import { createServer } from "http";
|
|
5
|
+
import { WebSocketServer } from "ws";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import type { WSClient, IncomingWSMessage } from "./types.js";
|
|
10
|
+
import store from "./db.js";
|
|
11
|
+
import { Session } from "./session.js";
|
|
12
|
+
import { getSettings } from "./mcp-config.js";
|
|
13
|
+
import sessionsRouter from "./routes/sessions.js";
|
|
14
|
+
import filesRouter from "./routes/files.js";
|
|
15
|
+
import settingsRouter from "./routes/settings.js";
|
|
16
|
+
import publishRouter from "./routes/publish.js";
|
|
17
|
+
import accountsRouter from "./routes/accounts.js";
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname = path.dirname(__filename);
|
|
21
|
+
|
|
22
|
+
const PORT = parseInt(process.env.PORT || "3001", 10);
|
|
23
|
+
// Security: bind to localhost only — this server runs bypassPermissions
|
|
24
|
+
const HOST = process.env.HOST || "127.0.0.1";
|
|
25
|
+
|
|
26
|
+
const app = express();
|
|
27
|
+
// Only allow our own frontend origins (exact port match)
|
|
28
|
+
const ALLOWED_ORIGINS = [
|
|
29
|
+
`http://localhost:${PORT}`,
|
|
30
|
+
`http://127.0.0.1:${PORT}`,
|
|
31
|
+
"http://localhost:5173", // Vite dev server
|
|
32
|
+
"http://127.0.0.1:5173",
|
|
33
|
+
];
|
|
34
|
+
app.use(cors({ origin: ALLOWED_ORIGINS }));
|
|
35
|
+
app.use(express.json());
|
|
36
|
+
|
|
37
|
+
// Serve built assets in production, raw client in dev
|
|
38
|
+
const distDir = path.join(__dirname, "../dist");
|
|
39
|
+
if (fs.existsSync(distDir)) {
|
|
40
|
+
app.use(express.static(distDir));
|
|
41
|
+
app.get("/", (_req, res) => {
|
|
42
|
+
res.sendFile(path.join(distDir, "index.html"));
|
|
43
|
+
});
|
|
44
|
+
} else {
|
|
45
|
+
app.use("/client", express.static(path.join(__dirname, "../client")));
|
|
46
|
+
app.get("/", (_req, res) => {
|
|
47
|
+
res.sendFile(path.join(__dirname, "../client/index.html"));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// REST API routes
|
|
52
|
+
app.use("/api/sessions", sessionsRouter);
|
|
53
|
+
app.use("/api/sessions", filesRouter);
|
|
54
|
+
app.use("/api/settings", settingsRouter);
|
|
55
|
+
app.use("/api/publish", publishRouter);
|
|
56
|
+
app.use("/api/accounts", accountsRouter);
|
|
57
|
+
|
|
58
|
+
// Session management (long-lived agent sessions)
|
|
59
|
+
const sessions: Map<string, Session> = new Map();
|
|
60
|
+
|
|
61
|
+
// Idle session cleanup interval (evict after 30 min without subscribers)
|
|
62
|
+
const SESSION_IDLE_MS = 30 * 60 * 1000;
|
|
63
|
+
const sessionLastActivity = new Map<string, number>();
|
|
64
|
+
|
|
65
|
+
function touchSession(sessionId: string) {
|
|
66
|
+
sessionLastActivity.set(sessionId, Date.now());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const idleCleanup = setInterval(() => {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
for (const [id, lastActive] of sessionLastActivity) {
|
|
72
|
+
const session = sessions.get(id);
|
|
73
|
+
if (session && !session.hasSubscribers() && now - lastActive > SESSION_IDLE_MS) {
|
|
74
|
+
session.close();
|
|
75
|
+
sessions.delete(id);
|
|
76
|
+
sessionLastActivity.delete(id);
|
|
77
|
+
console.log(`[Session] Evicted idle session ${id}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}, 60_000);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get or create an in-memory Session for a validated sessionId.
|
|
84
|
+
* Returns null if the session doesn't exist in DB.
|
|
85
|
+
*/
|
|
86
|
+
function getSession(sessionId: string): Session | null {
|
|
87
|
+
let session = sessions.get(sessionId);
|
|
88
|
+
if (session) {
|
|
89
|
+
touchSession(sessionId);
|
|
90
|
+
return session;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const dbSession = store.getSession(sessionId);
|
|
94
|
+
if (!dbSession) return null;
|
|
95
|
+
|
|
96
|
+
const settings = getSettings();
|
|
97
|
+
session = new Session(sessionId, dbSession.workspace_path, settings.language);
|
|
98
|
+
sessions.set(sessionId, session);
|
|
99
|
+
touchSession(sessionId);
|
|
100
|
+
return session;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Clean up an in-memory session (called on delete or interrupt).
|
|
105
|
+
*/
|
|
106
|
+
function removeSession(sessionId: string): void {
|
|
107
|
+
const session = sessions.get(sessionId);
|
|
108
|
+
if (session) {
|
|
109
|
+
session.close();
|
|
110
|
+
sessions.delete(sessionId);
|
|
111
|
+
sessionLastActivity.delete(sessionId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Expose removeSession so the sessions router can call it on DELETE
|
|
116
|
+
export { removeSession };
|
|
117
|
+
|
|
118
|
+
// Create HTTP server
|
|
119
|
+
const server = createServer(app);
|
|
120
|
+
|
|
121
|
+
// WebSocket server with origin check (same list as CORS)
|
|
122
|
+
const wss = new WebSocketServer({
|
|
123
|
+
server,
|
|
124
|
+
path: "/ws",
|
|
125
|
+
verifyClient: ({ origin }: { origin?: string }) => {
|
|
126
|
+
// Allow connections without origin (e.g. from CLI tools)
|
|
127
|
+
if (!origin) return true;
|
|
128
|
+
return ALLOWED_ORIGINS.includes(origin);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
wss.on("connection", (ws: WSClient) => {
|
|
133
|
+
ws.isAlive = true;
|
|
134
|
+
|
|
135
|
+
ws.send(
|
|
136
|
+
JSON.stringify({ type: "connected", message: "Connected to Claude World Studio" })
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
ws.on("pong", () => {
|
|
140
|
+
ws.isAlive = true;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
ws.on("message", (data) => {
|
|
144
|
+
try {
|
|
145
|
+
const message: IncomingWSMessage = JSON.parse(data.toString());
|
|
146
|
+
|
|
147
|
+
switch (message.type) {
|
|
148
|
+
case "subscribe": {
|
|
149
|
+
// Unsubscribe from any previous session first
|
|
150
|
+
if (ws.sessionId) {
|
|
151
|
+
const prev = sessions.get(ws.sessionId);
|
|
152
|
+
if (prev) prev.unsubscribe(ws);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const session = getSession(message.sessionId);
|
|
156
|
+
if (!session) {
|
|
157
|
+
ws.send(JSON.stringify({
|
|
158
|
+
type: "error",
|
|
159
|
+
error: "Session not found",
|
|
160
|
+
sessionId: message.sessionId,
|
|
161
|
+
}));
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
session.subscribe(ws);
|
|
165
|
+
|
|
166
|
+
// Send existing messages from DB
|
|
167
|
+
const messages = store.getMessages(message.sessionId);
|
|
168
|
+
ws.send(
|
|
169
|
+
JSON.stringify({
|
|
170
|
+
type: "history",
|
|
171
|
+
messages,
|
|
172
|
+
sessionId: message.sessionId,
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case "chat": {
|
|
179
|
+
if (!message.content?.trim()) {
|
|
180
|
+
ws.send(JSON.stringify({ type: "error", error: "Empty message" }));
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
// Unsubscribe from previous session if switching
|
|
184
|
+
if (ws.sessionId && ws.sessionId !== message.sessionId) {
|
|
185
|
+
const prev = sessions.get(ws.sessionId);
|
|
186
|
+
if (prev) prev.unsubscribe(ws);
|
|
187
|
+
}
|
|
188
|
+
const session = getSession(message.sessionId);
|
|
189
|
+
if (!session) {
|
|
190
|
+
ws.send(JSON.stringify({
|
|
191
|
+
type: "error",
|
|
192
|
+
error: "Session not found",
|
|
193
|
+
sessionId: message.sessionId,
|
|
194
|
+
}));
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
session.subscribe(ws);
|
|
198
|
+
session.sendMessage(message.content);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
case "interrupt": {
|
|
203
|
+
const session = sessions.get(message.sessionId);
|
|
204
|
+
if (session) {
|
|
205
|
+
removeSession(message.sessionId);
|
|
206
|
+
// Notify all subscribers that session was interrupted
|
|
207
|
+
const interruptMsg = JSON.stringify({
|
|
208
|
+
type: "interrupted",
|
|
209
|
+
sessionId: message.sessionId,
|
|
210
|
+
});
|
|
211
|
+
wss.clients.forEach((client) => {
|
|
212
|
+
const wsClient = client as WSClient;
|
|
213
|
+
if (wsClient.sessionId === message.sessionId && wsClient.readyState === wsClient.OPEN) {
|
|
214
|
+
wsClient.send(interruptMsg);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
default:
|
|
222
|
+
ws.send(JSON.stringify({ type: "error", error: "Unknown message type" }));
|
|
223
|
+
}
|
|
224
|
+
} catch (error) {
|
|
225
|
+
console.error("Error handling WebSocket message:", error);
|
|
226
|
+
ws.send(JSON.stringify({ type: "error", error: "Invalid message format" }));
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
ws.on("close", () => {
|
|
231
|
+
for (const session of sessions.values()) {
|
|
232
|
+
session.unsubscribe(ws);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Heartbeat to detect dead connections
|
|
238
|
+
const heartbeat = setInterval(() => {
|
|
239
|
+
wss.clients.forEach((ws) => {
|
|
240
|
+
const client = ws as WSClient;
|
|
241
|
+
if (client.isAlive === false) {
|
|
242
|
+
return client.terminate();
|
|
243
|
+
}
|
|
244
|
+
client.isAlive = false;
|
|
245
|
+
client.ping();
|
|
246
|
+
});
|
|
247
|
+
}, 30000);
|
|
248
|
+
|
|
249
|
+
wss.on("close", () => {
|
|
250
|
+
clearInterval(heartbeat);
|
|
251
|
+
clearInterval(idleCleanup);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Start server — bound to localhost for security
|
|
255
|
+
server.listen(PORT, HOST, () => {
|
|
256
|
+
console.log(`Claude World Studio running at http://${HOST}:${PORT}`);
|
|
257
|
+
console.log(`WebSocket endpoint at ws://${HOST}:${PORT}/ws`);
|
|
258
|
+
if (!fs.existsSync(distDir)) {
|
|
259
|
+
console.log(`Frontend dev server at http://localhost:5173`);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
|
|
8
|
+
const SCRIPTS_DIR = path.join(__dirname, "../../scripts");
|
|
9
|
+
const MAX_TEXT_LENGTH = 500;
|
|
10
|
+
const MIN_PUBLISH_SCORE = 70;
|
|
11
|
+
|
|
12
|
+
export interface PublishOptions {
|
|
13
|
+
text: string;
|
|
14
|
+
token: string;
|
|
15
|
+
score?: number;
|
|
16
|
+
imageUrl?: string;
|
|
17
|
+
pollOptions?: string; // pipe-separated: "A|B|C"
|
|
18
|
+
linkComment?: string;
|
|
19
|
+
tag?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PublishResult {
|
|
23
|
+
id: string;
|
|
24
|
+
permalink: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function publishToThreads(opts: PublishOptions): Promise<PublishResult> {
|
|
28
|
+
if (opts.score !== undefined && opts.score < MIN_PUBLISH_SCORE) {
|
|
29
|
+
throw new Error(`Score ${opts.score} below minimum ${MIN_PUBLISH_SCORE}. Improve content before publishing.`);
|
|
30
|
+
}
|
|
31
|
+
if (opts.text.length > MAX_TEXT_LENGTH) {
|
|
32
|
+
throw new Error(`Text too long: ${opts.text.length} chars (max ${MAX_TEXT_LENGTH})`);
|
|
33
|
+
}
|
|
34
|
+
if (!opts.token) {
|
|
35
|
+
throw new Error("No token provided. Configure token in Settings for this account.");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const scriptPath = path.join(SCRIPTS_DIR, "threads_api.py");
|
|
40
|
+
const args = [scriptPath, "publish", "--token", opts.token, "--text", opts.text];
|
|
41
|
+
|
|
42
|
+
if (opts.imageUrl) {
|
|
43
|
+
args.push("--image", opts.imageUrl);
|
|
44
|
+
}
|
|
45
|
+
if (opts.pollOptions) {
|
|
46
|
+
args.push("--poll", opts.pollOptions);
|
|
47
|
+
}
|
|
48
|
+
if (opts.linkComment) {
|
|
49
|
+
args.push("--link-comment", opts.linkComment);
|
|
50
|
+
}
|
|
51
|
+
if (opts.tag) {
|
|
52
|
+
args.push("--tag", opts.tag);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
execFile(
|
|
56
|
+
"python3",
|
|
57
|
+
args,
|
|
58
|
+
{ timeout: 30000 },
|
|
59
|
+
(error, stdout, stderr) => {
|
|
60
|
+
if (error) {
|
|
61
|
+
reject(new Error(stderr || error.message));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const result = JSON.parse(stdout.trim());
|
|
67
|
+
resolve(result);
|
|
68
|
+
} catch {
|
|
69
|
+
resolve({ id: stdout.trim(), permalink: "" });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import store from "../db.js";
|
|
4
|
+
import { publishToThreads } from "./social-publisher.js";
|
|
5
|
+
|
|
6
|
+
const publishTool = tool(
|
|
7
|
+
"publish_to_threads",
|
|
8
|
+
"Publish content to Threads via Graph API. Quality gate: score >= 70 required. Pass account_id from the Social Accounts table.",
|
|
9
|
+
{
|
|
10
|
+
text: z.string().describe("Post text content (max 500 chars)"),
|
|
11
|
+
account_id: z.string().describe("Account ID from Social Accounts table"),
|
|
12
|
+
score: z.number().describe("Content quality score (must be >= 70)"),
|
|
13
|
+
image_url: z.string().optional().describe("Public image URL to attach"),
|
|
14
|
+
poll_options: z.string().optional().describe("Poll options separated by | (2-4 options, max 25 chars each)"),
|
|
15
|
+
link_comment: z.string().optional().describe("Auto-reply with this link (avoids reach penalty)"),
|
|
16
|
+
tag: z.string().optional().describe("Topic tag (no # prefix, one per post)"),
|
|
17
|
+
},
|
|
18
|
+
async (args) => {
|
|
19
|
+
const account = store.getAccount(args.account_id);
|
|
20
|
+
if (!account) {
|
|
21
|
+
return { content: [{ type: "text" as const, text: `Error: Account not found: ${args.account_id}` }], isError: true };
|
|
22
|
+
}
|
|
23
|
+
if (!account.token) {
|
|
24
|
+
return { content: [{ type: "text" as const, text: `Error: No token configured for account "${account.name}". Add token in Settings.` }], isError: true };
|
|
25
|
+
}
|
|
26
|
+
if (account.platform !== "threads") {
|
|
27
|
+
return { content: [{ type: "text" as const, text: `Error: Account "${account.name}" is ${account.platform}, not threads.` }], isError: true };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const result = await publishToThreads({
|
|
32
|
+
text: args.text,
|
|
33
|
+
token: account.token,
|
|
34
|
+
score: args.score,
|
|
35
|
+
imageUrl: args.image_url,
|
|
36
|
+
pollOptions: args.poll_options,
|
|
37
|
+
linkComment: args.link_comment,
|
|
38
|
+
tag: args.tag,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Log to publish history
|
|
42
|
+
store.addPublish({
|
|
43
|
+
session_id: null,
|
|
44
|
+
platform: "threads",
|
|
45
|
+
account: args.account_id,
|
|
46
|
+
content: args.text,
|
|
47
|
+
post_id: result.id,
|
|
48
|
+
post_url: result.permalink,
|
|
49
|
+
status: "published",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
content: [{
|
|
54
|
+
type: "text" as const,
|
|
55
|
+
text: JSON.stringify({
|
|
56
|
+
success: true,
|
|
57
|
+
post_id: result.id,
|
|
58
|
+
permalink: result.permalink,
|
|
59
|
+
account: account.name,
|
|
60
|
+
handle: account.handle,
|
|
61
|
+
}),
|
|
62
|
+
}],
|
|
63
|
+
};
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// Log failed attempt
|
|
66
|
+
store.addPublish({
|
|
67
|
+
session_id: null,
|
|
68
|
+
platform: "threads",
|
|
69
|
+
account: args.account_id,
|
|
70
|
+
content: args.text,
|
|
71
|
+
post_id: null,
|
|
72
|
+
post_url: null,
|
|
73
|
+
status: "failed",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: "text" as const, text: `Error: ${(err as Error).message}` }],
|
|
78
|
+
isError: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const historyTool = tool(
|
|
85
|
+
"get_publish_history",
|
|
86
|
+
"Get recent publish history from local database. No API token needed.",
|
|
87
|
+
{
|
|
88
|
+
limit: z.number().optional().describe("Number of records to return (default 20, max 500)"),
|
|
89
|
+
},
|
|
90
|
+
async (args) => {
|
|
91
|
+
const limit = Math.min(Math.max(args.limit || 20, 1), 500);
|
|
92
|
+
const history = store.getPublishHistory(limit);
|
|
93
|
+
return {
|
|
94
|
+
content: [{
|
|
95
|
+
type: "text" as const,
|
|
96
|
+
text: JSON.stringify(history, null, 2),
|
|
97
|
+
}],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
export function createStudioMcpServer() {
|
|
103
|
+
return createSdkMcpServer({
|
|
104
|
+
name: "studio",
|
|
105
|
+
tools: [publishTool, historyTool],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { WSClient, Language } from "./types.js";
|
|
2
|
+
import { AgentSession } from "./ai-client.js";
|
|
3
|
+
import store from "./db.js";
|
|
4
|
+
|
|
5
|
+
export class Session {
|
|
6
|
+
public readonly sessionId: string;
|
|
7
|
+
private subscribers: Set<WSClient> = new Set();
|
|
8
|
+
private agentSession: AgentSession;
|
|
9
|
+
private isListening = false;
|
|
10
|
+
|
|
11
|
+
constructor(sessionId: string, workspacePath?: string, language?: Language) {
|
|
12
|
+
this.sessionId = sessionId;
|
|
13
|
+
this.agentSession = new AgentSession(workspacePath, language);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private async startListening() {
|
|
17
|
+
if (this.isListening) return;
|
|
18
|
+
this.isListening = true;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
for await (const message of this.agentSession.getOutputStream()) {
|
|
22
|
+
this.handleSDKMessage(message);
|
|
23
|
+
}
|
|
24
|
+
} catch (error) {
|
|
25
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
26
|
+
console.error(`Error in session ${this.sessionId}:`, errorMsg);
|
|
27
|
+
this.broadcastError(errorMsg);
|
|
28
|
+
} finally {
|
|
29
|
+
this.isListening = false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
sendMessage(content: string) {
|
|
34
|
+
store.addMessage(this.sessionId, { role: "user", content });
|
|
35
|
+
|
|
36
|
+
this.broadcast({
|
|
37
|
+
type: "user_message",
|
|
38
|
+
content,
|
|
39
|
+
sessionId: this.sessionId,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
this.agentSession.sendMessage(content);
|
|
43
|
+
|
|
44
|
+
if (!this.isListening) {
|
|
45
|
+
this.startListening();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private handleSDKMessage(message: any) {
|
|
50
|
+
if (message.type === "assistant") {
|
|
51
|
+
const content = message.message?.content;
|
|
52
|
+
if (!content) return;
|
|
53
|
+
|
|
54
|
+
if (typeof content === "string") {
|
|
55
|
+
store.addMessage(this.sessionId, { role: "assistant", content });
|
|
56
|
+
this.broadcast({
|
|
57
|
+
type: "assistant_message",
|
|
58
|
+
content,
|
|
59
|
+
sessionId: this.sessionId,
|
|
60
|
+
});
|
|
61
|
+
} else if (Array.isArray(content)) {
|
|
62
|
+
for (const block of content) {
|
|
63
|
+
if (block.type === "text" && block.text) {
|
|
64
|
+
store.addMessage(this.sessionId, {
|
|
65
|
+
role: "assistant",
|
|
66
|
+
content: block.text,
|
|
67
|
+
});
|
|
68
|
+
this.broadcast({
|
|
69
|
+
type: "assistant_message",
|
|
70
|
+
content: block.text,
|
|
71
|
+
sessionId: this.sessionId,
|
|
72
|
+
});
|
|
73
|
+
} else if (block.type === "tool_use" && block.name) {
|
|
74
|
+
store.addMessage(this.sessionId, {
|
|
75
|
+
role: "tool_use",
|
|
76
|
+
tool_name: block.name,
|
|
77
|
+
tool_id: block.id,
|
|
78
|
+
tool_input: JSON.stringify(block.input),
|
|
79
|
+
});
|
|
80
|
+
this.broadcast({
|
|
81
|
+
type: "tool_use",
|
|
82
|
+
toolName: block.name,
|
|
83
|
+
toolId: block.id,
|
|
84
|
+
toolInput: block.input,
|
|
85
|
+
sessionId: this.sessionId,
|
|
86
|
+
});
|
|
87
|
+
} else if (block.type === "tool_result") {
|
|
88
|
+
const resultContent =
|
|
89
|
+
typeof block.content === "string"
|
|
90
|
+
? block.content
|
|
91
|
+
: JSON.stringify(block.content);
|
|
92
|
+
store.addMessage(this.sessionId, {
|
|
93
|
+
role: "tool_result",
|
|
94
|
+
tool_id: block.tool_use_id,
|
|
95
|
+
content: resultContent,
|
|
96
|
+
});
|
|
97
|
+
this.broadcast({
|
|
98
|
+
type: "tool_result",
|
|
99
|
+
toolId: block.tool_use_id,
|
|
100
|
+
content: resultContent,
|
|
101
|
+
sessionId: this.sessionId,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} else if (message.type === "result") {
|
|
107
|
+
const costUsd = message.total_cost_usd;
|
|
108
|
+
const durationMs = message.duration_ms;
|
|
109
|
+
|
|
110
|
+
store.addMessage(this.sessionId, {
|
|
111
|
+
role: "result",
|
|
112
|
+
content: JSON.stringify({
|
|
113
|
+
success: message.subtype === "success",
|
|
114
|
+
cost: costUsd,
|
|
115
|
+
duration: durationMs,
|
|
116
|
+
}),
|
|
117
|
+
cost_usd: costUsd,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
this.broadcast({
|
|
121
|
+
type: "result",
|
|
122
|
+
success: message.subtype === "success",
|
|
123
|
+
sessionId: this.sessionId,
|
|
124
|
+
cost: costUsd,
|
|
125
|
+
duration: durationMs,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
subscribe(client: WSClient) {
|
|
131
|
+
this.subscribers.add(client);
|
|
132
|
+
client.sessionId = this.sessionId;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
unsubscribe(client: WSClient) {
|
|
136
|
+
this.subscribers.delete(client);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
hasSubscribers(): boolean {
|
|
140
|
+
return this.subscribers.size > 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private broadcast(message: any) {
|
|
144
|
+
const messageStr = JSON.stringify(message);
|
|
145
|
+
for (const client of this.subscribers) {
|
|
146
|
+
try {
|
|
147
|
+
if (client.readyState === client.OPEN) {
|
|
148
|
+
client.send(messageStr);
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
this.subscribers.delete(client);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private broadcastError(error: string) {
|
|
157
|
+
this.broadcast({
|
|
158
|
+
type: "error",
|
|
159
|
+
error,
|
|
160
|
+
sessionId: this.sessionId,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
close() {
|
|
165
|
+
this.agentSession.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
package/server/types.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { WebSocket } from "ws";
|
|
2
|
+
|
|
3
|
+
export interface WSClient extends WebSocket {
|
|
4
|
+
sessionId?: string;
|
|
5
|
+
isAlive?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Session {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
workspace_path: string;
|
|
12
|
+
created_at: string;
|
|
13
|
+
updated_at: string;
|
|
14
|
+
status: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Message {
|
|
18
|
+
id: string;
|
|
19
|
+
session_id: string;
|
|
20
|
+
role: "user" | "assistant" | "tool_use" | "tool_result" | "result";
|
|
21
|
+
content: string | null;
|
|
22
|
+
tool_name: string | null;
|
|
23
|
+
tool_id: string | null;
|
|
24
|
+
tool_input: string | null;
|
|
25
|
+
cost_usd: number | null;
|
|
26
|
+
created_at: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Language = "zh-TW" | "en" | "ja";
|
|
30
|
+
|
|
31
|
+
export type Platform = "threads" | "instagram";
|
|
32
|
+
|
|
33
|
+
export interface SocialAccount {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
handle: string;
|
|
37
|
+
platform: Platform;
|
|
38
|
+
token: string;
|
|
39
|
+
user_id: string;
|
|
40
|
+
style: string;
|
|
41
|
+
persona_prompt: string;
|
|
42
|
+
created_at: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Settings {
|
|
46
|
+
language: Language;
|
|
47
|
+
trendPulseVenvPython: string;
|
|
48
|
+
cfBrowserVenvPython: string;
|
|
49
|
+
notebooklmServerPath: string;
|
|
50
|
+
cfBrowserUrl: string;
|
|
51
|
+
cfBrowserApiKey: string;
|
|
52
|
+
defaultWorkspace: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PublishRecord {
|
|
56
|
+
id: string;
|
|
57
|
+
session_id: string | null;
|
|
58
|
+
platform: string;
|
|
59
|
+
account: string;
|
|
60
|
+
content: string;
|
|
61
|
+
post_id: string | null;
|
|
62
|
+
post_url: string | null;
|
|
63
|
+
status: string;
|
|
64
|
+
created_at: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface WSChatMessage {
|
|
68
|
+
type: "chat";
|
|
69
|
+
content: string;
|
|
70
|
+
sessionId: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface WSSubscribeMessage {
|
|
74
|
+
type: "subscribe";
|
|
75
|
+
sessionId: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface WSInterruptMessage {
|
|
79
|
+
type: "interrupt";
|
|
80
|
+
sessionId: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type IncomingWSMessage =
|
|
84
|
+
| WSChatMessage
|
|
85
|
+
| WSSubscribeMessage
|
|
86
|
+
| WSInterruptMessage;
|