dsh-social-trace 0.1.13 → 0.1.20
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 +12 -5
- package/assets/NOTICE.md +2 -0
- package/assets/douyin.ico +0 -0
- package/lib/agent.js +4 -4
- package/lib/business.js +74 -1
- package/lib/chat-context.js +9 -6
- package/lib/chat-model.js +5 -4
- package/lib/chat-runtime.js +117 -0
- package/lib/chat.js +129 -82
- package/lib/client.js +159 -45
- package/lib/documents.js +13 -1
- package/lib/index.js +69 -39
- package/lib/plain-text.js +10 -0
- package/lib/platforms/douyin/client.js +44 -0
- package/lib/platforms/douyin/config.js +33 -0
- package/lib/platforms/douyin/data.js +49 -0
- package/lib/platforms/douyin/index.js +163 -0
- package/lib/platforms/douyin/library.js +154 -0
- package/lib/platforms/douyin/media.js +12 -0
- package/lib/platforms/douyin/module.js +18 -0
- package/lib/platforms/douyin/service.js +34 -0
- package/lib/platforms/douyin/storage.js +39 -0
- package/lib/platforms/douyin/transcribe.py +27 -0
- package/lib/platforms/registry.js +7 -0
- package/lib/platforms/weibo/index.js +1 -1
- package/lib/platforms/weibo/media.js +19 -0
- package/lib/platforms/weibo/module.js +5 -0
- package/lib/platforms/weibo/recovery.js +17 -0
- package/lib/platforms/xueqiu/module.js +5 -0
- package/lib/store.js +42 -11
- package/lib/text.js +10 -35
- package/lib/worker.js +71 -4
- package/package.json +9 -4
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: '
|
|
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 =
|
|
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
|
|
136
|
-
const history = this.runs(sessionId).filter(run => run.id !== id && run.status
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
166
|
+
const pendingActions = [], guard = new ProgressGuard(); let failedCall = false, batchFailed = false;
|
|
167
|
+
const perform = async call => {
|
|
158
168
|
signal.throwIfAborted();
|
|
159
|
-
const
|
|
160
|
-
steps.push(
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
218
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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 === '
|
|
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' ? `添加${
|
|
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 >
|
|
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 === '
|
|
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
|
}
|