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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { isAbsolute } from "path";
|
|
3
|
+
import type { Settings, Language } from "./types.js";
|
|
4
|
+
import store from "./db.js";
|
|
5
|
+
|
|
6
|
+
/** Validate a path setting: must be absolute and exist on disk */
|
|
7
|
+
function isValidPath(p: string): boolean {
|
|
8
|
+
return !!p && isAbsolute(p) && existsSync(p);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getSettings(): Settings {
|
|
12
|
+
const all = store.getAllSettings();
|
|
13
|
+
return {
|
|
14
|
+
language: (all.language as Language) || "zh-TW",
|
|
15
|
+
trendPulseVenvPython:
|
|
16
|
+
all.trendPulseVenvPython || process.env.TREND_PULSE_PYTHON || "",
|
|
17
|
+
cfBrowserVenvPython:
|
|
18
|
+
all.cfBrowserVenvPython || process.env.CF_BROWSER_PYTHON || "",
|
|
19
|
+
notebooklmServerPath:
|
|
20
|
+
all.notebooklmServerPath || process.env.NOTEBOOKLM_SERVER_PATH || "",
|
|
21
|
+
cfBrowserUrl:
|
|
22
|
+
all.cfBrowserUrl || process.env.CF_BROWSER_URL || "",
|
|
23
|
+
cfBrowserApiKey:
|
|
24
|
+
all.cfBrowserApiKey || process.env.CF_BROWSER_API_KEY || "",
|
|
25
|
+
defaultWorkspace:
|
|
26
|
+
all.defaultWorkspace || process.env.DEFAULT_WORKSPACE || process.cwd(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface McpServerConfig {
|
|
31
|
+
name: string;
|
|
32
|
+
path: string;
|
|
33
|
+
build: () => Record<string, any>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildMcpServers(settings: Settings) {
|
|
37
|
+
const servers: Record<string, any> = {};
|
|
38
|
+
|
|
39
|
+
const configs: McpServerConfig[] = [
|
|
40
|
+
{
|
|
41
|
+
name: "trend-pulse",
|
|
42
|
+
path: settings.trendPulseVenvPython,
|
|
43
|
+
build: () => ({
|
|
44
|
+
command: settings.trendPulseVenvPython,
|
|
45
|
+
args: ["-m", "trend_pulse.server"],
|
|
46
|
+
env: {},
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "cf-browser",
|
|
51
|
+
path: settings.cfBrowserVenvPython,
|
|
52
|
+
build: () => ({
|
|
53
|
+
command: settings.cfBrowserVenvPython,
|
|
54
|
+
args: ["-m", "cf_browser_mcp.server"],
|
|
55
|
+
env: {
|
|
56
|
+
CF_BROWSER_URL: settings.cfBrowserUrl,
|
|
57
|
+
CF_BROWSER_API_KEY: settings.cfBrowserApiKey,
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "notebooklm",
|
|
63
|
+
path: settings.notebooklmServerPath,
|
|
64
|
+
build: () => ({
|
|
65
|
+
command: "python3",
|
|
66
|
+
args: [settings.notebooklmServerPath],
|
|
67
|
+
env: {},
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
for (const cfg of configs) {
|
|
73
|
+
if (!cfg.path) continue;
|
|
74
|
+
|
|
75
|
+
if (!isValidPath(cfg.path)) {
|
|
76
|
+
console.warn(`[MCP] ${cfg.name} skipped: path not found → ${cfg.path}`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
servers[cfg.name] = cfg.build();
|
|
81
|
+
console.log(`[MCP] ${cfg.name} enabled → ${cfg.path}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return servers;
|
|
85
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import store from "../db.js";
|
|
3
|
+
|
|
4
|
+
const VALID_PLATFORMS = ["threads", "instagram"] as const;
|
|
5
|
+
|
|
6
|
+
function maskToken(token: string): string {
|
|
7
|
+
if (!token || token.length < 12) return token ? "***" : "";
|
|
8
|
+
return token.slice(0, 8) + "..." + token.slice(-4);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const router = Router();
|
|
12
|
+
|
|
13
|
+
// List all accounts (tokens masked)
|
|
14
|
+
router.get("/", (_req, res) => {
|
|
15
|
+
const accounts = store.getAllAccounts().map((a) => ({
|
|
16
|
+
...a,
|
|
17
|
+
token: maskToken(a.token),
|
|
18
|
+
}));
|
|
19
|
+
res.json(accounts);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Get single account (token masked)
|
|
23
|
+
router.get("/:id", (req, res) => {
|
|
24
|
+
const account = store.getAccount(req.params.id);
|
|
25
|
+
if (!account) {
|
|
26
|
+
return res.status(404).json({ error: "Account not found" });
|
|
27
|
+
}
|
|
28
|
+
res.json({ ...account, token: maskToken(account.token) });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Create account
|
|
32
|
+
router.post("/", (req, res) => {
|
|
33
|
+
const { name, handle, platform, token, user_id, style, persona_prompt } = req.body || {};
|
|
34
|
+
|
|
35
|
+
if (!name || !handle || !platform) {
|
|
36
|
+
return res.status(400).json({ error: "name, handle, and platform are required" });
|
|
37
|
+
}
|
|
38
|
+
if (!VALID_PLATFORMS.includes(platform)) {
|
|
39
|
+
return res.status(400).json({ error: `platform must be one of: ${VALID_PLATFORMS.join(", ")}` });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const account = store.createAccount({
|
|
43
|
+
name, handle, platform, token, user_id, style, persona_prompt,
|
|
44
|
+
});
|
|
45
|
+
res.status(201).json({ ...account, token: maskToken(account.token) });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Update account
|
|
49
|
+
router.put("/:id", (req, res) => {
|
|
50
|
+
const existing = store.getAccount(req.params.id);
|
|
51
|
+
if (!existing) {
|
|
52
|
+
return res.status(404).json({ error: "Account not found" });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { name, handle, platform, token, user_id, style, persona_prompt } = req.body || {};
|
|
56
|
+
|
|
57
|
+
if (platform && !VALID_PLATFORMS.includes(platform)) {
|
|
58
|
+
return res.status(400).json({ error: `platform must be one of: ${VALID_PLATFORMS.join(", ")}` });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
store.updateAccount(req.params.id, {
|
|
62
|
+
name: name ?? existing.name,
|
|
63
|
+
handle: handle ?? existing.handle,
|
|
64
|
+
platform: platform ?? existing.platform,
|
|
65
|
+
user_id: user_id ?? existing.user_id,
|
|
66
|
+
style: style ?? existing.style,
|
|
67
|
+
persona_prompt: persona_prompt ?? existing.persona_prompt,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Only update token if a non-empty value is provided
|
|
71
|
+
if (token) {
|
|
72
|
+
store.updateAccountToken(req.params.id, token);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const updated = store.getAccount(req.params.id)!;
|
|
76
|
+
res.json({ ...updated, token: maskToken(updated.token) });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Delete account
|
|
80
|
+
router.delete("/:id", (req, res) => {
|
|
81
|
+
const deleted = store.deleteAccount(req.params.id);
|
|
82
|
+
if (!deleted) {
|
|
83
|
+
return res.status(404).json({ error: "Account not found" });
|
|
84
|
+
}
|
|
85
|
+
res.json({ success: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export default router;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import fsp from "fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import store from "../db.js";
|
|
6
|
+
|
|
7
|
+
const MAX_DEPTH = 5;
|
|
8
|
+
const MAX_TEXT_BYTES = 100 * 1024;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check that `target` is strictly within `base` directory.
|
|
12
|
+
* Both paths must exist on disk so realpathSync can resolve symlinks.
|
|
13
|
+
* Returns false if either path doesn't exist (deny by default).
|
|
14
|
+
*/
|
|
15
|
+
function isWithinWorkspace(base: string, target: string): boolean {
|
|
16
|
+
try {
|
|
17
|
+
const resolvedBase = fs.realpathSync(base);
|
|
18
|
+
const resolvedTarget = fs.realpathSync(target);
|
|
19
|
+
return (
|
|
20
|
+
resolvedTarget === resolvedBase ||
|
|
21
|
+
resolvedTarget.startsWith(resolvedBase + path.sep)
|
|
22
|
+
);
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const router = Router();
|
|
29
|
+
|
|
30
|
+
interface FileEntry {
|
|
31
|
+
name: string;
|
|
32
|
+
path: string;
|
|
33
|
+
type: "file" | "directory";
|
|
34
|
+
size?: number;
|
|
35
|
+
modified?: string;
|
|
36
|
+
children?: FileEntry[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function buildFileTree(
|
|
40
|
+
dirPath: string,
|
|
41
|
+
basePath: string,
|
|
42
|
+
depth = 0,
|
|
43
|
+
maxDepth = 3
|
|
44
|
+
): Promise<FileEntry[]> {
|
|
45
|
+
if (depth >= maxDepth) return [];
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
|
49
|
+
const result: FileEntry[] = [];
|
|
50
|
+
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
// Skip hidden files and common non-useful dirs
|
|
53
|
+
if (
|
|
54
|
+
entry.name.startsWith(".") ||
|
|
55
|
+
entry.name === "node_modules" ||
|
|
56
|
+
entry.name === "__pycache__"
|
|
57
|
+
) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
62
|
+
const relativePath = path.relative(basePath, fullPath);
|
|
63
|
+
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
result.push({
|
|
66
|
+
name: entry.name,
|
|
67
|
+
path: relativePath,
|
|
68
|
+
type: "directory",
|
|
69
|
+
children: await buildFileTree(fullPath, basePath, depth + 1, maxDepth),
|
|
70
|
+
});
|
|
71
|
+
} else {
|
|
72
|
+
try {
|
|
73
|
+
const stat = await fsp.stat(fullPath);
|
|
74
|
+
result.push({
|
|
75
|
+
name: entry.name,
|
|
76
|
+
path: relativePath,
|
|
77
|
+
type: "file",
|
|
78
|
+
size: stat.size,
|
|
79
|
+
modified: stat.mtime.toISOString(),
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
// Skip files we can't stat (broken symlinks, permission issues)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Sort: directories first, then alphabetical
|
|
88
|
+
return result.sort((a, b) => {
|
|
89
|
+
if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
|
|
90
|
+
return a.name.localeCompare(b.name);
|
|
91
|
+
});
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// List workspace file tree
|
|
98
|
+
router.get("/:sessionId/files", async (req, res) => {
|
|
99
|
+
const session = store.getSession(req.params.sessionId);
|
|
100
|
+
if (!session) {
|
|
101
|
+
return res.status(404).json({ error: "Session not found" });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rawDepth = parseInt(req.query.depth as string) || 3;
|
|
105
|
+
const depth = Math.min(Math.max(rawDepth, 1), MAX_DEPTH);
|
|
106
|
+
const tree = await buildFileTree(session.workspace_path, session.workspace_path, 0, depth);
|
|
107
|
+
|
|
108
|
+
res.json({
|
|
109
|
+
workspace: session.workspace_path,
|
|
110
|
+
tree,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Read file content
|
|
115
|
+
router.get("/:sessionId/files/*", async (req, res) => {
|
|
116
|
+
const session = store.getSession(req.params.sessionId);
|
|
117
|
+
if (!session) {
|
|
118
|
+
return res.status(404).json({ error: "Session not found" });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Extract the file path from the wildcard params
|
|
122
|
+
const filePath = (req.params as Record<string, string>)[0] as string | undefined;
|
|
123
|
+
if (!filePath) {
|
|
124
|
+
return res.status(400).json({ error: "File path required" });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const requestedPath = path.resolve(session.workspace_path, filePath);
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
// Resolve symlinks FIRST, then use the resolved path for all ops (eliminates TOCTOU)
|
|
131
|
+
const resolvedPath = await fsp.realpath(requestedPath);
|
|
132
|
+
const resolvedBase = await fsp.realpath(session.workspace_path);
|
|
133
|
+
|
|
134
|
+
// Security: verify resolved path is within workspace
|
|
135
|
+
if (resolvedPath !== resolvedBase && !resolvedPath.startsWith(resolvedBase + path.sep)) {
|
|
136
|
+
return res.status(403).json({ error: "Path traversal not allowed" });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const stat = await fsp.stat(resolvedPath);
|
|
140
|
+
if (stat.isDirectory()) {
|
|
141
|
+
return res.status(400).json({ error: "Path is a directory" });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// For binary files, send raw; for text, send JSON
|
|
145
|
+
const ext = path.extname(resolvedPath).toLowerCase();
|
|
146
|
+
const binaryExts = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".pdf", ".mp3", ".mp4", ".wav", ".m4a", ".webm", ".ogg"];
|
|
147
|
+
|
|
148
|
+
if (binaryExts.includes(ext)) {
|
|
149
|
+
res.sendFile(resolvedPath);
|
|
150
|
+
} else {
|
|
151
|
+
// Text file - limit by byte size, then decode
|
|
152
|
+
if (stat.size > MAX_TEXT_BYTES) {
|
|
153
|
+
const fh = await fsp.open(resolvedPath, "r");
|
|
154
|
+
try {
|
|
155
|
+
const buf = Buffer.alloc(MAX_TEXT_BYTES);
|
|
156
|
+
await fh.read(buf, 0, MAX_TEXT_BYTES, 0);
|
|
157
|
+
const content = buf.toString("utf-8");
|
|
158
|
+
res.json({ path: filePath, content, truncated: true, size: stat.size });
|
|
159
|
+
} finally {
|
|
160
|
+
await fh.close();
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
const content = await fsp.readFile(resolvedPath, "utf-8");
|
|
164
|
+
res.json({ path: filePath, content, truncated: false, size: stat.size });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
169
|
+
if (code === "ENOENT") return res.status(404).json({ error: "File not found" });
|
|
170
|
+
if (code === "EACCES") return res.status(403).json({ error: "Permission denied" });
|
|
171
|
+
return res.status(500).json({ error: "Failed to read file" });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
export default router;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import store from "../db.js";
|
|
3
|
+
import { publishToThreads } from "../services/social-publisher.js";
|
|
4
|
+
|
|
5
|
+
const MAX_HISTORY_LIMIT = 500;
|
|
6
|
+
|
|
7
|
+
const router = Router();
|
|
8
|
+
|
|
9
|
+
// Publish content to a specific account
|
|
10
|
+
router.post("/", async (req, res) => {
|
|
11
|
+
const { accountId, text, sessionId, score, imageUrl, pollOptions, linkComment, tag } = req.body;
|
|
12
|
+
|
|
13
|
+
if (!accountId || !text) {
|
|
14
|
+
return res.status(400).json({ error: "accountId and text are required" });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const account = store.getAccount(accountId);
|
|
18
|
+
if (!account) {
|
|
19
|
+
return res.status(400).json({ error: `Account not found: ${accountId}` });
|
|
20
|
+
}
|
|
21
|
+
if (!account.token) {
|
|
22
|
+
return res.status(400).json({ error: `No token configured for account: ${account.name}` });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const record = store.addPublish({
|
|
26
|
+
session_id: sessionId || null,
|
|
27
|
+
platform: account.platform,
|
|
28
|
+
account: accountId,
|
|
29
|
+
content: text,
|
|
30
|
+
post_id: null,
|
|
31
|
+
post_url: null,
|
|
32
|
+
status: "pending",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
let result: any;
|
|
37
|
+
|
|
38
|
+
if (account.platform === "threads") {
|
|
39
|
+
result = await publishToThreads({
|
|
40
|
+
text,
|
|
41
|
+
token: account.token,
|
|
42
|
+
score,
|
|
43
|
+
imageUrl,
|
|
44
|
+
pollOptions,
|
|
45
|
+
linkComment,
|
|
46
|
+
tag,
|
|
47
|
+
});
|
|
48
|
+
} else {
|
|
49
|
+
throw new Error(`Publishing to ${account.platform} is not yet supported`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
store.updatePublishStatus(record.id, "published", result?.id, result?.permalink);
|
|
53
|
+
|
|
54
|
+
res.json({
|
|
55
|
+
success: true,
|
|
56
|
+
id: record.id,
|
|
57
|
+
postId: result?.id,
|
|
58
|
+
postUrl: result?.permalink,
|
|
59
|
+
});
|
|
60
|
+
} catch (error) {
|
|
61
|
+
store.updatePublishStatus(record.id, "failed");
|
|
62
|
+
res.status(500).json({
|
|
63
|
+
error: (error as Error).message,
|
|
64
|
+
id: record.id,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Get publish history
|
|
70
|
+
router.get("/history", (req, res) => {
|
|
71
|
+
const rawLimit = parseInt(req.query.limit as string) || 50;
|
|
72
|
+
const limit = Math.min(Math.max(rawLimit, 1), MAX_HISTORY_LIMIT);
|
|
73
|
+
const history = store.getPublishHistory(limit);
|
|
74
|
+
res.json(history);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export default router;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import store from "../db.js";
|
|
3
|
+
import { removeSession } from "../server.js";
|
|
4
|
+
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
// List all sessions
|
|
8
|
+
router.get("/", (_req, res) => {
|
|
9
|
+
const sessions = store.getAllSessions();
|
|
10
|
+
res.json(sessions);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Create new session
|
|
14
|
+
router.post("/", (req, res) => {
|
|
15
|
+
const { title, workspacePath } = req.body || {};
|
|
16
|
+
const session = store.createSession(title, workspacePath);
|
|
17
|
+
res.status(201).json(session);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Get single session
|
|
21
|
+
router.get("/:id", (req, res) => {
|
|
22
|
+
const session = store.getSession(req.params.id);
|
|
23
|
+
if (!session) {
|
|
24
|
+
return res.status(404).json({ error: "Session not found" });
|
|
25
|
+
}
|
|
26
|
+
res.json(session);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Update session title
|
|
30
|
+
router.patch("/:id", (req, res) => {
|
|
31
|
+
const { title } = req.body || {};
|
|
32
|
+
if (!title || typeof title !== "string") {
|
|
33
|
+
return res.status(400).json({ error: "Title required" });
|
|
34
|
+
}
|
|
35
|
+
const session = store.getSession(req.params.id);
|
|
36
|
+
if (!session) {
|
|
37
|
+
return res.status(404).json({ error: "Session not found" });
|
|
38
|
+
}
|
|
39
|
+
store.updateSessionTitle(req.params.id, title.slice(0, 200));
|
|
40
|
+
res.json({ success: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Delete session (also clean up in-memory agent session)
|
|
44
|
+
router.delete("/:id", (req, res) => {
|
|
45
|
+
removeSession(req.params.id);
|
|
46
|
+
const deleted = store.deleteSession(req.params.id);
|
|
47
|
+
if (!deleted) {
|
|
48
|
+
return res.status(404).json({ error: "Session not found" });
|
|
49
|
+
}
|
|
50
|
+
res.json({ success: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Get session messages
|
|
54
|
+
router.get("/:id/messages", (req, res) => {
|
|
55
|
+
const messages = store.getMessages(req.params.id);
|
|
56
|
+
res.json(messages);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export default router;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import store from "../db.js";
|
|
6
|
+
import { getSettings } from "../mcp-config.js";
|
|
7
|
+
|
|
8
|
+
const { join, isAbsolute } = path;
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
|
|
12
|
+
const router = Router();
|
|
13
|
+
|
|
14
|
+
const SENSITIVE_KEYS = ["cfBrowserApiKey"] as const;
|
|
15
|
+
|
|
16
|
+
function maskValue(value: string): string {
|
|
17
|
+
if (!value || value.length < 12) return value ? "***" : "";
|
|
18
|
+
return value.slice(0, 8) + "..." + value.slice(-4);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Get all settings (tokens masked)
|
|
22
|
+
router.get("/", (_req, res) => {
|
|
23
|
+
const settings = getSettings();
|
|
24
|
+
const masked: Record<string, string> = { ...settings };
|
|
25
|
+
|
|
26
|
+
for (const key of SENSITIVE_KEYS) {
|
|
27
|
+
if (masked[key]) {
|
|
28
|
+
masked[key] = maskValue(masked[key]);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
res.json(masked);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Auto-detect installed MCP tools
|
|
36
|
+
router.get("/detect", (_req, res) => {
|
|
37
|
+
const home = process.env.HOME || "";
|
|
38
|
+
const workspace =
|
|
39
|
+
getSettings().defaultWorkspace ||
|
|
40
|
+
process.env.DEFAULT_WORKSPACE ||
|
|
41
|
+
process.cwd();
|
|
42
|
+
|
|
43
|
+
const searchRoots = [
|
|
44
|
+
workspace,
|
|
45
|
+
join(home, "github"),
|
|
46
|
+
join(home, "projects"),
|
|
47
|
+
join(home, "dev"),
|
|
48
|
+
join(home, "code"),
|
|
49
|
+
].filter((p) => isAbsolute(p) && existsSync(p));
|
|
50
|
+
|
|
51
|
+
// trend-pulse
|
|
52
|
+
const tpCandidates = searchRoots.flatMap((root) => [
|
|
53
|
+
join(root, "trend-pulse/.venv/bin/python"),
|
|
54
|
+
join(root, "trend-pulse/.venv/bin/python3"),
|
|
55
|
+
]);
|
|
56
|
+
const trendPulsePython = tpCandidates.find(existsSync) || "";
|
|
57
|
+
|
|
58
|
+
// cf-browser
|
|
59
|
+
const cbCandidates = searchRoots.flatMap((root) => [
|
|
60
|
+
join(root, "cf-browser/mcp-server/.venv/bin/python"),
|
|
61
|
+
join(root, "cf-browser/mcp-server/.venv/bin/python3"),
|
|
62
|
+
]);
|
|
63
|
+
const cfBrowserPython = cbCandidates.find(existsSync) || "";
|
|
64
|
+
|
|
65
|
+
// notebooklm-skill
|
|
66
|
+
const nlmCandidates = [
|
|
67
|
+
...searchRoots.flatMap((root) => [
|
|
68
|
+
join(root, "notebooklm-skill/mcp-server/server.py"),
|
|
69
|
+
join(root, "notebooklm-skill/mcp_server.py"),
|
|
70
|
+
]),
|
|
71
|
+
...searchRoots.flatMap((root) => {
|
|
72
|
+
try {
|
|
73
|
+
return readdirSync(root, { withFileTypes: true })
|
|
74
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
75
|
+
.flatMap((e) => [
|
|
76
|
+
join(root, e.name, "notebooklm-skill/mcp-server/server.py"),
|
|
77
|
+
]);
|
|
78
|
+
} catch { return []; }
|
|
79
|
+
}),
|
|
80
|
+
];
|
|
81
|
+
const notebooklmPath = nlmCandidates.find(existsSync) || "";
|
|
82
|
+
|
|
83
|
+
// cf-browser URL/Key
|
|
84
|
+
let cfBrowserUrl = "";
|
|
85
|
+
let cfBrowserApiKeyFound = false;
|
|
86
|
+
const cfEnvCandidates = [
|
|
87
|
+
...searchRoots.flatMap((root) => [
|
|
88
|
+
join(root, "cf-browser/.env"),
|
|
89
|
+
join(root, "cf-browser/worker/.env"),
|
|
90
|
+
]),
|
|
91
|
+
join(__dirname, "../../.env"),
|
|
92
|
+
];
|
|
93
|
+
for (const envPath of cfEnvCandidates) {
|
|
94
|
+
if (existsSync(envPath)) {
|
|
95
|
+
try {
|
|
96
|
+
const content = readFileSync(envPath, "utf-8");
|
|
97
|
+
const urlMatch = content.match(/^CF_BROWSER_URL=(.+)/m);
|
|
98
|
+
const keyMatch = content.match(/^CF_BROWSER_API_KEY=(.+)/m);
|
|
99
|
+
if (urlMatch && !cfBrowserUrl) cfBrowserUrl = urlMatch[1].trim();
|
|
100
|
+
if (keyMatch && !cfBrowserApiKeyFound) cfBrowserApiKeyFound = true;
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const detected: Record<string, { value: string; found: boolean }> = {
|
|
106
|
+
trendPulseVenvPython: { value: trendPulsePython, found: !!trendPulsePython },
|
|
107
|
+
cfBrowserVenvPython: { value: cfBrowserPython, found: !!cfBrowserPython },
|
|
108
|
+
notebooklmServerPath: { value: notebooklmPath, found: !!notebooklmPath },
|
|
109
|
+
cfBrowserUrl: { value: cfBrowserUrl, found: !!cfBrowserUrl },
|
|
110
|
+
cfBrowserApiKey: { value: "", found: cfBrowserApiKeyFound },
|
|
111
|
+
defaultWorkspace: { value: workspace, found: existsSync(workspace) },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
res.json(detected);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Apply detected values to DB
|
|
118
|
+
router.post("/detect/apply", (_req, res) => {
|
|
119
|
+
const home = process.env.HOME || "";
|
|
120
|
+
const workspace =
|
|
121
|
+
getSettings().defaultWorkspace ||
|
|
122
|
+
process.env.DEFAULT_WORKSPACE ||
|
|
123
|
+
process.cwd();
|
|
124
|
+
|
|
125
|
+
let applied = 0;
|
|
126
|
+
|
|
127
|
+
// cf-browser .env
|
|
128
|
+
const searchRoots = [workspace, join(home, "github")]
|
|
129
|
+
.filter((p) => isAbsolute(p) && existsSync(p));
|
|
130
|
+
|
|
131
|
+
const cfEnvCandidates = [
|
|
132
|
+
...searchRoots.flatMap((root) => [
|
|
133
|
+
join(root, "cf-browser/.env"),
|
|
134
|
+
join(root, "cf-browser/worker/.env"),
|
|
135
|
+
]),
|
|
136
|
+
join(__dirname, "../../.env"),
|
|
137
|
+
];
|
|
138
|
+
for (const envPath of cfEnvCandidates) {
|
|
139
|
+
if (existsSync(envPath)) {
|
|
140
|
+
try {
|
|
141
|
+
const content = readFileSync(envPath, "utf-8");
|
|
142
|
+
const urlMatch = content.match(/^CF_BROWSER_URL=(.+)/m);
|
|
143
|
+
const keyMatch = content.match(/^CF_BROWSER_API_KEY=(.+)/m);
|
|
144
|
+
if (urlMatch) { store.setSetting("cfBrowserUrl", urlMatch[1].trim()); applied++; }
|
|
145
|
+
if (keyMatch) { store.setSetting("cfBrowserApiKey", keyMatch[1].trim()); applied++; }
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.warn("[Settings] Error reading cf-browser .env:", err);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// MCP paths
|
|
153
|
+
const mcpRoots = [workspace, join(home, "github"), join(home, "projects")]
|
|
154
|
+
.filter((p) => isAbsolute(p) && existsSync(p));
|
|
155
|
+
|
|
156
|
+
const tpPath = mcpRoots.flatMap((r) => [
|
|
157
|
+
join(r, "trend-pulse/.venv/bin/python"),
|
|
158
|
+
join(r, "trend-pulse/.venv/bin/python3"),
|
|
159
|
+
]).find(existsSync);
|
|
160
|
+
if (tpPath) { store.setSetting("trendPulseVenvPython", tpPath); applied++; }
|
|
161
|
+
|
|
162
|
+
const cbPath = mcpRoots.flatMap((r) => [
|
|
163
|
+
join(r, "cf-browser/mcp-server/.venv/bin/python"),
|
|
164
|
+
join(r, "cf-browser/mcp-server/.venv/bin/python3"),
|
|
165
|
+
]).find(existsSync);
|
|
166
|
+
if (cbPath) { store.setSetting("cfBrowserVenvPython", cbPath); applied++; }
|
|
167
|
+
|
|
168
|
+
const nlmPath = [
|
|
169
|
+
...mcpRoots.flatMap((r) => [join(r, "notebooklm-skill/mcp-server/server.py")]),
|
|
170
|
+
...mcpRoots.flatMap((r) => {
|
|
171
|
+
try {
|
|
172
|
+
return readdirSync(r, { withFileTypes: true })
|
|
173
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
174
|
+
.map((e) => join(r, e.name, "notebooklm-skill/mcp-server/server.py"));
|
|
175
|
+
} catch { return []; }
|
|
176
|
+
}),
|
|
177
|
+
].find(existsSync);
|
|
178
|
+
if (nlmPath) { store.setSetting("notebooklmServerPath", nlmPath); applied++; }
|
|
179
|
+
|
|
180
|
+
res.json({ success: true, applied });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Update settings
|
|
184
|
+
router.put("/", (req, res) => {
|
|
185
|
+
const updates = req.body;
|
|
186
|
+
if (!updates || typeof updates !== "object") {
|
|
187
|
+
return res.status(400).json({ error: "Invalid settings" });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const allowedKeys = [
|
|
191
|
+
"language",
|
|
192
|
+
"trendPulseVenvPython",
|
|
193
|
+
"cfBrowserVenvPython",
|
|
194
|
+
"notebooklmServerPath",
|
|
195
|
+
"cfBrowserUrl",
|
|
196
|
+
"cfBrowserApiKey",
|
|
197
|
+
"defaultWorkspace",
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
const pathKeys = [
|
|
201
|
+
"trendPulseVenvPython",
|
|
202
|
+
"cfBrowserVenvPython",
|
|
203
|
+
"notebooklmServerPath",
|
|
204
|
+
"defaultWorkspace",
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
208
|
+
if (!allowedKeys.includes(key) || typeof value !== "string") continue;
|
|
209
|
+
|
|
210
|
+
if (pathKeys.includes(key) && value) {
|
|
211
|
+
if (!isAbsolute(value) || /[;&|`$(){}]/.test(value)) continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
store.setSetting(key, value);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
res.json({ success: true });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
export default router;
|