@leterhong/multichat 1.6.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/.agents/plugins/marketplace.json +32 -0
- package/.agents/plugins/plugins/developer-toolkit/.codex-plugin/plugin.json +24 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/code-review/SKILL.md +9 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/code-review/agents/openai.yaml +3 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/release-notes/SKILL.md +9 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/release-notes/agents/openai.yaml +3 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/security-review/SKILL.md +9 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/security-review/agents/openai.yaml +3 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/test-plan/SKILL.md +9 -0
- package/.agents/plugins/plugins/developer-toolkit/skills/test-plan/agents/openai.yaml +3 -0
- package/.agents/plugins/plugins/writing-toolkit/.codex-plugin/plugin.json +24 -0
- package/.agents/plugins/plugins/writing-toolkit/skills/meeting-notes/SKILL.md +9 -0
- package/.agents/plugins/plugins/writing-toolkit/skills/meeting-notes/agents/openai.yaml +3 -0
- package/.agents/plugins/plugins/writing-toolkit/skills/resume-tune/SKILL.md +9 -0
- package/.agents/plugins/plugins/writing-toolkit/skills/resume-tune/agents/openai.yaml +3 -0
- package/.agents/plugins/plugins/writing-toolkit/skills/weekly-report/SKILL.md +9 -0
- package/.agents/plugins/plugins/writing-toolkit/skills/weekly-report/agents/openai.yaml +3 -0
- package/.agents/skills/.gitkeep +1 -0
- package/.agents/skills/wanghong-handwritten-ppt/SKILL.md +172 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/HTML-PPT-LICENSE +21 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/NEAT-ANNOTATIONS-LICENSE +21 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/animations.css +138 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/base.css +150 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/deepseek-example.css +893 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/neat-annotations.css +152 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/preview-cover.png +0 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/preview-diagram.png +0 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/preview-summary.png +0 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/preview-table.png +0 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/runtime.js +960 -0
- package/.agents/skills/wanghong-handwritten-ppt/assets/template.css +198 -0
- package/.agents/skills/wanghong-handwritten-ppt/evals/evals.json +20 -0
- package/.agents/skills/wanghong-handwritten-ppt/examples/deepseek-v4-flash/index.html +414 -0
- package/.agents/skills/wanghong-handwritten-ppt/examples/deepseek-v4-flash/style.css +897 -0
- package/.agents/skills/wanghong-handwritten-ppt/references/prompts.md +170 -0
- package/.agents/skills/wanghong-handwritten-ppt/references/style-guide.md +133 -0
- package/.agents/skills/wanghong-handwritten-ppt/scripts/check_deck.py +41 -0
- package/.agents/skills/wanghong-handwritten-ppt/scripts/prepare_render_html.py +137 -0
- package/.agents/skills/wanghong-handwritten-ppt/scripts/render.sh +110 -0
- package/.agents/skills/wanghong-handwritten-ppt/templates/deck.html +94 -0
- package/.agents/skills/wanghong-handwritten-ppt/tests/test_font_contract.py +73 -0
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/backend/adapters/README.md +77 -0
- package/backend/adapters/anthropic.ts +115 -0
- package/backend/adapters/base.ts +47 -0
- package/backend/adapters/index.ts +41 -0
- package/backend/adapters/lmstudio.ts +7 -0
- package/backend/adapters/moonshot.ts +7 -0
- package/backend/adapters/ollama.ts +7 -0
- package/backend/adapters/openai-compatible.ts +51 -0
- package/backend/adapters/openai.ts +7 -0
- package/backend/adapters/qwen.ts +112 -0
- package/backend/adapters/wenxin.ts +136 -0
- package/backend/adapters/zhipu.ts +93 -0
- package/backend/dist/adapters/anthropic.js +130 -0
- package/backend/dist/adapters/anthropic.js.map +1 -0
- package/backend/dist/adapters/base.js +38 -0
- package/backend/dist/adapters/base.js.map +1 -0
- package/backend/dist/adapters/index.js +39 -0
- package/backend/dist/adapters/index.js.map +1 -0
- package/backend/dist/adapters/lmstudio.js +8 -0
- package/backend/dist/adapters/lmstudio.js.map +1 -0
- package/backend/dist/adapters/moonshot.js +8 -0
- package/backend/dist/adapters/moonshot.js.map +1 -0
- package/backend/dist/adapters/ollama.js +8 -0
- package/backend/dist/adapters/ollama.js.map +1 -0
- package/backend/dist/adapters/openai-compatible.js +47 -0
- package/backend/dist/adapters/openai-compatible.js.map +1 -0
- package/backend/dist/adapters/openai.js +8 -0
- package/backend/dist/adapters/openai.js.map +1 -0
- package/backend/dist/adapters/qwen.js +103 -0
- package/backend/dist/adapters/qwen.js.map +1 -0
- package/backend/dist/adapters/wenxin.js +114 -0
- package/backend/dist/adapters/wenxin.js.map +1 -0
- package/backend/dist/adapters/zhipu.js +86 -0
- package/backend/dist/adapters/zhipu.js.map +1 -0
- package/backend/dist/extensions/demo-mcp-server.js +75 -0
- package/backend/dist/extensions/demo-mcp-server.js.map +1 -0
- package/backend/dist/extensions/importer.js +828 -0
- package/backend/dist/extensions/importer.js.map +1 -0
- package/backend/dist/extensions/manager.js +1154 -0
- package/backend/dist/extensions/manager.js.map +1 -0
- package/backend/dist/lib/catalog.js +30 -0
- package/backend/dist/lib/catalog.js.map +1 -0
- package/backend/dist/lib/context.js +59 -0
- package/backend/dist/lib/context.js.map +1 -0
- package/backend/dist/lib/errors.js +75 -0
- package/backend/dist/lib/errors.js.map +1 -0
- package/backend/dist/lib/provider-store.js +116 -0
- package/backend/dist/lib/provider-store.js.map +1 -0
- package/backend/dist/lib/redact.js +11 -0
- package/backend/dist/lib/redact.js.map +1 -0
- package/backend/dist/lib/runtime-store.js +28 -0
- package/backend/dist/lib/runtime-store.js.map +1 -0
- package/backend/dist/lib/sqlite-store.js +218 -0
- package/backend/dist/lib/sqlite-store.js.map +1 -0
- package/backend/dist/lib/sse.js +46 -0
- package/backend/dist/lib/sse.js.map +1 -0
- package/backend/dist/lib/ssrf.js +131 -0
- package/backend/dist/lib/ssrf.js.map +1 -0
- package/backend/dist/lib/store.js +84 -0
- package/backend/dist/lib/store.js.map +1 -0
- package/backend/dist/lib/usage.js +219 -0
- package/backend/dist/lib/usage.js.map +1 -0
- package/backend/dist/lib/util.js +38 -0
- package/backend/dist/lib/util.js.map +1 -0
- package/backend/dist/lib/workspace-store.js +152 -0
- package/backend/dist/lib/workspace-store.js.map +1 -0
- package/backend/dist/mcp.js +429 -0
- package/backend/dist/mcp.js.map +1 -0
- package/backend/dist/routes/agents.js +51 -0
- package/backend/dist/routes/agents.js.map +1 -0
- package/backend/dist/routes/assistants.js +32 -0
- package/backend/dist/routes/assistants.js.map +1 -0
- package/backend/dist/routes/chat.js +697 -0
- package/backend/dist/routes/chat.js.map +1 -0
- package/backend/dist/routes/control-plane.js +200 -0
- package/backend/dist/routes/control-plane.js.map +1 -0
- package/backend/dist/routes/conversations.js +66 -0
- package/backend/dist/routes/conversations.js.map +1 -0
- package/backend/dist/routes/extensions-import.js +22 -0
- package/backend/dist/routes/extensions-import.js.map +1 -0
- package/backend/dist/routes/import.js +85 -0
- package/backend/dist/routes/import.js.map +1 -0
- package/backend/dist/routes/mcp-servers.js +56 -0
- package/backend/dist/routes/mcp-servers.js.map +1 -0
- package/backend/dist/routes/meta.js +37 -0
- package/backend/dist/routes/meta.js.map +1 -0
- package/backend/dist/routes/mock.js +28 -0
- package/backend/dist/routes/mock.js.map +1 -0
- package/backend/dist/routes/plugins.js +41 -0
- package/backend/dist/routes/plugins.js.map +1 -0
- package/backend/dist/routes/providers.js +197 -0
- package/backend/dist/routes/providers.js.map +1 -0
- package/backend/dist/routes/runs.js +57 -0
- package/backend/dist/routes/runs.js.map +1 -0
- package/backend/dist/routes/skills.js +73 -0
- package/backend/dist/routes/skills.js.map +1 -0
- package/backend/dist/routes/tools.js +40 -0
- package/backend/dist/routes/tools.js.map +1 -0
- package/backend/dist/routes/usage.js +14 -0
- package/backend/dist/routes/usage.js.map +1 -0
- package/backend/dist/routes/workspaces.js +158 -0
- package/backend/dist/routes/workspaces.js.map +1 -0
- package/backend/dist/runtime/agent.js +518 -0
- package/backend/dist/runtime/agent.js.map +1 -0
- package/backend/dist/server.js +103 -0
- package/backend/dist/server.js.map +1 -0
- package/backend/dist/types.js +3 -0
- package/backend/dist/types.js.map +1 -0
- package/backend/extensions/demo-mcp-server.js +70 -0
- package/backend/extensions/importer.ts +709 -0
- package/backend/extensions/manager.ts +1055 -0
- package/backend/lib/catalog.ts +28 -0
- package/backend/lib/context.ts +61 -0
- package/backend/lib/errors.ts +78 -0
- package/backend/lib/provider-store.ts +116 -0
- package/backend/lib/redact.ts +9 -0
- package/backend/lib/runtime-store.ts +38 -0
- package/backend/lib/sqlite-store.ts +220 -0
- package/backend/lib/sse.ts +42 -0
- package/backend/lib/ssrf.ts +120 -0
- package/backend/lib/store.ts +79 -0
- package/backend/lib/usage.ts +212 -0
- package/backend/lib/util.ts +31 -0
- package/backend/lib/workspace-store.ts +150 -0
- package/backend/mcp.ts +417 -0
- package/backend/package.json +34 -0
- package/backend/prompts.json +82 -0
- package/backend/routes/agents.ts +55 -0
- package/backend/routes/assistants.ts +30 -0
- package/backend/routes/chat.ts +660 -0
- package/backend/routes/control-plane.ts +165 -0
- package/backend/routes/conversations.ts +62 -0
- package/backend/routes/extensions-import.ts +15 -0
- package/backend/routes/import.ts +72 -0
- package/backend/routes/mcp-servers.ts +36 -0
- package/backend/routes/meta.ts +38 -0
- package/backend/routes/mock.ts +25 -0
- package/backend/routes/plugins.ts +27 -0
- package/backend/routes/providers.ts +166 -0
- package/backend/routes/runs.ts +55 -0
- package/backend/routes/skills.ts +52 -0
- package/backend/routes/tools.ts +40 -0
- package/backend/routes/usage.ts +13 -0
- package/backend/routes/workspaces.ts +124 -0
- package/backend/runtime/agent.ts +474 -0
- package/backend/server.ts +106 -0
- package/backend/types.ts +126 -0
- package/bin/multichat.mjs +535 -0
- package/frontend/dist/assets/agentPicker-C39VzmYa.js +13 -0
- package/frontend/dist/assets/compare-BpaGD32J.js +1 -0
- package/frontend/dist/assets/conversations-COuLmg99.js +7 -0
- package/frontend/dist/assets/data-ZH7Dux1K.js +1 -0
- package/frontend/dist/assets/extensionImport-CnTWKtWm.js +28 -0
- package/frontend/dist/assets/importExport-B3mB-9zs.js +70 -0
- package/frontend/dist/assets/index-CASVCOUO.js +34 -0
- package/frontend/dist/assets/index-DSvbjNLc.css +1 -0
- package/frontend/dist/assets/init-ZPK483vB.js +1 -0
- package/frontend/dist/assets/jsx-runtime-ZSsbr0oW.js +9 -0
- package/frontend/dist/assets/modal-CgHocv3n.js +2 -0
- package/frontend/dist/assets/modelPicker-EUmmVnbj.js +14 -0
- package/frontend/dist/assets/pluginsUI-B6jGUQkx.js +16 -0
- package/frontend/dist/assets/render-DgAg1K8A.js +1 -0
- package/frontend/dist/assets/send-BJhZ_5YA.js +14 -0
- package/frontend/dist/assets/settings-DdnYwZl-.js +2 -0
- package/frontend/dist/assets/shell-DFV2khQl.js +1 -0
- package/frontend/dist/assets/workbench-BY2J4vWC.js +41 -0
- package/frontend/dist/icon-maskable.svg +4 -0
- package/frontend/dist/icon.svg +4 -0
- package/frontend/dist/index.html +21 -0
- package/frontend/dist/manifest.webmanifest +26 -0
- package/package.json +58 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const ctx = require('../lib/context');
|
|
5
|
+
const agentRuntime = require('../runtime/agent');
|
|
6
|
+
const extensions = require('../extensions/manager');
|
|
7
|
+
|
|
8
|
+
const MEMORY_FILE = 'memories.json';
|
|
9
|
+
const SNAPSHOT_FILE = 'snapshots.json';
|
|
10
|
+
|
|
11
|
+
function now() { return new Date().toISOString(); }
|
|
12
|
+
function cleanText(value, max) { return String(value || '').trim().slice(0, max); }
|
|
13
|
+
function risk(permissions) {
|
|
14
|
+
if ((permissions || []).some(item => ['filesystem', 'process', 'external'].includes(item))) return 'high';
|
|
15
|
+
if ((permissions || []).length) return 'medium';
|
|
16
|
+
return 'low';
|
|
17
|
+
}
|
|
18
|
+
function sourceOf(item) {
|
|
19
|
+
return item.source?.kind || (item.managed ? 'managed' : 'builtin');
|
|
20
|
+
}
|
|
21
|
+
function gitState() {
|
|
22
|
+
const root = ctx.path.resolve(process.env.MULTICHAT_PROJECT_ROOT || ctx.path.join(ctx.BACKEND_ROOT, '..'));
|
|
23
|
+
const run = args => {
|
|
24
|
+
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 3000, windowsHide: true });
|
|
25
|
+
return result.status === 0 ? String(result.stdout || '').trim() : '';
|
|
26
|
+
};
|
|
27
|
+
return { branch: run(['branch', '--show-current']) || null, commit: run(['rev-parse', '--short', 'HEAD']) || null, dirty: Boolean(run(['status', '--porcelain'])) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function capabilityPassports() {
|
|
31
|
+
const skills = extensions.listSkills().map(item => ({
|
|
32
|
+
type: 'skill', id: item.key || item.id, name: item.name, version: item.modifiedAt || null,
|
|
33
|
+
description: item.description || '', enabled: item.enabled !== false, source: sourceOf(item), scope: item.scope || item.source?.scope || 'project',
|
|
34
|
+
permissions: [], risk: item.invalid ? 'high' : 'low', trust: item.invalid ? 'invalid' : 'reviewed-local',
|
|
35
|
+
integrity: item.invalid ? 'invalid' : 'discovered', path: item.path || null, issues: item.invalid ? [item.error || 'Skill 格式无效'] : [],
|
|
36
|
+
}));
|
|
37
|
+
const tools = ctx.store.read(ctx.SKILL_FILE, []).filter(item => !['prompt', 'mcp'].includes(item.type)).map(item => {
|
|
38
|
+
const permissions = agentRuntime.skillPermissions(item);
|
|
39
|
+
return { type: 'tool', id: item.id, name: item.name, description: item.description || '', enabled: item.enabled !== false, source: 'builtin', scope: 'runtime', permissions, risk: risk(permissions), trust: 'first-party', integrity: 'built-in', issues: [] };
|
|
40
|
+
});
|
|
41
|
+
const mcp = extensions.listMcpServers().map(item => {
|
|
42
|
+
const permissions = ['network', 'external'];
|
|
43
|
+
const issues = [];
|
|
44
|
+
if (item.trustLevel !== 'trusted') issues.push('未标记为可信,调用前需要审批');
|
|
45
|
+
if (item.lastError) issues.push(item.lastError);
|
|
46
|
+
return { type: 'mcp', id: item.id, name: item.name, description: item.description || '', enabled: item.enabled !== false, source: sourceOf(item), scope: item.scope || item.source?.scope || 'project', permissions, risk: risk(permissions), trust: item.trustLevel || 'untrusted', integrity: item.source?.kind === 'plugin' ? 'plugin-declared' : 'user-configured', version: item.updatedAt || null, issues, tools: (item.tools || []).length };
|
|
47
|
+
});
|
|
48
|
+
const plugins = extensions.listPlugins().map(item => {
|
|
49
|
+
const permissions = Array.isArray(item.policy?.permissions) ? item.policy.permissions.map(String) : [];
|
|
50
|
+
const issues = [];
|
|
51
|
+
if (item.invalid) issues.push('插件结构无效或组件路径越界');
|
|
52
|
+
if (!item.version || item.version === '0.0.0') issues.push('未声明可追踪版本');
|
|
53
|
+
return { type: 'plugin', id: item.key || item.id, name: item.name, description: item.description || '', enabled: item.enabled !== false, source: sourceOf(item), scope: item.source?.scope || 'project', permissions, risk: item.invalid ? 'high' : risk(permissions), trust: item.source?.kind === 'repo' ? 'project-owned' : 'managed', integrity: item.invalid ? 'invalid' : 'manifest-validated', version: item.version || null, author: item.author || null, components: item.components || {}, issues };
|
|
54
|
+
});
|
|
55
|
+
const items = [...plugins, ...skills, ...mcp, ...tools];
|
|
56
|
+
return { generatedAt: now(), summary: { total: items.length, enabled: items.filter(item => item.enabled).length, highRisk: items.filter(item => item.risk === 'high').length, issues: items.reduce((sum, item) => sum + item.issues.length, 0) }, items };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function snapshotRows() { return ctx.store.read(SNAPSHOT_FILE, []); }
|
|
60
|
+
function publicSnapshot(row) {
|
|
61
|
+
const { payload, ...rest } = row;
|
|
62
|
+
return { ...rest, assets: payload?.assets?.length || 0, memories: payload?.memories?.length || 0, size: Buffer.byteLength(JSON.stringify(payload || {}), 'utf8') };
|
|
63
|
+
}
|
|
64
|
+
function makeSnapshot(projectId, input: Record<string, any> = {}, automatic = false) {
|
|
65
|
+
const project = ctx.workspaceStore.requireProject(projectId);
|
|
66
|
+
const assets = ctx.workspaceStore.assets().filter(item => item.projectId === projectId);
|
|
67
|
+
const memories = ctx.store.read(MEMORY_FILE, []).filter(item => item.projectId === projectId);
|
|
68
|
+
const assetBytes = assets.reduce((sum, item) => sum + Buffer.byteLength(String(item.content || ''), 'utf8'), 0);
|
|
69
|
+
if (assetBytes > 7_000_000) throw new Error('snapshot project files exceed 7 MB; remove large files first');
|
|
70
|
+
const agents = ctx.store.read(ctx.AGENT_FILE, []);
|
|
71
|
+
const linkedAgent = project.defaultAgentId ? agents.find(item => item.id === project.defaultAgentId) || null : null;
|
|
72
|
+
const payload = { project, assets, memories, linkedAgent, capabilityRefs: capabilityPassports().items.filter(item => item.enabled).map(item => ({ type: item.type, id: item.id, version: item.version || null })) };
|
|
73
|
+
const serialized = JSON.stringify(payload);
|
|
74
|
+
if (Buffer.byteLength(serialized, 'utf8') > 8_000_000) throw new Error('snapshot exceeds 8 MB; remove large project files first');
|
|
75
|
+
const row = { id: `snap_${Date.now().toString(36)}_${crypto.randomBytes(3).toString('hex')}`, projectId, title: cleanText(input.title, 120) || (automatic ? '恢复前自动备份' : `项目快照 ${new Date().toLocaleString('zh-CN')}`), note: cleanText(input.note, 500), createdAt: now(), automatic, git: gitState(), fingerprint: crypto.createHash('sha256').update(serialized).digest('hex').slice(0, 16), payload };
|
|
76
|
+
const rows = snapshotRows(); rows.unshift(row); ctx.store.write(SNAPSHOT_FILE, rows.slice(0, 50));
|
|
77
|
+
return row;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = function registerControlPlane(app) {
|
|
81
|
+
app.get('/api/capabilities', (req, res, next) => { try { res.json(capabilityPassports()); } catch (error) { next(error); } });
|
|
82
|
+
|
|
83
|
+
app.get('/api/memories', (req, res) => {
|
|
84
|
+
let rows = ctx.store.read(MEMORY_FILE, []);
|
|
85
|
+
if (req.query.projectId) rows = rows.filter(item => item.projectId === req.query.projectId);
|
|
86
|
+
res.json(rows);
|
|
87
|
+
});
|
|
88
|
+
app.post('/api/memories', async (req, res) => {
|
|
89
|
+
try {
|
|
90
|
+
const project = ctx.workspaceStore.requireProject(ctx.safeId(req.body?.projectId, 'project id'));
|
|
91
|
+
const title = cleanText(req.body?.title, 120), content = cleanText(req.body?.content, 4000);
|
|
92
|
+
if (!title || !content) return res.status(400).json({ error: 'title and content are required' });
|
|
93
|
+
const row = { id: `mem_${Date.now().toString(36)}_${crypto.randomBytes(3).toString('hex')}`, projectId: project.id, title, content, enabled: req.body?.enabled !== false, createdAt: now(), updatedAt: now() };
|
|
94
|
+
await ctx.store.mutate(MEMORY_FILE, rows => [row, ...rows].slice(0, 1000), []);
|
|
95
|
+
res.status(201).json(row);
|
|
96
|
+
} catch (error) { res.status(400).json({ error: error.message }); }
|
|
97
|
+
});
|
|
98
|
+
app.put('/api/memories/:id', async (req, res) => {
|
|
99
|
+
let updated = null;
|
|
100
|
+
await ctx.store.mutate(MEMORY_FILE, rows => rows.map(item => {
|
|
101
|
+
if (item.id !== req.params.id) return item;
|
|
102
|
+
updated = { ...item, ...(req.body.title !== undefined ? { title: cleanText(req.body.title, 120) } : {}), ...(req.body.content !== undefined ? { content: cleanText(req.body.content, 4000) } : {}), ...(req.body.enabled !== undefined ? { enabled: req.body.enabled === true } : {}), updatedAt: now() };
|
|
103
|
+
return updated;
|
|
104
|
+
}), []);
|
|
105
|
+
if (!updated) return res.status(404).json({ error: 'memory not found' });
|
|
106
|
+
res.json(updated);
|
|
107
|
+
});
|
|
108
|
+
app.delete('/api/memories/:id', async (req, res) => {
|
|
109
|
+
let found = false;
|
|
110
|
+
await ctx.store.mutate(MEMORY_FILE, rows => rows.filter(item => { if (item.id === req.params.id) found = true; return item.id !== req.params.id; }), []);
|
|
111
|
+
if (!found) return res.status(404).json({ error: 'memory not found' });
|
|
112
|
+
res.json({ ok: true });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
app.get('/api/projects/:id/search', (req, res) => {
|
|
116
|
+
try {
|
|
117
|
+
ctx.workspaceStore.requireProject(req.params.id);
|
|
118
|
+
const query = cleanText(req.query.q, 1000);
|
|
119
|
+
if (!query) return res.json([]);
|
|
120
|
+
res.json(agentRuntime.projectSearch(req.params.id, query, null, Number(req.query.limit) || 12));
|
|
121
|
+
} catch (error) { res.status(400).json({ error: error.message }); }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
app.get('/api/snapshots', (req, res) => {
|
|
125
|
+
let rows = snapshotRows();
|
|
126
|
+
if (req.query.projectId) rows = rows.filter(item => item.projectId === req.query.projectId);
|
|
127
|
+
res.json(rows.map(publicSnapshot));
|
|
128
|
+
});
|
|
129
|
+
app.post('/api/snapshots', (req, res) => {
|
|
130
|
+
try { res.status(201).json(publicSnapshot(makeSnapshot(ctx.safeId(req.body?.projectId, 'project id'), req.body || {}))); }
|
|
131
|
+
catch (error) { res.status(400).json({ error: error.message }); }
|
|
132
|
+
});
|
|
133
|
+
app.post('/api/snapshots/:id/restore', (req, res) => {
|
|
134
|
+
try {
|
|
135
|
+
const snapshot = snapshotRows().find(item => item.id === req.params.id);
|
|
136
|
+
if (!snapshot) return res.status(404).json({ error: 'snapshot not found' });
|
|
137
|
+
makeSnapshot(snapshot.projectId, { title: '恢复前自动备份', note: `即将恢复 ${snapshot.title}` }, true);
|
|
138
|
+
const payload = snapshot.payload || {};
|
|
139
|
+
ctx.workspaceStore.updateProject(snapshot.projectId, {
|
|
140
|
+
name: payload.project?.name,
|
|
141
|
+
description: payload.project?.description,
|
|
142
|
+
defaultAgentId: payload.project?.defaultAgentId,
|
|
143
|
+
defaultProviderId: payload.project?.defaultProviderId,
|
|
144
|
+
defaultModel: payload.project?.defaultModel,
|
|
145
|
+
});
|
|
146
|
+
const otherAssets = ctx.workspaceStore.assets().filter(item => item.projectId !== snapshot.projectId);
|
|
147
|
+
const restoredAssets = (payload.assets || []).map(item => ({ ...item, projectId: snapshot.projectId, workspaceId: payload.project.workspaceId, updatedAt: now() }));
|
|
148
|
+
ctx.store.write('assets.json', [...restoredAssets, ...otherAssets]);
|
|
149
|
+
const otherMemories = ctx.store.read(MEMORY_FILE, []).filter(item => item.projectId !== snapshot.projectId);
|
|
150
|
+
ctx.store.write(MEMORY_FILE, [...(payload.memories || []), ...otherMemories]);
|
|
151
|
+
if (payload.linkedAgent) {
|
|
152
|
+
const agents = ctx.store.read(ctx.AGENT_FILE, []);
|
|
153
|
+
const index = agents.findIndex(item => item.id === payload.linkedAgent.id);
|
|
154
|
+
if (index >= 0) { agents[index] = payload.linkedAgent; ctx.store.write(ctx.AGENT_FILE, agents); }
|
|
155
|
+
}
|
|
156
|
+
res.json({ ok: true, snapshot: publicSnapshot(snapshot) });
|
|
157
|
+
} catch (error) { res.status(400).json({ error: error.message }); }
|
|
158
|
+
});
|
|
159
|
+
app.delete('/api/snapshots/:id', (req, res) => {
|
|
160
|
+
const rows = snapshotRows();
|
|
161
|
+
if (!rows.some(item => item.id === req.params.id)) return res.status(404).json({ error: 'snapshot not found' });
|
|
162
|
+
ctx.store.write(SNAPSHOT_FILE, rows.filter(item => item.id !== req.params.id));
|
|
163
|
+
res.json({ ok: true });
|
|
164
|
+
});
|
|
165
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// ── Conversations CRUD ──────────────────────────────────────────────────
|
|
3
|
+
const ctx = require('../lib/context');
|
|
4
|
+
|
|
5
|
+
module.exports = function registerConversations(app) {
|
|
6
|
+
app.get('/api/conversations', (req, res) => {
|
|
7
|
+
res.json(ctx.store.read('conversations.json', []));
|
|
8
|
+
});
|
|
9
|
+
app.get('/api/conversations/:id', (req, res) => {
|
|
10
|
+
const convs = ctx.store.read('conversations.json', []);
|
|
11
|
+
const conv = convs.find(c => c.id === req.params.id);
|
|
12
|
+
if (!conv) return res.status(404).json({ error: 'not found' });
|
|
13
|
+
const messages = ctx.store.read(`messages_${req.params.id}.json`, []);
|
|
14
|
+
res.json({ ...conv, messages });
|
|
15
|
+
});
|
|
16
|
+
app.post('/api/conversations', (req, res) => {
|
|
17
|
+
const convs = ctx.store.read('conversations.json', []);
|
|
18
|
+
const defaultWorkspace = ctx.workspaceStore.workspaces()[0];
|
|
19
|
+
const defaultProject = ctx.workspaceStore.projects().find(x => x.workspaceId === defaultWorkspace?.id);
|
|
20
|
+
const { messages: _messages, id: _id, createdAt: _createdAt, ...input } = req.body || {};
|
|
21
|
+
const conv = {
|
|
22
|
+
id: Date.now().toString(36),
|
|
23
|
+
createdAt: new Date().toISOString(),
|
|
24
|
+
compareMode: false,
|
|
25
|
+
modelA: '',
|
|
26
|
+
modelB: '',
|
|
27
|
+
systemPrompt: '',
|
|
28
|
+
inputPlaceholder: '',
|
|
29
|
+
workspaceId: input.workspaceId || defaultWorkspace?.id || null,
|
|
30
|
+
projectId: input.projectId || defaultProject?.id || null,
|
|
31
|
+
...input,
|
|
32
|
+
};
|
|
33
|
+
convs.unshift(conv);
|
|
34
|
+
ctx.store.write('conversations.json', convs);
|
|
35
|
+
ctx.store.write(`messages_${conv.id}.json`, []);
|
|
36
|
+
res.json(conv);
|
|
37
|
+
});
|
|
38
|
+
app.put('/api/conversations/:id', (req, res) => {
|
|
39
|
+
const convs = ctx.store.read('conversations.json', []);
|
|
40
|
+
const idx = convs.findIndex(c => c.id === req.params.id);
|
|
41
|
+
if (idx < 0) return res.status(404).json({ error: 'not found' });
|
|
42
|
+
const { messages: _messages, createdAt: _createdAt, ...changes } = req.body || {};
|
|
43
|
+
convs[idx] = { ...convs[idx], ...changes, id: req.params.id };
|
|
44
|
+
ctx.store.write('conversations.json', convs);
|
|
45
|
+
res.json(convs[idx]);
|
|
46
|
+
});
|
|
47
|
+
app.delete('/api/conversations/:id', (req, res) => {
|
|
48
|
+
let convs = ctx.store.read('conversations.json', []);
|
|
49
|
+
convs = convs.filter(c => c.id !== req.params.id);
|
|
50
|
+
ctx.store.write('conversations.json', convs);
|
|
51
|
+
const messageFile = `messages_${req.params.id}.json`;
|
|
52
|
+
ctx.store.remove(messageFile);
|
|
53
|
+
res.json({ ok: true });
|
|
54
|
+
});
|
|
55
|
+
// Save messages for a conversation
|
|
56
|
+
app.post('/api/conversations/:id/messages', (req, res) => {
|
|
57
|
+
const convs = ctx.store.read('conversations.json', []);
|
|
58
|
+
if (!convs.find(c => c.id === req.params.id)) return res.status(404).json({ error: 'not found' });
|
|
59
|
+
ctx.store.write(`messages_${req.params.id}.json`, req.body);
|
|
60
|
+
res.json({ ok: true });
|
|
61
|
+
});
|
|
62
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const importer = require('../extensions/importer');
|
|
4
|
+
|
|
5
|
+
module.exports = function registerExtensionImport(app) {
|
|
6
|
+
app.post('/api/extensions/import/:kind/inspect', (req, res, next) => {
|
|
7
|
+
try { res.json(importer.inspect(req.params.kind, req.body || {})); }
|
|
8
|
+
catch (error) { next(error); }
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
app.post('/api/extensions/import/:kind/install', (req, res, next) => {
|
|
12
|
+
try { res.status(201).json(importer.install(req.params.kind, req.body || {})); }
|
|
13
|
+
catch (error) { next(error); }
|
|
14
|
+
});
|
|
15
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Legacy JSON import is intentionally limited to declarative Agent records.
|
|
4
|
+
// Skills and plugins use their standard directory formats and MCP servers use
|
|
5
|
+
// /api/mcp-servers; this endpoint never accepts commands or executable config.
|
|
6
|
+
const ctx = require('../lib/context');
|
|
7
|
+
const extensions = require('../extensions/manager');
|
|
8
|
+
const { fail } = require('../lib/errors');
|
|
9
|
+
|
|
10
|
+
function normalizeAgent(raw) {
|
|
11
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('agent must be an object');
|
|
12
|
+
const id = ctx.safeId(raw.id || `ag_${Date.now().toString(36)}`, 'agent id');
|
|
13
|
+
const name = String(raw.name || id).trim();
|
|
14
|
+
const systemPrompt = String(raw.systemPrompt || '').trim();
|
|
15
|
+
if (!name || name.length > 120) throw new Error('agent name must be 1-120 characters');
|
|
16
|
+
if (systemPrompt.length > 30_000) throw new Error('systemPrompt exceeds 30000 characters');
|
|
17
|
+
const stringIds = value => Array.isArray(value) ? value.map(String).filter(item => /^[A-Za-z0-9._-]{1,128}$/.test(item)) : [];
|
|
18
|
+
const legacySkillIds = stringIds(raw.skillIds);
|
|
19
|
+
const builtinTools = ctx.store.read(ctx.SKILL_FILE, []).filter(item => !['prompt', 'mcp'].includes(item.type));
|
|
20
|
+
const builtinToolIds = new Set(builtinTools.map(item => item.id));
|
|
21
|
+
const workflowSkills = extensions.listSkills();
|
|
22
|
+
const skillRefs = Array.isArray(raw.skillRefs)
|
|
23
|
+
? stringIds(raw.skillRefs)
|
|
24
|
+
: legacySkillIds
|
|
25
|
+
.filter(item => !builtinToolIds.has(item) && item !== 'sk_mcp_time' && item !== 'sk_mcp_readfile')
|
|
26
|
+
.map(item => workflowSkills.find(skill => skill.key === item || skill.id === item || skill.name === item)?.key || item);
|
|
27
|
+
const toolIds = [...new Set([...stringIds(raw.toolIds), ...legacySkillIds.filter(item => builtinToolIds.has(item))])];
|
|
28
|
+
const mcpServerIds = [...new Set([
|
|
29
|
+
...stringIds(raw.mcpServerIds),
|
|
30
|
+
...(legacySkillIds.some(item => item === 'sk_mcp_time' || item === 'sk_mcp_readfile') ? ['multichat-demo'] : []),
|
|
31
|
+
])];
|
|
32
|
+
return {
|
|
33
|
+
id,
|
|
34
|
+
name,
|
|
35
|
+
description: String(raw.description || '').slice(0, 1000),
|
|
36
|
+
systemPrompt,
|
|
37
|
+
skillIds: legacySkillIds.filter(item => !builtinToolIds.has(item) && item !== 'sk_mcp_time' && item !== 'sk_mcp_readfile'),
|
|
38
|
+
skillRefs,
|
|
39
|
+
toolIds,
|
|
40
|
+
mcpServerIds,
|
|
41
|
+
createdAt: raw.createdAt || new Date().toISOString(),
|
|
42
|
+
_import: 'agent-json',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = function registerImport(app) {
|
|
47
|
+
app.post('/api/import', async (req, res) => {
|
|
48
|
+
const body = req.body || {};
|
|
49
|
+
if (body.url || body.format === 'zip' || body.skills || body.type || body.mcp) {
|
|
50
|
+
return fail(res, 400, 'INVALID_PACKAGE', 'Skill 请导入标准 SKILL.md 目录,Plugin 请使用 .codex-plugin/plugin.json,MCP 请使用独立 MCP server 配置');
|
|
51
|
+
}
|
|
52
|
+
let value = body;
|
|
53
|
+
if (typeof body.payload === 'string') {
|
|
54
|
+
if (body.payload.length > 2_000_000) return fail(res, 413, 'IMPORT_FAILED', '导入文件超过 2 MB');
|
|
55
|
+
try { value = JSON.parse(body.payload); }
|
|
56
|
+
catch { return fail(res, 400, 'INVALID_PACKAGE', 'payload 不是合法 JSON'); }
|
|
57
|
+
}
|
|
58
|
+
const incoming = Array.isArray(value) ? value : (Array.isArray(value.agents) ? value.agents : [value.agent || value]);
|
|
59
|
+
let normalized;
|
|
60
|
+
try { normalized = incoming.map(normalizeAgent); }
|
|
61
|
+
catch (error) { return fail(res, 400, 'INVALID_PACKAGE', error.message); }
|
|
62
|
+
await ctx.store.mutate(ctx.AGENT_FILE, agents => {
|
|
63
|
+
for (const item of normalized) {
|
|
64
|
+
const index = agents.findIndex(agent => agent.id === item.id);
|
|
65
|
+
if (index >= 0) agents[index] = item;
|
|
66
|
+
else agents.push(item);
|
|
67
|
+
}
|
|
68
|
+
return agents;
|
|
69
|
+
}, []);
|
|
70
|
+
res.json({ ok: true, agents: normalized.length, skills: 0 });
|
|
71
|
+
});
|
|
72
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MCP servers are managed independently from skills and plugins. Tool schemas
|
|
4
|
+
// are discovered from the live server via tools/list.
|
|
5
|
+
const extensions = require('../extensions/manager');
|
|
6
|
+
|
|
7
|
+
module.exports = function registerMcpServers(app) {
|
|
8
|
+
app.get('/api/mcp-servers', (req, res, next) => {
|
|
9
|
+
try { res.json(extensions.listMcpServers()); } catch (error) { next(error); }
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
app.post('/api/mcp-servers', (req, res, next) => {
|
|
13
|
+
try { res.status(201).json(extensions.createMcpServer(req.body || {})); }
|
|
14
|
+
catch (error) { next(error); }
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
app.put('/api/mcp-servers/:id', (req, res, next) => {
|
|
18
|
+
try { res.json(extensions.updateMcpServer(req.params.id, req.body || {})); }
|
|
19
|
+
catch (error) { next(error); }
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
app.delete('/api/mcp-servers/:id', (req, res, next) => {
|
|
23
|
+
try { res.json(extensions.deleteMcpServer(req.params.id)); }
|
|
24
|
+
catch (error) { next(error); }
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
app.post('/api/mcp-servers/:id/discover', async (req, res, next) => {
|
|
28
|
+
try { res.json(await extensions.discoverMcpTools(req.params.id, true)); }
|
|
29
|
+
catch (error) { next(error); }
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
app.post('/api/mcp-servers/sync/codex', (req, res, next) => {
|
|
33
|
+
try { res.json(extensions.syncCodexMcpConfig()); }
|
|
34
|
+
catch (error) { next(error); }
|
|
35
|
+
});
|
|
36
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// ── Meta:runtime / catalog / health(信息聚合端点) ─────────────────────
|
|
3
|
+
const ctx = require('../lib/context');
|
|
4
|
+
const extensions = require('../extensions/manager');
|
|
5
|
+
const { assetMeta } = require('../lib/util');
|
|
6
|
+
|
|
7
|
+
module.exports = function registerMeta(app) {
|
|
8
|
+
app.get('/api/runtime', (req, res) => {
|
|
9
|
+
const providers = ctx.providerStore.list();
|
|
10
|
+
const skills = extensions.listSkills();
|
|
11
|
+
const agents = ctx.store.read(ctx.AGENT_FILE, []);
|
|
12
|
+
const plugins = extensions.listPlugins();
|
|
13
|
+
const mcpServers = extensions.listMcpServers();
|
|
14
|
+
res.json({
|
|
15
|
+
name: 'MultiChat Agent Workspace',
|
|
16
|
+
version: ctx.VERSION,
|
|
17
|
+
node: process.version,
|
|
18
|
+
mode: process.env.NODE_ENV || 'development',
|
|
19
|
+
capabilities: ['chat', 'streaming', 'agents', 'agent-skills', 'plugins', 'mcp-discovery', 'codex-sync', 'git-diff', 'run-history', 'usage-ledger', 'context-lens', 'capability-passports', 'project-memory', 'project-snapshots'],
|
|
20
|
+
counts: { providers: providers.length, models: providers.reduce((n, p) => n + (p.models || []).length, 0), skills: skills.length, agents: agents.length, plugins: plugins.length, mcpServers: mcpServers.length, runs: ctx.store.read(ctx.RUN_FILE, []).length, workspaces: ctx.workspaceStore.workspaces().length, projects: ctx.workspaceStore.projects().length, assets: ctx.workspaceStore.assets().length },
|
|
21
|
+
adapters: Object.keys(ctx.ADAPTER_MAP),
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
app.get('/api/catalog', (req, res) => {
|
|
26
|
+
const providers = ctx.providerStore.list();
|
|
27
|
+
const skills = extensions.listSkills();
|
|
28
|
+
const tools = ctx.store.read(ctx.SKILL_FILE, []).filter(item => !['prompt', 'mcp'].includes(item.type));
|
|
29
|
+
const agents = ctx.store.read(ctx.AGENT_FILE, []);
|
|
30
|
+
const plugins = extensions.listPlugins();
|
|
31
|
+
const mcpServers = extensions.listMcpServers();
|
|
32
|
+
res.json({ providers: providers.map(ctx.providerStore.publicRecord), skills, tools, agents, plugins, mcpServers, workspaces: ctx.workspaceStore.workspaces(), projects: ctx.workspaceStore.projects(), assets: ctx.workspaceStore.assets().map(assetMeta) });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
app.get('/api/health', (req, res) => {
|
|
36
|
+
res.json({ status: 'ok', version: ctx.VERSION, uptime: process.uptime(), storage: ctx.store.kind || 'json', storageFallback: ctx.STORAGE_FALLBACK, skills: extensions.listSkills().length, mcpServers: extensions.listMcpServers().length, agents: ctx.store.read(ctx.AGENT_FILE, []).length });
|
|
37
|
+
});
|
|
38
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A deterministic, fully local OpenAI-compatible endpoint used by the
|
|
4
|
+
// built-in “Mock (本地体验)” provider. It never calls the network and makes the
|
|
5
|
+
// first-run/chat/usage UI testable without an API key.
|
|
6
|
+
module.exports = function registerMock(app) {
|
|
7
|
+
app.post('/api/mock/v1/chat/completions', (req, res) => {
|
|
8
|
+
const body = req.body || {};
|
|
9
|
+
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
10
|
+
const last = [...messages].reverse().find(item => item.role === 'user');
|
|
11
|
+
const input = typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content || '');
|
|
12
|
+
const output = input ? `本地回声:${input}` : 'MultiChat 本地体验已就绪。';
|
|
13
|
+
const promptTokens = Math.max(1, Math.ceil(JSON.stringify(messages).length / 4));
|
|
14
|
+
const completionTokens = Math.max(1, Math.ceil(output.length / 4));
|
|
15
|
+
const usage = { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens };
|
|
16
|
+
if (body.stream !== true) return res.json({ id: `mock-${Date.now()}`, model: body.model || 'echo', choices: [{ index: 0, message: { role: 'assistant', content: output }, finish_reason: 'stop' }], usage });
|
|
17
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
18
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
19
|
+
const chunks = output.match(/.{1,8}/gu) || [output];
|
|
20
|
+
for (const content of chunks) res.write(`data: ${JSON.stringify({ id: `mock-${Date.now()}`, model: body.model || 'echo', choices: [{ index: 0, delta: { content }, finish_reason: null }] })}\n\n`);
|
|
21
|
+
res.write(`data: ${JSON.stringify({ model: body.model || 'echo', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage })}\n\n`);
|
|
22
|
+
res.write('data: [DONE]\n\n');
|
|
23
|
+
res.end();
|
|
24
|
+
});
|
|
25
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Plugins are packages discovered through repo/managed marketplace.json files.
|
|
4
|
+
// Their components are referenced in place; they are never flattened into
|
|
5
|
+
// skills.json or agents.json.
|
|
6
|
+
const extensions = require('../extensions/manager');
|
|
7
|
+
|
|
8
|
+
module.exports = function registerPlugins(app) {
|
|
9
|
+
app.get('/api/plugins', (req, res, next) => {
|
|
10
|
+
try { res.json(extensions.listPlugins()); } catch (error) { next(error); }
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
app.get('/api/plugins/:id/diff', (req, res, next) => {
|
|
14
|
+
try { res.json(extensions.pluginDiff(req.params.id)); }
|
|
15
|
+
catch (error) { next(error); }
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
app.post('/api/plugins/:id/toggle', (req, res, next) => {
|
|
19
|
+
try { res.json(extensions.setPluginEnabled(req.params.id, req.body?.enabled !== false)); }
|
|
20
|
+
catch (error) { next(error); }
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
app.delete('/api/plugins/:id', (req, res, next) => {
|
|
24
|
+
try { res.json(extensions.deleteImportedPlugin(req.params.id)); }
|
|
25
|
+
catch (error) { next(error); }
|
|
26
|
+
});
|
|
27
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// ── Provider / Models 路由 ──────────────────────────────────────────────
|
|
3
|
+
// GET 接口返回脱敏后的 provider(apiKey 不出现在响应体中)。
|
|
4
|
+
const ctx = require('../lib/context');
|
|
5
|
+
const { readResponseJson, readResponseText } = require('../lib/util');
|
|
6
|
+
const { redactSecrets } = require('../lib/redact');
|
|
7
|
+
import type { JsonRecord } from '../types';
|
|
8
|
+
|
|
9
|
+
function normalizeProvider(input: JsonRecord, previous: JsonRecord | null = null) {
|
|
10
|
+
const id = previous?.id || ctx.safeId(input.id || Date.now().toString(36), 'provider id');
|
|
11
|
+
const apiType = String(input.apiType ?? previous?.apiType ?? 'openai').trim().toLowerCase();
|
|
12
|
+
if (!ctx.ADAPTER_MAP[apiType]) throw new Error('unsupported provider apiType');
|
|
13
|
+
const baseUrl = String(input.baseUrl ?? previous?.baseUrl ?? '').trim().replace(/\/+$/, '');
|
|
14
|
+
if (!baseUrl || baseUrl.length > 2048) throw new Error('baseUrl is required');
|
|
15
|
+
let parsed: URL;
|
|
16
|
+
try { parsed = new URL(baseUrl); } catch { throw new Error('baseUrl must be a valid URL'); }
|
|
17
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
|
|
18
|
+
throw new Error('baseUrl must be an http(s) URL without embedded credentials');
|
|
19
|
+
}
|
|
20
|
+
if ([...parsed.searchParams.keys()].some(key => /(?:api[-_]?key|access[-_]?token|token|secret|password|authorization|credential)/i.test(key))) {
|
|
21
|
+
throw new Error('baseUrl must not contain credential query parameters');
|
|
22
|
+
}
|
|
23
|
+
const modelInput = input.models ?? previous?.models;
|
|
24
|
+
const models: string[] = Array.isArray(modelInput)
|
|
25
|
+
? [...new Set(modelInput.map(value => String(value).trim()).filter(Boolean))].slice(0, 200)
|
|
26
|
+
: [];
|
|
27
|
+
if (models.some(model => model.length > 200)) throw new Error('model name is too long');
|
|
28
|
+
return {
|
|
29
|
+
id,
|
|
30
|
+
name: String(input.name ?? previous?.name ?? id).trim().slice(0, 120) || id,
|
|
31
|
+
apiType,
|
|
32
|
+
baseUrl,
|
|
33
|
+
apiKey: input.apiKey !== undefined ? String(input.apiKey || '') : String(previous?.apiKey || ''),
|
|
34
|
+
models,
|
|
35
|
+
allowPrivate: input.allowPrivate !== undefined
|
|
36
|
+
? input.allowPrivate === true
|
|
37
|
+
: (previous?.allowPrivate === true || ['ollama', 'lmstudio'].includes(apiType)),
|
|
38
|
+
updatedAt: new Date().toISOString(),
|
|
39
|
+
createdAt: previous?.createdAt || new Date().toISOString(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = function registerProviders(app) {
|
|
44
|
+
app.get('/api/providers', (req, res) => {
|
|
45
|
+
res.json(ctx.providerStore.publicList());
|
|
46
|
+
});
|
|
47
|
+
app.post('/api/providers', (req, res) => {
|
|
48
|
+
let provider;
|
|
49
|
+
try { provider = normalizeProvider(req.body || {}); }
|
|
50
|
+
catch (error) { return res.status(400).json({ error: error.message }); }
|
|
51
|
+
const providers = ctx.providerStore.list();
|
|
52
|
+
if (providers.some(item => item.id === provider.id)) return res.status(409).json({ error: 'provider id already exists' });
|
|
53
|
+
providers.push(provider);
|
|
54
|
+
ctx.providerStore.save(providers);
|
|
55
|
+
res.json(ctx.providerStore.publicRecord(provider));
|
|
56
|
+
});
|
|
57
|
+
app.put('/api/providers/:id', (req, res) => {
|
|
58
|
+
const providers = ctx.providerStore.list();
|
|
59
|
+
const idx = providers.findIndex(p => p.id === req.params.id);
|
|
60
|
+
if (idx < 0) return res.status(404).json({ error: 'not found' });
|
|
61
|
+
const body = req.body || {};
|
|
62
|
+
const prev = providers[idx];
|
|
63
|
+
// 未提供新密钥(或回传 masked 占位)时保留原 apiKey,避免被掩码覆盖
|
|
64
|
+
let apiKey = prev.apiKey;
|
|
65
|
+
if (body.apiKey && !body.apiKeyMasked && body.apiKey !== '••••••••') apiKey = body.apiKey;
|
|
66
|
+
let updated;
|
|
67
|
+
try { updated = normalizeProvider({ ...body, apiKey }, prev); }
|
|
68
|
+
catch (error) { return res.status(400).json({ error: error.message }); }
|
|
69
|
+
providers[idx] = updated;
|
|
70
|
+
ctx.providerStore.save(providers);
|
|
71
|
+
res.json(ctx.providerStore.publicRecord(updated));
|
|
72
|
+
});
|
|
73
|
+
app.delete('/api/providers/:id', (req, res) => {
|
|
74
|
+
let providers = ctx.providerStore.list();
|
|
75
|
+
providers = providers.filter(p => p.id !== req.params.id);
|
|
76
|
+
ctx.providerStore.save(providers);
|
|
77
|
+
res.json({ ok: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── Models: list all models grouped by provider ──
|
|
81
|
+
app.get('/api/models', (req, res) => {
|
|
82
|
+
const providers = ctx.providerStore.list();
|
|
83
|
+
const models = [];
|
|
84
|
+
for (const p of providers) {
|
|
85
|
+
(p.models || []).forEach(m => {
|
|
86
|
+
models.push({ id: `${p.id}:${m}`, name: m, providerId: p.id, providerName: p.name });
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
res.json(models);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── 连接测试:用服务端保存的密钥探测 OpenAI 兼容的 GET /models,返回可达性与模型清单 ──
|
|
93
|
+
app.post('/api/providers/:id/probe', async (req, res) => {
|
|
94
|
+
const providers = ctx.providerStore.list();
|
|
95
|
+
const provider = providers.find(p => p.id === req.params.id);
|
|
96
|
+
if (!provider) return res.status(404).json({ error: 'provider not found', code: 'NOT_FOUND' });
|
|
97
|
+
const baseUrl = String(provider.baseUrl || '').replace(/\/+$/, '');
|
|
98
|
+
let parsed: URL;
|
|
99
|
+
try { parsed = new URL(baseUrl); } catch { return res.status(400).json({ error: 'provider baseUrl 无效' }); }
|
|
100
|
+
const modelsUrl = `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}/models`;
|
|
101
|
+
const headers: Record<string, string> = { accept: 'application/json' };
|
|
102
|
+
if (provider.apiKey) headers.authorization = `Bearer ${provider.apiKey}`;
|
|
103
|
+
try {
|
|
104
|
+
const resp = await ctx.safeFetch(modelsUrl, { headers, signal: AbortSignal.timeout(8000) }, provider.allowPrivate === true);
|
|
105
|
+
if (!resp.ok) {
|
|
106
|
+
const detail = await readResponseText(resp).catch(() => '');
|
|
107
|
+
const brief = redactSecrets(String(detail || '').slice(0, 200));
|
|
108
|
+
return res.status(resp.status).json({ ok: false, error: `HTTP ${resp.status}${brief ? `:${brief}` : ''}` });
|
|
109
|
+
}
|
|
110
|
+
const data = await readResponseJson(resp, 2_000_000) as JsonRecord;
|
|
111
|
+
const raw = Array.isArray(data.data) ? data.data : Array.isArray(data.models) ? data.models : [];
|
|
112
|
+
const models = raw.map((m: JsonRecord) => String(m?.id || m?.name || '')).filter(Boolean);
|
|
113
|
+
const capabilities = {
|
|
114
|
+
vision: models.some(m => /(?:vision|vl|omni|multimodal|4o|gpt-4-turbo|gemini|claude-[35])/i.test(m)),
|
|
115
|
+
reasoning: models.some(m => /(?:reason|think|o[1345]-|r1|qwq)/i.test(m)),
|
|
116
|
+
};
|
|
117
|
+
res.json({ ok: true, models, capabilities, modelCount: models.length });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
120
|
+
const causeCode = (error as { cause?: { code?: string } })?.cause?.code || '';
|
|
121
|
+
const timedOut = /timeout|abort/i.test(raw);
|
|
122
|
+
const friendly = timedOut ? '连接超时(8s)——请确认 baseUrl 可达'
|
|
123
|
+
: /fetch failed/i.test(raw) ? `无法连接到 ${parsed.host}${causeCode ? `(${causeCode})` : ''}`
|
|
124
|
+
: redactSecrets(raw);
|
|
125
|
+
res.status(502).json({ ok: false, error: friendly });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ── Local model discovery (Ollama / LM Studio) ──
|
|
130
|
+
app.post('/api/fetch-local-models', async (req, res) => {
|
|
131
|
+
const { apiType, baseUrl } = req.body || {};
|
|
132
|
+
if (!apiType || !baseUrl) {
|
|
133
|
+
return res.status(400).json({ error: 'apiType and baseUrl are required' });
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
let parsed: URL;
|
|
137
|
+
try { parsed = new URL(baseUrl); } catch { return res.status(400).json({ error: 'baseUrl must be a valid URL' }); }
|
|
138
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
139
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !['localhost', '127.0.0.1', '::1'].includes(hostname)) {
|
|
140
|
+
return res.status(400).json({ error: 'local model discovery only accepts loopback addresses' });
|
|
141
|
+
}
|
|
142
|
+
if (!['ollama', 'lmstudio'].includes(String(apiType).toLowerCase())) {
|
|
143
|
+
return res.status(400).json({ error: `Unsupported apiType for local discovery: ${apiType}` });
|
|
144
|
+
}
|
|
145
|
+
if (apiType === 'ollama') {
|
|
146
|
+
const url = new URL('/api/tags', parsed.origin).toString();
|
|
147
|
+
const resp = await ctx.safeFetch(url, { signal: AbortSignal.timeout(5000) }, true);
|
|
148
|
+
if (!resp.ok) return res.status(resp.status).json({ error: `Ollama returned HTTP ${resp.status}` });
|
|
149
|
+
const data = await readResponseJson(resp, 2_000_000) as JsonRecord;
|
|
150
|
+
const models = (data.models || []).map(m => m.name || m.model).filter(Boolean);
|
|
151
|
+
return res.json({ models });
|
|
152
|
+
}
|
|
153
|
+
if (apiType === 'lmstudio') {
|
|
154
|
+
const base = `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
|
|
155
|
+
const url = `${base}${/\/v1$/i.test(base) ? '' : '/v1'}/models`;
|
|
156
|
+
const resp = await ctx.safeFetch(url, { signal: AbortSignal.timeout(5000) }, true);
|
|
157
|
+
if (!resp.ok) return res.status(resp.status).json({ error: `LM Studio returned HTTP ${resp.status}` });
|
|
158
|
+
const data = await readResponseJson(resp, 2_000_000) as JsonRecord;
|
|
159
|
+
const models = (data.data || []).map(m => m.id).filter(Boolean);
|
|
160
|
+
return res.json({ models });
|
|
161
|
+
}
|
|
162
|
+
} catch (e) {
|
|
163
|
+
res.status(502).json({ error: `Cannot connect to the local model service: ${redactSecrets(e.message)}` });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
};
|