@steerable/agent-shell 0.6.20 → 0.6.22
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 +34 -0
- package/dist/auth/index.d.ts +38 -0
- package/dist/auth/index.js +20 -0
- package/dist/host/pack-assembly.d.ts +7 -7
- package/dist/host/runtime.d.ts +6 -2
- package/dist/host/runtime.js +54 -15
- package/dist/host/visible-terminal-exec.d.ts +0 -2
- package/dist/host/visible-terminal-exec.js +4 -8
- package/dist/insights/flush.d.ts +5 -4
- package/dist/insights/flush.js +14 -15
- package/dist/insights/record.d.ts +6 -5
- package/dist/insights/record.js +9 -10
- package/dist/llm/index.d.ts +6 -2
- package/dist/llm/index.js +19 -9
- package/dist/local-backend/ai-suggestions.d.ts +32 -0
- package/dist/local-backend/ai-suggestions.js +160 -0
- package/dist/local-backend/coreloop-stream.js +1 -1
- package/dist/local-backend/mentioned-paths.d.ts +28 -0
- package/dist/local-backend/mentioned-paths.js +67 -0
- package/dist/local-backend/prompt-builder.d.ts +10 -4
- package/dist/local-backend/prompt-builder.js +13 -12
- package/dist/local-backend/router.d.ts +26 -4
- package/dist/local-backend/router.js +243 -96
- package/dist/local-backend/skills/00-identity/SKILL.md +1 -1
- package/dist/local-backend/task-service.d.ts +14 -13
- package/dist/local-backend/task-service.js +32 -29
- package/dist/local-backend/turn-files.d.ts +57 -0
- package/dist/local-backend/turn-files.js +285 -0
- package/dist/local-backend/worktree-service.d.ts +1 -1
- package/dist/local-backend/worktree-service.js +6 -6
- package/dist/main.js +45 -26
- package/dist/preload.cjs +30 -10
- package/dist/preload.js +33 -10
- package/dist/server/http-server.d.ts +13 -1
- package/dist/server/http-server.js +45 -8
- package/dist/server/index.js +4 -90
- package/dist/server/sse-bus.d.ts +1 -1
- package/dist/server/start.d.ts +26 -0
- package/dist/server/start.js +111 -0
- package/dist/sidecar/boot.d.ts +3 -1
- package/dist/sidecar/boot.js +6 -7
- package/dist/sidecar/reverse-approval.d.ts +2 -0
- package/dist/sidecar/reverse-approval.js +2 -1
- package/dist/sidecar/reverse-ask-user.d.ts +2 -0
- package/dist/sidecar/reverse-ask-user.js +2 -1
- package/dist/sidecar/reverse-spawn.js +12 -4
- package/dist/sidecar/reverse-tools.d.ts +1 -1
- package/dist/sidecar/reverse-tools.js +1 -1
- package/dist/sidecar/storage-path.js +2 -3
- package/dist/storage/driver.d.ts +57 -0
- package/dist/storage/driver.js +104 -0
- package/dist/storage/index.d.ts +80 -67
- package/dist/storage/index.js +528 -296
- package/dist/storage/pack-migrations.d.ts +2 -4
- package/dist/storage/pack-migrations.js +2 -4
- package/dist/storage/scoped-store.d.ts +125 -0
- package/dist/storage/scoped-store.js +1 -0
- package/dist/storage/sqlite-driver.d.ts +16 -0
- package/dist/storage/sqlite-driver.js +135 -0
- package/dist/storage/write-lease-error.d.ts +11 -0
- package/dist/storage/write-lease-error.js +18 -0
- package/package.json +14 -5
- package/dist/storage/local-store-singleton.d.ts +0 -28
- package/dist/storage/local-store-singleton.js +0 -38
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
5
|
import os from 'node:os';
|
|
5
|
-
import { getAppRootDir } from '../runtime.js';
|
|
6
|
+
import { getAppRootDir, shellOpenPath } from '../runtime.js';
|
|
6
7
|
import { llmService, getSidecarSupervisor, whenSidecarSupervisor } from '../llm/index.js';
|
|
7
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
import { buildWorldState, getActiveCoreLoopStreamId, streamCoreLoopTurn, } from './coreloop-stream.js';
|
|
@@ -10,7 +11,7 @@ import { driveWithAutoContinue, resolveAutoContinueMax, } from './auto-continue-
|
|
|
10
11
|
import { builtinSubagentParam } from './subagent-profiles.js';
|
|
11
12
|
import { appendTimelineDelta, syncTimelineTools, } from './turn-timeline.js';
|
|
12
13
|
import { turnDurationMs } from './turn-duration.js';
|
|
13
|
-
import {
|
|
14
|
+
import { DEFAULT_SYSTEM_PROMPT, telemetryEnabled } from '../storage/index.js';
|
|
14
15
|
import { isSkillPinned, isToolAllowed, mergeAgentCapabilities, normalizeToolPolicy, resolveSkillExcludes, } from './agent-capability.js';
|
|
15
16
|
import { getBrand } from '../brand.js';
|
|
16
17
|
import { recordInsightEvent, recordInsightTurn, recordInsightProfile } from '../insights/record.js';
|
|
@@ -21,11 +22,12 @@ import { allowEgressForBaseUrl, getActiveEgressBroker, getEgressPosture } from '
|
|
|
21
22
|
import { buildExecSandbox, parseExecPolicy } from '../sidecar/exec-sandbox.js';
|
|
22
23
|
import { SidecarSupervisor } from '../sidecar/index.js';
|
|
23
24
|
import { diagnoseLlmConnection } from './llm-diagnose.js';
|
|
24
|
-
import { buildSystemPrompt, buildForcedSkillMessage, conditionsFromTools, } from './prompt-builder.js';
|
|
25
|
+
import { brandSkillVars, buildSystemPrompt, buildForcedSkillMessage, conditionsFromTools, } from './prompt-builder.js';
|
|
25
26
|
import { parseUserMessageTriggers } from './message-triggers.js';
|
|
26
27
|
import { findSkill, loadSkills, classifySkillOrigin, listSkillRoots } from './skill-loader.js';
|
|
27
28
|
import { installSkillFromDirectory } from './skill-install.js';
|
|
28
29
|
import { generateChatTitle } from './ai-title.js';
|
|
30
|
+
import { fallbackSuggestedReplies, generateSuggestedReplies, } from './ai-suggestions.js';
|
|
29
31
|
import { detectDeferredExecution } from './deferred-detector.js';
|
|
30
32
|
import { formatHistoryForSummary, truncateMiddle, } from './context-compactor.js';
|
|
31
33
|
import { planRegenerateTruncate, resolveRegenerateContext, resolveRegenerateForkOrdinal, } from './regenerate-helper.js';
|
|
@@ -37,6 +39,9 @@ import { loadProjectRuleFiles } from '../project-rules.js';
|
|
|
37
39
|
import { registerLiveStream, getLiveStream, removeLiveStream } from './live-stream.js';
|
|
38
40
|
import { beginPackTurnObservers, collectPackExecWritableRoots, collectPackForcedSkillVars, collectPackWorldState, } from './pack-turn-hooks.js';
|
|
39
41
|
import { matchPackBackendRoute } from './pack-backend-routes.js';
|
|
42
|
+
import { collectTurnFiles } from './turn-files.js';
|
|
43
|
+
import { resolveMentionedPaths } from './mentioned-paths.js';
|
|
44
|
+
import { getAuthProvider } from '../auth/index.js';
|
|
40
45
|
/**
|
|
41
46
|
* 把 CoreLoop/LLM 的底层错误翻译成适合直接显示在助手消息里的中文提示。
|
|
42
47
|
* 原始错误仍保存在 messageMetadata.completionReason 里,便于排查。
|
|
@@ -59,7 +64,7 @@ export function userFacingCoreLoopFailure(reason) {
|
|
|
59
64
|
/**
|
|
60
65
|
* Parses a pagination-style query param (`?page=`, `?limit=`) into a finite
|
|
61
66
|
* positive integer, falling back to `fallback` for missing/non-numeric input
|
|
62
|
-
* (e.g. `?page=abc`) instead of letting `NaN` leak into
|
|
67
|
+
* (e.g. `?page=abc`) instead of letting `NaN` leak into storage queries
|
|
63
68
|
* and the echoed-back `pagination` block of the response.
|
|
64
69
|
*/
|
|
65
70
|
function parsePositiveIntParam(raw, fallback) {
|
|
@@ -145,19 +150,44 @@ function buildLocalAgent() {
|
|
|
145
150
|
}
|
|
146
151
|
export class LocalBackendRouter {
|
|
147
152
|
toolRouter;
|
|
153
|
+
defaultStore;
|
|
154
|
+
storeContext = new AsyncLocalStorage();
|
|
155
|
+
resolveStore;
|
|
148
156
|
broadcast;
|
|
149
157
|
/** 4.6a/4.6c:任务面板的路由入口(列表 + 合并/丢弃)。未注入时任务路由 503。 */
|
|
150
158
|
taskService;
|
|
151
|
-
constructor(toolRouter, options
|
|
159
|
+
constructor(toolRouter, options) {
|
|
152
160
|
this.toolRouter = toolRouter;
|
|
161
|
+
this.defaultStore = options.store;
|
|
162
|
+
this.resolveStore = options.resolveStore ?? (() => options.store);
|
|
153
163
|
this.broadcast = options.broadcast ?? null;
|
|
154
164
|
this.taskService = options.taskService ?? null;
|
|
155
165
|
}
|
|
156
|
-
|
|
166
|
+
get store() {
|
|
167
|
+
return this.storeContext.getStore() ?? this.defaultStore;
|
|
168
|
+
}
|
|
169
|
+
/** Store selected for the active request, or the host default outside one. */
|
|
170
|
+
get activeStore() {
|
|
171
|
+
return this.store;
|
|
172
|
+
}
|
|
173
|
+
handle(request) {
|
|
174
|
+
return this.storeContext.run(this.resolveStore(request.principal), () => this.handleScoped(request));
|
|
175
|
+
}
|
|
176
|
+
async handleScoped(request) {
|
|
157
177
|
const { method } = request;
|
|
158
178
|
const url = new URL(request.path, 'http://local.backend');
|
|
159
179
|
const pathname = url.pathname;
|
|
160
180
|
if (method === 'GET' && pathname === '/api/v2/auth/me') {
|
|
181
|
+
const authProvider = getAuthProvider();
|
|
182
|
+
if (authProvider) {
|
|
183
|
+
if (!request.principal) {
|
|
184
|
+
return { status: 401, data: { detail: 'unauthorized' } };
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
status: 200,
|
|
188
|
+
data: await authProvider.describeSelf(request.principal),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
161
191
|
return { status: 200, data: buildLocalApiUser() };
|
|
162
192
|
}
|
|
163
193
|
// Desktop Agent —— 在本地模式下虚拟一个永远在线的 agent,所有 exec 直接走 ToolRouter
|
|
@@ -215,7 +245,7 @@ export class LocalBackendRouter {
|
|
|
215
245
|
if (method === 'GET' && pathname === '/api/v2/chats') {
|
|
216
246
|
const page = parsePositiveIntParam(url.searchParams.get('page'), 1);
|
|
217
247
|
const limit = parsePositiveIntParam(url.searchParams.get('limit'), 50);
|
|
218
|
-
const { chats, total } =
|
|
248
|
+
const { chats, total } = await this.store.listChats(page, limit);
|
|
219
249
|
return {
|
|
220
250
|
status: 200,
|
|
221
251
|
data: {
|
|
@@ -247,10 +277,10 @@ export class LocalBackendRouter {
|
|
|
247
277
|
const branchesMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/branches$/);
|
|
248
278
|
if (method === 'GET' && branchesMatch) {
|
|
249
279
|
const chatId = branchesMatch[1];
|
|
250
|
-
const chat =
|
|
280
|
+
const chat = await this.store.getChat(chatId);
|
|
251
281
|
if (!chat)
|
|
252
282
|
return { status: 404, data: { detail: 'chat not found' } };
|
|
253
|
-
const activeRecordId =
|
|
283
|
+
const activeRecordId = await this.store.getChatRecordId(chatId) ?? chatId;
|
|
254
284
|
const supervisor = getSidecarSupervisor();
|
|
255
285
|
const branches = supervisor ? await supervisor.sessionBranches(activeRecordId) : null;
|
|
256
286
|
return {
|
|
@@ -268,10 +298,10 @@ export class LocalBackendRouter {
|
|
|
268
298
|
const branchTreeMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/branches\/tree$/);
|
|
269
299
|
if (method === 'GET' && branchTreeMatch) {
|
|
270
300
|
const chatId = branchTreeMatch[1];
|
|
271
|
-
const chat =
|
|
301
|
+
const chat = await this.store.getChat(chatId);
|
|
272
302
|
if (!chat)
|
|
273
303
|
return { status: 404, data: { detail: 'chat not found' } };
|
|
274
|
-
const activeRecordId =
|
|
304
|
+
const activeRecordId = await this.store.getChatRecordId(chatId) ?? chatId;
|
|
275
305
|
const supervisor = getSidecarSupervisor();
|
|
276
306
|
const family = supervisor ? await supervisor.sessionTree(activeRecordId) : null;
|
|
277
307
|
return {
|
|
@@ -292,7 +322,7 @@ export class LocalBackendRouter {
|
|
|
292
322
|
const activateMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/branches\/activate$/);
|
|
293
323
|
if (method === 'POST' && activateMatch) {
|
|
294
324
|
const chatId = activateMatch[1];
|
|
295
|
-
const chat =
|
|
325
|
+
const chat = await this.store.getChat(chatId);
|
|
296
326
|
if (!chat)
|
|
297
327
|
return { status: 404, data: { detail: 'chat not found' } };
|
|
298
328
|
const payload = this.toRecord(request.body);
|
|
@@ -304,7 +334,7 @@ export class LocalBackendRouter {
|
|
|
304
334
|
if (!supervisor) {
|
|
305
335
|
return { status: 503, data: { detail: 'sidecar unavailable' } };
|
|
306
336
|
}
|
|
307
|
-
const activeRecordId =
|
|
337
|
+
const activeRecordId = await this.store.getChatRecordId(chatId) ?? chatId;
|
|
308
338
|
const family = await supervisor.sessionTree(activeRecordId);
|
|
309
339
|
const activation = resolveBranchActivation(activeRecordId, family?.tree ?? null, targetRecordId);
|
|
310
340
|
if (!activation.ok) {
|
|
@@ -314,8 +344,8 @@ export class LocalBackendRouter {
|
|
|
314
344
|
if (!projection) {
|
|
315
345
|
return { status: 404, data: { detail: `record not found: ${targetRecordId}` } };
|
|
316
346
|
}
|
|
317
|
-
|
|
318
|
-
|
|
347
|
+
await this.store.setChatRecordId(chatId, targetRecordId);
|
|
348
|
+
await this.store.replaceChatMessages(chatId, projection.messages
|
|
319
349
|
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
320
350
|
.map((m) => ({ role: m.role, content: m.content })));
|
|
321
351
|
return {
|
|
@@ -339,7 +369,7 @@ export class LocalBackendRouter {
|
|
|
339
369
|
};
|
|
340
370
|
}
|
|
341
371
|
}
|
|
342
|
-
const created =
|
|
372
|
+
const created = await this.store.createChat('新对话', typeof payload.agentId === 'string' ? payload.agentId : getBrand().defaultAgentId, projectId);
|
|
343
373
|
return {
|
|
344
374
|
status: 200,
|
|
345
375
|
data: {
|
|
@@ -359,7 +389,7 @@ export class LocalBackendRouter {
|
|
|
359
389
|
const exceptChatId = typeof payload.exceptChatId === 'string' && payload.exceptChatId.trim()
|
|
360
390
|
? payload.exceptChatId.trim()
|
|
361
391
|
: null;
|
|
362
|
-
const deletedChatIds =
|
|
392
|
+
const deletedChatIds = await this.store.deleteEmptyChats(exceptChatId);
|
|
363
393
|
return { status: 200, data: { deletedChatIds } };
|
|
364
394
|
}
|
|
365
395
|
// ───── 项目模式:projects CRUD ─────
|
|
@@ -419,7 +449,7 @@ export class LocalBackendRouter {
|
|
|
419
449
|
if (!ok)
|
|
420
450
|
return this.notFound('项目不存在');
|
|
421
451
|
// 会话不删,只解绑——它们变成"无项目对话"。
|
|
422
|
-
const detached =
|
|
452
|
+
const detached = await this.store.clearProjectAssignment(projectId);
|
|
423
453
|
return { status: 200, data: { success: true, detachedChats: detached } };
|
|
424
454
|
}
|
|
425
455
|
}
|
|
@@ -455,17 +485,17 @@ export class LocalBackendRouter {
|
|
|
455
485
|
return { status: 503, data: { detail: 'task service unavailable' } };
|
|
456
486
|
}
|
|
457
487
|
const chatId = chatTasksMatch[1];
|
|
458
|
-
const chat =
|
|
488
|
+
const chat = await this.store.getChat(chatId);
|
|
459
489
|
if (!chat)
|
|
460
490
|
return { status: 404, data: { detail: 'chat not found' } };
|
|
461
|
-
return { status: 200, data: { tasks:
|
|
491
|
+
return { status: 200, data: { tasks: await this.store.listTasks(chatId) } };
|
|
462
492
|
}
|
|
463
493
|
const taskProcessMatch = pathname.match(/^\/api\/v2\/tasks\/([^/]+)\/process$/);
|
|
464
494
|
if (method === 'GET' && taskProcessMatch) {
|
|
465
495
|
if (!this.taskService) {
|
|
466
496
|
return { status: 503, data: { detail: 'task service unavailable' } };
|
|
467
497
|
}
|
|
468
|
-
const snapshot = this.taskService.getProcess(taskProcessMatch[1]);
|
|
498
|
+
const snapshot = await this.taskService.getProcess(taskProcessMatch[1]);
|
|
469
499
|
if (!snapshot)
|
|
470
500
|
return { status: 404, data: { detail: '任务不存在' } };
|
|
471
501
|
return {
|
|
@@ -503,7 +533,7 @@ export class LocalBackendRouter {
|
|
|
503
533
|
const projectContextMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/project-context$/);
|
|
504
534
|
if (projectContextMatch && method === 'GET') {
|
|
505
535
|
const chatId = projectContextMatch[1];
|
|
506
|
-
const chat =
|
|
536
|
+
const chat = await this.store.getChat(chatId);
|
|
507
537
|
if (!chat)
|
|
508
538
|
return this.notFound('Chat not found');
|
|
509
539
|
const registry = this.toolRouter.projectRegistry;
|
|
@@ -528,7 +558,7 @@ export class LocalBackendRouter {
|
|
|
528
558
|
if (chatMatch) {
|
|
529
559
|
const chatId = chatMatch[1];
|
|
530
560
|
if (method === 'GET') {
|
|
531
|
-
const chat =
|
|
561
|
+
const chat = await this.store.getChat(chatId);
|
|
532
562
|
if (!chat)
|
|
533
563
|
return this.notFound('Chat not found');
|
|
534
564
|
return {
|
|
@@ -549,13 +579,13 @@ export class LocalBackendRouter {
|
|
|
549
579
|
}
|
|
550
580
|
if (method === 'DELETE') {
|
|
551
581
|
if (url.searchParams.get('onlyIfEmpty') === '1') {
|
|
552
|
-
const deleted =
|
|
582
|
+
const deleted = await this.store.deleteChatIfEmpty(chatId);
|
|
553
583
|
return {
|
|
554
584
|
status: 200,
|
|
555
585
|
data: { success: true, deleted, chatId },
|
|
556
586
|
};
|
|
557
587
|
}
|
|
558
|
-
const ok =
|
|
588
|
+
const ok = await this.store.deleteChat(chatId);
|
|
559
589
|
if (!ok)
|
|
560
590
|
return this.notFound('Chat not found');
|
|
561
591
|
return {
|
|
@@ -572,7 +602,7 @@ export class LocalBackendRouter {
|
|
|
572
602
|
if (chatPinMatch && method === 'PUT') {
|
|
573
603
|
const chatId = chatPinMatch[1];
|
|
574
604
|
const payload = this.toRecord(request.body);
|
|
575
|
-
const updated =
|
|
605
|
+
const updated = await this.store.updateChat(chatId, { isPinned: Boolean(payload.isPinned) });
|
|
576
606
|
if (!updated)
|
|
577
607
|
return this.notFound('Chat not found');
|
|
578
608
|
return {
|
|
@@ -607,7 +637,7 @@ export class LocalBackendRouter {
|
|
|
607
637
|
return this.badRequest('projectId 必须是项目 id 字符串或 null');
|
|
608
638
|
}
|
|
609
639
|
}
|
|
610
|
-
const updated =
|
|
640
|
+
const updated = await this.store.updateChat(chatId, {
|
|
611
641
|
title: typeof payload.title === 'string' ? payload.title : undefined,
|
|
612
642
|
systemPrompt: typeof payload.systemPrompt === 'string' ? payload.systemPrompt : undefined,
|
|
613
643
|
pinnedRefs: Array.isArray(payload.pinnedRefs) ? payload.pinnedRefs : undefined,
|
|
@@ -635,7 +665,7 @@ export class LocalBackendRouter {
|
|
|
635
665
|
if (messageListMatch && method === 'GET') {
|
|
636
666
|
const chatId = messageListMatch[1];
|
|
637
667
|
const limit = parsePositiveIntParam(url.searchParams.get('limit'), 200);
|
|
638
|
-
const records =
|
|
668
|
+
const records = await this.store.listMessages(chatId, limit);
|
|
639
669
|
const messages = records.map(item => ({
|
|
640
670
|
id: item.id,
|
|
641
671
|
chatId: item.chatId,
|
|
@@ -652,7 +682,7 @@ export class LocalBackendRouter {
|
|
|
652
682
|
// 同一 record 上开出第二个写者)。listMessages 是 DESC,records[0]
|
|
653
683
|
// 即最后一条。
|
|
654
684
|
const interrupted = detectInterruptedTurn({
|
|
655
|
-
turnActive:
|
|
685
|
+
turnActive: await this.store.getTurnActive(chatId) !== null,
|
|
656
686
|
streamActive: getActiveCoreLoopStreamId(chatId) !== undefined,
|
|
657
687
|
lastMessageRole: records[0]?.role ?? null,
|
|
658
688
|
});
|
|
@@ -708,7 +738,7 @@ export class LocalBackendRouter {
|
|
|
708
738
|
}
|
|
709
739
|
if (method === 'GET' && pathname === '/api/v2/chat-agents') {
|
|
710
740
|
const includeArchived = url.searchParams.get('include_archived') === 'true';
|
|
711
|
-
const agents =
|
|
741
|
+
const agents = await this.store.listChatAgents(includeArchived);
|
|
712
742
|
return {
|
|
713
743
|
status: 200,
|
|
714
744
|
data: {
|
|
@@ -719,7 +749,7 @@ export class LocalBackendRouter {
|
|
|
719
749
|
}
|
|
720
750
|
if (method === 'POST' && pathname === '/api/v2/chat-agents') {
|
|
721
751
|
const payload = this.toRecord(request.body);
|
|
722
|
-
const agent =
|
|
752
|
+
const agent = await this.store.createChatAgent({
|
|
723
753
|
name: String(payload.name || '新助手'),
|
|
724
754
|
icon: typeof payload.icon === 'string' ? payload.icon : null,
|
|
725
755
|
color: typeof payload.color === 'string' ? payload.color : null,
|
|
@@ -1144,14 +1174,14 @@ export class LocalBackendRouter {
|
|
|
1144
1174
|
if (chatAgentMatch) {
|
|
1145
1175
|
const agentId = chatAgentMatch[1];
|
|
1146
1176
|
if (method === 'GET') {
|
|
1147
|
-
const agent =
|
|
1177
|
+
const agent = await this.store.getChatAgent(agentId);
|
|
1148
1178
|
if (!agent)
|
|
1149
1179
|
return this.notFound('Agent not found');
|
|
1150
1180
|
return { status: 200, data: agent };
|
|
1151
1181
|
}
|
|
1152
1182
|
if (method === 'PATCH') {
|
|
1153
1183
|
const payload = this.toRecord(request.body);
|
|
1154
|
-
const agent =
|
|
1184
|
+
const agent = await this.store.updateChatAgent(agentId, {
|
|
1155
1185
|
name: typeof payload.name === 'string' ? payload.name : undefined,
|
|
1156
1186
|
icon: typeof payload.icon === 'string' ? payload.icon : undefined,
|
|
1157
1187
|
color: typeof payload.color === 'string' ? payload.color : undefined,
|
|
@@ -1170,7 +1200,7 @@ export class LocalBackendRouter {
|
|
|
1170
1200
|
return { status: 200, data: { agent } };
|
|
1171
1201
|
}
|
|
1172
1202
|
if (method === 'DELETE') {
|
|
1173
|
-
const ok =
|
|
1203
|
+
const ok = await this.store.archiveChatAgent(agentId);
|
|
1174
1204
|
if (!ok)
|
|
1175
1205
|
return this.notFound('Agent not found');
|
|
1176
1206
|
return {
|
|
@@ -1373,12 +1403,12 @@ export class LocalBackendRouter {
|
|
|
1373
1403
|
if (method === 'GET') {
|
|
1374
1404
|
return {
|
|
1375
1405
|
status: 200,
|
|
1376
|
-
data:
|
|
1406
|
+
data: await this.store.getLlmSettings(),
|
|
1377
1407
|
};
|
|
1378
1408
|
}
|
|
1379
1409
|
if (method === 'POST') {
|
|
1380
1410
|
const payload = this.toRecord(request.body);
|
|
1381
|
-
const saved = llmService.setSettings({
|
|
1411
|
+
const saved = await llmService.setSettings({
|
|
1382
1412
|
provider: sanitizeLlmProvider(payload.provider),
|
|
1383
1413
|
vendorId: sanitizeVendorId(payload.vendorId),
|
|
1384
1414
|
model: String(payload.model || 'llama3.1:8b'),
|
|
@@ -1398,7 +1428,7 @@ export class LocalBackendRouter {
|
|
|
1398
1428
|
: undefined,
|
|
1399
1429
|
compat: sanitizeCompatOverrides(payload.compat),
|
|
1400
1430
|
presets: sanitizePresetsChoice(payload.presets),
|
|
1401
|
-
});
|
|
1431
|
+
}, this.store);
|
|
1402
1432
|
// 立即让新默认超时对后续 local_exec_shell 生效(重启后由 main.ts 启动时恢复)。
|
|
1403
1433
|
setDefaultExecTimeoutMs(saved.execTimeoutSeconds ? saved.execTimeoutSeconds * 1000 : null);
|
|
1404
1434
|
// 代理白名单是 boot 时按当时的 baseUrl 派生的,换了网关后本会话(含
|
|
@@ -1418,12 +1448,12 @@ export class LocalBackendRouter {
|
|
|
1418
1448
|
if (method === 'GET') {
|
|
1419
1449
|
return {
|
|
1420
1450
|
status: 200,
|
|
1421
|
-
data:
|
|
1451
|
+
data: await this.store.getTelemetrySettings(),
|
|
1422
1452
|
};
|
|
1423
1453
|
}
|
|
1424
1454
|
if (method === 'POST') {
|
|
1425
1455
|
const payload = this.toRecord(request.body);
|
|
1426
|
-
const saved =
|
|
1456
|
+
const saved = await this.store.setTelemetrySettings({
|
|
1427
1457
|
endpoint: typeof payload.endpoint === 'string' ? payload.endpoint : undefined,
|
|
1428
1458
|
privacyMode: payload.privacyMode === 'full' ? 'full' : 'metadata',
|
|
1429
1459
|
serviceName: typeof payload.serviceName === 'string' ? payload.serviceName : undefined,
|
|
@@ -1438,12 +1468,12 @@ export class LocalBackendRouter {
|
|
|
1438
1468
|
if (method === 'GET') {
|
|
1439
1469
|
return {
|
|
1440
1470
|
status: 200,
|
|
1441
|
-
data:
|
|
1471
|
+
data: await this.store.getWebSearchSettings(),
|
|
1442
1472
|
};
|
|
1443
1473
|
}
|
|
1444
1474
|
if (method === 'POST') {
|
|
1445
1475
|
const payload = this.toRecord(request.body);
|
|
1446
|
-
const saved =
|
|
1476
|
+
const saved = await this.store.setWebSearchSettings({
|
|
1447
1477
|
provider: payload.provider === 'ddg' ? 'ddg' : 'tavily',
|
|
1448
1478
|
apiKey: typeof payload.apiKey === 'string' ? payload.apiKey : undefined,
|
|
1449
1479
|
});
|
|
@@ -1455,10 +1485,10 @@ export class LocalBackendRouter {
|
|
|
1455
1485
|
}
|
|
1456
1486
|
if (pathname === '/api/v2/local-settings/insights') {
|
|
1457
1487
|
if (method === 'GET') {
|
|
1458
|
-
const settings =
|
|
1488
|
+
const settings = await this.store.ensureInsightsSettings();
|
|
1459
1489
|
return {
|
|
1460
1490
|
status: 200,
|
|
1461
|
-
data: { ...settings, stats:
|
|
1491
|
+
data: { ...settings, stats: await this.store.insightStats() },
|
|
1462
1492
|
};
|
|
1463
1493
|
}
|
|
1464
1494
|
if (method === 'POST') {
|
|
@@ -1487,20 +1517,20 @@ export class LocalBackendRouter {
|
|
|
1487
1517
|
patch.apiBase = payload.apiBase;
|
|
1488
1518
|
if (Object.keys(profilePatch).length)
|
|
1489
1519
|
patch.profile = profilePatch;
|
|
1490
|
-
const saved =
|
|
1520
|
+
const saved = await this.store.setInsightsSettings(patch);
|
|
1491
1521
|
const hasProfileFields = Boolean(saved.profile.displayName ||
|
|
1492
1522
|
saved.profile.email ||
|
|
1493
1523
|
saved.profile.company ||
|
|
1494
1524
|
saved.profile.note);
|
|
1495
1525
|
if (hasProfileFields) {
|
|
1496
|
-
recordInsightProfile(saved.profile);
|
|
1526
|
+
await recordInsightProfile(this.store, saved.profile);
|
|
1497
1527
|
}
|
|
1498
|
-
void flushInsightsOutbox().catch((err) => {
|
|
1528
|
+
void flushInsightsOutbox(this.store).catch((err) => {
|
|
1499
1529
|
console.warn('[insights] flush after settings save failed', err);
|
|
1500
1530
|
});
|
|
1501
1531
|
return {
|
|
1502
1532
|
status: 200,
|
|
1503
|
-
data: { ...saved, stats:
|
|
1533
|
+
data: { ...saved, stats: await this.store.insightStats() },
|
|
1504
1534
|
};
|
|
1505
1535
|
}
|
|
1506
1536
|
}
|
|
@@ -1513,19 +1543,19 @@ export class LocalBackendRouter {
|
|
|
1513
1543
|
const properties = payload.properties && typeof payload.properties === 'object'
|
|
1514
1544
|
? payload.properties
|
|
1515
1545
|
: {};
|
|
1516
|
-
recordInsightEvent(eventName, properties);
|
|
1546
|
+
await recordInsightEvent(this.store, eventName, properties);
|
|
1517
1547
|
return { status: 200, data: { status: 'ok' } };
|
|
1518
1548
|
}
|
|
1519
1549
|
if (method === 'GET' && pathname === '/api/v2/insights/export') {
|
|
1520
|
-
return { status: 200, data: buildInsightsExportPayload() };
|
|
1550
|
+
return { status: 200, data: await buildInsightsExportPayload(this.store) };
|
|
1521
1551
|
}
|
|
1522
1552
|
if (method === 'POST' && pathname === '/api/v2/insights/upload-local') {
|
|
1523
|
-
const ok = await uploadInsightsBundle();
|
|
1553
|
+
const ok = await uploadInsightsBundle(this.store);
|
|
1524
1554
|
return {
|
|
1525
1555
|
status: ok ? 200 : 502,
|
|
1526
1556
|
data: {
|
|
1527
1557
|
ok,
|
|
1528
|
-
stats:
|
|
1558
|
+
stats: await this.store.insightStats(),
|
|
1529
1559
|
detail: ok ? 'uploaded' : 'upload_failed_kept_local',
|
|
1530
1560
|
},
|
|
1531
1561
|
};
|
|
@@ -1534,7 +1564,7 @@ export class LocalBackendRouter {
|
|
|
1534
1564
|
const days = parsePositiveIntParam(url.searchParams.get('days'), 30);
|
|
1535
1565
|
return {
|
|
1536
1566
|
status: 200,
|
|
1537
|
-
data:
|
|
1567
|
+
data: await this.store.getUsageSummary(days),
|
|
1538
1568
|
};
|
|
1539
1569
|
}
|
|
1540
1570
|
if (method === 'POST' && pathname.match(/^\/api\/v2\/chats\/[^/]+\/suggest-replies$/)) {
|
|
@@ -1620,7 +1650,7 @@ export class LocalBackendRouter {
|
|
|
1620
1650
|
if (!chatId) {
|
|
1621
1651
|
return { status: 400, data: { detail: 'chatId is required' } };
|
|
1622
1652
|
}
|
|
1623
|
-
const traces =
|
|
1653
|
+
const traces = (await this.store.listTracesByChat(chatId, limit)).map(item => ({
|
|
1624
1654
|
...item,
|
|
1625
1655
|
payload: this.safeJson(item.payload, {}),
|
|
1626
1656
|
}));
|
|
@@ -1631,7 +1661,7 @@ export class LocalBackendRouter {
|
|
|
1631
1661
|
}
|
|
1632
1662
|
const traceMatch = pathname.match(/^\/api\/v2\/local\/traces\/([^/]+)$/);
|
|
1633
1663
|
if (traceMatch && method === 'GET') {
|
|
1634
|
-
const trace =
|
|
1664
|
+
const trace = await this.store.getTrace(traceMatch[1]);
|
|
1635
1665
|
if (!trace)
|
|
1636
1666
|
return this.notFound('Trace not found');
|
|
1637
1667
|
return {
|
|
@@ -1642,6 +1672,37 @@ export class LocalBackendRouter {
|
|
|
1642
1672
|
},
|
|
1643
1673
|
};
|
|
1644
1674
|
}
|
|
1675
|
+
// 回合产物列表的「点击打开」:用系统默认应用打开本地路径(与 agent 的
|
|
1676
|
+
// local_open_path 工具同一宿主能力,这里给渲染层一个 HTTP 入口,CS/BS
|
|
1677
|
+
// 两种模式都经 localBackend.request 到达)。
|
|
1678
|
+
if (method === 'POST' && pathname === '/api/v2/local/open-path') {
|
|
1679
|
+
const payload = this.toRecord(request.body);
|
|
1680
|
+
const target = typeof payload.path === 'string' ? payload.path.trim() : '';
|
|
1681
|
+
if (!target || !path.isAbsolute(target)) {
|
|
1682
|
+
return { status: 400, data: { detail: 'absolute path is required' } };
|
|
1683
|
+
}
|
|
1684
|
+
const openError = await shellOpenPath(target);
|
|
1685
|
+
if (openError) {
|
|
1686
|
+
return { status: 200, data: { success: false, error: openError } };
|
|
1687
|
+
}
|
|
1688
|
+
return { status: 200, data: { success: true } };
|
|
1689
|
+
}
|
|
1690
|
+
// 正文里提到的路径能否点击打开:渲染层把行内代码里「像路径」的字面量
|
|
1691
|
+
// 批量送来,这里落地成绝对路径并 stat 验证,只有真实存在的才回。相对
|
|
1692
|
+
// 路径按对话绑定的项目根解析,无项目时按 home(同 exec 缺省 cwd 的回落)。
|
|
1693
|
+
if (method === 'POST' && pathname === '/api/v2/local/resolve-paths') {
|
|
1694
|
+
const payload = this.toRecord(request.body);
|
|
1695
|
+
const candidates = Array.isArray(payload.candidates)
|
|
1696
|
+
? payload.candidates.filter((item) => typeof item === 'string')
|
|
1697
|
+
: [];
|
|
1698
|
+
if (candidates.length === 0) {
|
|
1699
|
+
return { status: 200, data: { resolved: [] } };
|
|
1700
|
+
}
|
|
1701
|
+
const chatId = typeof payload.chatId === 'string' ? payload.chatId : '';
|
|
1702
|
+
const baseDir = (chatId ? (await this.resolveChatProject(chatId))?.folderPath : null) ?? os.homedir();
|
|
1703
|
+
const resolved = await resolveMentionedPaths({ candidates, baseDir });
|
|
1704
|
+
return { status: 200, data: { resolved } };
|
|
1705
|
+
}
|
|
1645
1706
|
// ─── 场景包路由(1.2:/api/v2/<包前缀>/* 由包经 pack-backend-routes
|
|
1646
1707
|
// 注册表贡献;在宿主自带路由之后、fallback 之前匹配,包不能遮蔽宿主
|
|
1647
1708
|
// 路由)。 ───
|
|
@@ -1696,10 +1757,14 @@ export class LocalBackendRouter {
|
|
|
1696
1757
|
* `accumulatedContent += parsedData.content` 一行追加渲染。
|
|
1697
1758
|
* - 同步真实 messageId:`data: {"type":"message_id","messageId":"..."}`
|
|
1698
1759
|
* - 工具执行结果(无需用户确认):`data: {"type":"executed_actions","actions":[...]}`
|
|
1760
|
+
* - 回合产物文件列表(仅非空时发,在 message_id 之前):`data: {"type":"turn_files","files":[...]}`
|
|
1699
1761
|
* - 错误:保留 `event:error` + `data: {"message":"..."}`(前端会忽略,但日志/未来 UI 用)
|
|
1700
1762
|
* - 结束:标准 SSE `[DONE]` 串。
|
|
1701
1763
|
*/
|
|
1702
|
-
|
|
1764
|
+
handleStream(request, emit, options = {}) {
|
|
1765
|
+
return this.storeContext.run(this.resolveStore(request.principal), () => this.handleStreamScoped(request, emit, options));
|
|
1766
|
+
}
|
|
1767
|
+
async handleStreamScoped(request, emit, options = {}) {
|
|
1703
1768
|
const signal = options.signal;
|
|
1704
1769
|
const url = new URL(request.path, 'http://local.backend');
|
|
1705
1770
|
const pathname = url.pathname;
|
|
@@ -1715,7 +1780,7 @@ export class LocalBackendRouter {
|
|
|
1715
1780
|
}
|
|
1716
1781
|
const chatId = sendMatch ? sendMatch[1] : regenerateMatch[1];
|
|
1717
1782
|
const payload = this.toRecord(request.body);
|
|
1718
|
-
let chat =
|
|
1783
|
+
let chat = await this.store.getChat(chatId);
|
|
1719
1784
|
if (!chat) {
|
|
1720
1785
|
// 会话 URL 即会话身份:外部平台(自动化/eval)把一个 chatId 当成一次
|
|
1721
1786
|
// 会话任务反复打开运行,本地会话却可能已经被用户清理、被空会话 prune、
|
|
@@ -1731,7 +1796,7 @@ export class LocalBackendRouter {
|
|
|
1731
1796
|
const agentId = typeof payload.agentId === 'string' && payload.agentId.trim()
|
|
1732
1797
|
? payload.agentId.trim()
|
|
1733
1798
|
: 'local-assistant';
|
|
1734
|
-
chat =
|
|
1799
|
+
chat = await this.store.createChatWithId(chatId, '新对话', agentId);
|
|
1735
1800
|
console.warn('[local-backend] stream: chat missing, auto-provisioned', {
|
|
1736
1801
|
chatId,
|
|
1737
1802
|
agentId,
|
|
@@ -1769,7 +1834,7 @@ export class LocalBackendRouter {
|
|
|
1769
1834
|
// the *real* request instead of a generic placeholder (matters for
|
|
1770
1835
|
// deferred-exec detection, mode preamble, etc., which all key off the
|
|
1771
1836
|
// actual ask).
|
|
1772
|
-
const priorMessages =
|
|
1837
|
+
const priorMessages = (await this.store.listMessages(chatId, 200)).reverse();
|
|
1773
1838
|
const resolution = resolveRegenerateContext(priorMessages, targetMessageId);
|
|
1774
1839
|
if (!resolution.ok) {
|
|
1775
1840
|
emit(this.sse('error', { message: 'regenerate target message not found or not an assistant message' }));
|
|
@@ -1788,13 +1853,13 @@ export class LocalBackendRouter {
|
|
|
1788
1853
|
const supervisor = getSidecarSupervisor();
|
|
1789
1854
|
const outcome = supervisor
|
|
1790
1855
|
? await supervisor.forkSession({
|
|
1791
|
-
recordId:
|
|
1856
|
+
recordId: await this.store.getChatRecordId(chatId) ?? chatId,
|
|
1792
1857
|
beforeUserIndex: resolveRegenerateForkOrdinal(priorMessages, targetMessageId),
|
|
1793
1858
|
newRecordId: `${chatId}:r${Date.now().toString(36)}`,
|
|
1794
1859
|
})
|
|
1795
1860
|
: null;
|
|
1796
1861
|
if (outcome?.ok) {
|
|
1797
|
-
|
|
1862
|
+
await this.store.setChatRecordId(chatId, outcome.fork.recordId);
|
|
1798
1863
|
console.log('[local-backend] regenerate: record forked (old tail preserved)', {
|
|
1799
1864
|
chatId,
|
|
1800
1865
|
branchRecordId: outcome.fork.recordId,
|
|
@@ -1813,7 +1878,7 @@ export class LocalBackendRouter {
|
|
|
1813
1878
|
emit(this.sse('error', { message: plan.message }));
|
|
1814
1879
|
return { status: 409 };
|
|
1815
1880
|
}
|
|
1816
|
-
const deleted =
|
|
1881
|
+
const deleted = await this.store.deleteMessagesFrom(chatId, targetMessageId);
|
|
1817
1882
|
console.log('[local-backend] regenerate: truncated history', {
|
|
1818
1883
|
chatId,
|
|
1819
1884
|
targetMessageId,
|
|
@@ -1825,9 +1890,9 @@ export class LocalBackendRouter {
|
|
|
1825
1890
|
// sidecar 回放 durable record 的投影作为循环种子。只在检测确为
|
|
1826
1891
|
// interrupted 时接受:对一个已正常完结的 turn 续跑只会给完整对话
|
|
1827
1892
|
// 凭空多接一段回复。
|
|
1828
|
-
const prior =
|
|
1893
|
+
const prior = await this.store.listMessages(chatId, 200);
|
|
1829
1894
|
const interrupted = detectInterruptedTurn({
|
|
1830
|
-
turnActive:
|
|
1895
|
+
turnActive: await this.store.getTurnActive(chatId) !== null,
|
|
1831
1896
|
streamActive: getActiveCoreLoopStreamId(chatId) !== undefined,
|
|
1832
1897
|
lastMessageRole: prior[0]?.role ?? null,
|
|
1833
1898
|
});
|
|
@@ -1858,8 +1923,8 @@ export class LocalBackendRouter {
|
|
|
1858
1923
|
// 3. 历史里目前没有任何 assistant 消息——也就是说,本轮即将产生的是第一条助手回复
|
|
1859
1924
|
// 这避免在已经聊到一半的对话里突然把标题换掉。listMessages 是 DESC,limit 拉
|
|
1860
1925
|
// 大一点确保看到完整历史(200 已经覆盖绝大多数本地会话)。
|
|
1861
|
-
const existingAssistant =
|
|
1862
|
-
.listMessages(chatId, 200)
|
|
1926
|
+
const existingAssistant = (await this.store
|
|
1927
|
+
.listMessages(chatId, 200))
|
|
1863
1928
|
.some((m) => m.role === 'assistant');
|
|
1864
1929
|
const shouldGenerateTitle = !regenerateMatch &&
|
|
1865
1930
|
!isResume &&
|
|
@@ -1880,7 +1945,7 @@ export class LocalBackendRouter {
|
|
|
1880
1945
|
// persisted (and already in the durable record).
|
|
1881
1946
|
let currentUserMessageId;
|
|
1882
1947
|
if (!regenerateMatch && !isResume) {
|
|
1883
|
-
const userMessage =
|
|
1948
|
+
const userMessage = await this.store.addMessage(chatId, 'user', userMessageText);
|
|
1884
1949
|
currentUserMessageId = userMessage.id;
|
|
1885
1950
|
emit(this.sseData({ type: 'user_message', message: userMessage }));
|
|
1886
1951
|
}
|
|
@@ -1889,7 +1954,7 @@ export class LocalBackendRouter {
|
|
|
1889
1954
|
const chatMode = payload.mode === 'plan' ? 'plan' : 'agent';
|
|
1890
1955
|
// 本轮生效的智能体:人设、技能勾选、工具策略同源。必须先解析——工具
|
|
1891
1956
|
// 策略决定工具列表,工具列表又决定技能的触发条件。
|
|
1892
|
-
const turnAgents = this.resolveTurnAgents(chatId, payload);
|
|
1957
|
+
const turnAgents = await this.resolveTurnAgents(chatId, payload);
|
|
1893
1958
|
// Wave 2 工具分层:模型可见列表只出 direct 层(内置工具 + tool_search
|
|
1894
1959
|
// 发现缝);MCP 动态工具全在 deferred 层,经 tool_search 命中即调。
|
|
1895
1960
|
// 附带收益:MCP 目录增删不再改动系统提示词里的工具名录,前缀更稳。
|
|
@@ -2001,7 +2066,7 @@ export class LocalBackendRouter {
|
|
|
2001
2066
|
});
|
|
2002
2067
|
return;
|
|
2003
2068
|
}
|
|
2004
|
-
const fresh =
|
|
2069
|
+
const fresh = await this.store.getChat(chatId);
|
|
2005
2070
|
if (!fresh) {
|
|
2006
2071
|
console.log('[local-backend] title-gen skip', {
|
|
2007
2072
|
chatId,
|
|
@@ -2017,7 +2082,7 @@ export class LocalBackendRouter {
|
|
|
2017
2082
|
});
|
|
2018
2083
|
return;
|
|
2019
2084
|
}
|
|
2020
|
-
const updated =
|
|
2085
|
+
const updated = await this.store.updateChat(chatId, { title: result.title });
|
|
2021
2086
|
if (!updated) {
|
|
2022
2087
|
console.warn('[local-backend] title-gen write failed', { chatId });
|
|
2023
2088
|
return;
|
|
@@ -2046,8 +2111,8 @@ export class LocalBackendRouter {
|
|
|
2046
2111
|
* Public: main.ts 的反向通道(reverse-tools.ts)经它给 CoreLoop 路径的
|
|
2047
2112
|
* 工具调用补上 projectRoot 围栏。
|
|
2048
2113
|
*/
|
|
2049
|
-
resolveChatProject(chatId) {
|
|
2050
|
-
const projectId =
|
|
2114
|
+
async resolveChatProject(chatId) {
|
|
2115
|
+
const projectId = (await this.store.getChat(chatId))?.projectId;
|
|
2051
2116
|
if (!projectId)
|
|
2052
2117
|
return null;
|
|
2053
2118
|
const project = this.toolRouter.projectRegistry?.get(projectId);
|
|
@@ -2068,9 +2133,9 @@ export class LocalBackendRouter {
|
|
|
2068
2133
|
*
|
|
2069
2134
|
* @param chatId 当前对话 id。
|
|
2070
2135
|
* @param payload 前端提交的流请求体。
|
|
2071
|
-
* @returns
|
|
2136
|
+
* @returns 本轮的智能体顺序、人设智能体、自称,以及合并后的能力面。
|
|
2072
2137
|
*/
|
|
2073
|
-
resolveTurnAgents(chatId, payload) {
|
|
2138
|
+
async resolveTurnAgents(chatId, payload) {
|
|
2074
2139
|
const mentionedAgentIds = Array.isArray(payload.mentionedAgentIds)
|
|
2075
2140
|
? payload.mentionedAgentIds.filter((s) => typeof s === 'string')
|
|
2076
2141
|
: [];
|
|
@@ -2082,19 +2147,20 @@ export class LocalBackendRouter {
|
|
|
2082
2147
|
].filter((id, idx, arr) => arr.indexOf(id) === idx);
|
|
2083
2148
|
if (orderedAgentIds.length === 0) {
|
|
2084
2149
|
const fallbackId = (typeof payload.agentId === 'string' && payload.agentId) ||
|
|
2085
|
-
|
|
2150
|
+
(await this.store.getChat(chatId))?.agentId ||
|
|
2086
2151
|
null;
|
|
2087
2152
|
if (fallbackId)
|
|
2088
2153
|
orderedAgentIds.push(fallbackId);
|
|
2089
2154
|
}
|
|
2090
|
-
const agents = orderedAgentIds
|
|
2091
|
-
.map((id) => localStore.getChatAgent(id))
|
|
2155
|
+
const agents = (await Promise.all(orderedAgentIds.map((id) => this.store.getChatAgent(id))))
|
|
2092
2156
|
.filter((agent) => Boolean(agent));
|
|
2093
2157
|
return {
|
|
2094
2158
|
orderedAgentIds,
|
|
2095
2159
|
// 能力面来自全部解析到的智能体,人设前言只用配了 rolePrompt 的那些——
|
|
2096
|
-
//
|
|
2160
|
+
// 自建智能体可以只勾技能、不写人设。自称始终跟第一个解析到的智能体,
|
|
2161
|
+
// 不要求写了人设——否则没 rolePrompt 的角色会回落成产品品牌。
|
|
2097
2162
|
personaAgents: agents.filter((agent) => Boolean(agent.rolePrompt)),
|
|
2163
|
+
identityName: agents[0]?.name.trim() || null,
|
|
2098
2164
|
capability: mergeAgentCapabilities(agents),
|
|
2099
2165
|
};
|
|
2100
2166
|
}
|
|
@@ -2105,7 +2171,7 @@ export class LocalBackendRouter {
|
|
|
2105
2171
|
// 摘要,也不再按 40 条窗口截断——让框架 record 累积完整对话并按需压缩。
|
|
2106
2172
|
// 上限即存储层 listMessages 的 1000 条硬上限;超出时最旧历史随
|
|
2107
2173
|
// host_revision 优雅退化。
|
|
2108
|
-
let rawHistory =
|
|
2174
|
+
let rawHistory = (await this.store.listMessages(chatId, HISTORY_SEED_LIMIT)).reverse();
|
|
2109
2175
|
// 剔除刚刚写入数据库的当前轮用户消息,避免在上下文历史中与我们显式添加的
|
|
2110
2176
|
// latestUserMessage 发生重复。send 路径按 id 精确剔除——上一条 assistant
|
|
2111
2177
|
// 回复在流结束后异步落库(createdAt 取落库时刻),快速连续发送时它会排到
|
|
@@ -2150,7 +2216,7 @@ export class LocalBackendRouter {
|
|
|
2150
2216
|
})))
|
|
2151
2217
|
.filter(Boolean);
|
|
2152
2218
|
const settings = llmService.getSettings();
|
|
2153
|
-
const { personaAgents, capability } = turnAgents;
|
|
2219
|
+
const { personaAgents, capability, identityName } = turnAgents;
|
|
2154
2220
|
let personaPreamble = '';
|
|
2155
2221
|
if (personaAgents.length === 1) {
|
|
2156
2222
|
personaPreamble = `【当前角色】${personaAgents[0].name}\n${personaAgents[0].rolePrompt}`;
|
|
@@ -2218,6 +2284,7 @@ export class LocalBackendRouter {
|
|
|
2218
2284
|
excludeSkillNames,
|
|
2219
2285
|
pinnedSkillNames: capability.pinnedSkills,
|
|
2220
2286
|
personaPreamble: effectivePersonaPreamble,
|
|
2287
|
+
identityName,
|
|
2221
2288
|
realityCheckSuffix: realityCheck,
|
|
2222
2289
|
forcedMcpTool,
|
|
2223
2290
|
ignoreConditions: capability.loadAllSkills,
|
|
@@ -2246,7 +2313,7 @@ export class LocalBackendRouter {
|
|
|
2246
2313
|
// 项目模式:绑定项目的对话在系统提示末尾追加沙箱约定(两条拼装路径
|
|
2247
2314
|
// 都追加)。硬围栏在 ToolRouter/LocalExecutor,这里让模型事先知道边界,
|
|
2248
2315
|
// 减少越界尝试。
|
|
2249
|
-
const chatProject = this.resolveChatProject(chatId);
|
|
2316
|
+
const chatProject = await this.resolveChatProject(chatId);
|
|
2250
2317
|
if (chatProject) {
|
|
2251
2318
|
systemPrompt +=
|
|
2252
2319
|
`\n\n【项目模式】当前对话绑定项目「${chatProject.name}」,根目录:${chatProject.folderPath}\n` +
|
|
@@ -2258,7 +2325,7 @@ export class LocalBackendRouter {
|
|
|
2258
2325
|
// 仅在用户显式信任该项目后才注入模型上下文——未信任一律不读取、不注入
|
|
2259
2326
|
// (打开恶意仓库时,一段构造的规则文件不能劫持 agent)。信任状态持久化、
|
|
2260
2327
|
// 可在设置里撤销;每回合重新读盘,规则保存即生效。
|
|
2261
|
-
const projectId =
|
|
2328
|
+
const projectId = (await this.store.getChat(chatId))?.projectId;
|
|
2262
2329
|
const registry = this.toolRouter.projectRegistry;
|
|
2263
2330
|
if (projectId && registry?.isTrusted(projectId)) {
|
|
2264
2331
|
const rules = loadProjectRuleFiles(chatProject.folderPath);
|
|
@@ -2284,7 +2351,10 @@ export class LocalBackendRouter {
|
|
|
2284
2351
|
const forcedSkill = await findSkill(forcedSkillName, { exclude: forcedSkillExcludes });
|
|
2285
2352
|
if (forcedSkill) {
|
|
2286
2353
|
// 场景包的正文占位符变量(如包技能正文里的工作区路径占位符)。
|
|
2287
|
-
finalUserContent = `${buildForcedSkillMessage(forcedSkill,
|
|
2354
|
+
finalUserContent = `${buildForcedSkillMessage(forcedSkill, {
|
|
2355
|
+
...brandSkillVars({ identityName }),
|
|
2356
|
+
...collectPackForcedSkillVars(chatId),
|
|
2357
|
+
})}\n\n---\n\n${latestUserMessage}`;
|
|
2288
2358
|
}
|
|
2289
2359
|
}
|
|
2290
2360
|
// W6-3 多模态:渲染进程把图片附件以 metadata.images({path,name})传上来,
|
|
@@ -2336,14 +2406,14 @@ export class LocalBackendRouter {
|
|
|
2336
2406
|
.slice(0, MAX_REFERENCED_CHATS);
|
|
2337
2407
|
const out = [];
|
|
2338
2408
|
for (const refChatId of ids) {
|
|
2339
|
-
const refChat =
|
|
2409
|
+
const refChat = await this.store.getChat(refChatId);
|
|
2340
2410
|
if (!refChat) {
|
|
2341
2411
|
console.warn('[local-backend] referenced chat not found, skipping', { refChatId });
|
|
2342
2412
|
continue;
|
|
2343
2413
|
}
|
|
2344
2414
|
const sections = [];
|
|
2345
|
-
const recent = (await Promise.all(
|
|
2346
|
-
.listMessages(refChatId, RECENT_MESSAGES_PER_CHAT)
|
|
2415
|
+
const recent = (await Promise.all((await this.store
|
|
2416
|
+
.listMessages(refChatId, RECENT_MESSAGES_PER_CHAT))
|
|
2347
2417
|
.reverse()
|
|
2348
2418
|
.map(async (item) => {
|
|
2349
2419
|
if (item.role !== 'user' && item.role !== 'assistant')
|
|
@@ -2718,7 +2788,7 @@ export class LocalBackendRouter {
|
|
|
2718
2788
|
// empty list, so writes confine to system scratch dirs only.
|
|
2719
2789
|
// `execPolicy: 'full'`(输入框「完整权限」)关闭这一层,让 mkdir
|
|
2720
2790
|
// Downloads 这类项目外写入不再被 Seatbelt 拦成 Operation not permitted。
|
|
2721
|
-
const chatProject = this.resolveChatProject(chatId);
|
|
2791
|
+
const chatProject = await this.resolveChatProject(chatId);
|
|
2722
2792
|
const execSandbox = buildExecSandbox([
|
|
2723
2793
|
// 场景包声明的每会话可写根(如文档包在项目根之外落盘产物的
|
|
2724
2794
|
// 工作区)。
|
|
@@ -2754,19 +2824,27 @@ export class LocalBackendRouter {
|
|
|
2754
2824
|
// 下方的清除点,残留的标记就是「上次回复被中断」的签名(与活进程写下的
|
|
2755
2825
|
// cancelled/failed 终态区分)。标记只覆盖「已交给 sidecar」的阶段:503
|
|
2756
2826
|
// (sidecar 未运行)在上方已 return,record 里没有这一轮,无可续跑。
|
|
2757
|
-
|
|
2827
|
+
await this.store.setTurnActive(chatId);
|
|
2758
2828
|
// 实时快照:executedActions / timeline / children 传引用(原地 mutate),
|
|
2759
2829
|
// content 由 onText 逐段更新。切走再切回的 renderer 靠 GET /live-stream 读它。
|
|
2760
2830
|
const live = registerLiveStream(chatId, { executedActions, timeline, children });
|
|
2761
2831
|
// 场景包的回合观察器(如识别包技能调用并广播产物更新)。
|
|
2762
2832
|
const packObservers = beginPackTurnObservers({ chatId, broadcast: this.broadcast ?? undefined });
|
|
2833
|
+
// 回合产物文件列表(回合收尾时收集):扫描根与 exec 沙箱可写根同源
|
|
2834
|
+
// (项目根 + 包工作区),与沙箱是否启用无关——「完整权限」下根列表只是
|
|
2835
|
+
// 不再被强制,产物仍大概率落在这里;根之外的显式写由工具参数并集补。
|
|
2836
|
+
const turnStartedAtMs = Date.now();
|
|
2837
|
+
const turnFileRoots = [
|
|
2838
|
+
...collectPackExecWritableRoots(chatId),
|
|
2839
|
+
...(chatProject ? [chatProject.folderPath] : []),
|
|
2840
|
+
];
|
|
2763
2841
|
try {
|
|
2764
2842
|
const coreLoopOptions = {
|
|
2765
2843
|
supervisor,
|
|
2766
2844
|
chatId,
|
|
2767
2845
|
// W5-2: after a regenerate-fork the chat's live record is the
|
|
2768
2846
|
// branch; undefined falls back to chatId on the sidecar.
|
|
2769
|
-
recordId:
|
|
2847
|
+
recordId: await this.store.getChatRecordId(chatId) ?? undefined,
|
|
2770
2848
|
systemPrompt,
|
|
2771
2849
|
messages,
|
|
2772
2850
|
resume,
|
|
@@ -2950,14 +3028,14 @@ export class LocalBackendRouter {
|
|
|
2950
3028
|
: // resume 要求 messages 为空:记录是唯一权威,再喂一份历史会
|
|
2951
3029
|
// 静默分叉(sidecar 对此 fail loud)。
|
|
2952
3030
|
{ ...coreLoopOptions, resume: true, messages: [] }),
|
|
2953
|
-
afterPass: (outcome) => {
|
|
3031
|
+
afterPass: async (outcome) => {
|
|
2954
3032
|
if (outcome.traceId)
|
|
2955
3033
|
passTraceIds.push(outcome.traceId);
|
|
2956
3034
|
// W6-9 用量归因:落一条 chat 用量事件(含成本估算,无单价模型 costUsd=null)。
|
|
2957
3035
|
// 每趟各落一条——续跑的花费是真实发生的,不该被合并掉。
|
|
2958
3036
|
if (outcome.usage) {
|
|
2959
3037
|
try {
|
|
2960
|
-
|
|
3038
|
+
await this.store.recordUsageEvent({
|
|
2961
3039
|
chatId,
|
|
2962
3040
|
kind: 'chat',
|
|
2963
3041
|
provider: settings.provider,
|
|
@@ -3011,8 +3089,22 @@ export class LocalBackendRouter {
|
|
|
3011
3089
|
passTraceIds.push(failureTraceId);
|
|
3012
3090
|
}
|
|
3013
3091
|
}
|
|
3014
|
-
const durationMs = turnDurationMs(
|
|
3015
|
-
|
|
3092
|
+
const durationMs = turnDurationMs((await this.store.getTurnActive(chatId))?.startedAt, Date.now());
|
|
3093
|
+
// 回合产物文件列表(成功/失败/取消都收集——半截回合写出的文件同样是
|
|
3094
|
+
// 产物)。收集失败只记日志,永远不该拖垮回合收尾。
|
|
3095
|
+
let turnFiles = [];
|
|
3096
|
+
try {
|
|
3097
|
+
turnFiles = await collectTurnFiles({
|
|
3098
|
+
roots: turnFileRoots,
|
|
3099
|
+
sinceMs: turnStartedAtMs,
|
|
3100
|
+
actions: executedActions,
|
|
3101
|
+
projectRoot: chatProject?.folderPath ?? null,
|
|
3102
|
+
});
|
|
3103
|
+
}
|
|
3104
|
+
catch (turnFilesErr) {
|
|
3105
|
+
console.warn('[local-backend] collect turn files failed', turnFilesErr);
|
|
3106
|
+
}
|
|
3107
|
+
const assistant = await this.store.addMessage(chatId, 'assistant', assistantText, JSON.stringify({
|
|
3016
3108
|
completionStatus,
|
|
3017
3109
|
completionReason,
|
|
3018
3110
|
executedActions,
|
|
@@ -3022,9 +3114,10 @@ export class LocalBackendRouter {
|
|
|
3022
3114
|
traceId: outcomeTraceId,
|
|
3023
3115
|
...(durationMs != null ? { durationMs } : {}),
|
|
3024
3116
|
...(autoContinuations > 0 ? { autoContinuations } : {}),
|
|
3117
|
+
...(turnFiles.length > 0 ? { turnFiles } : {}),
|
|
3025
3118
|
}));
|
|
3026
3119
|
try {
|
|
3027
|
-
recordInsightTurn({
|
|
3120
|
+
await recordInsightTurn(this.store, {
|
|
3028
3121
|
chatId,
|
|
3029
3122
|
mode: chatMode,
|
|
3030
3123
|
modelId: overrideModel || settings.model,
|
|
@@ -3041,7 +3134,7 @@ export class LocalBackendRouter {
|
|
|
3041
3134
|
// W7-1: 回复落库后才清标记(顺序不能反——先清后写的话,两者之间崩溃
|
|
3042
3135
|
// 会丢回复且无任何续跑入口;这个顺序的最坏情况是残留标记被检测器的
|
|
3043
3136
|
// 「末尾已是 assistant」规则判定为已完结)。
|
|
3044
|
-
|
|
3137
|
+
await this.store.clearTurnActive(chatId);
|
|
3045
3138
|
// 回合落库即结束——移除实时快照,后续 GET /live-stream 返回 active=false。
|
|
3046
3139
|
removeLiveStream(chatId);
|
|
3047
3140
|
// Persist the sidecar-recorded traces into harness_traces so the CoreLoop
|
|
@@ -3050,7 +3143,7 @@ export class LocalBackendRouter {
|
|
|
3050
3143
|
// 一趟一条:自动续跑的每一趟都是一次独立的 sidecar 运行,全部挂到本轮
|
|
3051
3144
|
// 那条 assistant 消息上(message_id 无唯一约束),否则中间趟的 trace 就
|
|
3052
3145
|
// 从消息侧不可达了。
|
|
3053
|
-
const telemetry =
|
|
3146
|
+
const telemetry = await this.store.getTelemetrySettings();
|
|
3054
3147
|
for (const [index, traceId] of passTraceIds.entries()) {
|
|
3055
3148
|
// 只有最后一趟携带本轮终态;之前每一趟都是撞墙截停才有下一趟。
|
|
3056
3149
|
const passStatus = index === passTraceIds.length - 1 ? completionStatus : 'budget_exhausted';
|
|
@@ -3060,7 +3153,7 @@ export class LocalBackendRouter {
|
|
|
3060
3153
|
const durationMs = typeof sidecarTrace.durationMs === 'number'
|
|
3061
3154
|
? sidecarTrace.durationMs
|
|
3062
3155
|
: null;
|
|
3063
|
-
|
|
3156
|
+
await this.store.saveTrace({
|
|
3064
3157
|
id: traceId,
|
|
3065
3158
|
chatId,
|
|
3066
3159
|
messageId: assistant.id,
|
|
@@ -3094,11 +3187,65 @@ export class LocalBackendRouter {
|
|
|
3094
3187
|
});
|
|
3095
3188
|
}
|
|
3096
3189
|
}
|
|
3190
|
+
// 产物文件列表赶在 message_id 之前发:前端按 message_id 把本轮队列
|
|
3191
|
+
// 归档到落库消息上,顺序保证产物列表随同一批次归档。
|
|
3192
|
+
if (turnFiles.length > 0) {
|
|
3193
|
+
emit(this.sseData({ type: 'turn_files', files: turnFiles }));
|
|
3194
|
+
}
|
|
3097
3195
|
emit(this.sseData({ type: 'message_id', messageId: assistant.id }));
|
|
3098
3196
|
emit('data: [DONE]\n\n');
|
|
3099
3197
|
this.runTitleGenInBackground(chatId, shouldGenerateTitle, firstUserMessageForTitle);
|
|
3198
|
+
this.runSuggestedRepliesInBackground({
|
|
3199
|
+
chatId,
|
|
3200
|
+
messageId: assistant.id,
|
|
3201
|
+
userText: firstUserMessageForTitle,
|
|
3202
|
+
assistantText,
|
|
3203
|
+
completionStatus,
|
|
3204
|
+
});
|
|
3100
3205
|
return { status: 200 };
|
|
3101
3206
|
}
|
|
3207
|
+
/**
|
|
3208
|
+
* 回合结束后生成 3 条下一轮用户输入建议。先同步推启发式兜底(芯片立刻出现),
|
|
3209
|
+
* 再 fire-and-forget 跑 LLM,成功则替换。走 broadcast 而不是 SSE:`[DONE]`
|
|
3210
|
+
* 之后渲染端已经不再监听这条流。
|
|
3211
|
+
*/
|
|
3212
|
+
runSuggestedRepliesInBackground(args) {
|
|
3213
|
+
const { chatId, messageId, userText, assistantText, completionStatus } = args;
|
|
3214
|
+
if (completionStatus === 'cancelled' || completionStatus === 'failed')
|
|
3215
|
+
return;
|
|
3216
|
+
if (!assistantText.trim())
|
|
3217
|
+
return;
|
|
3218
|
+
const fallback = fallbackSuggestedReplies(userText, assistantText);
|
|
3219
|
+
this.publishSuggestedReplies(chatId, messageId, fallback);
|
|
3220
|
+
void (async () => {
|
|
3221
|
+
try {
|
|
3222
|
+
const result = await generateSuggestedReplies(userText, assistantText, {
|
|
3223
|
+
perAttemptTimeoutMs: 60_000,
|
|
3224
|
+
});
|
|
3225
|
+
if (result.usedFallback)
|
|
3226
|
+
return;
|
|
3227
|
+
const same = result.suggestions.length === fallback.length &&
|
|
3228
|
+
result.suggestions.every((item, i) => item === fallback[i]);
|
|
3229
|
+
if (same)
|
|
3230
|
+
return;
|
|
3231
|
+
this.publishSuggestedReplies(chatId, messageId, result.suggestions);
|
|
3232
|
+
}
|
|
3233
|
+
catch (err) {
|
|
3234
|
+
console.warn('[local-backend] suggested-replies failed', { chatId, err });
|
|
3235
|
+
}
|
|
3236
|
+
})();
|
|
3237
|
+
}
|
|
3238
|
+
async publishSuggestedReplies(chatId, messageId, suggestions) {
|
|
3239
|
+
if (suggestions.length === 0)
|
|
3240
|
+
return;
|
|
3241
|
+
try {
|
|
3242
|
+
await this.store.patchMessageMetadata(chatId, messageId, { suggestedReplies: suggestions });
|
|
3243
|
+
}
|
|
3244
|
+
catch (err) {
|
|
3245
|
+
console.warn('[local-backend] suggested-replies persist failed', { chatId, messageId, err });
|
|
3246
|
+
}
|
|
3247
|
+
this.broadcast?.('suggested-replies', { chatId, messageId, suggestions });
|
|
3248
|
+
}
|
|
3102
3249
|
/**
|
|
3103
3250
|
* 标准"匿名事件" SSE:不带 event 行,前端 SSEParser 会落到默认 onMessage 分支,
|
|
3104
3251
|
* 用 parseSSEData 把 data 反序列化为 JSON 后用 type/content 字段路由。
|