c0de-agent 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -5
- package/dist/cli/commands/chat.js +87 -1
- package/dist/cli/commands/config.js +17 -3
- package/dist/cli/commands/sessions.js +62 -3
- package/dist/cli/index.js +26 -7
- package/dist/cli/modes/print.js +9 -2
- package/dist/core/compact.d.ts +2 -0
- package/dist/core/compact.js +1 -0
- package/dist/core/config.d.ts +11 -2
- package/dist/core/config.js +73 -6
- package/dist/core/loop/compaction.d.ts +2 -1
- package/dist/core/loop/compaction.js +19 -6
- package/dist/core/loop/stream-collect.js +9 -1
- package/dist/core/loop/subagent.js +23 -5
- package/dist/core/redact.d.ts +11 -0
- package/dist/core/redact.js +49 -0
- package/dist/core/slash.js +93 -51
- package/dist/core/title.js +7 -1
- package/dist/core/types.d.ts +2 -0
- package/dist/db/schema.d.ts +17 -0
- package/dist/db/schema.js +3 -1
- package/dist/kanban/index.d.ts +1 -1
- package/dist/kanban/index.js +1 -1
- package/dist/kanban/store.d.ts +5 -1
- package/dist/kanban/store.js +76 -2
- package/dist/llm/index.d.ts +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/provider.js +1 -1
- package/dist/llm/retry.d.ts +5 -2
- package/dist/llm/retry.js +6 -5
- package/dist/llm/routing.d.ts +24 -1
- package/dist/llm/routing.js +29 -1
- package/dist/project/index.d.ts +1 -1
- package/dist/project/index.js +1 -1
- package/dist/project/project.d.ts +14 -0
- package/dist/project/project.js +77 -1
- package/dist/server/agent-manager.d.ts +10 -1
- package/dist/server/agent-manager.js +14 -1
- package/dist/server/auth-manager.d.ts +11 -5
- package/dist/server/auth-manager.js +39 -20
- package/dist/server/context.js +3 -0
- package/dist/server/permission/interactive.d.ts +6 -3
- package/dist/server/permission/interactive.js +20 -4
- package/dist/server/permission/store.d.ts +12 -2
- package/dist/server/permission/store.js +31 -3
- package/dist/server/registry-config.d.ts +14 -0
- package/dist/server/registry-config.js +44 -0
- package/dist/server/routes/auth.js +32 -2
- package/dist/server/routes/chat.d.ts +12 -0
- package/dist/server/routes/chat.js +216 -38
- package/dist/server/routes/commands.d.ts +4 -2
- package/dist/server/routes/commands.js +10 -3
- package/dist/server/routes/config.js +103 -23
- package/dist/server/routes/files.js +22 -4
- package/dist/server/routes/kanban.js +68 -6
- package/dist/server/routes/permissions.d.ts +0 -4
- package/dist/server/routes/permissions.js +133 -7
- package/dist/server/routes/project.js +59 -10
- package/dist/server/routes/provider.js +48 -3
- package/dist/server/routes/session.js +298 -22
- package/dist/server/routes/update.d.ts +11 -5
- package/dist/server/routes/update.js +88 -28
- package/dist/server/server.d.ts +4 -12
- package/dist/server/server.js +198 -102
- package/dist/server/types.d.ts +7 -0
- package/dist/session/archive.d.ts +3 -1
- package/dist/session/archive.js +13 -1
- package/dist/session/branch.js +20 -2
- package/dist/session/import.d.ts +44 -0
- package/dist/session/import.js +83 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/index.js +1 -1
- package/dist/session/jobs.d.ts +12 -0
- package/dist/session/jobs.js +53 -0
- package/dist/session/message.d.ts +7 -1
- package/dist/session/message.js +21 -1
- package/dist/session/session.d.ts +128 -11
- package/dist/session/session.js +461 -30
- package/dist/session/snapshot.d.ts +5 -2
- package/dist/session/snapshot.js +10 -2
- package/dist/session/types.d.ts +11 -1
- package/dist/shared/types/agent.d.ts +11 -0
- package/dist/shared/types/config.d.ts +5 -1
- package/dist/shared/types/kanban.d.ts +13 -0
- package/dist/shared/types/message.d.ts +9 -0
- package/dist/tools/websearch/index.js +6 -3
- package/dist/update/index.d.ts +3 -3
- package/dist/update/index.js +2 -2
- package/dist/update/ipc.d.ts +13 -2
- package/dist/update/ipc.js +36 -2
- package/dist/update/snapshot.d.ts +1 -0
- package/dist/update/snapshot.js +2 -0
- package/dist/update/updater.d.ts +15 -1
- package/dist/update/updater.js +45 -17
- package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
- package/drizzle/meta/0008_snapshot.json +875 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +41 -42
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
+
import { loadConfigScopes, mergeConfig } from '../../core/config.js';
|
|
2
3
|
import { decryptSecret } from '../../core/secret.js';
|
|
4
|
+
import { resolveRoute } from '../../llm/registry.js';
|
|
5
|
+
import { getProject } from '../../project/project.js';
|
|
3
6
|
import { apiError } from '../middleware/error.js';
|
|
7
|
+
import { buildRegistryFromConfig } from '../registry-config.js';
|
|
4
8
|
/**
|
|
5
9
|
* 用给定 baseURL/apiKey 探测 OpenAI 兼容的 /models 端点。
|
|
6
10
|
* baseURL 应自行包含版本路径(如 https://api.openai.com/v1)。
|
|
@@ -39,15 +43,56 @@ async function probeModels(baseURL, apiKey) {
|
|
|
39
43
|
}
|
|
40
44
|
function createProviderRoute(ctx) {
|
|
41
45
|
const app = new Hono();
|
|
46
|
+
/** P1-1:解析目标项目配置与注册表(?projectId= 提供时按该项目合并配置)。
|
|
47
|
+
* 项目配置从磁盘实时读取(配置体量小);注册表优先用会话聊天路径缓存的。 */
|
|
48
|
+
const resolveForProject = async (c) => {
|
|
49
|
+
const projectId = c.req.query('projectId');
|
|
50
|
+
if (!projectId)
|
|
51
|
+
return { config: ctx.config, registry: ctx.llmRegistry };
|
|
52
|
+
const project = await getProject(ctx.db, projectId);
|
|
53
|
+
if (!project)
|
|
54
|
+
return null;
|
|
55
|
+
const scopes = loadConfigScopes(project.worktree);
|
|
56
|
+
const config = mergeConfig(scopes.global, scopes.project);
|
|
57
|
+
const registry = config.providers.length > 0 ? buildRegistryFromConfig(config) : ctx.llmRegistry;
|
|
58
|
+
return { config, registry };
|
|
59
|
+
};
|
|
42
60
|
// 列出已配置 providers(apiKey 脱敏,不含明文)
|
|
43
|
-
app.get('/', (c) => {
|
|
44
|
-
const
|
|
61
|
+
app.get('/', async (c) => {
|
|
62
|
+
const resolved = await resolveForProject(c);
|
|
63
|
+
if (!resolved)
|
|
64
|
+
return apiError(c, 404, 'PROJECT_NOT_FOUND', '项目不存在');
|
|
65
|
+
const providers = resolved.config.providers.map((p) => ({
|
|
45
66
|
name: p.name || p._tag || '',
|
|
46
67
|
protocol: p.protocol,
|
|
47
68
|
baseURL: p.baseURL ?? '',
|
|
48
69
|
hasKey: !!p.apiKey,
|
|
49
70
|
}));
|
|
50
|
-
return c.json({ providers, defaultProvider:
|
|
71
|
+
return c.json({ providers, defaultProvider: resolved.config.defaultProvider });
|
|
72
|
+
});
|
|
73
|
+
// 模型能力查询:前端据此控制多模态入口(supportsVision)等
|
|
74
|
+
app.get('/capabilities', async (c) => {
|
|
75
|
+
const provider = c.req.query('provider');
|
|
76
|
+
const model = c.req.query('model');
|
|
77
|
+
if (!provider || !model) {
|
|
78
|
+
return apiError(c, 400, 'BAD_REQUEST', 'provider and model are required');
|
|
79
|
+
}
|
|
80
|
+
const project = await resolveForProject(c);
|
|
81
|
+
if (!project)
|
|
82
|
+
return apiError(c, 404, 'PROJECT_NOT_FOUND', '项目不存在');
|
|
83
|
+
try {
|
|
84
|
+
const route = resolveRoute(project.registry, provider, model);
|
|
85
|
+
return c.json({
|
|
86
|
+
provider,
|
|
87
|
+
model,
|
|
88
|
+
supportsVision: route.capabilities.supportsVision,
|
|
89
|
+
supportsThinking: route.capabilities.supportsThinking,
|
|
90
|
+
contextWindow: route.capabilities.contextWindow,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return apiError(c, 404, 'NOT_FOUND', 'provider/model not registered');
|
|
95
|
+
}
|
|
51
96
|
});
|
|
52
97
|
// 连接测试:用请求体里的凭据探测 /models,不污染 registry
|
|
53
98
|
app.post('/test', async (c) => {
|
|
@@ -1,19 +1,77 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { eq } from 'drizzle-orm';
|
|
1
3
|
import { Hono } from 'hono';
|
|
2
4
|
import { createSummarizer, runCompaction } from '../../core/compact.js';
|
|
5
|
+
import { loadConfigScopes, mergeConfig } from '../../core/config.js';
|
|
6
|
+
import { sessions } from '../../db/schema.js';
|
|
7
|
+
import { buildFallbackChain } from '../../llm/routing.js';
|
|
3
8
|
import { fromDirectory } from '../../project/index.js';
|
|
4
|
-
import {
|
|
9
|
+
import { getProject } from '../../project/project.js';
|
|
10
|
+
import { archiveOriginalEntries, listArchives } from '../../session/archive.js';
|
|
5
11
|
import { BranchPointOutOfRangeError, forkSession, getBranches, getTree, } from '../../session/branch.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
12
|
+
import { importSessionData } from '../../session/import.js';
|
|
13
|
+
import { deleteEntriesByIds, getMessages, getSteeringAsMessages, insertEntry, } from '../../session/message.js';
|
|
14
|
+
import { createSession, emptyTrash, getLLMSegments, getSession, listDeletedSessions, listOrphanDeletedSessions, listSessions, listSessionsByProject, permanentlyDeleteSession, rebindSession, restoreSessionCore, searchSessions, softDeleteSession, touchLastOpened, touchTrashSeen, updateSessionTitle, } from '../../session/session.js';
|
|
8
15
|
import { applyShakeRegions, collectShakeRegions, DEFAULT_SHAKE_CONFIG, toRegionViews, } from '../../session/shake.js';
|
|
9
16
|
import { estimateMessageTokens } from '../../session/token.js';
|
|
10
17
|
import { generateId } from '../../shared/index.js';
|
|
11
18
|
import { apiError } from '../middleware/error.js';
|
|
19
|
+
import { buildRegistryFromConfig } from '../registry-config.js';
|
|
20
|
+
import { resolveAgentCwd } from './chat.js';
|
|
12
21
|
function createSessionRoute(ctx) {
|
|
13
22
|
const app = new Hono();
|
|
23
|
+
// 会话导入:GET /:id/export 的逆操作(数据备份/迁移闭环)。
|
|
24
|
+
// 消息/归档重新生成 id(保留内容与时间戳),同库复制、重复导入均安全。
|
|
25
|
+
// 绑定 projectId 后立即出现在对应项目视图。
|
|
26
|
+
// P1-2:projectId 必填——无归属会话在任何项目视图都不可见,导入即孤儿。
|
|
27
|
+
app.post('/import', async (c) => {
|
|
28
|
+
const body = (await c.req.json().catch(() => null));
|
|
29
|
+
if (body?.version !== 1 || !body?.session || !Array.isArray(body?.messages)) {
|
|
30
|
+
return apiError(c, 400, 'INVALID_EXPORT', '无效的会话导出 JSON:需要 version/session/messages 字段');
|
|
31
|
+
}
|
|
32
|
+
const projectId = typeof body.projectId === 'string' && body.projectId ? body.projectId : '';
|
|
33
|
+
if (!projectId) {
|
|
34
|
+
return apiError(c, 400, 'PROJECT_REQUIRED', '导入会话必须指定目标项目(projectId)');
|
|
35
|
+
}
|
|
36
|
+
const project = await getProject(ctx.db, projectId);
|
|
37
|
+
if (!project) {
|
|
38
|
+
return apiError(c, 404, 'PROJECT_NOT_FOUND', '目标项目不存在');
|
|
39
|
+
}
|
|
40
|
+
const title = typeof body.session.title === 'string' && body.session.title
|
|
41
|
+
? body.session.title
|
|
42
|
+
: '导入的会话';
|
|
43
|
+
// P0:权限态仅当调用方显式确认(importPermissions=true)时随迁。
|
|
44
|
+
// 默认剥离——用户在导出会话中建立的授权信任不应跨机器静默生效。
|
|
45
|
+
const importPermissions = body.importPermissions === true;
|
|
46
|
+
const metadata = {};
|
|
47
|
+
if (importPermissions) {
|
|
48
|
+
const srcMeta = body.session.metadata;
|
|
49
|
+
if (srcMeta && typeof srcMeta === 'object' && !Array.isArray(srcMeta)) {
|
|
50
|
+
const m = srcMeta;
|
|
51
|
+
if (m.permissionMode === 'auto' || m.permissionMode === 'default') {
|
|
52
|
+
metadata.permissionMode = m.permissionMode;
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(m.alwaysAllow)) {
|
|
55
|
+
metadata.alwaysAllow = m.alwaysAllow.filter((x) => typeof x === 'string');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// P2:导出含分支树结构(parentId),导入为独立根会话——告知前端提示扁平化。
|
|
60
|
+
const flattened = typeof body.session.parentId === 'string' && body.session.parentId.length > 0;
|
|
61
|
+
const result = await importSessionData(ctx.db, {
|
|
62
|
+
title,
|
|
63
|
+
projectId,
|
|
64
|
+
messages: body.messages,
|
|
65
|
+
archives: Array.isArray(body.archives)
|
|
66
|
+
? body.archives
|
|
67
|
+
: [],
|
|
68
|
+
metadata,
|
|
69
|
+
});
|
|
70
|
+
return c.json({ ok: true, ...result, flattened, permissionsMigrated: importPermissions });
|
|
71
|
+
});
|
|
14
72
|
// 创建会话
|
|
15
73
|
app.post('/', async (c) => {
|
|
16
|
-
const body = await c.req.json().catch(() => ({}));
|
|
74
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
17
75
|
const title = body.title ?? 'New Session';
|
|
18
76
|
const directory = body.directory;
|
|
19
77
|
const explicitProjectId = body.projectId;
|
|
@@ -25,6 +83,11 @@ function createSessionRoute(ctx) {
|
|
|
25
83
|
else if (explicitProjectId) {
|
|
26
84
|
projectId = explicitProjectId;
|
|
27
85
|
}
|
|
86
|
+
// P2 修复:projectId 必填——无归属 web 会话在任何项目视图都不可见,
|
|
87
|
+
// 且非删除孤儿无任何 UI 入口,API 直调会创建不可达会话。
|
|
88
|
+
if (!projectId) {
|
|
89
|
+
return apiError(c, 400, 'PROJECT_REQUIRED', '创建会话必须指定项目:传 directory(自动解析)或 projectId');
|
|
90
|
+
}
|
|
28
91
|
const session = await createSession(ctx.db, title, projectId);
|
|
29
92
|
return c.json(session, 201);
|
|
30
93
|
});
|
|
@@ -41,17 +104,53 @@ function createSessionRoute(ctx) {
|
|
|
41
104
|
const tree = await getTree(ctx.db);
|
|
42
105
|
return c.json(tree);
|
|
43
106
|
});
|
|
44
|
-
//
|
|
107
|
+
// 跨会话搜索(P2-6):标题 + 消息内容匹配;?q= 关键词,?projectId= 限定项目。
|
|
108
|
+
// P3:?includeDeleted=1 搜索回收站(配合回收站搜索框)。
|
|
109
|
+
// 注册在 /:id 之前避免被参数路由吞掉。
|
|
110
|
+
app.get('/search', async (c) => {
|
|
111
|
+
const q = c.req.query('q') ?? '';
|
|
112
|
+
const projectId = c.req.query('projectId');
|
|
113
|
+
const includeDeleted = c.req.query('includeDeleted') === '1' || c.req.query('includeDeleted') === 'true';
|
|
114
|
+
if (!q.trim())
|
|
115
|
+
return c.json({ results: [] });
|
|
116
|
+
const results = await searchSessions(ctx.db, q, projectId, { includeDeleted });
|
|
117
|
+
return c.json({ results });
|
|
118
|
+
});
|
|
119
|
+
// 回收站:已软删除的会话列表(必须注册在 /:id 之前,避免被参数路由吞掉)。
|
|
120
|
+
// ?projectId= 过滤本项目(P1-7:回收站此前全库共享,跨项目可见可清空)。
|
|
121
|
+
// ?orphan=1 → 仅列出未归属项目的已删会话(删除项目后 FK set null 导致的孤儿,F1)。
|
|
122
|
+
// A3:孤儿列表查询不再自动标记「已看到」——标记改为用户展开分组时显式调用
|
|
123
|
+
// POST /deleted/orphans/seen,避免打开任意项目回收站连带启动无关孤儿倒计时。
|
|
45
124
|
app.get('/deleted', async (c) => {
|
|
46
|
-
const
|
|
47
|
-
|
|
125
|
+
const projectId = c.req.query('projectId');
|
|
126
|
+
if (c.req.query('orphan') === '1') {
|
|
127
|
+
return c.json(await listOrphanDeletedSessions(ctx.db));
|
|
128
|
+
}
|
|
129
|
+
await touchTrashSeen(ctx.db, projectId ? { projectId } : {});
|
|
130
|
+
return c.json(await listDeletedSessions(ctx.db, projectId));
|
|
131
|
+
});
|
|
132
|
+
// A3:孤儿条目计数(不标记 seen;分组折叠时展示「N 条」用)。
|
|
133
|
+
app.get('/deleted/orphans/count', async (c) => {
|
|
134
|
+
const orphans = await listOrphanDeletedSessions(ctx.db);
|
|
135
|
+
return c.json({ count: orphans.length });
|
|
136
|
+
});
|
|
137
|
+
// A3:用户展开「未归属项目」分组时显式标记孤儿已看到(保留期自此刻起算)。
|
|
138
|
+
app.post('/deleted/orphans/seen', async (c) => {
|
|
139
|
+
const touched = await touchTrashSeen(ctx.db, { orphan: true });
|
|
140
|
+
return c.json({ ok: true, touched });
|
|
141
|
+
});
|
|
142
|
+
// 清空回收站:物理删除所有软删除会话(不可恢复)。?projectId= 仅清空该项目。
|
|
143
|
+
app.delete('/deleted', async (c) => {
|
|
144
|
+
const projectId = c.req.query('projectId');
|
|
145
|
+
const count = await emptyTrash(ctx.db, projectId);
|
|
146
|
+
return c.json({ ok: true, deleted: count });
|
|
48
147
|
});
|
|
49
148
|
// 获取会话详情
|
|
50
149
|
app.get('/:id', async (c) => {
|
|
51
150
|
try {
|
|
52
151
|
const session = await getSession(ctx.db, c.req.param('id'));
|
|
53
|
-
if (!session) {
|
|
54
|
-
return apiError(c, 404, 'NOT_FOUND', '
|
|
152
|
+
if (!session || session.deletedAt) {
|
|
153
|
+
return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
|
|
55
154
|
}
|
|
56
155
|
return c.json(session);
|
|
57
156
|
}
|
|
@@ -59,9 +158,29 @@ function createSessionRoute(ctx) {
|
|
|
59
158
|
return apiError(c, 404, 'NOT_FOUND', 'Session not found');
|
|
60
159
|
}
|
|
61
160
|
});
|
|
161
|
+
// P2-5:会话重命名(此前标题只能由 LLM 自动生成,用户无法修改)
|
|
162
|
+
app.patch('/:id', async (c) => {
|
|
163
|
+
const id = c.req.param('id');
|
|
164
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
165
|
+
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
|
166
|
+
if (!title)
|
|
167
|
+
return apiError(c, 400, 'BAD_REQUEST', 'title is required');
|
|
168
|
+
if (title.length > 120) {
|
|
169
|
+
return apiError(c, 400, 'BAD_REQUEST', 'title must be at most 120 characters');
|
|
170
|
+
}
|
|
171
|
+
const session = await getSession(ctx.db, id);
|
|
172
|
+
if (!session)
|
|
173
|
+
return apiError(c, 404, 'NOT_FOUND', 'Session not found');
|
|
174
|
+
await updateSessionTitle(ctx.db, id, title);
|
|
175
|
+
return c.json({ ok: true, title });
|
|
176
|
+
});
|
|
62
177
|
// 分支会话:未指定 messageIndex 时默认在最新一条消息处分叉(fork=完整副本语义)
|
|
63
178
|
app.post('/:id/fork', async (c) => {
|
|
64
179
|
const id = c.req.param('id');
|
|
180
|
+
// P3:fork 复制消息树,与正在写库的活跃 run 存在竞态——先拒绝。
|
|
181
|
+
if (ctx.agentManager.get(id)) {
|
|
182
|
+
return apiError(c, 409, 'RUN_ACTIVE', '该会话已有进行中的对话,请等待完成或中止后再分支');
|
|
183
|
+
}
|
|
65
184
|
const body = await c.req.json().catch(() => ({}));
|
|
66
185
|
let messageIndex = body.messageIndex;
|
|
67
186
|
if (messageIndex === undefined || !Number.isFinite(messageIndex)) {
|
|
@@ -83,24 +202,151 @@ function createSessionRoute(ctx) {
|
|
|
83
202
|
return apiError(c, 404, 'NOT_FOUND', 'Session not found');
|
|
84
203
|
}
|
|
85
204
|
});
|
|
86
|
-
// 删除会话(软删除:级联其 fork 后代进入回收站,30
|
|
205
|
+
// 删除会话(软删除:级联其 fork 后代进入回收站,30 天后物理清除)。
|
|
206
|
+
// P2 修复:删除运行中的会话前先中止其活跃 run(含子 agent)——
|
|
207
|
+
// 否则 run 继续向已删除会话写入消息,用户以为已删除而 agent 仍在执行。
|
|
87
208
|
app.delete('/:id', async (c) => {
|
|
88
|
-
const
|
|
209
|
+
const id = c.req.param('id');
|
|
210
|
+
ctx.agentManager.abort(id);
|
|
211
|
+
for (const child of ctx.agentManager.children(id)) {
|
|
212
|
+
ctx.agentManager.abort(child.sessionId);
|
|
213
|
+
}
|
|
214
|
+
const ok = await softDeleteSession(ctx.db, id);
|
|
89
215
|
if (!ok)
|
|
90
216
|
return apiError(c, 404, 'NOT_FOUND', 'Session not found');
|
|
91
217
|
return c.body(null, 204);
|
|
92
218
|
});
|
|
93
|
-
//
|
|
219
|
+
// 彻底删除回收站会话(不可恢复):会话 + 全部后代物理清除
|
|
220
|
+
app.delete('/:id/forever', async (c) => {
|
|
221
|
+
let count;
|
|
222
|
+
try {
|
|
223
|
+
count = await permanentlyDeleteSession(ctx.db, c.req.param('id'));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return apiError(c, 404, 'NOT_FOUND', '会话不存在或不在回收站');
|
|
227
|
+
}
|
|
228
|
+
if (count === 0)
|
|
229
|
+
return apiError(c, 404, 'NOT_FOUND', '会话不存在或不在回收站');
|
|
230
|
+
return c.json({ ok: true, deleted: count });
|
|
231
|
+
});
|
|
232
|
+
// 会话归档列表(compaction/squash/shake/clear 的原始内容);?q= 搜索归档文本
|
|
233
|
+
app.get('/:id/archives', async (c) => {
|
|
234
|
+
const id = c.req.param('id');
|
|
235
|
+
let session;
|
|
236
|
+
try {
|
|
237
|
+
session = await getSession(ctx.db, id);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return apiError(c, 404, 'NOT_FOUND', 'Session not found');
|
|
241
|
+
}
|
|
242
|
+
if (!session)
|
|
243
|
+
return apiError(c, 404, 'NOT_FOUND', 'Session not found');
|
|
244
|
+
const q = c.req.query('q');
|
|
245
|
+
const archives = await listArchives(ctx.db, id, q);
|
|
246
|
+
return c.json({ archives });
|
|
247
|
+
});
|
|
248
|
+
// 会话导出:元数据 + 消息 + 全部归档(JSON 下载,数据可迁移)
|
|
249
|
+
app.get('/:id/export', async (c) => {
|
|
250
|
+
const id = c.req.param('id');
|
|
251
|
+
let session;
|
|
252
|
+
try {
|
|
253
|
+
session = await getSession(ctx.db, id);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return apiError(c, 404, 'NOT_FOUND', 'Session not found');
|
|
257
|
+
}
|
|
258
|
+
if (!session)
|
|
259
|
+
return apiError(c, 404, 'NOT_FOUND', 'Session not found');
|
|
260
|
+
const messages = await getMessages(ctx.db, id);
|
|
261
|
+
const archives = await listArchives(ctx.db, id);
|
|
262
|
+
return c.json({
|
|
263
|
+
version: 1,
|
|
264
|
+
exportedAt: new Date().toISOString(),
|
|
265
|
+
session,
|
|
266
|
+
messages,
|
|
267
|
+
archives,
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
// 恢复会话(从回收站还原;仅还原该会话本身)。
|
|
271
|
+
// P1 可达性修复:会话所属项目已被删除时(FK set null),按 worktreePath 重建
|
|
272
|
+
// 项目记录并重新归属——否则恢复成功但会话不属于任何项目视图,UI 不可达。
|
|
273
|
+
// P1-2:body.projectId 提供时(当前项目视图),目录失效无法重建归属的孤儿
|
|
274
|
+
// 会话直接归属到该请求项目,恢复即可达。
|
|
94
275
|
app.post('/:id/restore', async (c) => {
|
|
95
|
-
const
|
|
96
|
-
|
|
276
|
+
const id = c.req.param('id');
|
|
277
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
278
|
+
const requestProjectId = typeof body.projectId === 'string' && body.projectId ? body.projectId : undefined;
|
|
279
|
+
const result = await restoreSessionCore(ctx.db, id);
|
|
280
|
+
if (!result.restored)
|
|
97
281
|
return apiError(c, 404, 'NOT_FOUND', '会话不存在或未删除');
|
|
98
|
-
|
|
282
|
+
const session = await getSession(ctx.db, id);
|
|
283
|
+
let rebound = false;
|
|
284
|
+
let orphaned = false;
|
|
285
|
+
if (session && !session.projectId) {
|
|
286
|
+
const wt = session.worktreePath;
|
|
287
|
+
if (wt && existsSync(wt)) {
|
|
288
|
+
try {
|
|
289
|
+
const project = await fromDirectory(ctx.db, wt);
|
|
290
|
+
await ctx.db.db.update(sessions).set({ projectId: project.id }).where(eq(sessions.id, id));
|
|
291
|
+
rebound = true;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
orphaned = true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
orphaned = true;
|
|
299
|
+
}
|
|
300
|
+
// P1-2:目录失效且请求方提供了项目上下文 → 归属到该请求项目
|
|
301
|
+
if (orphaned && requestProjectId) {
|
|
302
|
+
const project = await getProject(ctx.db, requestProjectId);
|
|
303
|
+
if (project) {
|
|
304
|
+
await rebindSession(ctx.db, id, project);
|
|
305
|
+
orphaned = false;
|
|
306
|
+
rebound = true;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return c.json({
|
|
311
|
+
ok: true,
|
|
312
|
+
rebound,
|
|
313
|
+
orphaned,
|
|
314
|
+
restoredAncestorCount: result.restoredAncestorCount,
|
|
315
|
+
crossedBatchAncestor: result.crossedBatchAncestor,
|
|
316
|
+
// A2:批次不同的已删后代滞留在回收站,显式告知数量供前端提示单独恢复。
|
|
317
|
+
leftBehindDescendantCount: result.leftBehindDescendantCount,
|
|
318
|
+
});
|
|
99
319
|
});
|
|
100
|
-
//
|
|
320
|
+
// 会话归属变更(P1-2):孤儿会话归属到指定项目,恢复后即可达。
|
|
321
|
+
app.post('/:id/rebind', async (c) => {
|
|
322
|
+
const id = c.req.param('id');
|
|
323
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
324
|
+
const projectId = typeof body.projectId === 'string' && body.projectId ? body.projectId : '';
|
|
325
|
+
if (!projectId)
|
|
326
|
+
return apiError(c, 400, 'PROJECT_REQUIRED', 'projectId is required');
|
|
327
|
+
const project = await getProject(ctx.db, projectId);
|
|
328
|
+
if (!project)
|
|
329
|
+
return apiError(c, 404, 'PROJECT_NOT_FOUND', '目标项目不存在');
|
|
330
|
+
const ok = await rebindSession(ctx.db, id, project);
|
|
331
|
+
if (!ok)
|
|
332
|
+
return apiError(c, 404, 'NOT_FOUND', 'Session not found');
|
|
333
|
+
return c.json({ ok: true, projectId: project.id });
|
|
334
|
+
});
|
|
335
|
+
// 获取消息列表(含 steering 条目:P0 追加指令持久化后需在时间线可见)
|
|
101
336
|
app.get('/:id/messages', async (c) => {
|
|
102
|
-
const
|
|
103
|
-
|
|
337
|
+
const id = c.req.param('id');
|
|
338
|
+
const session = await getSession(ctx.db, id);
|
|
339
|
+
if (!session || session.deletedAt) {
|
|
340
|
+
return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
|
|
341
|
+
}
|
|
342
|
+
const [messages, steering] = await Promise.all([
|
|
343
|
+
getMessages(ctx.db, id),
|
|
344
|
+
getSteeringAsMessages(ctx.db, id),
|
|
345
|
+
]);
|
|
346
|
+
if (steering.length === 0)
|
|
347
|
+
return c.json(messages);
|
|
348
|
+
// 按 createdAt 交错合并(steering 与消息时间序一致)
|
|
349
|
+
return c.json([...messages, ...steering].sort((a, b) => a.createdAt - b.createdAt));
|
|
104
350
|
});
|
|
105
351
|
// 获取 LLM 调用分段(段首快照 + 段内轻量 calls):优先取活跃 run 的内存记录(实时),回退 DB 持久化
|
|
106
352
|
app.get('/:id/llm-details', async (c) => {
|
|
@@ -111,8 +357,13 @@ function createSessionRoute(ctx) {
|
|
|
111
357
|
return c.json(persisted);
|
|
112
358
|
});
|
|
113
359
|
// 手动触发会话压缩(段切换确认弹窗「顺便压缩」调用)。用末段的 provider/model 构建摘要器。
|
|
360
|
+
// P1 多项目:keepRecentTokens 按会话项目配置解析(此前用启动目录配置)。
|
|
114
361
|
app.post('/:id/compact', async (c) => {
|
|
115
362
|
const id = c.req.param('id');
|
|
363
|
+
// P3:压缩改写消息树,与活跃 run 的并发写入存在竞态——先拒绝。
|
|
364
|
+
if (ctx.agentManager.get(id)) {
|
|
365
|
+
return apiError(c, 409, 'RUN_ACTIVE', '该会话已有进行中的对话,请等待完成或中止后再压缩');
|
|
366
|
+
}
|
|
116
367
|
let session;
|
|
117
368
|
try {
|
|
118
369
|
session = await getSession(ctx.db, id);
|
|
@@ -122,14 +373,35 @@ function createSessionRoute(ctx) {
|
|
|
122
373
|
}
|
|
123
374
|
if (!session)
|
|
124
375
|
return apiError(c, 404, 'NOT_FOUND', 'Session not found');
|
|
376
|
+
let sessionConfig = ctx.config;
|
|
377
|
+
let sessionRegistry = ctx.llmRegistry;
|
|
378
|
+
try {
|
|
379
|
+
const sessionCwd = await resolveAgentCwd(ctx, session);
|
|
380
|
+
if (sessionCwd !== ctx.cwd) {
|
|
381
|
+
const projectScope = loadConfigScopes(sessionCwd).project;
|
|
382
|
+
if (projectScope) {
|
|
383
|
+
sessionConfig = mergeConfig(ctx.config, projectScope);
|
|
384
|
+
// P1-1:压缩摘要同样需要项目级 provider 注册表。
|
|
385
|
+
if (sessionConfig.providers.length > 0) {
|
|
386
|
+
sessionRegistry = buildRegistryFromConfig(sessionConfig);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// cwd 解析失败不阻塞压缩:回退服务级配置(压缩优于报错)
|
|
393
|
+
}
|
|
125
394
|
const segs = await getLLMSegments(ctx.db, id);
|
|
126
395
|
const lastSeg = segs[segs.length - 1];
|
|
127
|
-
const provider = lastSeg?.provider ??
|
|
128
|
-
const model = lastSeg?.model ??
|
|
396
|
+
const provider = lastSeg?.provider ?? sessionConfig.defaultProvider;
|
|
397
|
+
const model = lastSeg?.model ?? sessionConfig.defaultModel;
|
|
129
398
|
try {
|
|
130
|
-
const
|
|
399
|
+
const fallback = buildFallbackChain(sessionConfig, provider, model);
|
|
400
|
+
const summarizer = createSummarizer(sessionRegistry, provider, model, {
|
|
401
|
+
...(fallback ? { fallback } : {}),
|
|
402
|
+
});
|
|
131
403
|
const result = await runCompaction(ctx.db, id, summarizer, {
|
|
132
|
-
keepRecentTokens:
|
|
404
|
+
keepRecentTokens: sessionConfig.compaction.keepRecentTokens,
|
|
133
405
|
});
|
|
134
406
|
return c.json(result);
|
|
135
407
|
}
|
|
@@ -175,6 +447,10 @@ function createSessionRoute(ctx) {
|
|
|
175
447
|
// shake apply:归档原始内容 + 原位替换
|
|
176
448
|
app.post('/:id/shake/apply', async (c) => {
|
|
177
449
|
const id = c.req.param('id');
|
|
450
|
+
// P3:shake 原位改写消息,与活跃 run 的并发写入存在竞态——先拒绝。
|
|
451
|
+
if (ctx.agentManager.get(id)) {
|
|
452
|
+
return apiError(c, 409, 'RUN_ACTIVE', '该会话已有进行中的对话,请等待完成或中止后再 Shake');
|
|
453
|
+
}
|
|
178
454
|
let session;
|
|
179
455
|
try {
|
|
180
456
|
session = await getSession(ctx.db, id);
|
|
@@ -5,11 +5,17 @@ import type { ServerContext } from '../types.js';
|
|
|
5
5
|
* 路由本身不触发 npm registry 请求;调度器周期性 checkNow 写入缓存,
|
|
6
6
|
* 路由读缓存(避免每个前端轮询都打外网)。
|
|
7
7
|
*
|
|
8
|
-
* POST /api/update/apply — 手动触发热更新(spec §18.2
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* POST /api/update/apply — 手动触发热更新(spec §18.2),P0 修复后顺序:
|
|
9
|
+
* 1. checkNow 确认有更新;
|
|
10
|
+
* 2. performInstall(仅安装新版本)——失败时**尚未暂停任何会话**,
|
|
11
|
+
* 直接返回可操作错误(409 + 手动更新命令),用户工作零损失;
|
|
12
|
+
* 3. 安装成功 → pauseAll 暂停活跃 run 至安全点(原子操作完成);
|
|
13
|
+
* 4. serializeSessions → performHandoff(写快照 + spawn 新实例);
|
|
14
|
+
* 5. spawn 失败 → resumeAll 回滚暂停的会话,返回明确错误。
|
|
15
|
+
*
|
|
16
|
+
* 旧实例通过 handoff 端点优雅退出,新实例 restore 快照并接管端口。
|
|
17
|
+
* 响应立即返回(不阻塞 spawn),实际接管由 handoff IPC 异步完成。
|
|
18
|
+
* dev(vite)模式无 handoff server,返回 409——热更新仅在独立 serve 进程可用。
|
|
13
19
|
*/
|
|
14
20
|
declare function createUpdateRoute(ctx: ServerContext): Hono;
|
|
15
21
|
export { createUpdateRoute };
|