@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,697 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
// ── Chat:OpenAI 兼容端点 + Agent 流式工具循环 ──────────────────────────
|
|
4
|
+
// /v1/chat/completions 与 /api/agents/:id/chat 共用 adapters + agent runtime。
|
|
5
|
+
const ctx = require('../lib/context');
|
|
6
|
+
const agent = require('../runtime/agent');
|
|
7
|
+
const extensions = require('../extensions/manager');
|
|
8
|
+
const usageLedger = require('../lib/usage');
|
|
9
|
+
const { readResponseText, readResponseJson } = require('../lib/util');
|
|
10
|
+
const { redactSecrets } = require('../lib/redact');
|
|
11
|
+
const { SSEDecoder } = require('../lib/sse');
|
|
12
|
+
function configuredProvider(providerId, modelName) {
|
|
13
|
+
const providers = ctx.providerStore.list();
|
|
14
|
+
if (providerId) {
|
|
15
|
+
const provider = providers.find(item => item.id === providerId) || null;
|
|
16
|
+
if (provider?.id === 'mock' && /(?:127\.0\.0\.1|localhost):3099/i.test(String(provider.baseUrl || ''))) {
|
|
17
|
+
return { ...provider, apiType: 'openai', baseUrl: `http://127.0.0.1:${ctx.PORT}/api/mock`, allowPrivate: true };
|
|
18
|
+
}
|
|
19
|
+
return provider;
|
|
20
|
+
}
|
|
21
|
+
const matches = providers.filter(provider => (provider.models || []).includes(modelName));
|
|
22
|
+
return matches.length === 1 ? matches[0] : null;
|
|
23
|
+
}
|
|
24
|
+
function allowPrivateProvider(provider) {
|
|
25
|
+
return provider.allowPrivate === true || ['ollama', 'lmstudio'].includes(String(provider.apiType || '').toLowerCase());
|
|
26
|
+
}
|
|
27
|
+
function usageContext(body, provider, modelName, extra = {}) {
|
|
28
|
+
return {
|
|
29
|
+
providerId: provider.id,
|
|
30
|
+
providerName: provider.name || provider.id,
|
|
31
|
+
model: modelName,
|
|
32
|
+
workspaceId: body.workspaceId || null,
|
|
33
|
+
projectId: body.projectId || null,
|
|
34
|
+
conversationId: body.conversationId || null,
|
|
35
|
+
...extra,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
module.exports = function registerChat(app) {
|
|
39
|
+
// ── OpenAI 兼容统一端点(透传上游,做 SSE 适配) ──
|
|
40
|
+
app.post('/v1/chat/completions', async (req, res) => {
|
|
41
|
+
const body = req.body;
|
|
42
|
+
const requestStartedAt = new Date().toISOString();
|
|
43
|
+
const requestStartedMs = Date.now();
|
|
44
|
+
const requestedModel = body.model || '';
|
|
45
|
+
// model format: "providerId:modelName" or plain "modelName"
|
|
46
|
+
// modelName may contain colons (e.g. Ollama: "llama3:latest")
|
|
47
|
+
const explicitProviderId = String(body.providerId || body.provider || '').trim();
|
|
48
|
+
const colonIdx = requestedModel.indexOf(':');
|
|
49
|
+
let providerId, modelName;
|
|
50
|
+
if (explicitProviderId) {
|
|
51
|
+
providerId = explicitProviderId;
|
|
52
|
+
modelName = colonIdx > 0 && requestedModel.substring(0, colonIdx) === explicitProviderId
|
|
53
|
+
? requestedModel.substring(colonIdx + 1)
|
|
54
|
+
: requestedModel;
|
|
55
|
+
}
|
|
56
|
+
else if (colonIdx > 0) {
|
|
57
|
+
providerId = requestedModel.substring(0, colonIdx);
|
|
58
|
+
modelName = requestedModel.substring(colonIdx + 1);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
modelName = requestedModel;
|
|
62
|
+
providerId = null;
|
|
63
|
+
}
|
|
64
|
+
// Provider credentials and network policy are server-owned. Never trust a
|
|
65
|
+
// browser-supplied _provider/baseUrl/apiKey override.
|
|
66
|
+
const provider = configuredProvider(providerId, modelName);
|
|
67
|
+
if (!provider && providerId) {
|
|
68
|
+
return res.status(400).json({ error: { message: `Provider "${providerId}" not found` } });
|
|
69
|
+
}
|
|
70
|
+
if (!provider)
|
|
71
|
+
return res.status(400).json({ error: { message: 'No uniquely configured provider matches this model' } });
|
|
72
|
+
const stream = body.stream === true;
|
|
73
|
+
try {
|
|
74
|
+
const adapter = ctx.createAdapter(provider);
|
|
75
|
+
await adapter.prepare();
|
|
76
|
+
const upstreamUrl = adapter.getEndpoint();
|
|
77
|
+
// 透传 OpenAI 标准字段(保持 /v1/chat/completions 协议完整兼容)
|
|
78
|
+
const PASS_THROUGH = ['temperature', 'max_tokens', 'top_p', 'tools', 'tool_choice',
|
|
79
|
+
'stream_options', 'response_format', 'logprobs', 'stop', 'frequency_penalty',
|
|
80
|
+
'presence_penalty', 'seed', 'n', 'user', 'parallel_tool_calls'];
|
|
81
|
+
const extra = {};
|
|
82
|
+
for (const k of PASS_THROUGH) {
|
|
83
|
+
if (body[k] !== undefined)
|
|
84
|
+
extra[k] = body[k];
|
|
85
|
+
}
|
|
86
|
+
if (body.tools && body.tool_choice === undefined)
|
|
87
|
+
extra.tool_choice = 'auto';
|
|
88
|
+
// 始终要求上游在流式末尾返回 usage 块(OpenAI 兼容协议;非 OpenAI provider 忽略该字段也无副作用)
|
|
89
|
+
extra.stream_options = Object.assign({ include_usage: true }, extra.stream_options || {});
|
|
90
|
+
// 普通模式也支持「文件上下文」:把当前项目被选中的资产内容注入 system,
|
|
91
|
+
// 与 agent 模式(buildAssetContext)保持一致,由前端勾选 assetIds 决定注入哪些文件。
|
|
92
|
+
let messages = body.messages;
|
|
93
|
+
if (body.projectId) {
|
|
94
|
+
const query = [...(body.messages || [])].reverse().find(message => message.role === 'user')?.content || '';
|
|
95
|
+
const assetCtx = agent.buildAssetContext(body.projectId, body.assetIds, query);
|
|
96
|
+
const memoryCtx = agent.buildMemoryContext(body.projectId);
|
|
97
|
+
const projectContext = [memoryCtx, assetCtx].filter(Boolean).join('\n\n');
|
|
98
|
+
if (projectContext)
|
|
99
|
+
messages = [{ role: 'system', content: projectContext }].concat(messages);
|
|
100
|
+
}
|
|
101
|
+
const upstreamBody = adapter.buildRequestBody(modelName, messages, stream, extra);
|
|
102
|
+
const upstreamAbort = new AbortController();
|
|
103
|
+
const onDisconnect = () => upstreamAbort.abort();
|
|
104
|
+
res.once('close', onDisconnect);
|
|
105
|
+
const signal = typeof AbortSignal.any === 'function'
|
|
106
|
+
? AbortSignal.any([AbortSignal.timeout(180000), upstreamAbort.signal])
|
|
107
|
+
: upstreamAbort.signal;
|
|
108
|
+
const upstream = await ctx.safeFetch(upstreamUrl, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: adapter.getHeaders(),
|
|
111
|
+
body: JSON.stringify(upstreamBody),
|
|
112
|
+
signal,
|
|
113
|
+
}, allowPrivateProvider(provider));
|
|
114
|
+
if (!upstream.ok) {
|
|
115
|
+
const errText = await readResponseText(upstream, 128_000);
|
|
116
|
+
await usageLedger.recordUsage(ctx.store, usageContext(body, provider, modelName, {
|
|
117
|
+
timestamp: requestStartedAt, durationMs: Date.now() - requestStartedMs,
|
|
118
|
+
interactionId: body.interactionId || res.locals?.requestId, mode: 'chat', status: 'error', errorCode: `HTTP_${upstream.status}`,
|
|
119
|
+
usage: { input: 0, output: 0, total: 0, source: 'estimated' },
|
|
120
|
+
}));
|
|
121
|
+
return res.status(upstream.status).type('application/json').send(redactSecrets(errText));
|
|
122
|
+
}
|
|
123
|
+
if (stream) {
|
|
124
|
+
// SSE with adapter transformation
|
|
125
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
126
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
127
|
+
res.setHeader('Connection', 'keep-alive');
|
|
128
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
129
|
+
const reader = upstream.body.getReader();
|
|
130
|
+
const decoder = new TextDecoder();
|
|
131
|
+
const sse = new SSEDecoder();
|
|
132
|
+
let observedUsage = {};
|
|
133
|
+
let output = '';
|
|
134
|
+
(async () => {
|
|
135
|
+
let status = 'success';
|
|
136
|
+
try {
|
|
137
|
+
while (true) {
|
|
138
|
+
const { done, value } = await reader.read();
|
|
139
|
+
if (done)
|
|
140
|
+
break;
|
|
141
|
+
for (const line of sse.push(decoder.decode(value, { stream: true }))) {
|
|
142
|
+
const transformed = adapter.transformSSEChunk(line);
|
|
143
|
+
if (transformed) {
|
|
144
|
+
if (transformed.usage)
|
|
145
|
+
observedUsage = usageLedger.mergeUsage(observedUsage, transformed.usage);
|
|
146
|
+
for (const choice of transformed.choices || []) {
|
|
147
|
+
output += choice?.delta?.content || choice?.message?.content || '';
|
|
148
|
+
}
|
|
149
|
+
res.write(`data: ${JSON.stringify(transformed)}\n\n`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const line of sse.flush()) {
|
|
154
|
+
const transformed = adapter.transformSSEChunk(line);
|
|
155
|
+
if (!transformed)
|
|
156
|
+
continue;
|
|
157
|
+
if (transformed.usage)
|
|
158
|
+
observedUsage = usageLedger.mergeUsage(observedUsage, transformed.usage);
|
|
159
|
+
for (const choice of transformed.choices || [])
|
|
160
|
+
output += choice?.delta?.content || choice?.message?.content || '';
|
|
161
|
+
res.write(`data: ${JSON.stringify(transformed)}\n\n`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
status = upstreamAbort.signal.aborted ? 'cancelled' : 'error';
|
|
166
|
+
console.error('Stream read error:', redactSecrets(e.message));
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
const normalized = usageLedger.normalizeUsage(observedUsage, messages, output);
|
|
170
|
+
await usageLedger.recordUsage(ctx.store, usageContext(body, provider, modelName, {
|
|
171
|
+
timestamp: requestStartedAt, durationMs: Date.now() - requestStartedMs,
|
|
172
|
+
interactionId: body.interactionId || res.locals?.requestId, mode: 'chat', status,
|
|
173
|
+
usage: normalized,
|
|
174
|
+
}));
|
|
175
|
+
if (!res.writableEnded && !res.destroyed) {
|
|
176
|
+
res.write('data: [DONE]\n\n');
|
|
177
|
+
res.end();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
const data = await readResponseJson(upstream, 20_000_000);
|
|
184
|
+
const transformed = adapter.transformResponse(data);
|
|
185
|
+
const output = transformed?.choices?.[0]?.message?.content || '';
|
|
186
|
+
await usageLedger.recordUsage(ctx.store, usageContext(body, provider, modelName, {
|
|
187
|
+
timestamp: requestStartedAt, durationMs: Date.now() - requestStartedMs,
|
|
188
|
+
interactionId: body.interactionId || res.locals?.requestId, mode: 'chat', status: 'success',
|
|
189
|
+
usage: usageLedger.normalizeUsage(transformed.usage, messages, output),
|
|
190
|
+
}));
|
|
191
|
+
res.json(transformed);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
const safeMessage = redactSecrets(err.message);
|
|
196
|
+
console.error('Upstream error:', safeMessage);
|
|
197
|
+
await usageLedger.recordUsage(ctx.store, usageContext(body, provider, modelName, {
|
|
198
|
+
timestamp: requestStartedAt, durationMs: Date.now() - requestStartedMs,
|
|
199
|
+
interactionId: body.interactionId || res.locals?.requestId, mode: 'chat', status: err.name === 'AbortError' ? 'cancelled' : 'error', errorCode: err.name || 'UPSTREAM_ERROR',
|
|
200
|
+
usage: { input: 0, output: 0, total: 0, source: 'estimated' },
|
|
201
|
+
}));
|
|
202
|
+
if (res.headersSent || res.destroyed)
|
|
203
|
+
return;
|
|
204
|
+
res.status(502).json({ error: { message: `Upstream request failed: ${safeMessage}` } });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
// ── Agent chat: inject system + tools, run tool-call loop (streaming) ─
|
|
208
|
+
app.post('/api/agents/:id/chat', (req, res, next) => {
|
|
209
|
+
void (async () => {
|
|
210
|
+
const agents = ctx.store.read(ctx.AGENT_FILE, []);
|
|
211
|
+
const found = agents.find(a => a.id === req.params.id);
|
|
212
|
+
if (!found)
|
|
213
|
+
return res.status(404).json({ error: { message: 'Agent not found: ' + req.params.id } });
|
|
214
|
+
const body = req.body || {};
|
|
215
|
+
const requestedModel = body.model || '';
|
|
216
|
+
const explicitProviderId = String(body.providerId || body.provider || '').trim();
|
|
217
|
+
const colonIdx = requestedModel.indexOf(':');
|
|
218
|
+
let providerId, modelName;
|
|
219
|
+
if (explicitProviderId) {
|
|
220
|
+
providerId = explicitProviderId;
|
|
221
|
+
modelName = colonIdx > 0 && requestedModel.substring(0, colonIdx) === explicitProviderId
|
|
222
|
+
? requestedModel.substring(colonIdx + 1)
|
|
223
|
+
: requestedModel;
|
|
224
|
+
}
|
|
225
|
+
else if (colonIdx > 0) {
|
|
226
|
+
providerId = requestedModel.substring(0, colonIdx);
|
|
227
|
+
modelName = requestedModel.substring(colonIdx + 1);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
modelName = requestedModel;
|
|
231
|
+
providerId = null;
|
|
232
|
+
}
|
|
233
|
+
const provider = configuredProvider(providerId, modelName);
|
|
234
|
+
if (!provider)
|
|
235
|
+
return res.status(400).json({ error: { message: 'No provider specified' } });
|
|
236
|
+
const legacyCapabilities = ctx.store.read(ctx.SKILL_FILE, []);
|
|
237
|
+
const workflowSkills = extensions.listSkills();
|
|
238
|
+
const skillRefs = found.skillRefs || found.skillIds || [];
|
|
239
|
+
const selectedWorkflowSkills = skillRefs.map(id => workflowSkills.find(s => s.key === id || s.id === id || s.name === id)).filter(s => s && s.enabled);
|
|
240
|
+
// Backward compatibility: old agents kept built-in tool IDs in skillIds.
|
|
241
|
+
const toolIds = new Set([...(found.toolIds || []), ...(found.skillIds || []).filter(id => legacyCapabilities.some(s => s.id === id && !['prompt', 'mcp'].includes(s.type)))]);
|
|
242
|
+
const enabledTools = [...toolIds].map(id => legacyCapabilities.find(s => s.id === id)).filter(s => s && s.enabled && !['prompt', 'mcp'].includes(s.type));
|
|
243
|
+
const workflowRuntime = agent.buildWorkflowTools(selectedWorkflowSkills);
|
|
244
|
+
const stream = body.stream !== false; // 默认 true
|
|
245
|
+
if (!stream)
|
|
246
|
+
return res.status(400).json({ error: { message: 'Only streaming mode is supported for agent chat' } });
|
|
247
|
+
const resumeSource = body.resumeFromRunId
|
|
248
|
+
? ctx.store.read(ctx.RUN_FILE, []).find(item => item.id === body.resumeFromRunId && item.agentId === found.id)
|
|
249
|
+
: null;
|
|
250
|
+
if (body.resumeFromRunId && (!resumeSource || resumeSource.status !== 'paused' || !resumeSource.checkpoint?.messages)) {
|
|
251
|
+
return res.status(409).json({ error: { message: 'run cannot be resumed from its current state' }, code: 'RUN_NOT_RESUMABLE' });
|
|
252
|
+
}
|
|
253
|
+
const run = resumeSource
|
|
254
|
+
? { ...resumeSource, status: 'running', finishedAt: null, error: null, pauseReason: null, resumedAt: new Date().toISOString() }
|
|
255
|
+
: await agent.createRun(found, body);
|
|
256
|
+
run.model = modelName;
|
|
257
|
+
run.provider = { id: provider.id, name: provider.name || provider.id, apiType: provider.apiType || 'openai' };
|
|
258
|
+
run.usage ||= { inputTokens: 0, outputTokens: 0, totalTokens: 0, cachedTokens: 0, reasoningTokens: 0, reportedTokens: 0, estimatedTokens: 0 };
|
|
259
|
+
const maxIterations = Math.max(1, Math.min(30, Number(body.maxIterations ?? found.maxIterations ?? process.env.AGENT_MAX_ITERATIONS ?? 12) || 12));
|
|
260
|
+
run.maxIterations = maxIterations;
|
|
261
|
+
if (resumeSource)
|
|
262
|
+
await agent.persistRun(run);
|
|
263
|
+
// 注册取消句柄:前端停止时可真正中断上游 fetch 与 MCP 子进程
|
|
264
|
+
const runAbort = new AbortController();
|
|
265
|
+
ctx.runAborts.set(run.id, runAbort);
|
|
266
|
+
const clearAbort = () => { ctx.runAborts.delete(run.id); };
|
|
267
|
+
const nowIso = () => new Date().toISOString();
|
|
268
|
+
const emit = (type, step) => res.write('data: ' + JSON.stringify({ agentEvent: { type, step } }) + '\n\n');
|
|
269
|
+
// 结构化轨迹:一次用户请求 = 一个 Turn;Turn 内含若干 Step
|
|
270
|
+
const turn = { turn: (run.turns?.length || 0) + 1, startedAt: nowIso(), finishedAt: null, steps: [] };
|
|
271
|
+
run.turns = [...(run.turns || []), turn];
|
|
272
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
273
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
274
|
+
res.setHeader('Connection', 'keep-alive');
|
|
275
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
276
|
+
res.on('close', () => {
|
|
277
|
+
if (!res.writableEnded)
|
|
278
|
+
runAbort.abort();
|
|
279
|
+
clearAbort();
|
|
280
|
+
});
|
|
281
|
+
// Send the run id before MCP discovery so the UI can cancel a slow or
|
|
282
|
+
// unreachable server while initialization/tools-list is in progress.
|
|
283
|
+
res.write('data: ' + JSON.stringify({ meta: { agent: { id: found.id, name: found.name }, run, mcpWarnings: [] } }) + '\n\n');
|
|
284
|
+
let mcpRuntime;
|
|
285
|
+
try {
|
|
286
|
+
mcpRuntime = await extensions.runtimeMcpTools(found.mcpServerIds || [], { signal: runAbort.signal });
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
const cancelled = runAbort.signal.aborted || error.name === 'AbortError';
|
|
290
|
+
run.status = cancelled ? 'cancelled' : 'error';
|
|
291
|
+
run.error = cancelled ? 'cancelled by user' : ('MCP discovery failed: ' + error.message);
|
|
292
|
+
turn.finishedAt = nowIso();
|
|
293
|
+
await agent.finishRun(run, { status: run.status, error: run.error, turns: run.turns });
|
|
294
|
+
if (!res.writableEnded) {
|
|
295
|
+
res.write('data: ' + JSON.stringify({ error: { message: run.error } }) + '\n\n');
|
|
296
|
+
res.write('data: [DONE]\n\n');
|
|
297
|
+
res.end();
|
|
298
|
+
}
|
|
299
|
+
clearAbort();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const tools = [...agent.buildToolsFor(enabledTools), ...workflowRuntime.definitions, ...mcpRuntime.definitions];
|
|
303
|
+
const mcpWarningText = mcpRuntime.errors.length ? '[Unavailable MCP servers]\n' + mcpRuntime.errors.map(item => `- ${item.name}: ${item.error}`).join('\n') : '';
|
|
304
|
+
const userQuery = [...(body.messages || [])].reverse().find(message => message.role === 'user')?.content || '';
|
|
305
|
+
const assetContext = agent.buildAssetContext(body.projectId, body.assetIds, userQuery);
|
|
306
|
+
const memoryContext = agent.buildMemoryContext(body.projectId);
|
|
307
|
+
const systemParts = [agent.buildAgentSystemText(found, selectedWorkflowSkills), mcpWarningText, memoryContext, assetContext].filter(Boolean);
|
|
308
|
+
const systemText = systemParts.join('\n\n');
|
|
309
|
+
const selectedAssetIds = Array.isArray(body.assetIds) ? new Set(body.assetIds) : null;
|
|
310
|
+
const selectedAssets = body.projectId
|
|
311
|
+
? ctx.workspaceStore.assets().filter(item => item.projectId === body.projectId && (!selectedAssetIds || selectedAssetIds.has(item.id)))
|
|
312
|
+
: [];
|
|
313
|
+
run.contextManifest = {
|
|
314
|
+
workspaceId: body.workspaceId || null,
|
|
315
|
+
projectId: body.projectId || null,
|
|
316
|
+
conversationId: body.conversationId || null,
|
|
317
|
+
messages: Array.isArray(body.messages) ? body.messages.length : 0,
|
|
318
|
+
systemCharacters: systemText.length,
|
|
319
|
+
assets: selectedAssets.map(item => ({ id: item.id, name: item.name, size: item.size })),
|
|
320
|
+
memories: memoryContext ? ctx.store.read('memories.json', []).filter(item => item.projectId === body.projectId && item.enabled !== false).map(item => ({ id: item.id, title: item.title })) : [],
|
|
321
|
+
skills: selectedWorkflowSkills.map(item => ({ id: item.key || item.id, name: item.name, source: item.source?.kind || 'unknown' })),
|
|
322
|
+
tools: enabledTools.map(item => ({ id: item.id, name: item.name, permissions: agent.skillPermissions(item) })),
|
|
323
|
+
mcpServers: (found.mcpServerIds || []).map(id => ({ id })),
|
|
324
|
+
mcpWarnings: mcpRuntime.errors,
|
|
325
|
+
};
|
|
326
|
+
await agent.persistRun(run);
|
|
327
|
+
if (mcpRuntime.errors.length) {
|
|
328
|
+
res.write('data: ' + JSON.stringify({ meta: { agent: { id: found.id, name: found.name }, run, mcpWarnings: mcpRuntime.errors } }) + '\n\n');
|
|
329
|
+
}
|
|
330
|
+
const toolPolicy = body.toolPolicy === 'safe' ? 'safe' : 'auto';
|
|
331
|
+
let messages = resumeSource
|
|
332
|
+
? resumeSource.checkpoint.messages.map(message => ({ ...message }))
|
|
333
|
+
: (Array.isArray(body.messages) ? body.messages.map(m => ({ role: m.role, content: m.content })) : []);
|
|
334
|
+
if (!resumeSource && systemText)
|
|
335
|
+
messages = [{ role: 'system', content: systemText }, ...messages];
|
|
336
|
+
// 透传 OpenAI 字段
|
|
337
|
+
const PASS_THROUGH = ['temperature', 'max_tokens', 'top_p', 'stream_options', 'response_format', 'logprobs', 'stop', 'frequency_penalty', 'presence_penalty', 'seed', 'n', 'user', 'parallel_tool_calls'];
|
|
338
|
+
const extra = {};
|
|
339
|
+
for (const k of PASS_THROUGH)
|
|
340
|
+
if (body[k] !== undefined)
|
|
341
|
+
extra[k] = body[k];
|
|
342
|
+
// 始终要求上游在流式末尾返回 usage 块
|
|
343
|
+
extra.stream_options = Object.assign({ include_usage: true }, extra.stream_options || {});
|
|
344
|
+
extra.parallel_tool_calls = false;
|
|
345
|
+
try {
|
|
346
|
+
const adapter = ctx.createAdapter(provider);
|
|
347
|
+
await adapter.prepare();
|
|
348
|
+
const upstreamUrl = adapter.getEndpoint();
|
|
349
|
+
const adapterHeaders = adapter.getHeaders();
|
|
350
|
+
if (resumeSource?.checkpoint?.pendingApproval) {
|
|
351
|
+
const pending = resumeSource.checkpoint.pendingApproval;
|
|
352
|
+
const approval = (run.approvals || []).find(item => item.id === pending.approvalId);
|
|
353
|
+
const skill = workflowRuntime.calls.get(pending.toolName) || mcpRuntime.calls.get(pending.toolName)?.skill || agent.findSkillForTool(pending.toolName, enabledTools);
|
|
354
|
+
let result;
|
|
355
|
+
if (approval?.status === 'approved' && skill)
|
|
356
|
+
result = await agent.executeSkill(skill, pending.args || {}, runAbort.signal);
|
|
357
|
+
else
|
|
358
|
+
result = { ok: false, error: approval?.status === 'rejected' ? 'rejected by user' : 'approval was not granted', approvalId: pending.approvalId };
|
|
359
|
+
const resultText = typeof result === 'string' ? result : JSON.stringify(result);
|
|
360
|
+
messages.push({ role: 'tool', tool_call_id: pending.toolCallId, content: resultText });
|
|
361
|
+
for (const deferred of pending.deferredToolCalls || []) {
|
|
362
|
+
messages.push({ role: 'tool', tool_call_id: deferred.id, content: JSON.stringify({ ok: false, error: 'deferred across approval boundary; request this tool again if still needed' }) });
|
|
363
|
+
}
|
|
364
|
+
const previousStep = (run.turns || []).flatMap(item => item.steps || []).find(item => item.approvalId === pending.approvalId);
|
|
365
|
+
if (previousStep) {
|
|
366
|
+
previousStep.status = result?.ok ? 'success' : 'rejected';
|
|
367
|
+
previousStep.approvalStatus = approval?.status || 'cancelled';
|
|
368
|
+
previousStep.finishedAt = nowIso();
|
|
369
|
+
previousStep.result = resultText.slice(0, 500);
|
|
370
|
+
}
|
|
371
|
+
run.checkpoint = { messages, savedAt: nowIso(), iteration: resumeSource.checkpoint.iteration || 0 };
|
|
372
|
+
await agent.persistRun(run);
|
|
373
|
+
res.write('data: ' + JSON.stringify({ agentEvent: { type: 'approval_resolved', approval } }) + '\n\n');
|
|
374
|
+
res.write('data: ' + JSON.stringify({ choices: [{ delta: { tool_result: { id: pending.toolCallId, name: pending.toolName, content: resultText } } }] }) + '\n\n');
|
|
375
|
+
}
|
|
376
|
+
let iter = 0;
|
|
377
|
+
while (iter++ < maxIterations) {
|
|
378
|
+
run.steps = iter;
|
|
379
|
+
// ── Step: 模型请求(结构化轨迹 + 实时事件)──
|
|
380
|
+
const modelStep = {
|
|
381
|
+
sid: 't' + turn.turn + '-s' + iter + '-model',
|
|
382
|
+
kind: 'model_request',
|
|
383
|
+
index: iter,
|
|
384
|
+
status: 'running',
|
|
385
|
+
startedAt: nowIso(),
|
|
386
|
+
model: modelName,
|
|
387
|
+
toolCount: tools.length,
|
|
388
|
+
messageCount: messages.length,
|
|
389
|
+
};
|
|
390
|
+
turn.steps.push(modelStep);
|
|
391
|
+
emit('step', modelStep);
|
|
392
|
+
await agent.persistRun(run);
|
|
393
|
+
const requestOptions = tools.length > 0 ? { ...extra, tools, tool_choice: 'auto' } : extra;
|
|
394
|
+
const upstreamBody = adapter.buildRequestBody(modelName, messages, true, requestOptions);
|
|
395
|
+
// 合并超时与用户取消信号:取消时可真正中断上游
|
|
396
|
+
const signal = (typeof AbortSignal !== 'undefined' && AbortSignal.any)
|
|
397
|
+
? AbortSignal.any([AbortSignal.timeout(180000), runAbort.signal])
|
|
398
|
+
: runAbort.signal;
|
|
399
|
+
let upstream;
|
|
400
|
+
try {
|
|
401
|
+
upstream = await ctx.safeFetch(upstreamUrl, {
|
|
402
|
+
method: 'POST',
|
|
403
|
+
headers: adapterHeaders,
|
|
404
|
+
body: JSON.stringify(upstreamBody),
|
|
405
|
+
signal,
|
|
406
|
+
}, allowPrivateProvider(provider));
|
|
407
|
+
}
|
|
408
|
+
catch (fe) {
|
|
409
|
+
const cancelled = fe.name === 'AbortError';
|
|
410
|
+
modelStep.status = 'error';
|
|
411
|
+
modelStep.finishedAt = nowIso();
|
|
412
|
+
modelStep.error = cancelled ? 'cancelled by user' : redactSecrets(fe.message);
|
|
413
|
+
emit('step_update', modelStep);
|
|
414
|
+
run.status = cancelled ? 'cancelled' : 'error';
|
|
415
|
+
run.error = modelStep.error;
|
|
416
|
+
await usageLedger.recordUsage(ctx.store, usageContext(body, provider, modelName, {
|
|
417
|
+
timestamp: modelStep.startedAt, durationMs: Date.parse(modelStep.finishedAt) - Date.parse(modelStep.startedAt),
|
|
418
|
+
interactionId: run.id, runId: run.id, mode: 'agent', status: run.status, errorCode: cancelled ? 'CANCELLED' : 'NETWORK_ERROR', step: iter,
|
|
419
|
+
usage: { input: 0, output: 0, total: 0, source: 'estimated' },
|
|
420
|
+
}));
|
|
421
|
+
await agent.persistRun(run);
|
|
422
|
+
if (cancelled)
|
|
423
|
+
res.write('data: ' + JSON.stringify({ agentEvent: { type: 'cancelled' } }) + '\n\n');
|
|
424
|
+
else
|
|
425
|
+
res.write('data: ' + JSON.stringify({ error: { message: 'Upstream request failed: ' + redactSecrets(fe.message) } }) + '\n\n');
|
|
426
|
+
res.write('data: [DONE]\n\n');
|
|
427
|
+
res.end();
|
|
428
|
+
clearAbort();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (!upstream.ok) {
|
|
432
|
+
const errText = await readResponseText(upstream, 128_000);
|
|
433
|
+
modelStep.status = 'error';
|
|
434
|
+
modelStep.finishedAt = nowIso();
|
|
435
|
+
modelStep.error = 'Upstream HTTP ' + upstream.status;
|
|
436
|
+
emit('step_update', modelStep);
|
|
437
|
+
run.status = 'error';
|
|
438
|
+
run.error = modelStep.error;
|
|
439
|
+
await usageLedger.recordUsage(ctx.store, usageContext(body, provider, modelName, {
|
|
440
|
+
timestamp: modelStep.startedAt, durationMs: Date.parse(modelStep.finishedAt) - Date.parse(modelStep.startedAt),
|
|
441
|
+
interactionId: run.id, runId: run.id, mode: 'agent', status: 'error', errorCode: `HTTP_${upstream.status}`, step: iter,
|
|
442
|
+
usage: { input: 0, output: 0, total: 0, source: 'estimated' },
|
|
443
|
+
}));
|
|
444
|
+
await agent.persistRun(run);
|
|
445
|
+
res.write('data: ' + JSON.stringify({ error: { message: 'Upstream HTTP ' + upstream.status + ': ' + redactSecrets(errText).substring(0, 500), upstreamStatus: upstream.status } }) + '\n\n');
|
|
446
|
+
res.write('data: [DONE]\n\n');
|
|
447
|
+
res.end();
|
|
448
|
+
clearAbort();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (!upstream.body) {
|
|
452
|
+
modelStep.status = 'error';
|
|
453
|
+
modelStep.finishedAt = nowIso();
|
|
454
|
+
modelStep.error = 'upstream has no body';
|
|
455
|
+
emit('step_update', modelStep);
|
|
456
|
+
run.status = 'error';
|
|
457
|
+
run.error = modelStep.error;
|
|
458
|
+
await usageLedger.recordUsage(ctx.store, usageContext(body, provider, modelName, {
|
|
459
|
+
timestamp: modelStep.startedAt, durationMs: Date.parse(modelStep.finishedAt) - Date.parse(modelStep.startedAt),
|
|
460
|
+
interactionId: run.id, runId: run.id, mode: 'agent', status: 'error', errorCode: 'EMPTY_BODY', step: iter,
|
|
461
|
+
usage: { input: 0, output: 0, total: 0, source: 'estimated' },
|
|
462
|
+
}));
|
|
463
|
+
await agent.persistRun(run);
|
|
464
|
+
res.write('data: ' + JSON.stringify({ error: { message: 'upstream has no body' } }) + '\n\n');
|
|
465
|
+
res.write('data: [DONE]\n\n');
|
|
466
|
+
res.end();
|
|
467
|
+
clearAbort();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const reader = upstream.body.getReader();
|
|
471
|
+
const decoder = new TextDecoder();
|
|
472
|
+
const sse = new SSEDecoder();
|
|
473
|
+
let accContent = '';
|
|
474
|
+
const toolCalls = {}; // by index
|
|
475
|
+
let observedUsage = {};
|
|
476
|
+
while (true) {
|
|
477
|
+
const { done, value } = await reader.read();
|
|
478
|
+
if (done)
|
|
479
|
+
break;
|
|
480
|
+
for (const line of sse.push(decoder.decode(value, { stream: true }))) {
|
|
481
|
+
const transformed = adapter.transformSSEChunk(line);
|
|
482
|
+
if (!transformed)
|
|
483
|
+
continue;
|
|
484
|
+
if (transformed.usage)
|
|
485
|
+
observedUsage = usageLedger.mergeUsage(observedUsage, transformed.usage);
|
|
486
|
+
res.write('data: ' + JSON.stringify(transformed) + '\n\n');
|
|
487
|
+
if (transformed.choices) {
|
|
488
|
+
for (const ch of transformed.choices) {
|
|
489
|
+
const delta = ch.delta || {};
|
|
490
|
+
if (delta.content)
|
|
491
|
+
accContent += delta.content;
|
|
492
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
493
|
+
for (const tc of delta.tool_calls) {
|
|
494
|
+
const i = tc.index || 0;
|
|
495
|
+
if (!toolCalls[i])
|
|
496
|
+
toolCalls[i] = { id: '', type: 'function', function: { name: '', arguments: '' } };
|
|
497
|
+
if (tc.id)
|
|
498
|
+
toolCalls[i].id = tc.id;
|
|
499
|
+
if (tc.type)
|
|
500
|
+
toolCalls[i].type = tc.type;
|
|
501
|
+
if (tc.function && tc.function.name)
|
|
502
|
+
toolCalls[i].function.name += tc.function.name;
|
|
503
|
+
if (tc.function && tc.function.arguments)
|
|
504
|
+
toolCalls[i].function.arguments += tc.function.arguments;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
for (const line of sse.flush()) {
|
|
512
|
+
const transformed = adapter.transformSSEChunk(line);
|
|
513
|
+
if (!transformed)
|
|
514
|
+
continue;
|
|
515
|
+
if (transformed.usage)
|
|
516
|
+
observedUsage = usageLedger.mergeUsage(observedUsage, transformed.usage);
|
|
517
|
+
res.write('data: ' + JSON.stringify(transformed) + '\n\n');
|
|
518
|
+
for (const choice of transformed.choices || []) {
|
|
519
|
+
const delta = choice.delta || {};
|
|
520
|
+
if (delta.content)
|
|
521
|
+
accContent += delta.content;
|
|
522
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
523
|
+
for (const tc of delta.tool_calls) {
|
|
524
|
+
const i = tc.index || 0;
|
|
525
|
+
if (!toolCalls[i])
|
|
526
|
+
toolCalls[i] = { id: '', type: 'function', function: { name: '', arguments: '' } };
|
|
527
|
+
if (tc.id)
|
|
528
|
+
toolCalls[i].id = tc.id;
|
|
529
|
+
if (tc.type)
|
|
530
|
+
toolCalls[i].type = tc.type;
|
|
531
|
+
if (tc.function?.name)
|
|
532
|
+
toolCalls[i].function.name += tc.function.name;
|
|
533
|
+
if (tc.function?.arguments)
|
|
534
|
+
toolCalls[i].function.arguments += tc.function.arguments;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// 模型请求 step 完成
|
|
540
|
+
modelStep.finishedAt = nowIso();
|
|
541
|
+
modelStep.durationMs = Date.parse(modelStep.finishedAt) - Date.parse(modelStep.startedAt);
|
|
542
|
+
modelStep.outputLen = accContent.length;
|
|
543
|
+
modelStep.status = 'success';
|
|
544
|
+
const normalized = usageLedger.normalizeUsage(observedUsage, messages, accContent);
|
|
545
|
+
modelStep.usage = normalized;
|
|
546
|
+
run.usage.inputTokens += normalized.input;
|
|
547
|
+
run.usage.outputTokens += normalized.output;
|
|
548
|
+
run.usage.totalTokens += normalized.total;
|
|
549
|
+
run.usage.cachedTokens += normalized.cached;
|
|
550
|
+
run.usage.reasoningTokens += normalized.reasoning;
|
|
551
|
+
if (normalized.source === 'reported')
|
|
552
|
+
run.usage.reportedTokens += normalized.total;
|
|
553
|
+
else
|
|
554
|
+
run.usage.estimatedTokens += normalized.total;
|
|
555
|
+
await usageLedger.recordUsage(ctx.store, usageContext(body, provider, modelName, {
|
|
556
|
+
timestamp: modelStep.startedAt, durationMs: modelStep.durationMs,
|
|
557
|
+
interactionId: run.id, runId: run.id, mode: 'agent', status: 'success', step: iter,
|
|
558
|
+
usage: normalized,
|
|
559
|
+
}));
|
|
560
|
+
emit('step_update', modelStep);
|
|
561
|
+
await agent.persistRun(run);
|
|
562
|
+
const toolCallsArr = Object.values(toolCalls).filter(tc => tc.function && tc.function.name);
|
|
563
|
+
if (toolCallsArr.length === 0) {
|
|
564
|
+
run.status = 'completed';
|
|
565
|
+
turn.finishedAt = nowIso();
|
|
566
|
+
await agent.finishRun(run, { status: 'completed', steps: iter, toolCalls: run.toolCalls, turns: run.turns });
|
|
567
|
+
res.write('data: [DONE]\n\n');
|
|
568
|
+
res.end();
|
|
569
|
+
clearAbort();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
run.toolCalls += toolCallsArr.length;
|
|
573
|
+
// 追加 assistant 消息(包含 tool_calls)
|
|
574
|
+
messages.push({
|
|
575
|
+
role: 'assistant',
|
|
576
|
+
content: accContent || null,
|
|
577
|
+
tool_calls: toolCallsArr.map(tc => ({ id: tc.id, type: 'function', function: { name: tc.function.name, arguments: tc.function.arguments } })),
|
|
578
|
+
});
|
|
579
|
+
// 顺序执行工具调用
|
|
580
|
+
for (const tc of toolCallsArr) {
|
|
581
|
+
let args = {};
|
|
582
|
+
try {
|
|
583
|
+
args = JSON.parse(tc.function.arguments || '{}');
|
|
584
|
+
}
|
|
585
|
+
catch (e) { /* 保持 args 为空对象 */ }
|
|
586
|
+
const skill = workflowRuntime.calls.get(tc.function.name) || mcpRuntime.calls.get(tc.function.name)?.skill || agent.findSkillForTool(tc.function.name, enabledTools);
|
|
587
|
+
// ── Step: 工具调用(结构化轨迹 + 实时事件)──
|
|
588
|
+
const tstep = {
|
|
589
|
+
sid: 't' + turn.turn + '-s' + iter + '-tool-' + tc.id,
|
|
590
|
+
kind: 'tool_call',
|
|
591
|
+
index: iter,
|
|
592
|
+
tool: tc.function.name,
|
|
593
|
+
args,
|
|
594
|
+
status: 'running',
|
|
595
|
+
startedAt: nowIso(),
|
|
596
|
+
permissions: skill ? agent.skillPermissions(skill) : [],
|
|
597
|
+
};
|
|
598
|
+
turn.steps.push(tstep);
|
|
599
|
+
emit('step', tstep);
|
|
600
|
+
await agent.persistRun(run);
|
|
601
|
+
let result;
|
|
602
|
+
if (!skill) {
|
|
603
|
+
result = { ok: false, error: 'unknown tool: ' + tc.function.name };
|
|
604
|
+
}
|
|
605
|
+
else if (!agent.isSkillAllowed(skill, toolPolicy)) {
|
|
606
|
+
result = { ok: false, error: 'permission denied by safe policy', policy: toolPolicy, permissions: agent.skillPermissions(skill) };
|
|
607
|
+
}
|
|
608
|
+
else if (agent.requiresApproval(skill, toolPolicy)) {
|
|
609
|
+
// Approval is a durable pause boundary. The HTTP stream is closed
|
|
610
|
+
// after persisting the checkpoint; a later resume request can
|
|
611
|
+
// continue even when the process restarted in between.
|
|
612
|
+
const approvalId = 'ap_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
|
613
|
+
const perms = agent.skillPermissions(skill);
|
|
614
|
+
const approval = {
|
|
615
|
+
id: approvalId,
|
|
616
|
+
tool: tc.function.name,
|
|
617
|
+
args,
|
|
618
|
+
permissions: perms,
|
|
619
|
+
trustLevel: agent.trustLevel(skill),
|
|
620
|
+
risk: agent.riskLevel(perms),
|
|
621
|
+
requestedAt: nowIso(),
|
|
622
|
+
status: 'pending',
|
|
623
|
+
};
|
|
624
|
+
// 轨迹步骤进入「等待审批」态
|
|
625
|
+
tstep.status = 'waiting_approval';
|
|
626
|
+
tstep.approvalId = approvalId;
|
|
627
|
+
tstep.permissions = perms;
|
|
628
|
+
tstep.risk = approval.risk;
|
|
629
|
+
emit('step_update', tstep);
|
|
630
|
+
// 通知前端需要授权
|
|
631
|
+
res.write('data: ' + JSON.stringify({ agentEvent: { type: 'approval_required', approval } }) + '\n\n');
|
|
632
|
+
// 记录审批历史(持久化,便于恢复/重放)
|
|
633
|
+
run.approvals.push(approval);
|
|
634
|
+
run.status = 'waiting_approval';
|
|
635
|
+
run.pauseReason = 'approval required';
|
|
636
|
+
run.checkpoint = {
|
|
637
|
+
messages,
|
|
638
|
+
iteration: iter,
|
|
639
|
+
savedAt: nowIso(),
|
|
640
|
+
pendingApproval: {
|
|
641
|
+
approvalId,
|
|
642
|
+
toolName: tc.function.name,
|
|
643
|
+
toolCallId: tc.id,
|
|
644
|
+
args,
|
|
645
|
+
deferredToolCalls: toolCallsArr.filter(item => item.id !== tc.id).map(item => ({ id: item.id, name: item.function?.name || '' })),
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
await agent.persistRun(run);
|
|
649
|
+
res.write('data: [DONE]\n\n');
|
|
650
|
+
res.end();
|
|
651
|
+
clearAbort();
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
result = await agent.executeSkill(skill, args, runAbort.signal);
|
|
656
|
+
}
|
|
657
|
+
const tEnd = nowIso();
|
|
658
|
+
tstep.status = (result && result.ok) ? 'success' : (tstep.approvalStatus && tstep.approvalStatus !== 'approved' ? 'rejected' : 'error');
|
|
659
|
+
tstep.finishedAt = tEnd;
|
|
660
|
+
tstep.durationMs = Date.parse(tEnd) - Date.parse(tstep.startedAt);
|
|
661
|
+
const resultText = (typeof result === 'string') ? result : JSON.stringify(result);
|
|
662
|
+
tstep.result = resultText.slice(0, 500);
|
|
663
|
+
emit('step_update', tstep);
|
|
664
|
+
await agent.persistRun(run);
|
|
665
|
+
const toolMsg = { role: 'tool', tool_call_id: tc.id, content: resultText };
|
|
666
|
+
messages.push(toolMsg);
|
|
667
|
+
res.write('data: ' + JSON.stringify({ choices: [{ delta: { tool_result: { id: tc.id, name: tc.function.name, content: resultText } } }] }) + '\n\n');
|
|
668
|
+
}
|
|
669
|
+
run.checkpoint = { messages, iteration: iter, savedAt: nowIso() };
|
|
670
|
+
await agent.persistRun(run);
|
|
671
|
+
}
|
|
672
|
+
run.status = 'paused';
|
|
673
|
+
run.pauseReason = `iteration limit reached (${maxIterations})`;
|
|
674
|
+
turn.finishedAt = nowIso();
|
|
675
|
+
await agent.persistRun(run);
|
|
676
|
+
res.write('data: ' + JSON.stringify({ agentEvent: { type: 'paused', runId: run.id, reason: run.pauseReason } }) + '\n\n');
|
|
677
|
+
res.write('data: [DONE]\n\n');
|
|
678
|
+
res.end();
|
|
679
|
+
clearAbort();
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
catch (err) {
|
|
683
|
+
const cancelled = err.name === 'AbortError';
|
|
684
|
+
run.status = cancelled ? 'cancelled' : 'error';
|
|
685
|
+
run.error = cancelled ? 'cancelled by user' : ('agent error: ' + redactSecrets(err.message));
|
|
686
|
+
turn.finishedAt = nowIso();
|
|
687
|
+
await agent.finishRun(run, { status: run.status, error: run.error, steps: run.steps, toolCalls: run.toolCalls, turns: run.turns });
|
|
688
|
+
res.write('data: ' + JSON.stringify({ error: { message: run.error } }) + '\n\n');
|
|
689
|
+
res.write('data: [DONE]\n\n');
|
|
690
|
+
res.end();
|
|
691
|
+
clearAbort();
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
})().catch(next);
|
|
695
|
+
});
|
|
696
|
+
};
|
|
697
|
+
//# sourceMappingURL=chat.js.map
|