@iamsamyiok/agents-chat 3.18.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/README.md +42 -0
- package/app/lib/agent.js +487 -0
- package/app/lib/aux-llm.js +107 -0
- package/app/lib/cards.js +479 -0
- package/app/lib/env.js +47 -0
- package/app/lib/memory.js +202 -0
- package/app/lib/oc.js +244 -0
- package/app/lib/orchestrator.js +1177 -0
- package/app/lib/store.js +686 -0
- package/app/mock/mock-agent.js +125 -0
- package/app/public/cards.html +628 -0
- package/app/public/index.html +3279 -0
- package/app/server.js +1334 -0
- package/bin/agents-chat.js +263 -0
- package/lib/start.js +10 -0
- package/package.json +39 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// 管家长期记忆:跨会话记住用户偏好、项目事实与教训(借鉴 Hermes MEMORY.md/USER.md 双仓设计)
|
|
2
|
+
// 鲁棒性原则(任何一步失败都不阻塞、不报硬错):
|
|
3
|
+
// - 写入阶梯:直接写入 → 超限时辅助小模型整理合并 → 辅助失败/输出无效时淘汰最旧条目
|
|
4
|
+
// - 一切异常吞掉并返回结果说明,调用方据此展示 💾 提示或静默降级
|
|
5
|
+
// - 未配置辅助模型时全部走确定性路径,功能照常
|
|
6
|
+
const store = require('./store');
|
|
7
|
+
const { auxReady, auxChat, parseAuxJSON } = require('./aux-llm');
|
|
8
|
+
|
|
9
|
+
const LIMITS = { memory: 2000, user: 1200 }; // 字符上限(约 700/450 tokens,常驻规划上下文)
|
|
10
|
+
|
|
11
|
+
function memoryEnabled() {
|
|
12
|
+
return process.env.AGENTS_CHAT_MEMORY !== '0';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const entryChars = (entries) => entries.reduce((n, e) => n + String(e).length, 0);
|
|
16
|
+
const usage = (target) => {
|
|
17
|
+
const d = store.getMemoryData();
|
|
18
|
+
const used = entryChars(d[target]);
|
|
19
|
+
return { used, limit: LIMITS[target], pct: Math.round(used / LIMITS[target] * 100) };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// 注入管家的记忆块(冻结快照,带用量便于模型自觉控制)
|
|
23
|
+
function memoryBlock(targets) {
|
|
24
|
+
const d = store.getMemoryData();
|
|
25
|
+
const parts = [];
|
|
26
|
+
for (const t of (targets || ['memory', 'user'])) {
|
|
27
|
+
const entries = d[t];
|
|
28
|
+
if (!entries.length) continue;
|
|
29
|
+
const label = t === 'user' ? '用户画像' : '工作笔记';
|
|
30
|
+
const u = usage(t);
|
|
31
|
+
parts.push(`〔${label} ${u.pct}% ${u.used}/${u.limit}字〕\n${entries.join('\n§\n')}`);
|
|
32
|
+
}
|
|
33
|
+
return parts.join('\n\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------- 确定性整理(无辅助模型时的兜底) ----------
|
|
37
|
+
// 淘汰最旧条目直到放得下;至少保留 1 条(单条超长则截断该条)
|
|
38
|
+
function makeRoom(entries, incoming, limit) {
|
|
39
|
+
const list = entries.slice();
|
|
40
|
+
while (list.length > 0 && entryChars(list) + incoming.length > limit) list.shift();
|
|
41
|
+
if (!list.length && incoming.length > limit) return [incoming.slice(0, limit)];
|
|
42
|
+
return list;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isDup = (entries, text) => entries.some(e => e.trim() === text.trim());
|
|
46
|
+
|
|
47
|
+
// ---------- 辅助小模型整理:把现有条目 + 新条目合并成更紧凑的清单 ----------
|
|
48
|
+
async function llmConsolidate(target, entries, incoming) {
|
|
49
|
+
const limit = LIMITS[target];
|
|
50
|
+
const label = target === 'user' ? '用户画像' : '工作笔记';
|
|
51
|
+
const res = await auxChat([
|
|
52
|
+
{ role: 'system', content: `你是记忆整理器。把「现有条目」与「新条目」合并成一份更紧凑的清单:合并重复信息、删去过时内容、保留全部关键事实。总字数必须不超过 ${limit} 字。直接输出 JSON:{"entries":["条目1","条目2"]},每条是一句信息密集的完整陈述,不要输出其他内容。` },
|
|
53
|
+
{ role: 'user', content: `〔${label}·现有条目〕\n${entries.join('\n§\n') || '(空)'}\n\n〔新条目〕\n${incoming}` }
|
|
54
|
+
], { maxTokens: 700, timeoutMs: 30000 });
|
|
55
|
+
if (!res.ok) return null;
|
|
56
|
+
const j = parseAuxJSON(res.text);
|
|
57
|
+
if (!j || !Array.isArray(j.entries)) return null;
|
|
58
|
+
const merged = j.entries.map(s => String(s).trim()).filter(Boolean).slice(0, 20);
|
|
59
|
+
if (!merged.length || entryChars(merged) > limit) return null; // 输出仍超限视为无效
|
|
60
|
+
return merged;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------- 写入操作(鲁棒阶梯,永不抛异常) ----------
|
|
64
|
+
// op: {action:'add'|'replace'|'remove', target:'memory'|'user', content, old}
|
|
65
|
+
async function applyOps(ops) {
|
|
66
|
+
const results = [];
|
|
67
|
+
for (const op of (Array.isArray(ops) ? ops : []).slice(0, 10)) {
|
|
68
|
+
const target = op.target === 'user' ? 'user' : 'memory';
|
|
69
|
+
const action = op.action;
|
|
70
|
+
const content = String(op.content || '').trim().slice(0, Math.max(200, LIMITS[target]));
|
|
71
|
+
const old = String(op.old || '').trim();
|
|
72
|
+
if (action !== 'add' && !old) { results.push({ ok: false, why: '缺少 old' }); continue; }
|
|
73
|
+
|
|
74
|
+
const d = store.getMemoryData();
|
|
75
|
+
const entries = d[target].slice();
|
|
76
|
+
let note = '';
|
|
77
|
+
|
|
78
|
+
if (action === 'remove') {
|
|
79
|
+
const i = entries.findIndex(e => e.includes(old));
|
|
80
|
+
if (i < 0) { results.push({ ok: false, why: '未匹配' }); continue; }
|
|
81
|
+
entries.splice(i, 1);
|
|
82
|
+
note = '删除 1 条';
|
|
83
|
+
} else if (action === 'replace') {
|
|
84
|
+
if (!content) { results.push({ ok: false, why: '缺少 content' }); continue; }
|
|
85
|
+
const i = entries.findIndex(e => e.includes(old));
|
|
86
|
+
if (i < 0) { results.push({ ok: false, why: '未匹配' }); continue; }
|
|
87
|
+
entries[i] = content;
|
|
88
|
+
note = '更新 1 条';
|
|
89
|
+
} else { // add
|
|
90
|
+
if (!content) { results.push({ ok: false, why: '空内容' }); continue; }
|
|
91
|
+
if (isDup(entries, content)) { results.push({ ok: true, note: '重复已跳过', dup: true }); continue; }
|
|
92
|
+
if (entryChars(entries) + content.length <= LIMITS[target]) {
|
|
93
|
+
entries.push(content);
|
|
94
|
+
note = '新增 1 条';
|
|
95
|
+
} else {
|
|
96
|
+
// 超限 → 辅助模型整理;失败 → 淘汰最旧
|
|
97
|
+
const merged = auxReady() ? await llmConsolidate(target, entries, content) : null;
|
|
98
|
+
if (merged) {
|
|
99
|
+
entries.length = 0;
|
|
100
|
+
entries.push(...merged);
|
|
101
|
+
note = `整理合并后写入(${merged.length} 条)`;
|
|
102
|
+
} else {
|
|
103
|
+
entries.length = 0;
|
|
104
|
+
entries.push(...makeRoom(entries, content, LIMITS[target]));
|
|
105
|
+
if (!entries.includes(content)) entries.push(content);
|
|
106
|
+
note = '空间不足,淘汰最旧后写入';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
d[target] = entries;
|
|
111
|
+
store.saveMemoryData(d);
|
|
112
|
+
results.push({ ok: true, note });
|
|
113
|
+
}
|
|
114
|
+
return results;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------- 历史回忆:跨会话/跨任务关键词检索(确定性、毫秒级、零 token) ----------
|
|
118
|
+
// 返回拼接片段(含来源标注),供规划时注入「以前聊过什么」
|
|
119
|
+
function recallFromMessages(allMessages, query, opts) {
|
|
120
|
+
const o = opts || {};
|
|
121
|
+
const terms = String(query || '')
|
|
122
|
+
.replace(/[,。!?、,.!?;;::\s@]+/g, ' ')
|
|
123
|
+
.split(' ')
|
|
124
|
+
.filter(t => t.length >= 2 && t.length <= 12)
|
|
125
|
+
.slice(0, 8);
|
|
126
|
+
if (!terms.length) return '';
|
|
127
|
+
const curTask = o.excludeTaskId || '';
|
|
128
|
+
const curEpoch = o.excludeEpoch;
|
|
129
|
+
const scored = [];
|
|
130
|
+
for (const m of allMessages) {
|
|
131
|
+
if (!m || !m.content) continue;
|
|
132
|
+
if (curTask && (m.taskId || '') === curTask) continue; // 同一任务的历史不重复回忆(已在工作背景里)
|
|
133
|
+
if (!curTask && curEpoch !== undefined && !m.taskId && (Number(m.epoch) || 0) === curEpoch) continue; // 主会话排除当前轮次(已在会话背景里)
|
|
134
|
+
const text = String(m.content);
|
|
135
|
+
if (text.length < 10) continue;
|
|
136
|
+
let score = 0;
|
|
137
|
+
for (const t of terms) {
|
|
138
|
+
let idx = text.indexOf(t);
|
|
139
|
+
while (idx >= 0 && score < 50) { score++; idx = text.indexOf(t, idx + t.length); }
|
|
140
|
+
}
|
|
141
|
+
if (score >= 2) scored.push({ m, score, text });
|
|
142
|
+
}
|
|
143
|
+
scored.sort((a, b) => b.score - a.score);
|
|
144
|
+
const parts = [];
|
|
145
|
+
let total = 0;
|
|
146
|
+
for (const s of scored.slice(0, 4)) {
|
|
147
|
+
const src = s.m.taskId ? '历史任务' : '早前会话';
|
|
148
|
+
const who = s.m.role === 'user' ? '用户' : (s.m.agentName || '智能体');
|
|
149
|
+
const line = `[${src}] ${who}:${s.text.replace(/\s+/g, ' ').slice(0, 260)}`;
|
|
150
|
+
if (total + line.length > 900) break;
|
|
151
|
+
parts.push(line);
|
|
152
|
+
total += line.length;
|
|
153
|
+
}
|
|
154
|
+
return parts.join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------- 上下文压缩:辅助模型把长历史压成要点(失败→确定性保留结尾) ----------
|
|
158
|
+
async function compressHistory(history, opts) {
|
|
159
|
+
const o = opts || {};
|
|
160
|
+
const limit = o.limit || 1200;
|
|
161
|
+
const text = String(history || '').trim();
|
|
162
|
+
if (!text || text.length <= (o.minLen || 4000)) return { text, compressed: false };
|
|
163
|
+
const head = text.slice(0, 200);
|
|
164
|
+
const res = await auxChat([
|
|
165
|
+
{ role: 'system', content: `把这段会话历史压缩成不超过 ${limit} 字的要点:保留用户偏好、已做决定、关键事实与结论,去掉寒暄与过程细节。直接输出要点文本。` },
|
|
166
|
+
{ role: 'user', content: `${head}\n…(中略)…\n${text.slice(-2500)}` }
|
|
167
|
+
], { maxTokens: Math.min(900, limit), timeoutMs: 30000 });
|
|
168
|
+
if (res.ok && res.text.length >= 50) {
|
|
169
|
+
return { text: `〔会话历史要点(原文 ${text.length} 字已压缩)〕\n${res.text}`, compressed: true };
|
|
170
|
+
}
|
|
171
|
+
// 确定性回退:直接保留结尾(最近的内容通常最重要)
|
|
172
|
+
return { text: text.slice(-limit), compressed: false };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------- 编排后自省:从本轮对话提取值得长期记住的内容 ----------
|
|
176
|
+
async function reflectOnRun(digest) {
|
|
177
|
+
if (!auxReady()) return null;
|
|
178
|
+
const mem = memoryBlock(['memory', 'user']);
|
|
179
|
+
const res = await auxChat([
|
|
180
|
+
{ role: 'system', content: `你是管家的记忆助手。根据本轮对话提取「值得跨会话长期记住」的信息,只记稳定的偏好与事实,忽略一次性细节。
|
|
181
|
+
当前记忆:
|
|
182
|
+
${mem || '(空)'}
|
|
183
|
+
|
|
184
|
+
输出 JSON(无值得记的输出 {"ops":[]}):
|
|
185
|
+
{"ops":[{"action":"add","target":"user 或 memory","content":"一句完整陈述"}],"note":"不超过 15 字的说明"}
|
|
186
|
+
target 规则:用户偏好/沟通习惯/身份 → user;项目事实/环境/教训/约定 → memory。
|
|
187
|
+
优先 add;若新内容与现有条目重复或更新现有条目,用 {"action":"replace","target":"…","old":"现有条目的唯一片段","content":"更新后的完整条目"}。` },
|
|
188
|
+
{ role: 'user', content: digest }
|
|
189
|
+
], { maxTokens: 500, timeoutMs: 30000 });
|
|
190
|
+
if (!res.ok) return null;
|
|
191
|
+
const j = parseAuxJSON(res.text);
|
|
192
|
+
if (!j || !Array.isArray(j.ops) || !j.ops.length) return null;
|
|
193
|
+
const results = await applyOps(j.ops);
|
|
194
|
+
const applied = results.filter(r => r.ok && !r.dup);
|
|
195
|
+
if (!applied.length) return null;
|
|
196
|
+
return { note: String(j.note || '').slice(0, 30), applied: applied.length, results };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
LIMITS, memoryEnabled, memoryBlock, usage, applyOps,
|
|
201
|
+
recallFromMessages, compressHistory, reflectOnRun, makeRoom, isDup
|
|
202
|
+
};
|
package/app/lib/oc.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// 单聊工作台引擎:网页会话 ←→ opencode 会话(-s 续聊)
|
|
2
|
+
// - chatSolo:spawn `opencode run --format json [-m model] [-s ses_xxx]`,prompt 经 stdin
|
|
3
|
+
// - 事件流:text/reasoning 为全量快照(按 partId 覆盖渲染),tool_use 为过程提示
|
|
4
|
+
// - 首个 sessionID 事件回填 store,实现同一网页会话跨轮次续聊
|
|
5
|
+
// - 非 opencode 内核(claude/codex/pi)无 -s 续聊能力:单聊退化为一次性对话,每轮全新上下文
|
|
6
|
+
// - 演示模式(AGENTS_CHAT_MOCK=1)走 mock 子进程,输出模拟为快照事件
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { spawn, execFileSync } = require('child_process');
|
|
9
|
+
const { registerChild, describeTool, resolveCwd } = require('./agent');
|
|
10
|
+
|
|
11
|
+
const MOCK_SCRIPT = path.join(__dirname, '..', 'mock', 'mock-agent.js');
|
|
12
|
+
|
|
13
|
+
// 模型标识必须形如 provider/model,且只含安全字符(会进入命令行参数)
|
|
14
|
+
const MODEL_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
15
|
+
// opencode 会话 ID(ses_ 开头的安全字符序列)
|
|
16
|
+
const OC_SESSION_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
17
|
+
|
|
18
|
+
// ---------- 模型列表:`opencode models` 输出尽力解析(60s 缓存) ----------
|
|
19
|
+
let modelCache = { ts: 0, list: null };
|
|
20
|
+
|
|
21
|
+
function listOcModels(runner, force) {
|
|
22
|
+
if (!runner || runner.kind !== 'opencode' || !runner.cmd) return [];
|
|
23
|
+
if (!force && modelCache.list && Date.now() - modelCache.ts < 60000) return modelCache.list;
|
|
24
|
+
let out = '';
|
|
25
|
+
try {
|
|
26
|
+
// 同步执行(带超时);输出为人类可读列表,统一按 provider/model 形态抓取
|
|
27
|
+
out = execFileSync(runner.cmd, ['models'], {
|
|
28
|
+
encoding: 'utf8', timeout: 20000, shell: runner.shell,
|
|
29
|
+
windowsHide: true, cwd: resolveCwd(), maxBuffer: 4 * 1024 * 1024
|
|
30
|
+
});
|
|
31
|
+
} catch (e) {
|
|
32
|
+
// 失败时仍可能带部分 stdout,尽力解析
|
|
33
|
+
out = (e && e.stdout) ? String(e.stdout) : '';
|
|
34
|
+
}
|
|
35
|
+
const ids = new Set();
|
|
36
|
+
for (const m of String(out).match(/[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*/g) || []) {
|
|
37
|
+
if (MODEL_RE.test(m)) ids.add(m);
|
|
38
|
+
}
|
|
39
|
+
const list = [...ids].sort().map(id => ({ id, label: id.split('/').pop() }));
|
|
40
|
+
modelCache = { ts: Date.now(), list };
|
|
41
|
+
return list;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 演示模式的假模型列表(仅 UI 可交互,无真实调用)
|
|
45
|
+
function demoModels() {
|
|
46
|
+
return [
|
|
47
|
+
{ id: 'demo/qwen-plus', label: 'qwen-plus(演示)' },
|
|
48
|
+
{ id: 'demo/glm', label: 'glm(演示)' }
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------- opencode 事件行 → 统一单聊事件 ----------
|
|
53
|
+
// onEvent 事件:
|
|
54
|
+
// {type:'session', ocSessionId} 首个 sessionID(回填续聊)
|
|
55
|
+
// {type:'text', partId, text} 正文快照(覆盖式)
|
|
56
|
+
// {type:'reasoning', partId, text} 思考快照(覆盖式)
|
|
57
|
+
// {type:'tool', partId, name, summary} 工具调用提示
|
|
58
|
+
// state.errEvents 收集错误,close 时合并上报
|
|
59
|
+
function parseSoloEventLine(line, onEvent, state) {
|
|
60
|
+
let ev;
|
|
61
|
+
try { ev = JSON.parse(line); } catch { return; }
|
|
62
|
+
if (!ev || !ev.type) return;
|
|
63
|
+
if (ev.sessionID && !state.ocSessionId && OC_SESSION_RE.test(String(ev.sessionID))) {
|
|
64
|
+
state.ocSessionId = String(ev.sessionID);
|
|
65
|
+
onEvent({ type: 'session', ocSessionId: state.ocSessionId });
|
|
66
|
+
}
|
|
67
|
+
const part = ev.part;
|
|
68
|
+
if (ev.type === 'text' && part && typeof part.text === 'string') {
|
|
69
|
+
if (part.text.trim()) {
|
|
70
|
+
onEvent({ type: 'text', partId: String(part.id || part.messageID || 'txt'), text: part.text });
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (ev.type === 'reasoning' && part && typeof part.text === 'string') {
|
|
75
|
+
if (part.text.trim()) {
|
|
76
|
+
onEvent({ type: 'reasoning', partId: String(part.id || 'rs'), text: part.text });
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (ev.type === 'tool_use' && part) {
|
|
81
|
+
const name = String(part.tool || 'tool');
|
|
82
|
+
let summary = '';
|
|
83
|
+
try { summary = describeTool(part).replace(/\n$/, ''); } catch { /* ignore */ }
|
|
84
|
+
state.toolSeq = (state.toolSeq || 0) + 1;
|
|
85
|
+
onEvent({ type: 'tool', partId: `tool-${state.toolSeq}`, name, summary });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (ev.type === 'error') {
|
|
89
|
+
const e = ev.error;
|
|
90
|
+
let msg;
|
|
91
|
+
if (e && e.data && e.data.message) msg = String(e.data.message);
|
|
92
|
+
else if (e && e.name) msg = String(e.name);
|
|
93
|
+
else msg = JSON.stringify(e).slice(0, 500);
|
|
94
|
+
if (msg) state.errEvents.push(msg);
|
|
95
|
+
}
|
|
96
|
+
// step_start / step_finish / message.updated 等对聊天界面是噪音,忽略
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------- 通用子进程运行器 ----------
|
|
100
|
+
// runnerSpec: { cmd, shell, json, cwd };json=false 时把输出行累积为快照(演示模式用)
|
|
101
|
+
function spawnSoloRunner(runnerSpec, args, prompt, env, onEvent, finish) {
|
|
102
|
+
let child;
|
|
103
|
+
try {
|
|
104
|
+
child = spawn(runnerSpec.cmd, args, {
|
|
105
|
+
cwd: runnerSpec.cwd || process.cwd(),
|
|
106
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
107
|
+
shell: !!runnerSpec.shell,
|
|
108
|
+
windowsHide: true,
|
|
109
|
+
env: env || process.env
|
|
110
|
+
});
|
|
111
|
+
} catch (error) {
|
|
112
|
+
finish(`启动失败:${error.message}`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
registerChild(child, 'solo'); // 停止/退出清理复用 agent.js 的登记表(scope='solo')
|
|
116
|
+
|
|
117
|
+
child.stdin.on('error', () => { /* stdin 已关闭则忽略 */ });
|
|
118
|
+
if (prompt) child.stdin.write(prompt);
|
|
119
|
+
child.stdin.end();
|
|
120
|
+
|
|
121
|
+
let stdoutBuf = '';
|
|
122
|
+
let stderrBuf = '';
|
|
123
|
+
let killed = false;
|
|
124
|
+
const state = { ocSessionId: '', errEvents: [], toolSeq: 0, mockText: '', textOrder: [] };
|
|
125
|
+
|
|
126
|
+
const timeoutMs = Number(process.env.AGENTS_CHAT_TIMEOUT_MS) > 0
|
|
127
|
+
? Number(process.env.AGENTS_CHAT_TIMEOUT_MS)
|
|
128
|
+
: 600000;
|
|
129
|
+
const timer = setTimeout(() => { killed = true; killTree(child); }, timeoutMs);
|
|
130
|
+
|
|
131
|
+
const runLine = (line) => {
|
|
132
|
+
if (runnerSpec.json) {
|
|
133
|
+
parseSoloEventLine(line, onEvent, state);
|
|
134
|
+
} else {
|
|
135
|
+
// 非 JSON 输出(演示模式):按行累积成快照
|
|
136
|
+
state.mockText += line + '\n';
|
|
137
|
+
onEvent({ type: 'text', partId: 'mock', text: state.mockText });
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
child.stdout.on('data', (data) => {
|
|
142
|
+
stdoutBuf += data.toString();
|
|
143
|
+
let idx;
|
|
144
|
+
while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
|
|
145
|
+
const line = stdoutBuf.slice(0, idx).trim();
|
|
146
|
+
stdoutBuf = stdoutBuf.slice(idx + 1);
|
|
147
|
+
if (line) runLine(line);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
child.stderr.on('data', (data) => { stderrBuf += data.toString(); });
|
|
152
|
+
|
|
153
|
+
child.on('error', (error) => {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
finish(`调用失败:${error.message}`, state);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
child.on('close', (code) => {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
if (stdoutBuf.trim()) runLine(stdoutBuf.trim());
|
|
161
|
+
let error;
|
|
162
|
+
if (child._stopped) error = '已手动停止';
|
|
163
|
+
else if (killed) error = `执行超时(超过 ${Math.round(timeoutMs / 1000)} 秒已强制终止)。可在 .env 调大 AGENTS_CHAT_TIMEOUT_MS`;
|
|
164
|
+
else if (state.errEvents.length) error = state.errEvents.join('\n').slice(0, 2000);
|
|
165
|
+
else if (code !== 0) error = (stderrBuf.trim() || `进程异常退出(退出码 ${code})`).slice(0, 2000);
|
|
166
|
+
finish(error, state);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return child;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function killTree(child) {
|
|
173
|
+
if (!child || child.exitCode !== null) return;
|
|
174
|
+
try {
|
|
175
|
+
if (process.platform === 'win32') {
|
|
176
|
+
require('child_process').execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'ignore', timeout: 10000 });
|
|
177
|
+
} else {
|
|
178
|
+
child.kill('SIGKILL');
|
|
179
|
+
}
|
|
180
|
+
} catch { /* 进程可能已退出 */ }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------- 单轮对话 ----------
|
|
184
|
+
// runnerKind:
|
|
185
|
+
// 'demo':演示模式(mock 子进程,快照事件 + 本地 fake 会话 ID)
|
|
186
|
+
// 'opencode':真实链路(ocSessionId 存在则 -s 续聊;首个 sessionID 事件回填)
|
|
187
|
+
// 其他内核:退化为一次性对话(无续聊,文本经 agent.js 内核解析转发)
|
|
188
|
+
// onEvent 收到 {type:'done', error} 即本轮结束
|
|
189
|
+
function chatSolo(runnerKind, runner, opts, onEvent) {
|
|
190
|
+
const prompt = String(opts.prompt || '');
|
|
191
|
+
const model = String(opts.model || '');
|
|
192
|
+
const ocSessionId = String(opts.ocSessionId || '');
|
|
193
|
+
// 工作区:指定后 Agent 在该目录读写文件(卡牌可选 workspace)
|
|
194
|
+
const cwd = opts.cwd && fs.existsSync(opts.cwd) && fs.statSync(opts.cwd).isDirectory() ? opts.cwd : '';
|
|
195
|
+
const cwdEnv = cwd ? { ...process.env, AGENTS_CHAT_CWD: cwd } : process.env;
|
|
196
|
+
|
|
197
|
+
// ---- 演示模式:mock 子进程 + 快照模拟 ----
|
|
198
|
+
if (runnerKind === 'demo') {
|
|
199
|
+
return spawnSoloRunner(
|
|
200
|
+
{ cmd: process.execPath, shell: false, json: false, cwd: cwd || process.cwd() },
|
|
201
|
+
[MOCK_SCRIPT, prompt],
|
|
202
|
+
'',
|
|
203
|
+
cwdEnv,
|
|
204
|
+
onEvent,
|
|
205
|
+
(error) => {
|
|
206
|
+
// 演示模式的会话 ID:续聊时复用传入 ID(打通链路),新任务本地生成(无真实续聊)
|
|
207
|
+
onEvent({ type: 'session', ocSessionId: ocSessionId || 'ses_demo-' + Date.now().toString(36) });
|
|
208
|
+
onEvent({ type: 'done', error });
|
|
209
|
+
}
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- opencode 真实链路 ----
|
|
214
|
+
if (runnerKind === 'opencode') {
|
|
215
|
+
const args = ['run', '--format', 'json'];
|
|
216
|
+
if (model && MODEL_RE.test(model)) args.push('-m', model);
|
|
217
|
+
if (ocSessionId && OC_SESSION_RE.test(ocSessionId)) args.push('-s', ocSessionId);
|
|
218
|
+
// 非交互模式下 opencode 对权限请求默认自动拒绝,导致无法真实干活;
|
|
219
|
+
// 默认加 --auto,.env 可用 AGENTS_CHAT_AUTO_APPROVE=0 关闭(与群聊一致)
|
|
220
|
+
if (process.env.AGENTS_CHAT_AUTO_APPROVE !== '0') args.push('--auto');
|
|
221
|
+
return spawnSoloRunner(
|
|
222
|
+
{ cmd: runner.cmd, shell: runner.shell, json: true, cwd: cwd || resolveCwd() },
|
|
223
|
+
args, prompt, cwdEnv,
|
|
224
|
+
onEvent,
|
|
225
|
+
(error) => onEvent({ type: 'done', error })
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- 其他内核(claude/codex/pi):一次性对话,无续聊 ----
|
|
230
|
+
// 这些内核的文本事件是增量块,这里累积成快照后再发(与 opencode 快照语义对齐)
|
|
231
|
+
const { runAgent } = require('./agent');
|
|
232
|
+
let acc = '';
|
|
233
|
+
return runAgent(
|
|
234
|
+
{ model: model || process.env.AGENTS_CHAT_MODEL || '', behavior: 'echo', id: 'solo', name: runner.kernel ? runner.kernel.label : 'AI' },
|
|
235
|
+
prompt,
|
|
236
|
+
(chunk) => {
|
|
237
|
+
if (chunk.content) { acc += chunk.content; onEvent({ type: 'text', partId: 'solo', text: acc }); }
|
|
238
|
+
if (chunk.done) onEvent({ type: 'done', error: chunk.error, noResume: true });
|
|
239
|
+
},
|
|
240
|
+
'solo'
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = { chatSolo, listOcModels, demoModels, MODEL_RE, parseSoloEventLine };
|