dsh-social-trace 0.1.13 → 0.1.18

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/lib/chat.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { ProgressGuard } from './chat-runtime.js';
3
+ import { message } from './chat-model.js';
2
4
  import { assembleContext, budgets } from './chat-context.js';
5
+ import { platformModule } from './platforms/registry.js';
3
6
  import { authorIndex } from './author-index.js';
4
7
 
5
8
  const text = (value, max, label) => {
@@ -17,14 +20,16 @@ export const tools = [
17
20
  { name: 'search_posts', description: '按博主查本地完整消息。from/to 是北京时间日期 YYYY-MM-DD(含结束日);省略日期查询全部历史。返回有限摘录,完整内容使用 read_posts。', parameters: objectSchema({ uids, from: string, to: string, query: string, kind: { enum: ['all', 'original', 'repost'] }, offset: { type: 'integer', minimum: 0, maximum: 1000 } }, ['uids']) },
18
21
  { name: 'read_posts', description: '读取指定帖子正文和转发原文,保留来源编号;长内容会明确注明截断。', parameters: objectSchema({ ids: { type: 'array', items: string, minItems: 1, maxItems: 8 } }, ['ids']) },
19
22
  { name: 'read_author_index', description: '读取一个博主的本地资料数量、原创/转发、完整性和日期分布。仅用于导航和覆盖判断,不是观点证据;分析观点须继续读取原文。', parameters: objectSchema({ uid: string }, ['uid']) },
20
- { name: 'add_monitor', description: '创建从现在开始记录的博主监控。不会打开浏览器或开启历史缓存,需要用户在界面确认。', parameters: objectSchema({ platform: { enum: ['weibo', 'xueqiu'] }, url: string }, ['platform', 'url']) },
23
+ { name: 'get_monitor_scope', description: '读取博主的历史缓存日期、原创范围、检查时间及内容保存选项;不会返回本机执行路径。', parameters: objectSchema({ uids }, ['uids']) },
24
+ { name: 'set_monitor_scope', description: '修改一个或多个博主的历史日期/原创范围、历史缓存开关,或抖音视频/音频/平台解析/转写和每日时间点。未填写字段保留原值;空日期表示不限制。只预览,确认后生效,已有资料不删除;开启媒体处理会为已存作品补排未请求任务。', parameters: objectSchema({ uids, from: string, to: string, originalOnly: boolean, historyEnabled: boolean, saveVideo: boolean, saveAudio: boolean, platformAI: boolean, transcribe: boolean, times: { type: 'array', items: string, minItems: 1, maxItems: 8 }, repeat: { enum: ['daily', 'weekdays'] } }, ['uids']) },
25
+ { name: 'add_monitor', description: '创建从现在开始记录的博主监控。不会打开浏览器或开启历史缓存,需要用户在界面确认。', parameters: objectSchema({ platform: { enum: ['weibo', 'xueqiu', 'douyin'] }, url: string }, ['platform', 'url']) },
21
26
  { name: 'control_monitor', description: '开启或暂停指定博主的新动态或历史缓存;不改变另一类任务,不解除账号或平台冷却,需要确认。', parameters: objectSchema({ uid: string, kind: { enum: ['updates', 'history'] }, enabled: boolean }, ['uid', 'kind', 'enabled']) },
22
27
  { name: 'set_frequency', description: '为一个博主设置固定检查间隔(分钟),替换其分时规则,其他博主不变,需要确认。', parameters: objectSchema({ uid: string, minutes: { type: 'integer', minimum: 1, maximum: 10080 } }, ['uid', 'minutes']) },
23
28
  { name: 'create_summary_task', description: '创建能实际执行的本地总结任务,多个北京时间 HH:MM;默认工作日。每次总结当天本地消息,不是增量简报。首版只保存结果,不向 IM 自动发送。需要确认。', parameters: objectSchema({ name: string, uids, times: { type: 'array', items: string, minItems: 1, maxItems: 8 }, repeat: { enum: ['weekdays', 'daily'] }, instructions: string }, ['name', 'uids', 'times']) },
24
29
  { name: 'control_summary_task', description: '暂停或恢复已有总结任务,需确认。', parameters: objectSchema({ id: string, enabled: boolean }, ['id', 'enabled']) },
25
30
  { name: 'run_summary_task', description: '立即执行一次已有总结任务,仅保存本地报告,不改变原任务的定时开关或下一次时间,需要确认。', parameters: objectSchema({ id: string }, ['id']) },
26
31
  ];
27
- const reads = new Set(['list_status', 'search_posts', 'read_posts', 'read_author_index']);
32
+ const reads = new Set(['list_status', 'search_posts', 'read_posts', 'read_author_index', 'get_monitor_scope']);
28
33
  const writes = new Set(tools.filter(tool => !reads.has(tool.name)).map(tool => tool.name));
29
34
  const schemaMap = new Map(tools.map(tool => [tool.name, tool.parameters]));
30
35
  const dateAt = (value, end = false) => {
@@ -52,6 +57,7 @@ export class BusinessChat {
52
57
  CREATE TABLE IF NOT EXISTS chat_runs (id TEXT PRIMARY KEY,session_id TEXT NOT NULL REFERENCES chat_sessions(id),input TEXT NOT NULL,status TEXT NOT NULL,answer TEXT NOT NULL DEFAULT '',steps TEXT NOT NULL DEFAULT '[]',sources TEXT NOT NULL DEFAULT '[]',action TEXT,created_at INTEGER NOT NULL,finished_at INTEGER);
53
58
  CREATE INDEX IF NOT EXISTS chat_session_runs ON chat_runs(session_id,created_at);
54
59
  CREATE TABLE IF NOT EXISTS chat_tasks (id TEXT PRIMARY KEY,name TEXT NOT NULL,uids TEXT NOT NULL,times TEXT NOT NULL,repeat TEXT NOT NULL,instructions TEXT NOT NULL,enabled INTEGER NOT NULL DEFAULT 1,next_run_at INTEGER NOT NULL,last_run_id TEXT,created_at INTEGER NOT NULL);`);
60
+ db.exec('CREATE TABLE IF NOT EXISTS chat_source_ids (session_id TEXT NOT NULL REFERENCES chat_sessions(id),post_id TEXT NOT NULL,number INTEGER NOT NULL,PRIMARY KEY(session_id,post_id),UNIQUE(session_id,number))');
55
61
  for (const [table, column, definition] of [['chat_sessions', 'deleted_at', 'INTEGER'], ['chat_runs', 'error', "TEXT NOT NULL DEFAULT ''"], ['chat_runs', 'context', "TEXT NOT NULL DEFAULT '{}'"]]) {
56
62
  if (!db.prepare('PRAGMA table_info(' + table + ')').all().some(row => row.name === column)) db.exec('ALTER TABLE ' + table + ' ADD COLUMN ' + column + ' ' + definition);
57
63
  }
@@ -82,7 +88,7 @@ export class BusinessChat {
82
88
  sources: run.sources.map(({ sourceId, id, uid, author, publishedAt, url, truncated }) => ({ sourceId, id, uid, author, publishedAt, url, truncated })),
83
89
  steps: run.steps.map(step => ({ ...step, result: step.result ? { error: step.result.error, total: step.result.total, count: step.result.items?.length } : undefined })),
84
90
  })) : [];
85
- return { available: this.model.available(), sessions: this.sessions(), runs, tasks: this.tasks(), busy: this.busy?.id || null };
91
+ return { available: this.model.available(), runtime: this.model.info?.() || { driver: 'standalone-preview' }, sessions: this.sessions(), runs, tasks: this.tasks(), busy: this.busy?.id || null };
86
92
  }
87
93
  patch(id, patch) {
88
94
  const allowed = ['status', 'answer', 'steps', 'sources', 'action', 'finished_at', 'error', 'context'];
@@ -95,7 +101,7 @@ export class BusinessChat {
95
101
  const id = randomUUID(); this.store.db.prepare('INSERT INTO chat_sessions(id,title,created_at) VALUES(?,?,?)').run(id, title.slice(0, 40), this.now()); return id;
96
102
  }
97
103
  start(input, sessionId = '', task = null) {
98
- if (this.closed || this.busy || this.paused()) throw new Error('后台忙碌,请等待或停止当前任务');
104
+ if (this.closed || this.busy || this.confirmWork || this.paused()) throw new Error('后台忙碌,请等待或停止当前任务');
99
105
  input = text(input, 4000, '消息');
100
106
  if (sessionId && !this.store.db.prepare('SELECT id FROM chat_sessions WHERE id=? AND deleted_at IS NULL').get(sessionId)) throw new Error('对话不存在');
101
107
  const selection = this.model.selection();
@@ -104,7 +110,7 @@ export class BusinessChat {
104
110
  this.store.db.prepare("INSERT INTO chat_runs(id,session_id,input,status,created_at) VALUES(?,?,?,'running',?)").run(id, sessionId, input, this.now());
105
111
  this.controller = controller;
106
112
  this.busy = { id, sessionId, taskId: task?.id || null };
107
- const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(180000)]);
113
+ const signal = controller.signal;
108
114
  const aborted = new Promise((_, reject) => signal.addEventListener('abort', () => reject(new Error('执行已停止或超时,未完成的操作不会继续执行')), { once: true }));
109
115
  this.work = Promise.race([this.execute(id, sessionId, input, selection, signal, task), aborted])
110
116
  .catch(error => this.patch(id, { status: controller.signal.aborted ? 'cancelled' : 'failed', error: error.message, finished_at: this.now() }))
@@ -132,78 +138,58 @@ export class BusinessChat {
132
138
  return strings(ids).map(uid => { const target = this.store.target(uid); if (!target) throw new Error('博主不存在,请先查询准确 ID'); return target; });
133
139
  }
134
140
  async execute(id, sessionId, input, selection, signal, task) {
135
- const steps = [], references = new Map(); let size = 0, count = 0, readThisTurn = false;
136
- const history = this.runs(sessionId).filter(run => run.id !== id && run.status === 'completed').slice(-budgets.historyRuns);
137
- let number = 0;
141
+ const steps = [], references = new Map(); let readThisTurn = false;
142
+ const history = this.runs(sessionId).filter(run => run.id !== id && ['completed', 'partial'].includes(run.status)).slice(-budgets.historyRuns);
143
+ let number = this.store.db.prepare('SELECT COALESCE(MAX(number),0) AS n FROM chat_source_ids WHERE session_id=?').get(sessionId).n;
138
144
  for (const run of history) for (const source of run.sources) if (this.store.target(source.uid)) {
139
145
  references.set(source.id, source); number = Math.max(number, Number(source.sourceId.slice(1)) || 0);
146
+ this.store.db.prepare('INSERT OR IGNORE INTO chat_source_ids(session_id,post_id,number) VALUES(?,?,?)').run(sessionId, source.id, Number(source.sourceId.slice(1)));
140
147
  }
141
148
  const greeting = /^(?:你好|您好|嗨|hello|hi|早上好|晚上好)[\s!!,,。.??]*$/i.test(input.trim());
142
149
  const availableTools = greeting && !task ? [] : task ? tools.filter(tool => reads.has(tool.name)) : tools;
143
150
  const allowedTools = new Set(availableTools.map(tool => tool.name));
144
- const { system, messages, workingContext } = assembleContext({ input, history, task, now: this.now(), tools: availableTools, selection });
151
+ const { system, messages, workingContext } = assembleContext({ input, history, task, now: this.now(), tools: availableTools, selection, sessionId, driver: this.model.run ? 'dsh-agent' : 'standalone-preview' });
145
152
  this.patch(id, { context: workingContext });
146
153
  const sourceFor = (post, full) => {
147
154
  if (task && !task.uids.includes(post.uid)) throw new Error('该博主不在任务范围内');
148
155
  if (task && (post.published_at < dateAt(workingContext.dateScope.from) || post.published_at >= dateAt(workingContext.dateScope.to, true))) throw new Error('该消息不在本次总结日期内');
149
- const old = references.get(post.id);
150
- const source = { sourceId: old?.sourceId || 'S' + (++number), id: post.id, uid: post.uid, author: post.author, publishedAt: post.published_at,
156
+ let sourceNumber = this.store.db.prepare('SELECT number FROM chat_source_ids WHERE session_id=? AND post_id=?').get(sessionId, post.id)?.number;
157
+ if (!sourceNumber) { sourceNumber = ++number; this.store.db.prepare('INSERT INTO chat_source_ids(session_id,post_id,number) VALUES(?,?,?)').run(sessionId, post.id, sourceNumber); }
158
+ const source = { sourceId: 'S' + sourceNumber, id: post.id, uid: post.uid, author: post.author, publishedAt: post.published_at,
151
159
  article: post.article === true,
160
+ ...(post.video ? { video: { summary: { ...post.video.summary, abstract: post.video.summary.abstract.slice(0, full ? 6000 : 2000), chapters: (post.video.summary.chapters || []).slice(0, full ? 20 : 8).map(chapter => ({ ...chapter, title: chapter.title.slice(0, 200), text: chapter.text.slice(0, full ? 1500 : 500) })) }, transcript: post.video.transcript.slice(0, full ? 12000 : 1500), stages: post.video.stages } } : {}),
152
161
  detectedAt: post.detected_at, firstSavedAt: post.first_seen_at, url: post.url, excerpt: post.text.slice(0, full ? 12000 : 800),
153
162
  quote: post.quote ? { author: post.quote.author, text: post.quote.text.slice(0, full ? 6000 : 500), article: post.quote.article === true } : null,
154
- truncated: post.text.length > (full ? 12000 : 800) || !!post.quote && post.quote.text.length > (full ? 6000 : 500) };
163
+ truncated: !!post.video && (post.video.transcript.length > (full ? 12000 : 1500) || post.video.summary.abstract.length > (full ? 6000 : 2000) || post.video.summary.chapters?.length > (full ? 20 : 8) || post.video.summary.chapters?.some(chapter => chapter.text.length > (full ? 1500 : 500) || chapter.title.length > 200)) || post.text.length > (full ? 12000 : 800) || !!post.quote && post.quote.text.length > (full ? 6000 : 500) };
155
164
  references.set(post.id, source); return source;
156
165
  };
157
- for (let round = 0; round < budgets.rounds; round++) {
166
+ const pendingActions = [], guard = new ProgressGuard(); let failedCall = false, batchFailed = false;
167
+ const perform = async call => {
158
168
  signal.throwIfAborted();
159
- const step = { kind: 'model', round: round + 1, model: selection.model, status: 'running', phase: 'requesting', startedAt: this.now() };
160
- steps.push(step);
161
- this.patch(id, { answer: '', steps });
162
- let streamed = '', last = 0;
163
- const response = await this.model.generate({ system, messages, tools: availableTools, selection, signal, onPhase: phase => {
164
- signal.throwIfAborted();
165
- if (['thinking', 'answering', 'preparing_tools'].includes(phase)) { step.phase = phase; this.patch(id, { steps }); }
166
- }, onText: delta => {
167
- streamed += delta; signal.throwIfAborted();
168
- if (streamed.length > 18000) throw new Error('回答超过长度限制,请缩小问题范围');
169
- if (this.now() - last > 250) { this.patch(id, { answer: streamed }); last = this.now(); }
170
- } });
171
- signal.throwIfAborted();
172
- Object.assign(step, { status: 'completed', finishedAt: this.now(), usage: response.usage || null });
173
- if (!response.calls.length) {
174
- const answer = text(response.text, 18000, '模型回答');
175
- const cited = [...answer.matchAll(/\[S(\d+)\]/g)].map(match => match[0].slice(1, -1));
176
- if (cited.some(sourceId => ![...references.values()].some(source => source.sourceId === sourceId)) || readThisTurn && !cited.length) {
177
- steps.push({ kind: 'validation', name: 'validate_sources', status: 'failed', finishedAt: this.now() });
178
- this.patch(id, { answer, steps, sources: [...references.values()] });
179
- throw new Error('来源核对未通过:部分引用不存在或未标注,以上内容尚不能作为已核实结论');
180
- }
181
- this.patch(id, { status: 'completed', answer, steps, sources: [...references.values()].filter(source => cited.includes(source.sourceId)), finished_at: this.now() });
182
- return;
183
- }
184
- messages.push({ id: randomUUID(), role: 'assistant', source: response.source, content: response.content });
185
- const pendingActions = []; let failedCall = false;
186
- for (const call of response.calls) {
187
- signal.throwIfAborted();
188
- if (++count > budgets.toolCalls) throw new Error('工具调用达到上限,请缩小问题范围');
189
- const toolStep = { kind: 'tool', name: call.name, status: 'running', startedAt: this.now() };
190
- steps.push(toolStep); this.patch(id, { steps });
191
- let result, failed = false;
192
- try {
193
- if (!allowedTools.has(call.name)) throw new Error('本次执行未开放该工具');
194
- const args = JSON.parse(call.arguments); this.validate(call.name, args);
195
- if (task && writes.has(call.name)) throw new Error('定时任务不能修改监控配置');
196
- if (writes.has(call.name)) {
197
- this.checkWrite(call.name, args);
198
- const action = { name: call.name, args, label: this.actionLabel(call.name, args) };
199
- Object.assign(toolStep, { status: 'awaiting_confirmation', actionIndex: pendingActions.length, finishedAt: this.now() });
200
- pendingActions.push(action);
201
- continue;
202
- }
203
- if (call.name === 'list_status') {
169
+ const toolStep = { kind: 'tool', name: call.name, status: 'running', startedAt: this.now() };
170
+ steps.push(toolStep); this.patch(id, { steps });
171
+ let result, failed = false, args;
172
+ try {
173
+ if (!allowedTools.has(call.name)) throw new Error('本次执行未开放该工具');
174
+ args = JSON.parse(call.arguments); this.validate(call.name, args);
175
+ if (task && writes.has(call.name)) throw new Error('定时任务不能修改监控配置');
176
+ if (writes.has(call.name)) {
177
+ this.checkWrite(call.name, args);
178
+ if (batchFailed) failedCall = true;
179
+ const action = { name: call.name, args, label: this.actionLabel(call.name, args) };
180
+ const existing = pendingActions.findIndex(item => item.name === action.name && JSON.stringify(item.args) === JSON.stringify(action.args));
181
+ toolStep.actionIndex = existing >= 0 ? existing : pendingActions.length;
182
+ if (existing < 0) pendingActions.push(action);
183
+ result = { pendingConfirmation: true, label: action.label, note: '尚未执行,等待界面确认;同一请求的其他对象也应一起提交预览' };
184
+ } else {
185
+ if (call.name === 'get_monitor_scope') {
186
+ this.targets(args.uids);
187
+ if (task && args.uids.some(uid => !task.uids.includes(uid))) throw new Error('不能读取任务范围之外的博主');
188
+ result = { targets: args.uids.map(uid => this.business.scopeInfo(uid)) };
189
+ } else if (call.name === 'list_status') {
204
190
  result = { targets: this.store.targets().filter(target => !task || task.uids.includes(target.uid)).slice(0, 200).map(target => ({ uid: target.uid, name: target.name, platform: target.platform,
205
191
  enabled: this.store.monitoringEnabled(target.platform, target.uid), accountStatus: this.store.account(target.platform).status,
206
- policy: this.store.monitoringPolicy(target), jobs: this.store.jobs().filter(job => job.uid === target.uid).map(job => ({ id: job.id, kind: job.kind, status: job.status, lastCheckedAt: job.last_success_at })) })), tasks: task ? [] : this.tasks() };
192
+ policy: this.store.monitoringPolicy(target), ...platformModule(target.platform).publicConfig?.(this.store, target), jobs: this.store.jobs().filter(job => job.uid === target.uid).map(job => ({ id: job.id, kind: job.kind, status: job.status, lastCheckedAt: job.last_success_at })) })), tasks: task ? [] : this.tasks() };
207
193
  } else if (call.name === 'read_author_index') {
208
194
  this.targets([args.uid]);
209
195
  if (task && !task.uids.includes(args.uid)) throw new Error('不能读取任务范围之外的博主');
@@ -214,14 +200,8 @@ export class BusinessChat {
214
200
  const to = Math.min(dateAt(args.to, true), task ? dateAt(workingContext.dateScope.to, true) : Infinity);
215
201
  if (task && targets.some(target => !task.uids.includes(target.uid))) throw new Error('不能读取任务范围之外的博主');
216
202
  if (from >= to || args.query !== undefined && (typeof args.query !== 'string' || args.query.length > 200)) throw new Error('时间范围或关键词无效');
217
- const where = ['complete=1', 'uid IN (' + targets.map(() => '?').join(',') + ')', 'published_at>=?', 'published_at<?'];
218
- const values = [...targets.map(target => target.uid), from, Number.isFinite(to) ? to : Number.MAX_SAFE_INTEGER];
219
- if (args.query) { where.push('(instr(text,?)>0 OR instr(COALESCE(quote,\'\'),?)>0)'); values.push(args.query, args.query); }
220
- if (args.kind === 'original') where.push('quote IS NULL');
221
- if (args.kind === 'repost') where.push('quote IS NOT NULL');
222
- const sql = where.join(' AND '), total = this.store.db.prepare('SELECT COUNT(*) AS n FROM posts WHERE ' + sql).get(...values).n;
223
- const ids = this.store.db.prepare('SELECT id FROM posts WHERE ' + sql + ' ORDER BY published_at DESC,id DESC LIMIT 20 OFFSET ?').all(...values, args.offset || 0).map(row => row.id);
224
- result = { total, offset: args.offset || 0, items: this.store.posts({ ids, limit: 20 }).map(post => sourceFor(post, false)), note: '仅查询本地完整资料;总数超过本页时不能声称已覆盖全部' };
203
+ const found = this.store.searchPosts({ uids: targets.map(target => target.uid), from, to: Number.isFinite(to) ? to : Number.MAX_SAFE_INTEGER, query: args.query, kind: args.kind, offset: args.offset || 0 });
204
+ result = { ...found, items: found.items.map(post => sourceFor(post, false)), note: '仅查询本地完整资料;总数超过本页时不能声称已覆盖全部' };
225
205
  if (result.items.length) readThisTurn = true;
226
206
  } else if (call.name === 'read_posts') {
227
207
  const ids = strings(args.ids, 8), posts = this.store.posts({ ids, limit: 8 });
@@ -229,13 +209,18 @@ export class BusinessChat {
229
209
  result = { items: posts.map(post => sourceFor(post, true)) };
230
210
  readThisTurn = true;
231
211
  } else throw new Error('工具不存在');
232
- } catch (error) { result = { error: error.message }; failed = true; failedCall = true; }
233
- const encoded = JSON.stringify(result); size += encoded.length;
234
- if (size > budgets.toolResultChars) throw new Error('本次资料超出上下文预算,请分博主或缩小时间范围后重试;未标记资料已处理');
235
- Object.assign(toolStep, { status: failed ? 'failed' : 'completed', result, finishedAt: this.now() });
236
- this.patch(id, { steps, sources: [...references.values()] });
237
- messages.push({ id: randomUUID(), role: 'tool', source: { kind: 'tool', callId: call.id }, toolCallId: call.id, isError: failed, content: [{ type: 'text', text: encoded }] });
238
- }
212
+
213
+ }
214
+ } catch (error) { result = { error: error.message }; failed = true; batchFailed = true; if (writes.has(call.name) || pendingActions.length) failedCall = true; }
215
+ Object.assign(toolStep, { status: failed ? 'failed' : writes.has(call.name) ? 'awaiting_confirmation' : 'completed', result, finishedAt: this.now() });
216
+ this.patch(id, { steps, sources: [...references.values()] });
217
+ const warning = guard.observe(call.name, args, result);
218
+ if (warning) result = { ...result, progressWarning: warning };
219
+ Object.assign(toolStep, { status: failed ? 'failed' : writes.has(call.name) ? 'awaiting_confirmation' : 'completed', result, finishedAt: this.now() });
220
+ this.patch(id, { steps, sources: [...references.values()] });
221
+ return result;
222
+ };
223
+ const finalize = (answer, partial = false) => {
239
224
  if (pendingActions.length) {
240
225
  if (failedCall) {
241
226
  for (const step of steps.filter(step => step.status === 'awaiting_confirmation')) step.status = 'cancelled';
@@ -246,11 +231,67 @@ export class BusinessChat {
246
231
  this.patch(id, { status: 'awaiting_confirmation', answer: '请确认以下变更,确认前不会修改配置。', steps, sources: [...references.values()], action });
247
232
  return;
248
233
  }
234
+ answer = text(answer, budgets.answerChars, '模型回答');
235
+ const cited = [...answer.matchAll(/\[S(\d+)\]/g)].map(match => match[0].slice(1, -1));
236
+ if (!partial && (cited.some(sourceId => ![...references.values()].some(source => source.sourceId === sourceId)) || readThisTurn && !cited.length)) {
237
+ steps.push({ kind: 'validation', name: 'validate_sources', status: 'failed', finishedAt: this.now() });
238
+ this.patch(id, { answer, steps, sources: [...references.values()] });
239
+ throw new Error('来源核对未通过:部分引用不存在或未标注,以上内容尚不能作为已核实结论');
240
+ }
241
+ this.patch(id, { status: partial ? 'partial' : 'completed', answer, steps, sources: [...references.values()].filter(source => partial || cited.includes(source.sourceId)), finished_at: this.now() });
242
+ };
243
+ if (this.model.run) {
244
+ let currentStep, lastTextAt = 0;
245
+ const response = await this.model.run({ sessionId, system, messages, tools: availableTools, catalog: tools, selection, signal,
246
+ resume: this.store.getSetting('hostAgentSession:' + sessionId, false),
247
+ onSession: () => this.store.setSetting('hostAgentSession:' + sessionId, true),
248
+ onTool: perform,
249
+ onModel: (phase, usage) => {
250
+ if (phase === 'start') { batchFailed = false; currentStep = { kind: 'model', model: selection.model, status: 'running', startedAt: this.now() }; steps.push(currentStep); }
251
+ else if (currentStep) Object.assign(currentStep, { status: 'completed', usage, finishedAt: this.now() });
252
+ this.patch(id, { steps });
253
+ },
254
+ onPhase: phase => { if (currentStep && currentStep.phase !== phase) { currentStep.phase = phase; this.patch(id, { steps }); } },
255
+ onText: answer => { signal.throwIfAborted(); if (this.now() - lastTextAt >= 250) { this.patch(id, { answer }); lastTextAt = this.now(); } },
256
+ });
257
+ finalize(response.text, response.truncated); return;
258
+ }
259
+ // Standalone preview/test adapter only. Production uses the host Agent above.
260
+ let answerPrefix = '', continuations = 0;
261
+ for (let round = 0; ; round++) {
262
+ signal.throwIfAborted();
263
+ const step = { kind: 'model', round: round + 1, model: selection.model, status: 'running', phase: 'requesting', startedAt: this.now() };
264
+ steps.push(step); this.patch(id, { answer: answerPrefix, steps });
265
+ let streamed = '';
266
+ const response = await this.model.generate({ system, messages, tools: continuations ? [] : availableTools, selection, signal,
267
+ onPhase: phase => { step.phase = phase; this.patch(id, { steps }); },
268
+ onText: delta => { streamed += delta; signal.throwIfAborted(); this.patch(id, { answer: answerPrefix + streamed }); },
269
+ });
270
+ signal.throwIfAborted(); Object.assign(step, { status: 'completed', finishedAt: this.now(), usage: response.usage || null });
271
+ if (continuations && response.calls.length) throw new Error('续写只允许补全回答,未执行额外工具或配置操作');
272
+ if (response.truncated) {
273
+ if (response.calls.length) throw new Error('操作请求未生成完整,未执行任何配置修改');
274
+ answerPrefix += response.text || streamed;
275
+ if (continuations >= budgets.outputContinuations) { finalize(answerPrefix, true); return; }
276
+ continuations++;
277
+ if (response.content?.length) messages.push({ id: randomUUID(), role: 'assistant', source: response.source, content: response.content });
278
+ messages.push(message('user', '请从上一条回答的中断处继续,只补全尚未输出的正文,不重复前文。不调用工具,不提出或执行配置修改。', selection));
279
+ continue;
280
+ }
281
+ if (!response.calls.length) { finalize(answerPrefix + response.text); return; }
282
+ messages.push({ id: randomUUID(), role: 'assistant', source: response.source, content: response.content });
283
+ batchFailed = false;
284
+ for (const call of response.calls) {
285
+ const result = await perform(call);
286
+ messages.push({ id: randomUUID(), role: 'tool', source: { kind: 'tool', callId: call.id }, toolCallId: call.id, isError: !!result.error, content: [{ type: 'text', text: JSON.stringify(result) }] });
287
+ }
288
+ if (pendingActions.length) { finalize(''); return; }
249
289
  }
250
- throw new Error('达到本次执行轮数上限,请缩小问题范围后重试');
251
290
  }
252
291
  checkWrite(name, args) {
292
+ if (name === 'set_monitor_scope') this.business.scopePlan(args);
253
293
  if (['control_monitor', 'set_frequency'].includes(name)) this.targets([args.uid]);
294
+ if (name === 'set_frequency' && platformModule(this.store.target(args.uid)?.platform).intervalScheduling === false) throw new Error(platformModule(this.store.target(args.uid).platform).scheduleHelp);
254
295
  if (name === 'control_monitor' && !['updates', 'history'].includes(args.kind)) throw new Error('请选择新动态或历史缓存');
255
296
  if (name === 'add_monitor') text(args.url, 300, '主页');
256
297
  if (name === 'create_summary_task') {
@@ -263,21 +304,22 @@ export class BusinessChat {
263
304
  }
264
305
  actionLabel(name, args) {
265
306
  const names = args.uid ? this.store.target(args.uid).name : args.uids ? this.targets(args.uids).map(target => target.name).join('、') : '';
266
- return name === 'control_monitor' ? `${names}:${args.enabled ? '开启' : '暂停'}${args.kind === 'updates' ? '新动态监控' : '历史缓存'}`
307
+ return name === 'set_monitor_scope' ? `${names}:${Object.entries(args).filter(([key]) => key !== 'uids').map(([key, value]) => ({ from: '起始日期', to: '结束日期', originalOnly: '仅原创', historyEnabled: '历史缓存', saveVideo: '保存视频', saveAudio: '保存音频', platformAI: '平台解析', transcribe: '本地转写', times: '检查时间', repeat: '执行日期' }[key] + ':' + (typeof value === 'boolean' ? value ? '开启' : '关闭' : Array.isArray(value) ? value.join('、') : value === 'daily' ? '每天' : value === 'weekdays' ? '工作日' : value || '不限制'))).join(';')}`
308
+ : name === 'control_monitor' ? `${names}:${args.enabled ? '开启' : '暂停'}${args.kind === 'updates' ? '新动态监控' : '历史缓存'}`
267
309
  : name === 'set_frequency' ? `${names}:改为固定每 ${args.minutes} 分钟检查,替换该博主的分时规则`
268
- : name === 'add_monitor' ? `添加${args.platform === 'weibo' ? '微博' : '雪球'}博主 ${args.url},从现在开始监控`
310
+ : name === 'add_monitor' ? `添加${{ weibo: '微博', xueqiu: '雪球', douyin: '抖音' }[args.platform]}博主 ${args.url},从现在开始监控`
269
311
  : name === 'create_summary_task' ? `创建总结任务「${args.name}」:${names};${args.repeat === 'daily' ? '每天' : '工作日'} ${args.times.join('、')}(北京时间);仅保存本地`
270
312
  : name === 'run_summary_task' ? `立即执行总结任务「${this.tasks().find(task => task.id === args.id)?.name}」,仅保存本地报告`
271
313
  : `${args.enabled ? '恢复' : '暂停'}总结任务「${this.tasks().find(task => task.id === args.id)?.name}」`;
272
314
  }
273
315
  confirm(id) {
274
- if (this.busy || this.closed || this.paused()) throw new Error('请等当前执行结束后再确认');
316
+ if (this.busy || this.confirmWork || this.closed || this.paused()) throw new Error('请等当前执行结束后再确认');
275
317
  const run = this.store.db.prepare('SELECT * FROM chat_runs WHERE id=?').get(id);
276
318
  if (!run || run.status !== 'awaiting_confirmation' || !run.action) throw new Error('该变更已处理或不存在,未重复执行');
277
319
  if (this.now() - run.created_at > 600000) throw new Error('变更预览已过期,请重新提出请求');
278
320
  if (!this.store.db.prepare('SELECT id FROM chat_sessions WHERE id=? AND deleted_at IS NULL').get(run.session_id)) throw new Error('该对话已删除,请先恢复对话再确认');
279
321
  const preview = JSON.parse(run.action), actions = preview.name === 'batch' ? preview.actions : [preview];
280
- if (!Array.isArray(actions) || !actions.length || actions.length > budgets.toolCalls) throw new Error('变更预览格式无效');
322
+ if (!Array.isArray(actions) || !actions.length || actions.length > 200) throw new Error('变更预览格式无效');
281
323
  for (const action of actions) {
282
324
  if (!writes.has(action.name)) throw new Error('变更工具无效');
283
325
  this.validate(action.name, action.args); this.checkWrite(action.name, action.args);
@@ -286,11 +328,12 @@ export class BusinessChat {
286
328
  this.patch(id, { status: 'executing' });
287
329
  const steps = JSON.parse(run.steps), results = [], receipts = [];
288
330
  const prefix = actions.length === 1 ? '已执行:' : '已执行:\n';
331
+ const operation = (async () => {
289
332
  for (let index = 0; index < actions.length; index++) {
290
333
  const action = actions[index];
291
334
  const pending = steps.find(step => step.kind === 'tool' && step.status === 'awaiting_confirmation' && (step.actionIndex === index || step.actionIndex === undefined && step.name === action.name));
292
335
  try {
293
- const result = this.executeAction(action);
336
+ const result = await this.executeAction(action);
294
337
  results.push(result); receipts.push(action.label + (result.note ? '\n' + result.note : ''));
295
338
  if (pending) pending.status = 'completed';
296
339
  steps.push({ kind: 'operation', name: action.name, label: action.label, status: 'completed', result });
@@ -309,11 +352,15 @@ export class BusinessChat {
309
352
  }
310
353
  this.patch(id, { status: 'completed', answer: prefix + receipts.join('\n\n'), steps, action: null, finished_at: this.now() });
311
354
  return actions.length === 1 ? { result: results[0] } : { results };
355
+ })();
356
+ this.confirmWork = operation;
357
+ return operation.finally(() => { this.confirmWork = null; });
312
358
  }
313
359
  executeAction(action) {
314
360
  const args = action.args;
315
361
  let result;
316
- if (action.name === 'add_monitor') result = this.business.add(args.platform, args.url);
362
+ if (action.name === 'set_monitor_scope') result = this.business.scope(args);
363
+ else if (action.name === 'add_monitor') result = this.business.add(args.platform, args.url);
317
364
  else if (action.name === 'control_monitor') {
318
365
  const job = this.store.jobs().find(job => job.uid === args.uid && job.kind === args.kind);
319
366
  if (!job) throw new Error('任务不存在');
@@ -352,5 +399,5 @@ export class BusinessChat {
352
399
  this.store.db.prepare('UPDATE chat_tasks SET next_run_at=?,last_run_id=? WHERE id=?').run(scheduled ? nextTaskAt(task, this.now()) : task.next_run_at, result.id, task.id);
353
400
  return { runId: result.id, sessionId: result.sessionId, note: '已受理,报告生成中;完成结果请查看任务的最近报告' };
354
401
  }
355
- async close() { this.closed = true; clearInterval(this.timer); this.controller?.abort(); if (this.work) await this.work; }
402
+ async close() { this.closed = true; clearInterval(this.timer); this.controller?.abort(); if (this.work) await this.work; if (this.confirmWork) await this.confirmWork.catch(() => {}); await this.model.close?.(); }
356
403
  }