dsh-knowhub 0.8.6 → 0.8.8

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 CHANGED
@@ -10,9 +10,10 @@
10
10
 
11
11
  **知识库与作用域**
12
12
 
13
- - 用户级知识库(跨工作区共享)与工作区级知识库(仅当前项目可见),检索、注入、审核都按作用域隔离。
13
+ - 用户级知识库(跨工作区共享)与工作区级知识库(仅当前项目可见);检索与上下文注入按工作区作用域隔离,审核队列与知识库列表覆盖本地全部知识库。
14
14
  - 每个知识库可绑定不同的模型与生成策略(economy / balanced / quality),初始化、提炼、增量都使用库绑定的模型。
15
- - 创建后可修改名称、模型与策略,不影响已有知识与版本历史。
15
+ - 创建后可修改名称、模型与策略,不影响已有知识与版本历史;编辑配置里可先「测试模型」确认该模型当前可调通。
16
+ - 模型输出被截断(推理模型把 token 预算消耗在思考过程)时,任务会自动加倍预算重试一次;仍失败会明确提示换用非推理模型或调高「模型使用策略」。
16
17
 
17
18
  **Repo Wiki 与增量更新**
18
19
 
@@ -34,7 +35,7 @@
34
35
 
35
36
  **审核与版本**
36
37
 
37
- - 待审核队列分候选、冲突、草稿、过时四类,真实计数、关键字/类型/来源筛选与排序。
38
+ - 待审核队列分候选、冲突、草稿、过时四类,覆盖本地全部知识库(用户级 + 各工作区),真实计数、关键字/类型/来源筛选与排序。
38
39
  - 支持单条与批量审批(单次最多 50 条)、编辑后批准、冲突人工合并。
39
40
  - 候选与冲突提供文档对比页:绿色新增、红色删除,行内词元高亮,统一视图与并排视图,改动处导航。
40
41
  - 版本历史可查看、对比与恢复,恢复同样以新版本追加,可回退。
@@ -80,7 +81,7 @@ dsh --profile knowhub --port 29961 --no-open
80
81
 
81
82
  ```powershell
82
83
  # tarball
83
- & dsh plugin --profile knowhub add .\dsh-knowhub-0.8.3.tgz
84
+ & dsh plugin --profile knowhub add .\dsh-knowhub-0.8.8.tgz
84
85
 
85
86
  # 开发期:软链到源码目录,构建后重启即生效
86
87
  cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\knowhub\node_modules\dsh-knowhub" "C:\path\to\KnowHub"
@@ -116,8 +117,8 @@ cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\knowhub\node_modules\dsh-knowhu
116
117
  | Repo Wiki | 按知识库分组的 Wiki 文档树 + 阅读器:正文、来源清单、版本历史、编辑、标记过时、父级 / 子级知识(最多 5 层)、是否有帮助反馈、导出 Markdown;状态过滤与真实分页 |
117
118
  | 知识卡片 | 决策 / 约定 / 问题 / 待办四类知识,类型与状态过滤,列表 + 阅读器 |
118
119
  | 记忆 | 记忆 / 偏好两类知识,类型与状态过滤,列表 + 阅读器 |
119
- | 知识库 | 知识库列表(固定列出全部:用户级 + 各工作区,不做工作区筛选)+ 详情:新建与编辑配置、初始化与增量任务(进度 / 日志 / 取消 / 重试)、项目索引、模型调用记录、知识列表分页、导出全部、导入 Markdown、删除 |
120
- | 待审核 | 候选 / 冲突 / 草稿 / 过时四类队列,真实计数、筛选、排序、批量审批;点「查看详情与差异」进入文档对比页;版本历史与审核日志 |
120
+ | 知识库 | 知识库列表(固定列出全部:用户级 + 各工作区,不做工作区筛选)+ 详情:新建与编辑配置(含「测试模型」)、初始化与增量任务(进度 / 日志 / 取消 / 重试)、项目索引、模型调用记录、知识列表分页、导出全部、导入 Markdown、删除 |
121
+ | 待审核 | 候选 / 冲突 / 草稿 / 过时四类队列(覆盖本地全部知识库,不按当前工作区过滤),真实计数、筛选、排序、批量审批;点「查看详情与差异」进入文档对比页;版本历史与审核日志 |
121
122
  | 检索 | 关键词 + 标签 + 知识库 / 类型 / 状态 / 排序;显示来源、作用域、审核状态与检索模式;结果分页加载 |
122
123
  | 设置 | 默认策略、提炼开关与阈值、审核人与自动批准、知识注入开关与预算、备份保留、排除目录、存储位置迁移、备份预览与恢复、诊断信息 |
123
124
 
@@ -128,6 +129,28 @@ cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\knowhub\node_modules\dsh-knowhu
128
129
  - 弹窗统一使用 DSH 视觉规范:半透明遮罩 + 背景模糊、层级阴影、进出场动画;打开时锁定页面滚动,Esc 或点击遮罩关闭。
129
130
  - 表单内的字段校验仍在输入框旁就地提示;接口错误既进入浮层提示,也在受影响的区域给出定位。
130
131
 
132
+ ## 常见问题
133
+
134
+ **初始化或增量任务失败,提示模型调用失败怎么办?**
135
+
136
+ 在知识库详情点右上「编辑配置」→「测试模型」,先用一句 ping 确认该 provider / model 当前能否调通;不通说明是模型配置问题(凭据、baseURL、模型名),与知识库无关。若提示「模型输出被截断」,说明该模型把输出预算消耗在思考过程上:换一个非推理模型,或把「模型使用策略」调高(economy 1600 → balanced 2200 → quality 3200 tokens,截断时还会自动加倍重试一次)。
137
+
138
+ **为什么生成的知识都是「待审核」?**
139
+
140
+ 自动生成、增量更新、Agent 提议、自动提炼、Markdown 导入的结果一律先落为草稿或候选,必须人工批准后才成为正式知识。这是为了避免模型臆断污染长期记忆。
141
+
142
+ **生成成功了,待审核里却什么都没有?**
143
+
144
+ 先确认宿主进程已经重启:升级插件只替换磁盘文件,正在运行的进程仍用旧代码,必须完全退出并重开 DSH。0.8.8 起待审核队列覆盖本地全部知识库(用户级 + 各工作区),不再只看当前工作区;若仍为空,检查桌面版与开发实例是否指向同一份存储根(「设置 → 存储位置」),以及任务是否真的产出了草稿(知识库详情的任务日志会写明生成篇数)。
145
+
146
+ **已经批准的知识会被增量更新悄悄改掉吗?**
147
+
148
+ 不会。已批准知识只会生成「更新候选」进入待审核队列,批准后才以新版本追加,旧版本仍可查看与恢复。
149
+
150
+ **恢复备份会覆盖当前知识吗?**
151
+
152
+ 不会。恢复是合并语义:备份里有、当前库里没有的知识会按历史版本重建;当前库里已有的知识不会被删除或覆盖。恢复前还会自动创建一个恢复点,并先显示预览(知识库数、知识数、同名重叠)。
153
+
131
154
  ## 配置项
132
155
 
133
156
  设置保存在知识库存储的配置库中,面板「设置」页可修改。
@@ -151,7 +174,7 @@ cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\knowhub\node_modules\dsh-knowhu
151
174
 
152
175
  ## HTTP 接口
153
176
 
154
- 共 56 条,均挂在 `/knowhub/api` 下,返回 `{ok:true,value}` 或 `{ok:false,error:{code,message}}`。
177
+ 共 57 条,均挂在 `/knowhub/api` 下,返回 `{ok:true,value}` 或 `{ok:false,error:{code,message}}`。
155
178
 
156
179
  | 分组 | 端点 |
157
180
  |---|---|
@@ -184,7 +207,7 @@ knowledge-center/
184
207
  1. 不读取、不落盘、不打印任何明文密钥;模型调用只传 provider / model,凭据由 DSH 适配层管理。
185
208
  2. 扫描默认排除 `.git`、`node_modules`、构建产物、缓存、`.env*`、`*.pem`、`id_rsa` 等敏感文件;不跟随符号链接;不执行被扫描仓库的任何脚本。
186
209
  3. 文件访问限制在已注册工作区与当前工作目录内,扫描接口需 realpath 命中授权根。
187
- 4. 作用域由服务端从会话 `cwd` 反查得出,客户端参数不能扩大范围;跨知识库的详情与检索一律隔离。
210
+ 4. 检索与上下文注入的作用域由服务端从会话 `cwd` 反查得出,客户端参数不能扩大检索范围;审核、知识库管理与版本操作是本地单人管理视图,覆盖本地全部未删除知识库(桌面版宿主进程的 `cwd` 是应用目录,按工作区过滤会导致其它工作区的草稿不可见)。
188
211
  5. 自动生成的知识默认待审核,批准后才成为正式知识;已批准文档不会被流水线静默覆盖。
189
212
  6. 模型输出必须通过结构校验(JSON 解析、类型/长度/置信度范围、敏感信息清洗、绝对路径剔除)才进候选。
190
213
  7. Git 只读调用,失败自动降级为内容哈希模式并记录原因。
@@ -731,7 +731,36 @@ function EditKbForm(props) {
731
731
  const [strategy, setStrategy] = R.useState(kb.modelStrategy ?? 'balanced');
732
732
  const [busy, setBusy] = R.useState(false);
733
733
  const [error, setError] = R.useState(null);
734
+ const [probing, setProbing] = R.useState(false);
735
+ const [probeResult, setProbeResult] = R.useState(null);
734
736
  const nameLength = [...name].length;
737
+ // 直接向宿主发一次最小模型调用,确认这个模型当前真的能调通(不写任何知识)。
738
+ const testModel = async () => {
739
+ if (modelKey === '') {
740
+ setError('请先选择知识处理模型');
741
+ return;
742
+ }
743
+ const parts = modelKey.split('|');
744
+ setProbing(true);
745
+ setProbeResult(null);
746
+ setError(null);
747
+ try {
748
+ const probe = await request('/probe', { method: 'POST', body: { provider: parts[0], model: parts[1], prompt: 'ping' } });
749
+ if (probe && probe.ok === false) {
750
+ const reason = probe.failure ? String(probe.failure.code ?? '') + ':' + String(probe.failure.message ?? '') : '模型不可用';
751
+ setProbeResult({ ok: false, text: reason });
752
+ }
753
+ else {
754
+ setProbeResult({ ok: true, text: '模型可用(' + Number(probe?.elapsedMs ?? 0) + ' ms,返回 ' + JSON.stringify(String(probe?.text ?? '')).slice(1, 41) + ')' });
755
+ }
756
+ }
757
+ catch (failure) {
758
+ setProbeResult({ ok: false, text: message(failure) });
759
+ }
760
+ finally {
761
+ setProbing(false);
762
+ }
763
+ };
735
764
  const submit = async () => {
736
765
  const trimmed = name.trim();
737
766
  if (trimmed === '') {
@@ -780,7 +809,23 @@ function EditKbForm(props) {
780
809
  onChange: (event) => setName(event.target.value),
781
810
  }), h('div', { style: styles.dialogCounter }, nameLength + ' / 50')), h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, '生效范围(创建后不可修改)'), h('div', { style: styles.hint }, (kb.scope === 'user' ? '用户级:跨工作区共享' : '工作区级:仅当前项目可见') + (kb.workspacePath ? ';绑定工作区:' + kb.workspacePath : ''))), h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, '知识处理模型', h('span', { style: styles.labelRequired }, '*')), (models ?? []).length === 0
782
811
  ? h('div', { style: styles.hint }, '当前 DSH 没有已配置模型。请先在 DSH 设置中配置模型,知枢不会要求重新填写 API Key。')
783
- : h('select', { style: styles.dialogInput, value: modelKey, onChange: (event) => setModelKey(event.target.value) }, h('option', { value: '' }, '请选择模型…'), (models ?? []).map((model) => h('option', { key: model.provider + '|' + model.id, value: model.provider + '|' + model.id }, model.name + '(' + model.id + ')· ' + model.provider))), h('div', { style: styles.hint }, '模型列表来自 DSH 当前真实配置(ctx.llm.listProviders/listModels);只保存 provider 与 model 标识,不存密钥。')), h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, '模型使用策略', h('span', { style: styles.labelRequired }, '*')), h('div', { style: styles.choiceColumn }, strategyChoices)), error !== null ? h('div', { style: { ...styles.error, margin: '4px 0 12px' } }, error) : null), h('div', { style: styles.dialogFoot }, h('div', { style: styles.dialogFootHint }, '下一次初始化或增量更新会使用新的模型与策略;已经生成的 Wiki 不受影响。'), h('div', { style: { display: 'flex', gap: '8px', flexShrink: 0 } }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: onCancel }, '取消'), h('button', { type: 'button', style: busy ? { ...styles.primary, ...styles.buttonDisabled } : styles.primary, className: 'kh-primary', disabled: busy, onClick: () => void submit() }, busy ? '保存中…' : '保存配置'))));
812
+ : h('select', {
813
+ style: styles.dialogInput, value: modelKey,
814
+ onChange: (event) => { setModelKey(event.target.value); setProbeResult(null); },
815
+ }, h('option', { value: '' }, '请选择模型…'), (models ?? []).map((model) => h('option', { key: model.provider + '|' + model.id, value: model.provider + '|' + model.id }, model.name + '(' + model.id + ')· ' + model.provider))), h('div', { style: styles.hint }, '模型列表来自 DSH 当前真实配置(ctx.llm.listProviders/listModels);只保存 provider 与 model 标识,不存密钥。'), h('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', marginTop: '8px', flexWrap: 'wrap' } }, h('button', {
816
+ type: 'button',
817
+ style: probing || modelKey === '' ? { ...styles.button, ...styles.buttonDisabled } : styles.button,
818
+ className: 'kh-hover',
819
+ disabled: probing || modelKey === '',
820
+ onClick: () => void testModel(),
821
+ }, probing ? '测试中…' : '测试模型'), probeResult === null
822
+ ? h('span', { style: styles.hint }, '建议先点一次,确认该模型当前可调通(只发一句 ping,不写入任何知识)。')
823
+ : h('span', {
824
+ style: {
825
+ fontSize: '12px', lineHeight: '18px', wordBreak: 'break-all',
826
+ color: probeResult.ok ? 'var(--dsw-alias-state-success-label)' : 'var(--dsw-alias-state-error-label)',
827
+ },
828
+ }, (probeResult.ok ? '✓ ' : '✗ ') + String(probeResult.text)))), h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, '模型使用策略', h('span', { style: styles.labelRequired }, '*')), h('div', { style: styles.choiceColumn }, strategyChoices)), error !== null ? h('div', { style: { ...styles.error, margin: '4px 0 12px' } }, error) : null), h('div', { style: styles.dialogFoot }, h('div', { style: styles.dialogFootHint }, '下一次初始化或增量更新会使用新的模型与策略;已经生成的 Wiki 不受影响。'), h('div', { style: { display: 'flex', gap: '8px', flexShrink: 0 } }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: onCancel }, '取消'), h('button', { type: 'button', style: busy ? { ...styles.primary, ...styles.buttonDisabled } : styles.primary, className: 'kh-primary', disabled: busy, onClick: () => void submit() }, busy ? '保存中…' : '保存配置'))));
784
829
  }
785
830
  function AddDocForm(props) {
786
831
  const { kbId, onDone } = props;
@@ -1137,7 +1182,9 @@ function KbView(props) {
1137
1182
  }
1138
1183
  return h('div', null, h('div', { style: styles.divider }), h('div', { style: styles.sectionTitle }, '项目初始化与 Repo Wiki'), h('div', { style: styles.hint }, '初始化会用该知识库绑定的模型(' + detail.knowledgeBase.modelProvider + ' / ' + detail.knowledgeBase.modelId + ',策略 ' + detail.knowledgeBase.modelStrategy + ')分析仓库,生成 Wiki 候选草稿;扫描不执行仓库内脚本,自动排除 node_modules、.git、构建产物与密钥文件。'), detail.knowledgeBase.scope === 'user' ? h('div', { style: styles.field, marginTop: '10px' }, h('label', { style: styles.label }, '选择要分析的工作区', h('span', { style: styles.labelRequired }, '*')), h('select', { style: styles.input, value: initWorkspaceId, onChange: (event) => setInitWorkspaceId(event.target.value) }, h('option', { value: '' }, '请选择工作区'), workspaces.map((workspace) => h('option', { key: workspace.id, value: workspace.id }, (workspace.title ?? workspace.path) + '(' + workspace.id + ')')))) : null, h('div', { style: { ...styles.row, marginTop: '10px' } }, h('button', { type: 'button', style: { ...styles.primary, ...(initBusy || activeTask !== null ? styles.buttonDisabled : {}) }, disabled: initBusy || activeTask !== null, onClick: () => void startInitialize() }, activeTask !== null ? '初始化进行中…' : initBusy ? '提交中…' : '开始初始化'), activeTask !== null ? h('button', { type: 'button', style: styles.danger, className: 'kh-danger', onClick: () => void cancelTask(activeTask.id) }, '取消任务') : null, latestTask !== null && latestTask.status === 'failed' ? h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: initBusy, onClick: () => void retryTask(latestTask.id) }, '重试失败任务') : null, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => { if (detail)
1139
1184
  void loadTaskData(detail.knowledgeBase.id); } }, '刷新任务状态')), taskError !== null ? h('div', { style: { ...styles.error, margin: '10px 0 0' } }, message(taskError)) : null, tasks.length === 0 ? h('div', { style: styles.hint }, '尚未运行初始化。点击「开始初始化」后,会先建立项目索引,再按选择的模型生成 Wiki 候选。')
1140
- : tasks.slice(0, 3).map((task) => h('div', { key: task.id, style: { ...styles.card, marginTop: '10px', background: 'var(--dsw-alias-bg-layer-1)' } }, h('div', { style: styles.cardTitle }, (TASK_KIND_LABEL[task.kind] ?? task.kind) + '任务 #' + task.id.slice(-8), h(Badge, { text: TASK_STATUS_LABEL[task.status] ?? task.status, style: task.status === 'failed' ? styles.badgeAmber : styles.badgeGreen }), h('span', { style: styles.hint }, '尝试 ' + (task.attempt ?? 1) + ' 次 · 创建于 ' + new Date(task.createdAt).toLocaleString('zh-CN'))), h('div', { style: styles.progressTrack }, h('div', { style: { ...styles.progressBar, width: String(Math.max(0, Math.min(100, Number(task.progress ?? 0)))) + '%', background: task.status === 'failed' ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-business-primary)' } })), h('div', { style: { ...styles.meta } }, h('span', null, '进度:' + Number(task.progress ?? 0) + '%'), h('span', null, '阶段:' + (task.stage ?? '—')), task.errorCode ? h('span', null, '错误码:' + task.errorCode) : null, task.errorDetail ? h('span', null, '错误:' + task.errorDetail) : null), task.logs && task.logs.length > 0 ? h('pre', { style: styles.logs }, task.logs.slice(-12).map((item) => '[' + new Date(item.at).toLocaleTimeString('zh-CN') + '] ' + item.level + ' ' + item.message).join('\n')) : null)), h('div', { style: styles.sectionTitle }, '项目索引状态'), indexInfo === null ? h('div', { style: styles.hint }, '还没有索引数据(初始化完成后自动建立)')
1185
+ : tasks.slice(0, 3).map((task) => h('div', { key: task.id, style: { ...styles.card, marginTop: '10px', background: 'var(--dsw-alias-bg-layer-1)' } }, h('div', { style: styles.cardTitle }, (TASK_KIND_LABEL[task.kind] ?? task.kind) + '任务 #' + task.id.slice(-8), h(Badge, { text: TASK_STATUS_LABEL[task.status] ?? task.status, style: task.status === 'failed' ? styles.badgeAmber : styles.badgeGreen }), h('span', { style: styles.hint }, '尝试 ' + (task.attempt ?? 1) + ' 次 · 创建于 ' + new Date(task.createdAt).toLocaleString('zh-CN'))), h('div', { style: styles.progressTrack }, h('div', { style: { ...styles.progressBar, width: String(Math.max(0, Math.min(100, Number(task.progress ?? 0)))) + '%', background: task.status === 'failed' ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-business-primary)' } })), h('div', { style: { ...styles.meta } }, h('span', null, '进度:' + Number(task.progress ?? 0) + '%'), h('span', null, '阶段:' + (task.stage ?? '—')), task.errorCode ? h('span', null, '错误码:' + task.errorCode) : null, task.errorDetail ? h('span', null, '错误:' + task.errorDetail) : null), task.status === 'failed'
1186
+ ? h('div', { style: { ...styles.hint, marginTop: '4px' } }, '提示:模型调用失败时,点右上角「编辑配置」→「测试模型」可直接确认该模型当前能否调通;若提示输出被截断,换一个非推理模型或把「模型使用策略」调高。')
1187
+ : null, task.logs && task.logs.length > 0 ? h('pre', { style: styles.logs }, task.logs.slice(-12).map((item) => '[' + new Date(item.at).toLocaleTimeString('zh-CN') + '] ' + item.level + ' ' + item.message).join('\n')) : null)), h('div', { style: styles.sectionTitle }, '项目索引状态'), indexInfo === null ? h('div', { style: styles.hint }, '还没有索引数据(初始化完成后自动建立)')
1141
1188
  : h('div', { style: styles.meta }, h('span', null, '文件数:' + indexInfo.fileCount), h('span', null, '已哈希:' + (indexInfo.hashedCount ?? 0)), h('span', null, '最近索引:' + (indexInfo.lastIndexedAt ? new Date(indexInfo.lastIndexedAt).toLocaleString('zh-CN') : '—')), indexInfo.byLanguage && indexInfo.byLanguage.length > 0 ? h('span', null, '语言:' + indexInfo.byLanguage.slice(0, 6).map((item) => item.language + ' ' + item.count).join('、')) : null), h('div', { style: styles.sectionTitle }, '模型调用记录(证明实际使用的模型)'), calls.length === 0 ? h('div', { style: styles.hint }, '还没有模型调用记录')
1142
1189
  : h('div', null, calls.slice(0, 10).map((call) => h('div', { key: call.id, style: { ...styles.meta, borderTop: '1px dashed var(--dsw-alias-border-l1)', paddingTop: '6px', marginTop: '0' } }, h('span', null, (call.provider ?? '') + ' / ' + (call.model ?? '')), h('span', null, '用途:' + (call.purpose ?? '—')), h('span', null, 'tokens:' + (call.promptTokens ?? 0) + ' → ' + (call.completionTokens ?? 0)), h('span', null, '耗时:' + (call.latencyMs ?? 0) + 'ms'), call.failureCode ? h('span', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, '失败码:' + call.failureCode) : null))), h('div', { style: styles.divider }), h('div', { style: styles.sectionTitle }, 'Git 变更与增量更新(M4)'), h('div', { style: styles.hint }, '变更检测优先使用只读 Git 命令,不是 Git 仓库或无法执行 git 时自动降级为文件内容哈希比对;只有受影响文档会重新分析,已批准的知识不会被自动覆盖,而是生成「修改候选」进入待审核。'), h('div', { style: styles.meta }, gitState && gitState.checkpoint ? h('span', null, '检查点模式:' + (gitState.checkpoint.mode === 'git' ? 'Git' : '哈希')) : h('span', null, '检查点:尚未运行增量更新'), gitState && gitState.checkpoint && gitState.checkpoint.head ? h('span', null, 'HEAD:' + String(gitState.checkpoint.head).slice(0, 10)) : null, gitState && gitState.git ? h('span', null, 'Git 状态:' + (gitState.git.available ? (gitState.git.isRepo ? '可用(分支 ' + (gitState.git.branch ?? '—') + ',未提交变更 ' + (gitState.git.dirtyCount ?? 0) + ' 项)' : '目录不是 Git 仓库') : '宿主无法执行 git')) : null, gitState && gitState.checkpoint && gitState.checkpoint.lastScanAt ? h('span', null, '最近检测:' + new Date(gitState.checkpoint.lastScanAt).toLocaleString('zh-CN')) : null, changes.length > 0 ? h('span', null, '最近变更:' + changes.length + ' 条') : null), h('div', { style: { ...styles.row, marginTop: '10px' } }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: incBusy || activeTask !== null, onClick: () => void incrementalRequest(true) }, incBusy ? '检测中…' : '检测变更(仅预览)'), h('button', { type: 'button', style: { ...styles.primary, ...(incBusy || activeTask !== null ? styles.buttonDisabled : {}) }, disabled: incBusy || activeTask !== null, onClick: () => void incrementalRequest(false) }, activeTask !== null ? '任务进行中…' : incBusy ? '提交中…' : '开始增量更新'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => { if (detail)
1143
1190
  void loadTaskData(detail.knowledgeBase.id); } }, '刷新增量状态')), incError !== null ? h('div', { style: { ...styles.error, margin: '10px 0 0' } }, message(incError)) : null, incPlan !== null ? h('div', { style: { ...styles.card, marginTop: '10px', background: 'var(--dsw-alias-bg-layer-1)' } }, h('div', { style: styles.cardTitle }, '变更预览(未写入任何数据)'), h('div', { style: styles.meta }, h('span', null, '模式:' + (incPlan.mode === 'git' ? 'Git' : '哈希')), h('span', null, '基线:' + (incPlan.baseline ? '首次/重置(只建立检查点,不调用模型)' : '已有检查点')), h('span', null, '变更文件:' + incPlan.changeCount), h('span', null, '受影响文档:' + incPlan.affectedCount), h('span', null, '预计模型调用:' + incPlan.plannedModelCalls), incPlan.plan && incPlan.plan.length > 0 ? h('span', null, '计划:' + incPlan.plan.map((item) => item.title + '(' + (item.action === 'stale-proposal' ? '标记过时候选' : item.action === 'update-proposal' ? '修改候选' : '草稿直更') + ')').join(';')) : null), incPlan.structural && incPlan.structural.length > 0 ? h('div', { style: { ...styles.hint, color: 'var(--dsw-alias-state-warn-label)' } }, '结构性变更(需要人工重新检查):' + incPlan.structural.join('、')) : null, incPlan.unmatchedPaths && incPlan.unmatchedPaths.length > 0 ? h('div', { style: styles.hint }, '未关联任何文档的变更:' + incPlan.unmatchedPaths.slice(0, 8).join('、')) : null, incPlan.changes && incPlan.changes.length > 0 ? h('pre', { style: styles.logs }, incPlan.changes.slice(0, 12).map((item) => (CHANGE_LABEL[item.change] ?? item.change) + ' ' + item.path).join('\n')) : null, incPlan.note ? h('div', { style: styles.hint }, incPlan.note) : null) : null, changes.length > 0 ? h('div', { style: { marginTop: '10px' } }, h('div', { style: styles.hint }, '已记录的变更(来自 file_changes 表)'), h('pre', { style: styles.logs }, changes.slice(0, 10).map((item) => (CHANGE_LABEL[item.change] ?? item.change) + ' ' + item.path + ' [' + item.mode + ']').join('\n'))) : null, recheck.length > 0 ? h('div', { style: { marginTop: '10px' } }, h('div', { style: { ...styles.hint, color: 'var(--dsw-alias-state-warn-label)' } }, '需要重新检查的文档(' + recheck.length + ')'), h('pre', { style: styles.logs }, recheck.slice(0, 10).map((item) => (item.title ?? item.docTitle ?? item.docId) + ' ← ' + item.reason).join('\n'))) : null, h('div', { style: styles.divider }), h('div', { style: styles.sectionTitle }, '知识条目(Markdown + SQLite 索引;草稿需审核后生效)'), h('div', { style: styles.hint }, '共 ' + docsTotal + ' 条,已显示 ' + docs.length + ' 条(每页 50 条)'), docs.length === 0 ? h('div', { style: styles.empty }, '该知识库还没有知识条目') : docs.map((doc) => h('div', { key: doc.id, style: { ...styles.card, marginBottom: '8px' } }, h('div', { style: styles.cardTitle }, doc.title, h(Badge, { text: KIND_LABEL[doc.kind] ?? doc.kind }), h(Badge, { text: DOC_STATUS_LABEL[doc.status] ?? doc.status, style: styles.badgeAmber }), h(Badge, { text: VERIFY_LABEL[doc.verifyState] ?? doc.verifyState })), h('div', { style: styles.snippet }, String(doc.body ?? '').slice(0, 180)), h('div', { style: styles.path }, doc.path))), docsTotal > docs.length
@@ -1978,7 +2025,7 @@ function ReviewView(props) {
1978
2025
  const proposalCard = (item) => h('div', { key: item.id, style: styles.card }, h('div', { style: styles.cardTitle }, checkBox('proposal:' + item.id), item.title, h(Badge, { text: PROPOSAL_KIND_LABEL[item.kind] ?? item.kind, style: styles.badgeAmber }), item.docKind ? h(Badge, { text: KIND_LABEL[item.docKind] ?? item.docKind }) : null, h(Badge, { text: '候选 ' + String(item.id).slice(-6) })), h('div', { style: styles.meta }, h('span', null, '知识库:' + (item.kbName ?? item.kbId)), h('span', null, '来源:' + (item.source ?? '—')), h('span', null, '提交人:' + (item.createdBy ?? '—')), h('span', null, '创建于 ' + new Date(item.createdAt).toLocaleString('zh-CN'))), item.reason ? h('div', { style: styles.snippet }, '说明:' + item.reason) : null, h('div', { style: styles.snippet }, String(item.body ?? '').slice(0, 260) + (String(item.body ?? '').length > 260 ? '…' : '')), h('div', { style: styles.field, marginTop: '8px' }, noteInput(item.id)), h('div', { style: styles.row }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void openDiff('proposal', item.id) }, '查看详情与差异'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('批准候选', () => request('/review/proposals/' + item.id + '/approve', { method: 'POST', body: { note: noteOf(item.id) || null } })) }, '批准'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => setProposalEdit({ id: item.id, body: String(item.body ?? ''), title: item.title }) }, '编辑后批准'), h('button', { type: 'button', style: styles.danger, className: 'kh-danger', disabled: busy !== null, onClick: () => void run('拒绝候选', () => request('/review/proposals/' + item.id + '/reject', { method: 'POST', body: { note: noteOf(item.id) || null } })) }, '拒绝')), proposalEdit !== null && proposalEdit.id === item.id ? h('div', { style: styles.field, marginTop: '10px' }, h('label', { style: styles.label }, '修改标题'), h('input', { style: styles.input, value: proposalEdit.title, onChange: (e) => setProposalEdit({ ...proposalEdit, title: e.target.value }) }), h('label', { style: { ...styles.label, marginTop: '8px' } }, '修改正文'), h('textarea', { style: { ...styles.textarea, minHeight: '140px' }, value: proposalEdit.body, onChange: (e) => setProposalEdit({ ...proposalEdit, body: e.target.value }) }), h('div', { style: styles.row }, h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('编辑后批准', () => request('/review/proposals/' + item.id + '/approve', { method: 'POST', body: { note: noteOf(item.id) || '编辑后批准', title: proposalEdit.title, body: proposalEdit.body } }), () => setProposalEdit(null)) }, '保存并批准'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setProposalEdit(null) }, '取消编辑'))) : null);
1979
2026
  return h('div', null, h('div', { style: { ...styles.card, display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' } }, h('select', { style: { ...styles.input, width: '200px' }, value: kbFilter, onChange: (e) => { setKbFilter(e.target.value); setPage(1); } }, h('option', { value: '' }, '全部知识库'), (knowledgeBases ?? []).map((kb) => h('option', { key: kb.id, value: kb.id }, kb.name))), h('select', { style: { ...styles.input, width: '150px' }, value: kindFilter, onChange: (e) => { setKindFilter(e.target.value); setPage(1); } }, h('option', { value: '' }, '全部候选类型'), h('option', { value: 'create' }, '新增知识'), h('option', { value: 'update' }, '更新知识'), h('option', { value: 'restore' }, '恢复知识'), h('option', { value: 'stale' }, '标记过时')), h('input', { style: { ...styles.input, width: '200px' }, value: qInput, placeholder: '关键词(标题/正文)', onChange: (e) => setQInput(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter')
1980
2027
  applyFilters(); } }), h('input', { style: { ...styles.input, width: '160px' }, value: sourceInput, placeholder: '来源,如 pipeline', onChange: (e) => setSourceInput(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter')
1981
- applyFilters(); } }), h('select', { style: { ...styles.input, width: '150px' }, value: sort, onChange: (e) => { setSort(e.target.value); setPage(1); } }, h('option', { value: 'newest' }, '最新提交优先'), h('option', { value: 'oldest' }, '最早提交优先'), h('option', { value: 'updated' }, '最近更新优先')), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: loading, onClick: applyFilters }, '筛选'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: resetFilters }, '清空'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading, onClick: () => void load() }, loading ? '刷新中…' : '刷新'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void openLog() }, '审核日志')), h('div', { style: { ...styles.row, alignItems: 'center', marginTop: '8px' } }, h('span', { style: styles.badgeAmber }, '待审核合计 ' + pendingTotal + ' 项'), h('span', { style: styles.hint }, filterHint + ' · 第 ' + page + ' / ' + maxPages + ' 页(每页最多 ' + LIMIT + ' 条)'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading || page <= 1, onClick: () => setPage(page - 1) }, '上一页'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading || page >= maxPages, onClick: () => setPage(page + 1) }, '下一页')), selected.length > 0 ? h('div', { style: { ...styles.card, marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' } }, h('span', null, '已选 ' + selected.length + ' 条'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void bulkRun('approve') }, '批量批准'), h('button', { type: 'button', style: styles.danger, className: 'kh-danger', disabled: busy !== null, onClick: () => void bulkRun('reject') }, '批量拒绝'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: clearSelection }, '清空选择'), h('span', { style: styles.hint }, '单次最多 ' + LIMIT + ' 条,执行前会二次确认')) : null, busy !== null ? h('div', { style: styles.hint }, busy + '…') : null, error !== null ? h('div', { style: styles.error, role: 'alert' }, error) : null, queue === null ? h('div', { style: styles.empty }, loading ? '正在读取待审核数据…' : '暂无数据') : h('div', null, sectionHead('候选知识', counts.proposals, 'proposal', queue.proposals ?? []), (queue.proposals ?? []).length === 0 ? h('div', { style: styles.empty }, '没有符合条件的候选。Agent 或面板提交的知识会先进入这里。') : (queue.proposals ?? []).map(proposalCard), sectionHead('版本冲突', counts.conflicts), (queue.conflicts ?? []).length === 0 ? h('div', { style: styles.empty }, '没有待处理冲突。') : (queue.conflicts ?? []).map((item) => h('div', { key: item.id, style: styles.card }, h('div', { style: styles.cardTitle }, '冲突:' + (item.proposedTitle ?? item.docId), h(Badge, { text: '待处理', style: styles.badgeAmber })), h('div', { style: styles.meta }, h('span', null, '知识库:' + (item.kbName ?? item.kbId)), h('span', null, '来源:' + (item.source ?? '—')), h('span', null, '基础版本:第 ' + item.baseVersion + ' 版 / 当前:第 ' + item.currentVersion + ' 版'), h('span', null, '检测于 ' + new Date(item.detectedAt).toLocaleString('zh-CN'))), h('div', { style: styles.snippet }, '提议内容:'), h('pre', { style: styles.pre }, String(item.proposedBody ?? '').slice(0, 1600)), h('div', { style: styles.row, marginTop: '8px' }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => void openDiff('conflict', item.id) }, '查看差异'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void run('保留当前', () => request('/review/conflicts/' + item.id + '/resolve', { method: 'POST', body: { mode: 'keep_current' } })) }, '保留当前知识'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('采用新提议', () => request('/review/conflicts/' + item.id + '/resolve', { method: 'POST', body: { mode: 'use_proposed' } })) }, '采用新提议(追加合并版本)'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => setConflictEdit({ id: item.id, title: String(item.proposedTitle ?? ''), body: String(item.proposedBody ?? '') }) }, '人工合并…')), conflictEdit !== null && conflictEdit.id === item.id ? h('div', { style: styles.field, marginTop: '10px' }, h('label', { style: styles.label }, '合并后标题'), h('input', { style: styles.input, value: conflictEdit.title, onChange: (e) => setConflictEdit({ ...conflictEdit, title: e.target.value }) }), h('label', { style: { ...styles.label, marginTop: '8px' } }, '合并后正文(预填新提议,可自由编辑)'), h('textarea', { style: { ...styles.textarea, minHeight: '180px' }, value: conflictEdit.body, onChange: (e) => setConflictEdit({ ...conflictEdit, body: e.target.value }) }), h('div', { style: styles.row }, h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('保存人工合并', () => request('/review/conflicts/' + item.id + '/resolve', { method: 'POST', body: { mode: 'merge', title: conflictEdit.title, body: conflictEdit.body } }), () => setConflictEdit(null)) }, '保存合并结果'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setConflictEdit(null) }, '取消合并'))) : null)), sectionHead('待审核草稿', counts.drafts, 'doc', queue.drafts ?? []), (queue.drafts ?? []).length === 0 ? h('div', { style: styles.empty }, '没有符合条件的待审核草稿。') : (queue.drafts ?? []).map((item) => h('div', { key: item.id, style: styles.card }, h('div', { style: styles.cardTitle }, checkBox('doc:' + item.id), item.title, h(Badge, { text: KIND_LABEL[item.kind] ?? item.kind }), h(Badge, { text: VERIFY_LABEL[item.verifyState] ?? item.verifyState }), h(Badge, { text: '第 ' + item.currentVersion + ' 版' })), h('div', { style: styles.meta }, h('span', null, '知识库:' + (item.kbName ?? item.kbId)), h('span', null, '更新于 ' + new Date(item.updatedAt).toLocaleString('zh-CN')), h('span', null, item.path)), h('div', { style: { ...styles.row, marginTop: '8px' } }, h('div', { style: styles.field, flex: '1 1 260px' }, noteInput(item.id))), h('div', { style: styles.row }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void openDiff('document', item.id) }, '查看详情与差异'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => void openVersions(item.kbId, item.id) }, '版本历史'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('批准草稿', () => request('/kb/' + item.kbId + '/docs/' + item.id + '/review', { method: 'POST', body: { action: 'approve', note: noteOf(item.id) || null } })) }, '批准'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void openDraftEdit(item.kbId, item.id) }, '编辑后批准'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void run('标记过时', () => request('/kb/' + item.kbId + '/docs/' + item.id + '/review', { method: 'POST', body: { action: 'mark_stale', note: noteOf(item.id) || null } })) }, '标记过时'), h('button', { type: 'button', style: styles.danger, className: 'kh-danger', disabled: busy !== null, onClick: () => void run('拒绝草稿', () => request('/kb/' + item.kbId + '/docs/' + item.id + '/review', { method: 'POST', body: { action: 'reject', note: noteOf(item.id) || null } })) }, '拒绝(归档)')))), sectionHead('过时知识', counts.stale), (queue.staleDocuments ?? []).length === 0 ? h('div', { style: styles.empty }, '没有符合条件的过时知识。') : (queue.staleDocuments ?? []).map((item) => h('div', { key: item.id, style: styles.card }, h('div', { style: styles.cardTitle }, item.title, h(Badge, { text: '过时', style: styles.badgeAmber })), h('div', { style: styles.meta }, h('span', null, '知识库:' + (item.kbName ?? item.kbId)), h('span', null, '更新于 ' + new Date(item.updatedAt).toLocaleString('zh-CN'))), h('div', { style: styles.row, marginTop: '8px' }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void openDiff('document', item.id) }, '查看详情与差异'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => void openVersions(item.kbId, item.id) }, '版本历史'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('恢复为已批准', () => request('/kb/' + item.kbId + '/docs/' + item.id + '/review', { method: 'POST', body: { action: 'approve', note: '确认内容仍有效' } })) }, '确认仍有效(恢复已批准)')))), maxPages > 1 ? h('div', { style: { ...styles.row, marginTop: '10px' } }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading || page <= 1, onClick: () => setPage(page - 1) }, '上一页'), h('span', { style: styles.hint }, '第 ' + page + ' / ' + maxPages + ' 页'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading || page >= maxPages, onClick: () => setPage(page + 1) }, '下一页')) : null), draftEdit !== null ? h('div', { style: styles.divider }, h('div', { style: styles.sectionTitle }, '编辑后批准草稿(将在第 ' + Number(draftEdit.version ?? 0) + ' 版基础上生成审核版本)'), h('label', { style: styles.label }, '标题'), h('input', { style: styles.input, value: draftEdit.title, onChange: (e) => setDraftEdit({ ...draftEdit, title: e.target.value }) }), h('label', { style: { ...styles.label, marginTop: '8px' } }, '正文(Markdown)'), h('textarea', { style: { ...styles.textarea, minHeight: '220px' }, value: draftEdit.body, onChange: (e) => setDraftEdit({ ...draftEdit, body: e.target.value }) }), h('div', { style: styles.row, marginTop: '8px' }, h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('编辑并批准草稿', () => request('/kb/' + draftEdit.kbId + '/docs/' + draftEdit.docId + '/review', { method: 'POST', body: { action: 'approve', title: draftEdit.title, body: draftEdit.body, note: '审核时编辑后批准' } }), () => setDraftEdit(null)) }, '保存并批准'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setDraftEdit(null) }, '取消'))) : null, docPanel !== null ? h('div', { style: styles.divider }, h('div', { style: styles.sectionTitle }, '版本历史(共 ' + docPanel.versions.length + ' 版,当前第 ' + docPanel.currentVersion + ' 版)'), docPanel.versions.map((item) => h('div', { key: item.version, style: styles.card }, h('div', { style: styles.cardTitle }, '第 ' + item.version + ' 版', h(Badge, { text: CHANGE_KIND_LABEL[item.changeKind] ?? item.changeKind }), item.version === docPanel.currentVersion ? h(Badge, { text: '当前', style: styles.badgeGreen }) : null), h('div', { style: styles.meta }, h('span', null, '来源:' + (item.source ?? '—')), h('span', null, '操作人:' + (item.actor ?? '—')), h('span', null, '时间:' + new Date(item.createdAt).toLocaleString('zh-CN'))), item.reason ? h('div', { style: styles.snippet }, '说明:' + item.reason) : null, h('div', { style: styles.row, marginTop: '8px' }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: async () => {
2028
+ applyFilters(); } }), h('select', { style: { ...styles.input, width: '150px' }, value: sort, onChange: (e) => { setSort(e.target.value); setPage(1); } }, h('option', { value: 'newest' }, '最新提交优先'), h('option', { value: 'oldest' }, '最早提交优先'), h('option', { value: 'updated' }, '最近更新优先')), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: loading, onClick: applyFilters }, '筛选'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: resetFilters }, '清空'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading, onClick: () => void load() }, loading ? '刷新中…' : '刷新'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void openLog() }, '审核日志')), h('div', { style: { ...styles.hint, marginTop: '8px' } }, '审核队列覆盖本地全部知识库(用户级 + 各工作区),不按当前工作区过滤;检索与上下文注入仍按工作区隔离。'), h('div', { style: { ...styles.row, alignItems: 'center', marginTop: '8px' } }, h('span', { style: styles.badgeAmber }, '待审核合计 ' + pendingTotal + ' 项'), h('span', { style: styles.hint }, filterHint + ' · 第 ' + page + ' / ' + maxPages + ' 页(每页最多 ' + LIMIT + ' 条)'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading || page <= 1, onClick: () => setPage(page - 1) }, '上一页'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading || page >= maxPages, onClick: () => setPage(page + 1) }, '下一页')), selected.length > 0 ? h('div', { style: { ...styles.card, marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' } }, h('span', null, '已选 ' + selected.length + ' 条'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void bulkRun('approve') }, '批量批准'), h('button', { type: 'button', style: styles.danger, className: 'kh-danger', disabled: busy !== null, onClick: () => void bulkRun('reject') }, '批量拒绝'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: clearSelection }, '清空选择'), h('span', { style: styles.hint }, '单次最多 ' + LIMIT + ' 条,执行前会二次确认')) : null, busy !== null ? h('div', { style: styles.hint }, busy + '…') : null, error !== null ? h('div', { style: styles.error, role: 'alert' }, error) : null, queue === null ? h('div', { style: styles.empty }, loading ? '正在读取待审核数据…' : '暂无数据') : h('div', null, sectionHead('候选知识', counts.proposals, 'proposal', queue.proposals ?? []), (queue.proposals ?? []).length === 0 ? h('div', { style: styles.empty }, '没有符合条件的候选。Agent 或面板提交的知识会先进入这里。') : (queue.proposals ?? []).map(proposalCard), sectionHead('版本冲突', counts.conflicts), (queue.conflicts ?? []).length === 0 ? h('div', { style: styles.empty }, '没有待处理冲突。') : (queue.conflicts ?? []).map((item) => h('div', { key: item.id, style: styles.card }, h('div', { style: styles.cardTitle }, '冲突:' + (item.proposedTitle ?? item.docId), h(Badge, { text: '待处理', style: styles.badgeAmber })), h('div', { style: styles.meta }, h('span', null, '知识库:' + (item.kbName ?? item.kbId)), h('span', null, '来源:' + (item.source ?? '—')), h('span', null, '基础版本:第 ' + item.baseVersion + ' 版 / 当前:第 ' + item.currentVersion + ' 版'), h('span', null, '检测于 ' + new Date(item.detectedAt).toLocaleString('zh-CN'))), h('div', { style: styles.snippet }, '提议内容:'), h('pre', { style: styles.pre }, String(item.proposedBody ?? '').slice(0, 1600)), h('div', { style: styles.row, marginTop: '8px' }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => void openDiff('conflict', item.id) }, '查看差异'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void run('保留当前', () => request('/review/conflicts/' + item.id + '/resolve', { method: 'POST', body: { mode: 'keep_current' } })) }, '保留当前知识'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('采用新提议', () => request('/review/conflicts/' + item.id + '/resolve', { method: 'POST', body: { mode: 'use_proposed' } })) }, '采用新提议(追加合并版本)'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => setConflictEdit({ id: item.id, title: String(item.proposedTitle ?? ''), body: String(item.proposedBody ?? '') }) }, '人工合并…')), conflictEdit !== null && conflictEdit.id === item.id ? h('div', { style: styles.field, marginTop: '10px' }, h('label', { style: styles.label }, '合并后标题'), h('input', { style: styles.input, value: conflictEdit.title, onChange: (e) => setConflictEdit({ ...conflictEdit, title: e.target.value }) }), h('label', { style: { ...styles.label, marginTop: '8px' } }, '合并后正文(预填新提议,可自由编辑)'), h('textarea', { style: { ...styles.textarea, minHeight: '180px' }, value: conflictEdit.body, onChange: (e) => setConflictEdit({ ...conflictEdit, body: e.target.value }) }), h('div', { style: styles.row }, h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('保存人工合并', () => request('/review/conflicts/' + item.id + '/resolve', { method: 'POST', body: { mode: 'merge', title: conflictEdit.title, body: conflictEdit.body } }), () => setConflictEdit(null)) }, '保存合并结果'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setConflictEdit(null) }, '取消合并'))) : null)), sectionHead('待审核草稿', counts.drafts, 'doc', queue.drafts ?? []), (queue.drafts ?? []).length === 0 ? h('div', { style: styles.empty }, '没有符合条件的待审核草稿。') : (queue.drafts ?? []).map((item) => h('div', { key: item.id, style: styles.card }, h('div', { style: styles.cardTitle }, checkBox('doc:' + item.id), item.title, h(Badge, { text: KIND_LABEL[item.kind] ?? item.kind }), h(Badge, { text: VERIFY_LABEL[item.verifyState] ?? item.verifyState }), h(Badge, { text: '第 ' + item.currentVersion + ' 版' })), h('div', { style: styles.meta }, h('span', null, '知识库:' + (item.kbName ?? item.kbId)), h('span', null, '更新于 ' + new Date(item.updatedAt).toLocaleString('zh-CN')), h('span', null, item.path)), h('div', { style: { ...styles.row, marginTop: '8px' } }, h('div', { style: styles.field, flex: '1 1 260px' }, noteInput(item.id))), h('div', { style: styles.row }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void openDiff('document', item.id) }, '查看详情与差异'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => void openVersions(item.kbId, item.id) }, '版本历史'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('批准草稿', () => request('/kb/' + item.kbId + '/docs/' + item.id + '/review', { method: 'POST', body: { action: 'approve', note: noteOf(item.id) || null } })) }, '批准'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void openDraftEdit(item.kbId, item.id) }, '编辑后批准'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void run('标记过时', () => request('/kb/' + item.kbId + '/docs/' + item.id + '/review', { method: 'POST', body: { action: 'mark_stale', note: noteOf(item.id) || null } })) }, '标记过时'), h('button', { type: 'button', style: styles.danger, className: 'kh-danger', disabled: busy !== null, onClick: () => void run('拒绝草稿', () => request('/kb/' + item.kbId + '/docs/' + item.id + '/review', { method: 'POST', body: { action: 'reject', note: noteOf(item.id) || null } })) }, '拒绝(归档)')))), sectionHead('过时知识', counts.stale), (queue.staleDocuments ?? []).length === 0 ? h('div', { style: styles.empty }, '没有符合条件的过时知识。') : (queue.staleDocuments ?? []).map((item) => h('div', { key: item.id, style: styles.card }, h('div', { style: styles.cardTitle }, item.title, h(Badge, { text: '过时', style: styles.badgeAmber })), h('div', { style: styles.meta }, h('span', null, '知识库:' + (item.kbName ?? item.kbId)), h('span', null, '更新于 ' + new Date(item.updatedAt).toLocaleString('zh-CN'))), h('div', { style: styles.row, marginTop: '8px' }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void openDiff('document', item.id) }, '查看详情与差异'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => void openVersions(item.kbId, item.id) }, '版本历史'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('恢复为已批准', () => request('/kb/' + item.kbId + '/docs/' + item.id + '/review', { method: 'POST', body: { action: 'approve', note: '确认内容仍有效' } })) }, '确认仍有效(恢复已批准)')))), maxPages > 1 ? h('div', { style: { ...styles.row, marginTop: '10px' } }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading || page <= 1, onClick: () => setPage(page - 1) }, '上一页'), h('span', { style: styles.hint }, '第 ' + page + ' / ' + maxPages + ' 页'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading || page >= maxPages, onClick: () => setPage(page + 1) }, '下一页')) : null), draftEdit !== null ? h('div', { style: styles.divider }, h('div', { style: styles.sectionTitle }, '编辑后批准草稿(将在第 ' + Number(draftEdit.version ?? 0) + ' 版基础上生成审核版本)'), h('label', { style: styles.label }, '标题'), h('input', { style: styles.input, value: draftEdit.title, onChange: (e) => setDraftEdit({ ...draftEdit, title: e.target.value }) }), h('label', { style: { ...styles.label, marginTop: '8px' } }, '正文(Markdown)'), h('textarea', { style: { ...styles.textarea, minHeight: '220px' }, value: draftEdit.body, onChange: (e) => setDraftEdit({ ...draftEdit, body: e.target.value }) }), h('div', { style: styles.row, marginTop: '8px' }, h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('编辑并批准草稿', () => request('/kb/' + draftEdit.kbId + '/docs/' + draftEdit.docId + '/review', { method: 'POST', body: { action: 'approve', title: draftEdit.title, body: draftEdit.body, note: '审核时编辑后批准' } }), () => setDraftEdit(null)) }, '保存并批准'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setDraftEdit(null) }, '取消'))) : null, docPanel !== null ? h('div', { style: styles.divider }, h('div', { style: styles.sectionTitle }, '版本历史(共 ' + docPanel.versions.length + ' 版,当前第 ' + docPanel.currentVersion + ' 版)'), docPanel.versions.map((item) => h('div', { key: item.version, style: styles.card }, h('div', { style: styles.cardTitle }, '第 ' + item.version + ' 版', h(Badge, { text: CHANGE_KIND_LABEL[item.changeKind] ?? item.changeKind }), item.version === docPanel.currentVersion ? h(Badge, { text: '当前', style: styles.badgeGreen }) : null), h('div', { style: styles.meta }, h('span', null, '来源:' + (item.source ?? '—')), h('span', null, '操作人:' + (item.actor ?? '—')), h('span', null, '时间:' + new Date(item.createdAt).toLocaleString('zh-CN'))), item.reason ? h('div', { style: styles.snippet }, '说明:' + item.reason) : null, h('div', { style: styles.row, marginTop: '8px' }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: async () => {
1982
2029
  setError(null);
1983
2030
  try {
1984
2031
  const value = await request('/kb/' + encodeURIComponent(docPanel.kbId) + '/docs/' + encodeURIComponent(docPanel.docId) + '/versions/' + item.version);
@@ -16,6 +16,7 @@ import { getDocument, listDocuments } from '../search.js';
16
16
  import { appendVersion, diffLines } from '../docvers.js';
17
17
  import { autoApproveIfEligible, createProposal } from '../review.js';
18
18
  import { runModelCall } from '../model.js';
19
+ import { MAX_OUTPUT_ATTEMPTS, retryTokenBudget } from './model-output.js';
19
20
  import { readSettings } from '../settings.js';
20
21
  import { listWorkspaces } from '../workspace.js';
21
22
  import { indexProject, saveProjectIndex } from './index.js';
@@ -413,33 +414,55 @@ export async function runIncremental(input) {
413
414
  }
414
415
  const snippets = await collectSnippets(analysis.root, deltaChanges.filter((change) => change.change !== 'deleted').map((change) => change.path), analysis.limits);
415
416
  const prompt = buildUpdatePrompt({ doc, changes: deltaChanges, snippets, scan: analysis.scan, mode: analysis.mode });
416
- let result;
417
- try {
418
- result = await runModelCall(input.ctx, {
419
- provider: kb.modelProvider ?? '',
420
- model: kb.modelId ?? '',
421
- system: UPDATE_SYSTEM,
422
- prompt,
423
- maxTokens: analysis.limits.maxTokens,
424
- temperature: 0.2,
425
- timeoutMs: 120000,
426
- signal,
427
- });
428
- }
429
- catch (error) {
430
- summary.failures.push(doc.title + ' → ' + errorMessage(error));
431
- log('error', '文档「' + doc.title + '」模型调用异常:' + errorMessage(error) + ',已保留原文档');
432
- continue;
417
+ // 推理模型可能把 token 预算耗在思考上导致输出被截断:识别出来并加倍预算重试一次。
418
+ let budget = analysis.limits.maxTokens;
419
+ let parsed = null;
420
+ let failureText = null;
421
+ for (let attempt = 1;; attempt += 1) {
422
+ if (signal.aborted)
423
+ throw new IncrementalError('TASK_CANCELLED', '任务已取消');
424
+ let result;
425
+ try {
426
+ result = await runModelCall(input.ctx, {
427
+ provider: kb.modelProvider ?? '',
428
+ model: kb.modelId ?? '',
429
+ system: UPDATE_SYSTEM,
430
+ prompt,
431
+ maxTokens: budget,
432
+ temperature: 0.2,
433
+ timeoutMs: 120000,
434
+ signal,
435
+ });
436
+ }
437
+ catch (error) {
438
+ failureText = errorMessage(error);
439
+ break;
440
+ }
441
+ recordLlmCall(db, kb, taskId, 'wiki-update:' + doc.slug, result);
442
+ summary.modelCalls += 1;
443
+ if (!result.ok) {
444
+ failureText = result.failure ? String(result.failure.code) + ':' + String(result.failure.message) : '模型未返回内容';
445
+ break;
446
+ }
447
+ parsed = parseUpdatePayload(result.text, secrets);
448
+ if (parsed !== null)
449
+ break;
450
+ if (result.finishKind === 'max-tokens' && attempt < MAX_OUTPUT_ATTEMPTS) {
451
+ const nextBudget = retryTokenBudget(budget);
452
+ log('warn', '文档「' + doc.title + '」的模型输出被截断(' + budget + ' tokens 上限),改用 ' + nextBudget + ' tokens 重试一次');
453
+ budget = nextBudget;
454
+ continue;
455
+ }
456
+ failureText = result.finishKind === 'max-tokens'
457
+ ? '模型输出被截断(' + budget + ' tokens 上限,finish=max-tokens,思考过程 ' + String(result.reasoningChars ?? 0) + ' 字符),未产出完整 JSON;建议换用非推理模型或把策略调高'
458
+ : '模型输出结构不合法';
459
+ break;
433
460
  }
434
- recordLlmCall(db, kb, taskId, 'wiki-update:' + doc.slug, result);
435
- summary.modelCalls += 1;
436
- if (!result.ok) {
437
- const failure = result.failure ? String(result.failure.code) + ':' + String(result.failure.message) : '模型未返回内容';
438
- summary.failures.push(doc.title + ' → ' + failure);
439
- log('error', '文档「' + doc.title + '」增量分析失败(' + failure + '),已保留原文档');
461
+ if (failureText !== null) {
462
+ summary.failures.push(doc.title + ' → ' + failureText);
463
+ log('error', '文档「' + doc.title + '」增量分析失败(' + failureText + '),已保留原文档');
440
464
  continue;
441
465
  }
442
- const parsed = parseUpdatePayload(result.text, secrets);
443
466
  if (parsed === null) {
444
467
  summary.failures.push(doc.title + ' → 模型输出结构不合法');
445
468
  log('warn', '文档「' + doc.title + '」的模型输出未通过结构校验,已保留原文档');
@@ -17,6 +17,7 @@ import { contentHash } from '../kb/store.js';
17
17
  import { createDocument, getDocument, slugify } from '../search.js';
18
18
  import { recordConflict } from '../review.js';
19
19
  import { readModelCatalog, runModelCall } from '../model.js';
20
+ import { MAX_OUTPUT_ATTEMPTS, parseModelJson, retryTokenBudget } from './model-output.js';
20
21
  import { listWorkspaces } from '../workspace.js';
21
22
  import { indexProject, saveProjectIndex } from './index.js';
22
23
  import { createTask, runTask } from '../tasks.js';
@@ -122,22 +123,6 @@ function buildPrompt(topics, context) {
122
123
  context,
123
124
  ].join('\n');
124
125
  }
125
- function extractJson(text) {
126
- let cleaned = text.trim();
127
- const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i);
128
- if (fence && fence[1])
129
- cleaned = fence[1].trim();
130
- const first = cleaned.indexOf('{');
131
- const last = cleaned.lastIndexOf('}');
132
- if (first < 0 || last <= first)
133
- throw new InitError('MODEL_OUTPUT_INVALID', '模型输出中没有 JSON 对象');
134
- try {
135
- return JSON.parse(cleaned.slice(first, last + 1));
136
- }
137
- catch (error) {
138
- throw new InitError('MODEL_OUTPUT_INVALID', '模型输出的 JSON 解析失败:' + errorMessage(error));
139
- }
140
- }
141
126
  function validateDocuments(raw, knownPaths, secrets) {
142
127
  const documents = [];
143
128
  const rejected = [];
@@ -241,6 +226,7 @@ export async function runInitialize(deps) {
241
226
  let documentsSeen = 0;
242
227
  const callSummaries = [];
243
228
  const secrets = { root, storageRoot };
229
+ currentStage = '生成 Wiki 候选';
244
230
  for (let position = 0; position < calls.length; position += 1) {
245
231
  const planned = calls[position];
246
232
  if (!planned)
@@ -250,26 +236,51 @@ export async function runInitialize(deps) {
250
236
  progress(percent, '调用模型生成:' + planned.topics.join('、'));
251
237
  const prompt = buildPrompt(planned.topics, context);
252
238
  log('info', '调用模型(' + planned.purpose + ',主题:' + planned.topics.join('、') + ',提示长度 ' + prompt.length + ' 字符)');
253
- const result = await runModelCall(ctx, {
254
- provider: kb.modelProvider,
255
- model: kb.modelId,
256
- system: '你是严谨的代码仓库文档分析器,只依据给定事实输出严格 JSON。',
257
- prompt,
258
- maxTokens: limits.maxTokens,
259
- temperature: 0.2,
260
- timeoutMs: 150000,
261
- signal,
262
- });
263
- recordLlmCall(db, kb, taskId, planned.purpose, result);
264
- callSummaries.push({ purpose: planned.purpose, provider: result.provider, model: result.model, ok: result.ok, finishKind: result.finishKind, failure: result.failure, elapsedMs: result.elapsedMs, usage: result.usage });
265
- if (result.aborted || signal.aborted)
266
- throw new InitError('TASK_CANCELLED', '任务已取消(模型调用被中断)');
267
- if (!result.ok) {
268
- const failureCode = result.failure?.code ?? 'MODEL_CALL_FAILED';
269
- throw new InitError('MODEL_CALL_FAILED', '模型调用失败(' + kb.modelProvider + ' / ' + kb.modelId + ',' + failureCode + '):' + (result.failure?.message ?? '未知错误'));
239
+ // 推理模型可能把 token 预算耗在思考上导致输出被截断:识别出来并加倍预算重试一次。
240
+ let budget = limits.maxTokens;
241
+ let parsed = null;
242
+ for (let attempt = 1;; attempt += 1) {
243
+ ensureNotAborted(signal);
244
+ const result = await runModelCall(ctx, {
245
+ provider: kb.modelProvider,
246
+ model: kb.modelId,
247
+ system: '你是严谨的代码仓库文档分析器,只依据给定事实输出严格 JSON。',
248
+ prompt,
249
+ maxTokens: budget,
250
+ temperature: 0.2,
251
+ timeoutMs: 150000,
252
+ signal,
253
+ });
254
+ recordLlmCall(db, kb, taskId, planned.purpose, result);
255
+ callSummaries.push({ purpose: planned.purpose, provider: result.provider, model: result.model, ok: result.ok, finishKind: result.finishKind, failure: result.failure, elapsedMs: result.elapsedMs, usage: result.usage, attempt, maxTokens: budget });
256
+ if (result.aborted || signal.aborted)
257
+ throw new InitError('TASK_CANCELLED', '任务已取消(模型调用被中断)');
258
+ if (!result.ok) {
259
+ const failureCode = result.failure?.code ?? 'MODEL_CALL_FAILED';
260
+ throw new InitError('MODEL_CALL_FAILED', '模型调用失败(' + kb.modelProvider + ' / ' + kb.modelId + ',' + failureCode + '):' + (result.failure?.message ?? '未知错误'));
261
+ }
262
+ log('info', '模型返回 ' + result.text.length + ' 字符(finish=' + String(result.finishKind) + ',预算 ' + budget + ' tokens,耗时 ' + result.elapsedMs + 'ms)');
263
+ const output = parseModelJson(result.text, { finishKind: result.finishKind, maxTokens: budget, attempt, reasoningChars: result.reasoningChars });
264
+ const truncated = result.finishKind === 'max-tokens';
265
+ if (output.ok) {
266
+ const docCount = Array.isArray(output.value?.documents) ? output.value.documents.length : 0;
267
+ if (docCount === 0 && truncated && attempt < MAX_OUTPUT_ATTEMPTS) {
268
+ const nextBudget = retryTokenBudget(budget);
269
+ log('warn', '模型在 ' + budget + ' tokens 预算内没有写出任何文档(finish=max-tokens,思考 ' + String(result.reasoningChars ?? 0) + ' 字符),改用 ' + nextBudget + ' tokens 重试一次');
270
+ budget = nextBudget;
271
+ continue;
272
+ }
273
+ parsed = output.value;
274
+ break;
275
+ }
276
+ if (output.truncated && attempt < MAX_OUTPUT_ATTEMPTS) {
277
+ const nextBudget = retryTokenBudget(budget);
278
+ log('warn', '模型输出被截断(' + budget + ' tokens 上限),改用 ' + nextBudget + ' tokens 重试一次:' + planned.purpose);
279
+ budget = nextBudget;
280
+ continue;
281
+ }
282
+ throw new InitError(output.code, output.message);
270
283
  }
271
- log('info', '模型返回 ' + result.text.length + ' 字符(finish=' + String(result.finishKind) + ',耗时 ' + result.elapsedMs + 'ms)');
272
- const parsed = extractJson(result.text);
273
284
  const validated = validateDocuments(parsed, knownPaths, secrets);
274
285
  rejected.push(...validated.rejected);
275
286
  documentsSeen += validated.documents.length;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 模型输出解析与「输出被截断」诊断(0.8.7)。
3
+ *
4
+ * 背景:推理模型会把 token 预算耗在思考过程上,finish_kind = max-tokens 时正文还没开始写,
5
+ * 之前统一报「模型输出中没有 JSON 对象」,既看不出是预算不足,也没有自动补救的机会。
6
+ * 这里把「截断」识别出来,让流水线可以加倍预算重试一次,并把可操作的建议写进错误信息。
7
+ */
8
+ /** 每个生成步骤最多尝试次数(首次 + 截断后加倍预算重试一次)。 */
9
+ export const MAX_OUTPUT_ATTEMPTS = 2;
10
+ export function isTruncatedModelOutput(meta) {
11
+ return meta.finishKind === 'max-tokens';
12
+ }
13
+ /** 截断重试时的预算:翻倍,但不超过 runModelCall 的上限 8192。 */
14
+ export function retryTokenBudget(maxTokens) {
15
+ const base = Number.isFinite(maxTokens) ? Math.max(1, Math.trunc(maxTokens)) : 1;
16
+ return Math.min(8192, base * 2);
17
+ }
18
+ function compactHead(text) {
19
+ const compact = text.replace(/\s+/g, ' ').trim();
20
+ if (compact === '')
21
+ return '(空输出)';
22
+ return compact.slice(0, 120);
23
+ }
24
+ function truncationMessage(meta, textLength, detail) {
25
+ const thinking = typeof meta.reasoningChars === 'number' && meta.reasoningChars > 0 ? ',其中思考过程 ' + meta.reasoningChars + ' 字符' : '';
26
+ const suffix = detail === null ? '' : '(' + detail + ')';
27
+ return '模型输出被截断:达到 ' + meta.maxTokens + ' tokens 上限(finish=max-tokens,输出 ' + textLength + ' 字符' + thinking + suffix + '),没有产出完整 JSON。'
28
+ + '请在「编辑配置」里换用非推理模型(可先点「测试模型」确认可用),或把「模型使用策略」调高后重试。';
29
+ }
30
+ /**
31
+ * 解析模型输出里的第一个 JSON 对象。
32
+ * - 允许 Markdown 代码围栏与前后解释文字;
33
+ * - 输出被截断(finish=max-tokens)时返回 MODEL_OUTPUT_TRUNCATED,调用方可以加倍预算重试;
34
+ * - 其它情况返回 MODEL_OUTPUT_INVALID,并带上输出开头方便排查。
35
+ */
36
+ export function parseModelJson(text, meta) {
37
+ const raw = typeof text === 'string' ? text : '';
38
+ let cleaned = raw.trim();
39
+ const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i);
40
+ if (fence && fence[1])
41
+ cleaned = fence[1].trim();
42
+ const truncated = isTruncatedModelOutput(meta);
43
+ const first = cleaned.indexOf('{');
44
+ const last = cleaned.lastIndexOf('}');
45
+ if (first < 0 || last <= first) {
46
+ if (truncated)
47
+ return { ok: false, code: 'MODEL_OUTPUT_TRUNCATED', truncated: true, message: truncationMessage(meta, raw.length, null) };
48
+ return {
49
+ ok: false,
50
+ code: 'MODEL_OUTPUT_INVALID',
51
+ truncated: false,
52
+ message: '模型输出中没有 JSON 对象(finish=' + String(meta.finishKind ?? '未知') + ',输出 ' + raw.length + ' 字符):' + compactHead(raw),
53
+ };
54
+ }
55
+ try {
56
+ return { ok: true, value: JSON.parse(cleaned.slice(first, last + 1)) };
57
+ }
58
+ catch (error) {
59
+ const detail = 'JSON 解析失败:' + String(error instanceof Error ? error.message : error);
60
+ if (truncated)
61
+ return { ok: false, code: 'MODEL_OUTPUT_TRUNCATED', truncated: true, message: truncationMessage(meta, raw.length, detail) };
62
+ return { ok: false, code: 'MODEL_OUTPUT_INVALID', truncated: false, message: '模型输出的 ' + detail };
63
+ }
64
+ }
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * M3 审核 HTTP 接口(前缀 /knowhub/api)。
3
3
  *
4
- * 作用域:与 /search 一致,只允许当前工作区可见(user:* + ws:<当前 workspaceId>)的知识库;
5
- * 客户端传参不能扩大范围,任何 kbId / docId 都在服务层重新校验。
4
+ * 作用域:审核是「人」对本地知识库的管理动作,因此队列、详情、差异、冲突与批量覆盖
5
+ * 所有未删除的知识库(用户级 + 各工作区),不按当前工作区过滤——桌面版宿主进程的 cwd 是
6
+ * 应用目录,反查不到工作区,按工作区过滤会让其它工作区的草稿/候选完全不可见。
7
+ * 检索与上下文注入仍按工作区隔离(scope.ts / search.ts),不受本文件影响。
6
8
  */
7
9
  import { errorMessage, readBody, writeError, writeOk } from '../http.js';
8
10
  import { all, openDatabase } from '../db/db.js';
@@ -16,14 +18,17 @@ import { resolveScope } from '../scope.js';
16
18
  function str(value) {
17
19
  return typeof value === 'string' ? value.trim() : '';
18
20
  }
19
- function assertKb(db, id, scopeKeys) {
21
+ /** 审核覆盖本地全部知识库,这里只校验存在性(没有工作区范围限制)。 */
22
+ function assertKb(db, id) {
20
23
  const kb = getKnowledgeBase(db, id);
21
24
  if (!kb)
22
25
  throw new ReviewError('KB_NOT_FOUND', 404, '知识库不存在:' + id);
23
- if (!scopeKeys.includes(kb.scopeKey))
24
- throw new ReviewError('KB_FORBIDDEN', 403, '该知识库不在当前工作区的授权作用域内:' + id);
25
26
  return kb;
26
27
  }
28
+ /** 本地全部未删除知识库的 id:审核队列与日志的统计范围。 */
29
+ function allKbIds(db) {
30
+ return all(db, 'SELECT id FROM knowledge_bases WHERE deleted_at IS NULL').map((row) => String(row.id));
31
+ }
27
32
  /** 组装「当前已批准内容 → 提议内容」的行级差异,供面板对比页直接渲染。 */
28
33
  function buildDiffPayload(input) {
29
34
  // 新增知识没有基线正文:整体视为新增,避免与空行做 LCS 时出现「未变」的错觉。
@@ -76,12 +81,12 @@ export async function handleReviewRequest(request) {
76
81
  if (method === 'GET' && path === '/review') {
77
82
  const scope = await resolveScope(ctx, process.cwd());
78
83
  const handle = db();
79
- const kbIds = all(handle, 'SELECT id FROM knowledge_bases WHERE deleted_at IS NULL AND scope_key IN (' + scope.scopeKeys.map(() => '?').join(', ') + ')', scope.scopeKeys).map((row) => String(row.id));
84
+ const kbIds = allKbIds(handle);
80
85
  const kbId = str(url.searchParams.get('kbId'));
81
86
  let target = kbIds;
82
87
  if (kbId !== '') {
83
88
  if (!kbIds.includes(kbId))
84
- throw new ReviewError('KB_FORBIDDEN', 403, '该知识库不在当前工作区的授权作用域内:' + kbId);
89
+ throw new ReviewError('KB_NOT_FOUND', 404, '知识库不存在:' + kbId);
85
90
  target = [kbId];
86
91
  }
87
92
  const queue = reviewQueue(handle, target, {
@@ -96,23 +101,21 @@ export async function handleReviewRequest(request) {
96
101
  return true;
97
102
  }
98
103
  if (method === 'GET' && path === '/review/log') {
99
- const scope = await resolveScope(ctx, process.cwd());
100
104
  const handle = db();
101
- const kbIds = all(handle, 'SELECT id FROM knowledge_bases WHERE deleted_at IS NULL AND scope_key IN (' + scope.scopeKeys.map(() => '?').join(', ') + ')', scope.scopeKeys).map((row) => String(row.id));
105
+ const kbIds = allKbIds(handle);
102
106
  writeOk(res, { entries: listReviewLog(handle, { kbIds, limit: Number(url.searchParams.get('limit') ?? 50) || 50 }) });
103
107
  return true;
104
108
  }
105
109
  const proposalDetail = /^\/review\/proposals\/([^/]+)$/.exec(path);
106
110
  if (proposalDetail && method === 'GET') {
107
- const scope = await resolveScope(ctx, process.cwd());
108
111
  const handle = db();
109
112
  const id = decodeURIComponent(proposalDetail[1] ?? '');
110
113
  const proposal = getProposal(handle, id);
111
114
  if (!proposal)
112
115
  throw new ReviewError('PROPOSAL_NOT_FOUND', 404, '候选不存在:' + id);
113
116
  const kb = getKnowledgeBase(handle, proposal.kbId);
114
- if (!kb || !scope.scopeKeys.includes(kb.scopeKey))
115
- throw new ReviewError('KB_FORBIDDEN', 403, '该候选所属知识库不在当前工作区的授权作用域内');
117
+ if (!kb)
118
+ throw new ReviewError('KB_NOT_FOUND', 404, '候选所属知识库不存在:' + proposal.kbId);
116
119
  const doc = proposal.docId ? getDocument(handle, proposal.docId) : null;
117
120
  const currentDiff = doc ? diffVersions(handle, doc, null, null) : null;
118
121
  writeOk(res, { proposal, document: doc, currentDiff });
@@ -120,15 +123,14 @@ export async function handleReviewRequest(request) {
120
123
  }
121
124
  const proposalDiff = /^\/review\/proposals\/([^/]+)\/diff$/.exec(path);
122
125
  if (proposalDiff && method === 'GET') {
123
- const scope = await resolveScope(ctx, process.cwd());
124
126
  const handle = db();
125
127
  const id = decodeURIComponent(proposalDiff[1] ?? '');
126
128
  const proposal = getProposal(handle, id);
127
129
  if (!proposal)
128
130
  throw new ReviewError('PROPOSAL_NOT_FOUND', 404, '候选不存在:' + id);
129
131
  const kb = getKnowledgeBase(handle, proposal.kbId);
130
- if (!kb || !scope.scopeKeys.includes(kb.scopeKey))
131
- throw new ReviewError('KB_FORBIDDEN', 403, '该候选所属知识库不在当前工作区的授权作用域内');
132
+ if (!kb)
133
+ throw new ReviewError('KB_NOT_FOUND', 404, '候选所属知识库不存在:' + proposal.kbId);
132
134
  const doc = proposal.docId ? getDocument(handle, proposal.docId) : null;
133
135
  const payload = buildDiffPayload({
134
136
  baseTitle: doc ? doc.title : null,
@@ -149,7 +151,6 @@ export async function handleReviewRequest(request) {
149
151
  }
150
152
  const proposalAction = /^\/review\/proposals\/([^/]+)\/(approve|reject)$/.exec(path);
151
153
  if (proposalAction && method === 'POST') {
152
- const scope = await resolveScope(ctx, process.cwd());
153
154
  const handle = db();
154
155
  const id = decodeURIComponent(proposalAction[1] ?? '');
155
156
  const action = proposalAction[2] ?? '';
@@ -157,8 +158,8 @@ export async function handleReviewRequest(request) {
157
158
  if (!existing)
158
159
  throw new ReviewError('PROPOSAL_NOT_FOUND', 404, '候选不存在:' + id);
159
160
  const kb = getKnowledgeBase(handle, existing.kbId);
160
- if (!kb || !scope.scopeKeys.includes(kb.scopeKey))
161
- throw new ReviewError('KB_FORBIDDEN', 403, '该候选所属知识库不在当前工作区的授权作用域内');
161
+ if (!kb)
162
+ throw new ReviewError('KB_NOT_FOUND', 404, '候选所属知识库不存在:' + existing.kbId);
162
163
  const body = (await readBody(req)) ?? {};
163
164
  if (action === 'reject') {
164
165
  const proposal = rejectProposal(handle, id, { actor: resolveActor(handle, body.actor), note: str(body.note) || null });
@@ -175,7 +176,6 @@ export async function handleReviewRequest(request) {
175
176
  return true;
176
177
  }
177
178
  if (method === 'POST' && path === '/review/bulk') {
178
- const scope = await resolveScope(ctx, process.cwd());
179
179
  const handle = db();
180
180
  const body = (await readBody(req)) ?? {};
181
181
  const rawItems = Array.isArray(body.items) ? body.items : [];
@@ -189,22 +189,17 @@ export async function handleReviewRequest(request) {
189
189
  const id = str(raw?.id);
190
190
  if ((type !== 'proposal' && type !== 'doc') || id === '')
191
191
  throw new ReviewError('BULK_INVALID_ITEM', 400, '批量项必须是 {type:"proposal"|"doc", id}');
192
- // 不存在的 id 不在这里 404:交给 bulkReview 逐条记录失败,避免一条脏 id 让整批失败
192
+ // 不存在的 id 不在这里 404:交给 bulkReview 逐条记录失败,避免一条脏 id 让整批失败。
193
+ // 审核覆盖全部知识库,这里只做「对象存在但所属知识库已不存在」的一致性校验。
193
194
  if (type === 'proposal') {
194
195
  const proposal = getProposal(handle, id);
195
- if (proposal) {
196
- const kb = getKnowledgeBase(handle, proposal.kbId);
197
- if (!kb || !scope.scopeKeys.includes(kb.scopeKey))
198
- throw new ReviewError('KB_FORBIDDEN', 403, '候选所属知识库不在授权作用域内:' + id);
199
- }
196
+ if (proposal && !getKnowledgeBase(handle, proposal.kbId))
197
+ throw new ReviewError('KB_NOT_FOUND', 404, '候选所属知识库不存在:' + id);
200
198
  }
201
199
  else {
202
200
  const doc = getDocument(handle, id);
203
- if (doc) {
204
- const kb = getKnowledgeBase(handle, doc.kbId);
205
- if (!kb || !scope.scopeKeys.includes(kb.scopeKey))
206
- throw new ReviewError('KB_FORBIDDEN', 403, '知识所属知识库不在授权作用域内:' + id);
207
- }
201
+ if (doc && !getKnowledgeBase(handle, doc.kbId))
202
+ throw new ReviewError('KB_NOT_FOUND', 404, '知识所属知识库不存在:' + id);
208
203
  }
209
204
  items.push({ type, id });
210
205
  }
@@ -215,15 +210,14 @@ export async function handleReviewRequest(request) {
215
210
  }
216
211
  const conflictDiff = /^\/review\/conflicts\/([^/]+)\/diff$/.exec(path);
217
212
  if (conflictDiff && method === 'GET') {
218
- const scope = await resolveScope(ctx, process.cwd());
219
213
  const handle = db();
220
214
  const id = decodeURIComponent(conflictDiff[1] ?? '');
221
215
  const conflict = getConflict(handle, id);
222
216
  if (!conflict)
223
217
  throw new ReviewError('CONFLICT_NOT_FOUND', 404, '冲突不存在:' + id);
224
218
  const kb = getKnowledgeBase(handle, conflict.kbId);
225
- if (!kb || !scope.scopeKeys.includes(kb.scopeKey))
226
- throw new ReviewError('KB_FORBIDDEN', 403, '该冲突所属知识库不在当前工作区的授权作用域内');
219
+ if (!kb)
220
+ throw new ReviewError('KB_NOT_FOUND', 404, '冲突所属知识库不存在:' + conflict.kbId);
227
221
  const doc = getDocument(handle, conflict.docId);
228
222
  const payload = buildDiffPayload({
229
223
  baseTitle: doc ? doc.title : conflict.proposedTitle,
@@ -246,15 +240,14 @@ export async function handleReviewRequest(request) {
246
240
  // 基线取上一版正文(只有一版时整体视为新增),目标取知识当前正文。
247
241
  const documentDiff = /^\/review\/documents\/([^/]+)\/diff$/.exec(path);
248
242
  if (documentDiff && method === 'GET') {
249
- const scope = await resolveScope(ctx, process.cwd());
250
243
  const handle = db();
251
244
  const docId = decodeURIComponent(documentDiff[1] ?? '');
252
245
  const doc = getDocument(handle, docId);
253
246
  if (!doc)
254
247
  throw new ReviewError('DOC_NOT_FOUND', 404, '知识不存在:' + docId);
255
248
  const kb = getKnowledgeBase(handle, doc.kbId);
256
- if (!kb || !scope.scopeKeys.includes(kb.scopeKey))
257
- throw new ReviewError('KB_FORBIDDEN', 403, '该知识所属知识库不在当前工作区的授权作用域内');
249
+ if (!kb)
250
+ throw new ReviewError('KB_NOT_FOUND', 404, '知识所属知识库不存在:' + doc.kbId);
258
251
  const previous = doc.currentVersion > 1 ? getVersionContent(handle, doc.id, doc.currentVersion - 1) : null;
259
252
  const payload = buildDiffPayload({
260
253
  baseTitle: previous ? doc.title : null,
@@ -274,15 +267,14 @@ export async function handleReviewRequest(request) {
274
267
  }
275
268
  const conflictAction = /^\/review\/conflicts\/([^/]+)\/resolve$/.exec(path);
276
269
  if (conflictAction && method === 'POST') {
277
- const scope = await resolveScope(ctx, process.cwd());
278
270
  const handle = db();
279
271
  const id = decodeURIComponent(conflictAction[1] ?? '');
280
272
  const conflict = getConflict(handle, id);
281
273
  if (!conflict)
282
274
  throw new ReviewError('CONFLICT_NOT_FOUND', 404, '冲突不存在:' + id);
283
275
  const kb = getKnowledgeBase(handle, conflict.kbId);
284
- if (!kb || !scope.scopeKeys.includes(kb.scopeKey))
285
- throw new ReviewError('KB_FORBIDDEN', 403, '该冲突所属知识库不在当前工作区的授权作用域内');
276
+ if (!kb)
277
+ throw new ReviewError('KB_NOT_FOUND', 404, '冲突所属知识库不存在:' + conflict.kbId);
286
278
  const body = (await readBody(req)) ?? {};
287
279
  const mode = str(body.mode);
288
280
  const result = await resolveConflict(handle, storageRoot(), id, {
@@ -298,19 +290,17 @@ export async function handleReviewRequest(request) {
298
290
  }
299
291
  const kbReview = /^\/kb\/([^/]+)\/review$/.exec(path);
300
292
  if (kbReview && method === 'GET') {
301
- const scope = await resolveScope(ctx, process.cwd());
302
293
  const handle = db();
303
294
  const kbId = decodeURIComponent(kbReview[1] ?? '');
304
- assertKb(handle, kbId, scope.scopeKeys);
295
+ assertKb(handle, kbId);
305
296
  writeOk(res, reviewQueue(handle, [kbId], Number(url.searchParams.get('limit') ?? 50) || 50));
306
297
  return true;
307
298
  }
308
299
  const kbProposals = /^\/kb\/([^/]+)\/proposals$/.exec(path);
309
300
  if (kbProposals && method === 'POST') {
310
- const scope = await resolveScope(ctx, process.cwd());
311
301
  const handle = db();
312
302
  const kbId = decodeURIComponent(kbProposals[1] ?? '');
313
- const kb = assertKb(handle, kbId, scope.scopeKeys);
303
+ const kb = assertKb(handle, kbId);
314
304
  const body = (await readBody(req)) ?? {};
315
305
  const proposal = createProposal(handle, kb, { ...body, source: str(body.source) || 'web', createdBy: str(body.createdBy) || resolveActor(handle, null) });
316
306
  const auto = await autoApproveIfEligible(handle, storageRoot(), proposal, autoApproveRules(handle));
@@ -324,20 +314,18 @@ export async function handleReviewRequest(request) {
324
314
  }
325
315
  const versions = /^\/kb\/([^/]+)\/docs\/([^/]+)\/versions$/.exec(path);
326
316
  if (versions && method === 'GET') {
327
- const scope = await resolveScope(ctx, process.cwd());
328
317
  const handle = db();
329
318
  const kbId = decodeURIComponent(versions[1] ?? '');
330
- assertKb(handle, kbId, scope.scopeKeys);
319
+ assertKb(handle, kbId);
331
320
  const doc = assertDoc(handle, kbId, decodeURIComponent(versions[2] ?? ''));
332
321
  writeOk(res, { documentId: doc.id, currentVersion: doc.currentVersion, versions: listVersions(handle, doc.id) });
333
322
  return true;
334
323
  }
335
324
  const versionContent = /^\/kb\/([^/]+)\/docs\/([^/]+)\/versions\/(\d+)$/.exec(path);
336
325
  if (versionContent && method === 'GET') {
337
- const scope = await resolveScope(ctx, process.cwd());
338
326
  const handle = db();
339
327
  const kbId = decodeURIComponent(versionContent[1] ?? '');
340
- assertKb(handle, kbId, scope.scopeKeys);
328
+ assertKb(handle, kbId);
341
329
  const doc = assertDoc(handle, kbId, decodeURIComponent(versionContent[2] ?? ''));
342
330
  const entry = getVersionContent(handle, doc.id, Number(versionContent[3]));
343
331
  if (!entry)
@@ -347,10 +335,9 @@ export async function handleReviewRequest(request) {
347
335
  }
348
336
  const diff = /^\/kb\/([^/]+)\/docs\/([^/]+)\/diff$/.exec(path);
349
337
  if (diff && method === 'GET') {
350
- const scope = await resolveScope(ctx, process.cwd());
351
338
  const handle = db();
352
339
  const kbId = decodeURIComponent(diff[1] ?? '');
353
- assertKb(handle, kbId, scope.scopeKeys);
340
+ assertKb(handle, kbId);
354
341
  const doc = assertDoc(handle, kbId, decodeURIComponent(diff[2] ?? ''));
355
342
  const from = Number(url.searchParams.get('from') ?? '') || null;
356
343
  const to = Number(url.searchParams.get('to') ?? '') || null;
@@ -359,10 +346,9 @@ export async function handleReviewRequest(request) {
359
346
  }
360
347
  const docReview = /^\/kb\/([^/]+)\/docs\/([^/]+)\/review$/.exec(path);
361
348
  if (docReview && method === 'POST') {
362
- const scope = await resolveScope(ctx, process.cwd());
363
349
  const handle = db();
364
350
  const kbId = decodeURIComponent(docReview[1] ?? '');
365
- assertKb(handle, kbId, scope.scopeKeys);
351
+ assertKb(handle, kbId);
366
352
  const doc = assertDoc(handle, kbId, decodeURIComponent(docReview[2] ?? ''));
367
353
  const body = (await readBody(req)) ?? {};
368
354
  const action = str(body.action);
@@ -380,10 +366,9 @@ export async function handleReviewRequest(request) {
380
366
  }
381
367
  const docRestore = /^\/kb\/([^/]+)\/docs\/([^/]+)\/restore$/.exec(path);
382
368
  if (docRestore && method === 'POST') {
383
- const scope = await resolveScope(ctx, process.cwd());
384
369
  const handle = db();
385
370
  const kbId = decodeURIComponent(docRestore[1] ?? '');
386
- assertKb(handle, kbId, scope.scopeKeys);
371
+ assertKb(handle, kbId);
387
372
  const doc = assertDoc(handle, kbId, decodeURIComponent(docRestore[2] ?? ''));
388
373
  const body = (await readBody(req)) ?? {};
389
374
  const version = Number(body.version);
@@ -396,10 +381,9 @@ export async function handleReviewRequest(request) {
396
381
  }
397
382
  const docEdit = /^\/kb\/([^/]+)\/docs\/([^/]+)$/.exec(path);
398
383
  if (docEdit && method === 'PATCH') {
399
- const scope = await resolveScope(ctx, process.cwd());
400
384
  const handle = db();
401
385
  const kbId = decodeURIComponent(docEdit[1] ?? '');
402
- assertKb(handle, kbId, scope.scopeKeys);
386
+ assertKb(handle, kbId);
403
387
  const doc = assertDoc(handle, kbId, decodeURIComponent(docEdit[2] ?? ''));
404
388
  const body = (await readBody(req)) ?? {};
405
389
  const updated = await updateDocumentByHuman(handle, storageRoot(), doc, body, resolveActor(handle, body.actor));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-knowhub",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "知枢 KnowHub — DSH 全局知识中心与项目长期记忆:知识库、Repo Wiki、审核与版本、增量更新、检索与上下文注入、自动提炼、备份恢复、中文面板",
5
5
  "license": "MIT",
6
6
  "type": "module",