dsh-knowhub 0.8.7 → 0.9.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 +11 -5
- package/lib/client/index.js +43 -33
- package/lib/host/diagnostics.js +67 -8
- package/lib/host/index.js +4 -1
- package/lib/host/review/route.js +39 -55
- package/lib/host/route.js +14 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
> DSH 的全局知识中心与项目长期记忆。把项目知识与长期记忆沉淀成可检索、可审核、可追溯、可备份的知识库,并在 DSH 会话中按工作区作用域自动注入或按需检索。
|
|
4
4
|
|
|
5
|
+
版本变化见 [CHANGELOG](CHANGELOG.md);0.9 规划草案见 [ROADMAP-0.9-draft](ROADMAP-0.9-draft.md)。
|
|
6
|
+
|
|
5
7
|
## 界面预览
|
|
6
8
|
|
|
7
9
|

|
|
@@ -10,7 +12,7 @@
|
|
|
10
12
|
|
|
11
13
|
**知识库与作用域**
|
|
12
14
|
|
|
13
|
-
-
|
|
15
|
+
- 用户级知识库(跨工作区共享)与工作区级知识库(仅当前项目可见);检索与上下文注入按工作区作用域隔离,审核队列与知识库列表覆盖本地全部知识库。
|
|
14
16
|
- 每个知识库可绑定不同的模型与生成策略(economy / balanced / quality),初始化、提炼、增量都使用库绑定的模型。
|
|
15
17
|
- 创建后可修改名称、模型与策略,不影响已有知识与版本历史;编辑配置里可先「测试模型」确认该模型当前可调通。
|
|
16
18
|
- 模型输出被截断(推理模型把 token 预算消耗在思考过程)时,任务会自动加倍预算重试一次;仍失败会明确提示换用非推理模型或调高「模型使用策略」。
|
|
@@ -35,7 +37,7 @@
|
|
|
35
37
|
|
|
36
38
|
**审核与版本**
|
|
37
39
|
|
|
38
|
-
-
|
|
40
|
+
- 待审核队列分候选、冲突、草稿、过时四类,覆盖本地全部知识库(用户级 + 各工作区),真实计数、关键字/类型/来源筛选与排序。
|
|
39
41
|
- 支持单条与批量审批(单次最多 50 条)、编辑后批准、冲突人工合并。
|
|
40
42
|
- 候选与冲突提供文档对比页:绿色新增、红色删除,行内词元高亮,统一视图与并排视图,改动处导航。
|
|
41
43
|
- 版本历史可查看、对比与恢复,恢复同样以新版本追加,可回退。
|
|
@@ -81,7 +83,7 @@ dsh --profile knowhub --port 29961 --no-open
|
|
|
81
83
|
|
|
82
84
|
```powershell
|
|
83
85
|
# tarball
|
|
84
|
-
& dsh plugin --profile knowhub add .\dsh-knowhub-0.8.
|
|
86
|
+
& dsh plugin --profile knowhub add .\dsh-knowhub-0.8.8.tgz
|
|
85
87
|
|
|
86
88
|
# 开发期:软链到源码目录,构建后重启即生效
|
|
87
89
|
cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\knowhub\node_modules\dsh-knowhub" "C:\path\to\KnowHub"
|
|
@@ -118,7 +120,7 @@ cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\knowhub\node_modules\dsh-knowhu
|
|
|
118
120
|
| 知识卡片 | 决策 / 约定 / 问题 / 待办四类知识,类型与状态过滤,列表 + 阅读器 |
|
|
119
121
|
| 记忆 | 记忆 / 偏好两类知识,类型与状态过滤,列表 + 阅读器 |
|
|
120
122
|
| 知识库 | 知识库列表(固定列出全部:用户级 + 各工作区,不做工作区筛选)+ 详情:新建与编辑配置(含「测试模型」)、初始化与增量任务(进度 / 日志 / 取消 / 重试)、项目索引、模型调用记录、知识列表分页、导出全部、导入 Markdown、删除 |
|
|
121
|
-
| 待审核 | 候选 / 冲突 / 草稿 /
|
|
123
|
+
| 待审核 | 候选 / 冲突 / 草稿 / 过时四类队列(覆盖本地全部知识库,不按当前工作区过滤),真实计数、筛选、排序、批量审批;点「查看详情与差异」进入文档对比页;版本历史与审核日志 |
|
|
122
124
|
| 检索 | 关键词 + 标签 + 知识库 / 类型 / 状态 / 排序;显示来源、作用域、审核状态与检索模式;结果分页加载 |
|
|
123
125
|
| 设置 | 默认策略、提炼开关与阈值、审核人与自动批准、知识注入开关与预算、备份保留、排除目录、存储位置迁移、备份预览与恢复、诊断信息 |
|
|
124
126
|
|
|
@@ -139,6 +141,10 @@ cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\knowhub\node_modules\dsh-knowhu
|
|
|
139
141
|
|
|
140
142
|
自动生成、增量更新、Agent 提议、自动提炼、Markdown 导入的结果一律先落为草稿或候选,必须人工批准后才成为正式知识。这是为了避免模型臆断污染长期记忆。
|
|
141
143
|
|
|
144
|
+
**生成成功了,待审核里却什么都没有?**
|
|
145
|
+
|
|
146
|
+
先确认宿主进程已经重启:升级插件只替换磁盘文件,正在运行的进程仍用旧代码,必须完全退出并重开 DSH。0.8.8 起待审核队列覆盖本地全部知识库(用户级 + 各工作区),不再只看当前工作区;若仍为空,检查桌面版与开发实例是否指向同一份存储根(「设置 → 存储位置」),以及任务是否真的产出了草稿(知识库详情的任务日志会写明生成篇数)。
|
|
147
|
+
|
|
142
148
|
**已经批准的知识会被增量更新悄悄改掉吗?**
|
|
143
149
|
|
|
144
150
|
不会。已批准知识只会生成「更新候选」进入待审核队列,批准后才以新版本追加,旧版本仍可查看与恢复。
|
|
@@ -203,7 +209,7 @@ knowledge-center/
|
|
|
203
209
|
1. 不读取、不落盘、不打印任何明文密钥;模型调用只传 provider / model,凭据由 DSH 适配层管理。
|
|
204
210
|
2. 扫描默认排除 `.git`、`node_modules`、构建产物、缓存、`.env*`、`*.pem`、`id_rsa` 等敏感文件;不跟随符号链接;不执行被扫描仓库的任何脚本。
|
|
205
211
|
3. 文件访问限制在已注册工作区与当前工作目录内,扫描接口需 realpath 命中授权根。
|
|
206
|
-
4.
|
|
212
|
+
4. 检索与上下文注入的作用域由服务端从会话 `cwd` 反查得出,客户端参数不能扩大检索范围;审核、知识库管理与版本操作是本地单人管理视图,覆盖本地全部未删除知识库(桌面版宿主进程的 `cwd` 是应用目录,按工作区过滤会导致其它工作区的草稿不可见)。
|
|
207
213
|
5. 自动生成的知识默认待审核,批准后才成为正式知识;已批准文档不会被流水线静默覆盖。
|
|
208
214
|
6. 模型输出必须通过结构校验(JSON 解析、类型/长度/置信度范围、敏感信息清洗、绝对路径剔除)才进候选。
|
|
209
215
|
7. Git 只读调用,失败自动降级为内容哈希模式并记录原因。
|
package/lib/client/index.js
CHANGED
|
@@ -88,7 +88,7 @@ const styles = {
|
|
|
88
88
|
lineHeight: '20px', cursor: 'pointer',
|
|
89
89
|
},
|
|
90
90
|
danger: {
|
|
91
|
-
display: 'inline-flex', alignItems: 'center', height: '26px', padding: '0 10px', borderRadius: '
|
|
91
|
+
display: 'inline-flex', alignItems: 'center', height: '26px', padding: '0 10px', borderRadius: '14px',
|
|
92
92
|
border: '1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent)',
|
|
93
93
|
background: 'transparent', color: 'var(--dsw-alias-state-error-primary)', fontSize: '12px', cursor: 'pointer',
|
|
94
94
|
},
|
|
@@ -103,8 +103,15 @@ const styles = {
|
|
|
103
103
|
},
|
|
104
104
|
success: {
|
|
105
105
|
margin: '0 0 14px', padding: '10px 12px', borderRadius: '10px',
|
|
106
|
-
background: '
|
|
107
|
-
|
|
106
|
+
background: 'color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent)',
|
|
107
|
+
border: '1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 35%, transparent)',
|
|
108
|
+
color: 'var(--dsw-alias-state-success-label)', fontSize: '12px', whiteSpace: 'pre-wrap', lineHeight: '19px',
|
|
109
|
+
},
|
|
110
|
+
warn: {
|
|
111
|
+
margin: '0 0 14px', padding: '10px 12px', borderRadius: '10px',
|
|
112
|
+
background: 'color-mix(in srgb, var(--dsw-alias-state-warn-primary) 10%, transparent)',
|
|
113
|
+
border: '1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 35%, transparent)',
|
|
114
|
+
color: 'var(--dsw-alias-state-warn-label)', fontSize: '12px', whiteSpace: 'pre-wrap', lineHeight: '19px',
|
|
108
115
|
},
|
|
109
116
|
body: { display: 'flex', flexDirection: 'column', minWidth: 0 },
|
|
110
117
|
card: {
|
|
@@ -207,7 +214,7 @@ Object.assign(styles, {
|
|
|
207
214
|
display: 'inline-flex', alignItems: 'center', gap: '6px', cursor: 'pointer',
|
|
208
215
|
},
|
|
209
216
|
countBadge: {
|
|
210
|
-
minWidth: '18px', height: '18px', padding: '0 5px', borderRadius: '
|
|
217
|
+
minWidth: '18px', height: '18px', padding: '0 5px', borderRadius: '999px',
|
|
211
218
|
background: 'color-mix(in srgb, var(--dsw-alias-state-warn-primary) 22%, transparent)',
|
|
212
219
|
color: 'var(--dsw-alias-state-warn-label)', fontSize: '11px',
|
|
213
220
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
@@ -285,16 +292,6 @@ Object.assign(styles, {
|
|
|
285
292
|
},
|
|
286
293
|
overlayScroll: { flex: 1, minHeight: 0, overflow: 'auto', padding: '18px clamp(16px,3vw,40px) 48px' },
|
|
287
294
|
overlayInner: { maxWidth: '960px', margin: '0 auto' },
|
|
288
|
-
modalBackdrop: {
|
|
289
|
-
position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
290
|
-
padding: '24px', background: 'rgba(0,0,0,0.42)',
|
|
291
|
-
},
|
|
292
|
-
modalCard: {
|
|
293
|
-
width: 'min(640px, calc(100vw - 48px))', maxHeight: '84vh', overflow: 'hidden',
|
|
294
|
-
display: 'flex', flexDirection: 'column', minHeight: 0,
|
|
295
|
-
background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l2)',
|
|
296
|
-
borderRadius: '12px', boxShadow: '0 24px 60px rgba(0,0,0,0.35)',
|
|
297
|
-
},
|
|
298
295
|
});
|
|
299
296
|
Object.assign(styles, {
|
|
300
297
|
mdBody: { fontSize: '14px', lineHeight: '24px', color: 'var(--dsw-alias-label-primary)', wordBreak: 'break-word' },
|
|
@@ -340,7 +337,7 @@ Object.assign(styles, {
|
|
|
340
337
|
dialogFootHint: { flex: 1, minWidth: 0, fontSize: '11px', lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary)' },
|
|
341
338
|
dialogLabel: { display: 'block', marginBottom: '6px', fontSize: '12px', fontWeight: 500, color: 'var(--dsw-alias-label-secondary)' },
|
|
342
339
|
dialogInput: {
|
|
343
|
-
width: '100%', boxSizing: 'border-box', height: '
|
|
340
|
+
width: '100%', boxSizing: 'border-box', height: '32px', padding: '0 10px', borderRadius: '8px',
|
|
344
341
|
border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-base)',
|
|
345
342
|
color: 'var(--dsw-alias-label-primary)', fontSize: '13px', lineHeight: '20px', outline: 'none',
|
|
346
343
|
},
|
|
@@ -634,7 +631,7 @@ function ConfirmHost() {
|
|
|
634
631
|
const confirmStyle = pending.danger
|
|
635
632
|
? { ...styles.primary, border: '1px solid var(--dsw-alias-state-error-primary)', background: 'var(--dsw-alias-state-error-primary)' }
|
|
636
633
|
: styles.primary;
|
|
637
|
-
return h(Modal, { onClose: () => settleConfirm(false), maxWidth: '440px' }, h('div', { style: styles.dialog }, h('div', { style: styles.dialogHead }, h('div', { style: { minWidth: 0 } }, h('div', { style: styles.dialogTitle }, pending.title ?? '确认操作'))), h('div', { style: styles.dialogBody }, h('div', { style: styles.confirmBody }, pending.text)), h('div', { style: styles.dialogFoot }, h('div', { style: styles.dialogFootHint }, 'Esc 取消 · Enter 确认'), h('div', { style: { display: 'flex', gap: '8px' } }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => settleConfirm(false) }, pending.cancelText ?? '取消'), h('button', { type: 'button', style: confirmStyle, className: pending.danger ? '' : 'kh-primary', onClick: () => settleConfirm(true) }, pending.confirmText ?? '确认')))));
|
|
634
|
+
return h(Modal, { onClose: () => settleConfirm(false), maxWidth: '440px' }, h('div', { style: styles.dialog }, h('div', { style: styles.dialogHead }, h('div', { style: { minWidth: 0 } }, h('div', { style: styles.dialogTitle }, pending.title ?? '确认操作'))), h('div', { style: styles.dialogBody, className: 'kh-scroll' }, h('div', { style: styles.confirmBody }, pending.text)), h('div', { style: styles.dialogFoot }, h('div', { style: styles.dialogFootHint }, 'Esc 取消 · Enter 确认'), h('div', { style: { display: 'flex', gap: '8px' } }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => settleConfirm(false) }, pending.cancelText ?? '取消'), h('button', { type: 'button', style: confirmStyle, className: pending.danger ? '' : 'kh-primary', onClick: () => settleConfirm(true) }, pending.confirmText ?? '确认')))));
|
|
638
635
|
}
|
|
639
636
|
// ---------- 知识库 ----------
|
|
640
637
|
function CreateKbForm(props) {
|
|
@@ -708,7 +705,7 @@ function CreateKbForm(props) {
|
|
|
708
705
|
key: item.value,
|
|
709
706
|
style: { ...(strategy === item.value ? styles.choiceActive : styles.choice), flex: '1 1 100%' },
|
|
710
707
|
}, h('input', { type: 'radio', name: 'kb-strategy', checked: strategy === item.value, onChange: () => setStrategy(item.value) }), h('div', { style: { minWidth: 0 } }, h('div', { style: styles.choiceTitle }, item.label), h('div', { style: styles.choiceDesc }, item.desc))));
|
|
711
|
-
return h('div', { style: styles.dialog }, h('div', { style: styles.dialogHead }, h('div', { style: { minWidth: 0 } }, h('div', { style: styles.dialogTitle }, '创建知识库'), h('div', { style: styles.dialogSub }, '创建只保存配置并建立空知识库;不会立即扫描仓库,也不会自动调用模型。')), h('button', { type: 'button', style: styles.dialogClose, className: 'kh-hover', title: '关闭', onClick: onCancel }, '✕')), h('div', { style: styles.dialogBody }, h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, '名称', h('span', { style: styles.labelRequired }, '*')), h('input', {
|
|
708
|
+
return h('div', { style: styles.dialog }, h('div', { style: styles.dialogHead }, h('div', { style: { minWidth: 0 } }, h('div', { style: styles.dialogTitle }, '创建知识库'), h('div', { style: styles.dialogSub }, '创建只保存配置并建立空知识库;不会立即扫描仓库,也不会自动调用模型。')), h('button', { type: 'button', style: styles.dialogClose, className: 'kh-hover', title: '关闭', onClick: onCancel }, '✕')), h('div', { style: styles.dialogBody, className: 'kh-scroll' }, h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, '名称', h('span', { style: styles.labelRequired }, '*')), h('input', {
|
|
712
709
|
style: styles.dialogInput, value: name, maxLength: 80, placeholder: '例如:知枢 KnowHub 项目知识库',
|
|
713
710
|
onChange: (event) => { setName(event.target.value); if (touched)
|
|
714
711
|
setErrors(validate()); },
|
|
@@ -804,7 +801,7 @@ function EditKbForm(props) {
|
|
|
804
801
|
key: item.value,
|
|
805
802
|
style: { ...(strategy === item.value ? styles.choiceActive : styles.choice), flex: '1 1 100%' },
|
|
806
803
|
}, h('input', { type: 'radio', name: 'edit-kb-strategy', checked: strategy === item.value, onChange: () => setStrategy(item.value) }), h('div', { style: { minWidth: 0 } }, h('div', { style: styles.choiceTitle }, item.label), h('div', { style: styles.choiceDesc }, item.desc))));
|
|
807
|
-
return h('div', { style: styles.dialog }, h('div', { style: styles.dialogHead }, h('div', { style: { minWidth: 0 } }, h('div', { style: styles.dialogTitle }, '编辑知识库配置'), h('div', { style: styles.dialogSub }, '只改配置,不会重新扫描仓库,也不会改动已有知识与版本历史。')), h('button', { type: 'button', style: styles.dialogClose, className: 'kh-hover', title: '关闭', onClick: onCancel }, '✕')), h('div', { style: styles.dialogBody }, h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, '名称', h('span', { style: styles.labelRequired }, '*')), h('input', {
|
|
804
|
+
return h('div', { style: styles.dialog }, h('div', { style: styles.dialogHead }, h('div', { style: { minWidth: 0 } }, h('div', { style: styles.dialogTitle }, '编辑知识库配置'), h('div', { style: styles.dialogSub }, '只改配置,不会重新扫描仓库,也不会改动已有知识与版本历史。')), h('button', { type: 'button', style: styles.dialogClose, className: 'kh-hover', title: '关闭', onClick: onCancel }, '✕')), h('div', { style: styles.dialogBody, className: 'kh-scroll' }, h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, '名称', h('span', { style: styles.labelRequired }, '*')), h('input', {
|
|
808
805
|
style: styles.dialogInput, value: name, maxLength: 80, placeholder: '例如:知枢 KnowHub 项目知识库',
|
|
809
806
|
onChange: (event) => setName(event.target.value),
|
|
810
807
|
}), 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
|
|
@@ -856,7 +853,7 @@ function AddDocForm(props) {
|
|
|
856
853
|
setBusy(false);
|
|
857
854
|
}
|
|
858
855
|
};
|
|
859
|
-
return h('div', { style:
|
|
856
|
+
return h('div', { style: styles.card }, h('div', { style: styles.cardTitle }, '新增知识(进入待审核草稿)'), h('div', { style: styles.field }, h('label', { style: styles.label }, '标题'), h('input', { style: styles.input, value: title, onChange: (e) => setTitle(e.target.value) })), h('div', { style: styles.row }, h('div', { style: { ...styles.field, flex: 1 } }, h('label', { style: styles.label }, '类型'), h('select', { style: styles.input, value: kind, onChange: (e) => setKind(e.target.value) }, KINDS.map((item) => h('option', { key: item.value, value: item.value }, item.label)))), h('div', { style: { ...styles.field, flex: 1 } }, h('label', { style: styles.label }, '验证状态'), h('select', { style: styles.input, value: verifyState, onChange: (e) => setVerifyState(e.target.value) }, Object.keys(VERIFY_LABEL).map((key) => h('option', { key, value: key }, VERIFY_LABEL[key])))), h('div', { style: { ...styles.field, flex: 2 } }, h('label', { style: styles.label }, '标签(逗号分隔)'), h('input', { style: styles.input, value: tags, onChange: (e) => setTags(e.target.value) }))), h('div', { style: styles.field }, h('label', { style: styles.label }, '正文(Markdown)'), h('textarea', { style: styles.textarea, value: body, onChange: (e) => setBody(e.target.value) })), error !== null ? h('div', { style: { ...styles.error, margin: '0 0 10px' } }, error) : null, h('div', { style: styles.row }, h('button', { type: 'button', style: busy ? { ...styles.primary, ...styles.buttonDisabled } : styles.primary, disabled: busy, onClick: () => void submit() }, busy ? '保存中…' : '保存草稿'), h('div', { style: { ...styles.hint, alignSelf: 'center' } }, '正文写入 Markdown 文件,元数据与索引写入 SQLite(FTS5 trigram)。')));
|
|
860
857
|
}
|
|
861
858
|
/**
|
|
862
859
|
* 导入 Markdown(知枢导出的 .md 文件或手写 Markdown):
|
|
@@ -909,9 +906,9 @@ function ImportMarkdownForm(props) {
|
|
|
909
906
|
setBusy(false);
|
|
910
907
|
}
|
|
911
908
|
};
|
|
912
|
-
const resultBlock = result === null ? null : h('div', { style:
|
|
913
|
-
return h('div', { style: styles.dialog }, h('div', { style: styles.dialogHead }, h('div', { style: { minWidth: 0 } }, h('div', { style: styles.dialogTitle }, '导入 Markdown'), h('div', { style: styles.dialogSub }, '导入到「' + String(kbName ?? '') + '」;所有条目先成为草稿,人工审核后才生效。')), h('button', { type: 'button', style: styles.dialogClose, className: 'kh-hover', title: '关闭', onClick: onCancel }, '✕')), h('div', { style: styles.dialogBody }, h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, '选择 .md 文件(可选)'), h('input', { type: 'file', accept: '.md,.markdown,text/markdown', style: { fontSize: '12px' }, onChange: pickFile })), h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, 'Markdown 内容', h('span', { style: styles.labelRequired }, '*')), h('textarea', {
|
|
914
|
-
style: { ...styles.textarea, minHeight: '200px', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' },
|
|
909
|
+
const resultBlock = result === null ? null : h('div', { style: styles.card }, h('div', { style: styles.cardTitle }, '导入结果', h(Badge, { text: '新建 ' + (result.created ?? []).length, style: styles.badgeGreen }), h(Badge, { text: '跳过 ' + (result.skipped ?? []).length }), h(Badge, { text: '失败 ' + (result.failed ?? []).length, style: (result.failed ?? []).length > 0 ? styles.badgeAmber : undefined })), (result.created ?? []).length > 0 ? h('div', { style: styles.hint }, '新建草稿:' + (result.created ?? []).map((item) => item.title).join('、')) : null, (result.skipped ?? []).length > 0 ? h('div', { style: styles.hint }, '跳过:' + (result.skipped ?? []).map((item) => item.title + '(' + item.reason + ')').join(';')) : null, (result.failed ?? []).length > 0 ? h('div', { style: { ...styles.hint, color: 'var(--dsw-alias-state-error-primary)' } }, '失败:' + (result.failed ?? []).map((item) => item.title + '(' + item.message + ')').join(';')) : null, (result.warnings ?? []).length > 0 ? h('div', { style: styles.hint }, '提示:' + (result.warnings ?? []).join(';')) : null, result.parentLinked ? h('div', { style: styles.hint }, '已恢复 ' + result.parentLinked + ' 条父级关系') : null);
|
|
910
|
+
return h('div', { style: styles.dialog }, h('div', { style: styles.dialogHead }, h('div', { style: { minWidth: 0 } }, h('div', { style: styles.dialogTitle }, '导入 Markdown'), h('div', { style: styles.dialogSub }, '导入到「' + String(kbName ?? '') + '」;所有条目先成为草稿,人工审核后才生效。')), h('button', { type: 'button', style: styles.dialogClose, className: 'kh-hover', title: '关闭', onClick: onCancel }, '✕')), h('div', { style: styles.dialogBody, className: 'kh-scroll' }, h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, '选择 .md 文件(可选)'), h('input', { type: 'file', accept: '.md,.markdown,text/markdown', style: { fontSize: '12px' }, onChange: pickFile })), h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, 'Markdown 内容', h('span', { style: styles.labelRequired }, '*')), h('textarea', {
|
|
911
|
+
style: { ...styles.textarea, minHeight: '200px', fontFamily: 'var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace)' },
|
|
915
912
|
placeholder: '粘贴知枢导出的 Markdown(含 <!-- knowhub:doc --> 块),或手写 Markdown;没有块时会作为一整篇知识导入。',
|
|
916
913
|
value: content,
|
|
917
914
|
onChange: (event) => setContent(event.target.value),
|
|
@@ -1182,12 +1179,12 @@ function KbView(props) {
|
|
|
1182
1179
|
}
|
|
1183
1180
|
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)
|
|
1184
1181
|
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 候选。')
|
|
1185
|
-
: tasks.slice(0, 3).map((task) => h('div', { key: task.id, style: { ...styles.card, marginTop: '10px'
|
|
1182
|
+
: tasks.slice(0, 3).map((task) => h('div', { key: task.id, style: { ...styles.card, marginTop: '10px' } }, 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
1183
|
? h('div', { style: { ...styles.hint, marginTop: '4px' } }, '提示:模型调用失败时,点右上角「编辑配置」→「测试模型」可直接确认该模型当前能否调通;若提示输出被截断,换一个非推理模型或把「模型使用策略」调高。')
|
|
1187
1184
|
: 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 }, '还没有索引数据(初始化完成后自动建立)')
|
|
1188
1185
|
: 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 }, '还没有模型调用记录')
|
|
1189
1186
|
: 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)
|
|
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'
|
|
1187
|
+
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' } }, 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
|
|
1191
1188
|
? h('div', { style: { ...styles.row, marginBottom: '8px' } }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: docsLoading, onClick: () => void loadMoreDocs() }, docsLoading ? '加载中…' : '加载更多知识(还剩 ' + (docsTotal - docs.length) + ' 条)'))
|
|
1192
1189
|
: null, h('div', { style: styles.divider }), showDocForm ? h(AddDocForm, { kbId: detail.knowledgeBase.id, onDone: async () => { setShowDocForm(false); await openDetail(detail.knowledgeBase.id); notify('知识草稿已保存'); await load(); } }) : null, h('div', { style: styles.row }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setShowDocForm(!showDocForm) }, showDocForm ? '收起新增表单' : '+ 新增知识'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setDetail(null) }, '返回列表'), h('button', { type: 'button', style: styles.danger, className: 'kh-danger', onClick: () => setPendingDelete({ id: detail.knowledgeBase.id, name: detail.knowledgeBase.name }) }, '删除知识库…')), pendingDelete !== null && pendingDelete.id === detail.knowledgeBase.id ? h('div', { style: { ...styles.card, borderColor: 'color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent)' } }, h('div', { style: styles.cardTitle }, '确认删除「' + pendingDelete.name + '」'), h('div', { style: styles.hint }, '删除配置:只隐藏记录并保留知识文件;删除全部:记录与知识文件一起删除。此操作不可自动撤销(文件删除后不可恢复)。'), h('div', { style: styles.row, marginTop: '10px' }, h('button', { type: 'button', style: styles.danger, className: 'kh-danger', disabled: deleting, onClick: () => void remove(pendingDelete.id, 'config') }, '删除配置(保留文件)'), h('button', { type: 'button', style: styles.danger, className: 'kh-danger', disabled: deleting, onClick: () => void remove(pendingDelete.id, 'all') }, '删除全部数据'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setPendingDelete(null) }, '取消'))) : null);
|
|
1193
1190
|
};
|
|
@@ -1335,22 +1332,22 @@ Object.assign(styles, {
|
|
|
1335
1332
|
diffStat: { display: 'inline-flex', alignItems: 'center', height: '22px', padding: '0 8px', borderRadius: '6px', fontSize: '12px', border: '1px solid var(--dsw-alias-border-l2)', color: 'var(--dsw-alias-label-secondary)' },
|
|
1336
1333
|
diffStatAdd: { color: 'var(--dsw-alias-state-success-primary)', borderColor: 'color-mix(in srgb, var(--dsw-alias-state-success-primary) 45%, transparent)' },
|
|
1337
1334
|
diffStatDel: { color: 'var(--dsw-alias-state-error-primary)', borderColor: 'color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent)' },
|
|
1338
|
-
diffPane: { marginTop: '10px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '10px', background: 'var(--dsw-alias-bg-l1)', overflow: 'auto', maxHeight: '58vh', fontFamily: 'ui-monospace, SFMono-Regular, Menlo,
|
|
1335
|
+
diffPane: { marginTop: '10px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '10px', background: 'var(--dsw-alias-bg-l1, var(--dsw-alias-bg-layer-1))', overflow: 'auto', maxHeight: '58vh', fontFamily: 'var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace)', fontSize: '12px', lineHeight: '20px' },
|
|
1339
1336
|
diffRow: { display: 'flex', width: '100%' },
|
|
1340
1337
|
diffRowAdd: { background: 'color-mix(in srgb, var(--dsw-alias-state-success-primary) 16%, transparent)' },
|
|
1341
1338
|
diffRowDel: { background: 'color-mix(in srgb, var(--dsw-alias-state-error-primary) 16%, transparent)' },
|
|
1342
|
-
diffRowEmpty: { background: 'color-mix(in srgb, var(--dsw-alias-bg-l2) 55%, transparent)' },
|
|
1339
|
+
diffRowEmpty: { background: 'color-mix(in srgb, var(--dsw-alias-bg-l2, var(--dsw-alias-bg-layer-1)) 55%, transparent)' },
|
|
1343
1340
|
diffGutter: { flex: '0 0 44px', textAlign: 'right', padding: '0 6px', color: 'var(--dsw-alias-label-tertiary)', userSelect: 'none', whiteSpace: 'nowrap' },
|
|
1344
1341
|
diffMarkerCell: { flex: '0 0 20px', textAlign: 'center', userSelect: 'none', color: 'var(--dsw-alias-label-secondary)' },
|
|
1345
1342
|
diffText: { flex: '1 1 auto', minWidth: 0, padding: '0 10px 0 2px', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', color: 'var(--dsw-alias-label-primary)' },
|
|
1346
1343
|
diffSplitCol: { flex: '1 1 50%', minWidth: 0 },
|
|
1347
1344
|
diffSplitLeft: { flex: '1 1 50%', minWidth: 0, borderRight: '1px solid var(--dsw-alias-border-l2)' },
|
|
1348
|
-
diffHead: { display: 'flex', position: 'sticky', top: 0, zIndex: 2, background: 'var(--dsw-alias-bg-l2)', borderBottom: '1px solid var(--dsw-alias-border-l2)', color: 'var(--dsw-alias-label-tertiary)', fontSize: '11px', lineHeight: '22px' },
|
|
1345
|
+
diffHead: { display: 'flex', position: 'sticky', top: 0, zIndex: 2, background: 'var(--dsw-alias-bg-l2, var(--dsw-alias-bg-layer-1))', borderBottom: '1px solid var(--dsw-alias-border-l2)', color: 'var(--dsw-alias-label-tertiary)', fontSize: '11px', lineHeight: '22px' },
|
|
1349
1346
|
diffHeadCell: { flex: '1 1 auto', padding: '0 10px 0 2px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
|
1350
1347
|
diffTextNoWrap: { flex: '1 1 auto', padding: '0 10px 0 2px', whiteSpace: 'pre', wordBreak: 'normal', color: 'var(--dsw-alias-label-primary)' },
|
|
1351
|
-
diffWordAdd: { borderRadius: '
|
|
1352
|
-
diffWordDel: { borderRadius: '
|
|
1353
|
-
diffFoldRow: { display: 'flex', alignItems: 'center', gap: '6px', width: '100%', padding: '2px 10px', border: '0', borderTop: '1px dashed var(--dsw-alias-border-l2)', borderBottom: '1px dashed var(--dsw-alias-border-l2)', background: 'color-mix(in srgb, var(--dsw-alias-bg-l2) 55%, transparent)', color: 'var(--dsw-alias-label-tertiary)', fontSize: '11px', lineHeight: '20px', cursor: 'pointer', textAlign: 'left' },
|
|
1348
|
+
diffWordAdd: { borderRadius: '4px', background: 'color-mix(in srgb, var(--dsw-alias-state-success-primary) 45%, transparent)' },
|
|
1349
|
+
diffWordDel: { borderRadius: '4px', background: 'color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent)' },
|
|
1350
|
+
diffFoldRow: { display: 'flex', alignItems: 'center', gap: '6px', width: '100%', padding: '2px 10px', border: '0', borderTop: '1px dashed var(--dsw-alias-border-l2)', borderBottom: '1px dashed var(--dsw-alias-border-l2)', background: 'color-mix(in srgb, var(--dsw-alias-bg-l2, var(--dsw-alias-bg-layer-1)) 55%, transparent)', color: 'var(--dsw-alias-label-tertiary)', fontSize: '11px', lineHeight: '20px', cursor: 'pointer', textAlign: 'left' },
|
|
1354
1351
|
diffChangeBlockActive: { boxShadow: 'inset 2px 0 0 var(--dsw-alias-state-business-primary)' },
|
|
1355
1352
|
});
|
|
1356
1353
|
/** 折叠未变内容时上下各保留的上下文行数。 */
|
|
@@ -2025,7 +2022,7 @@ function ReviewView(props) {
|
|
|
2025
2022
|
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);
|
|
2026
2023
|
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')
|
|
2027
2024
|
applyFilters(); } }), h('input', { style: { ...styles.input, width: '160px' }, value: sourceInput, placeholder: '来源,如 pipeline', onChange: (e) => setSourceInput(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter')
|
|
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.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 () => {
|
|
2025
|
+
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 () => {
|
|
2029
2026
|
setError(null);
|
|
2030
2027
|
try {
|
|
2031
2028
|
const value = await request('/kb/' + encodeURIComponent(docPanel.kbId) + '/docs/' + encodeURIComponent(docPanel.docId) + '/versions/' + item.version);
|
|
@@ -2767,7 +2764,7 @@ function SettingsView() {
|
|
|
2767
2764
|
}
|
|
2768
2765
|
const storage = snapshot.storage ?? {};
|
|
2769
2766
|
const checkbox = (key, label, hintText) => settingsCheckbox(form, patch, key, label, hintText);
|
|
2770
|
-
return h('div', null, error !== null ? ErrorBox({ text: error }) : null, h('div', { style: styles.card }, h('div', { style: styles.cardTitle }, '知识库默认设置'), h('div', { style: styles.field }, h('label', { style: styles.label }, '默认模型使用策略(新建知识库时的默认值)'), h('select', { style: styles.input, value: form.defaultStrategy ?? 'balanced', onChange: (event) => patch({ defaultStrategy: event.target.value }) }, STRATEGIES.map((item) => h('option', { key: item.value, value: item.value }, item.label + '(' + item.desc + ')')))), h('div', { style: styles.row }, h('div', { style: { ...styles.field, flex: '1 1 160px' } }, h('label', { style: styles.label }, '自动提炼最短字符数(80–4000)'), h('input', { type: 'number', style: styles.input, value: form.distillMinChars ?? 200, onChange: (event) => patch({ distillMinChars: event.target.value }) })), h('div', { style: { ...styles.field, flex: '1 1 160px' } }, h('label', { style: styles.label }, '备份保留份数(1–50)'), h('input', { type: 'number', style: styles.input, value: form.backupKeep ?? 10, onChange: (event) => patch({ backupKeep: event.target.value }) }))), checkbox('autoDistill', '开启自动提炼(默认关闭)', '会话 turn 结束时自动提炼长期知识,结果进入待审核队列,不会直接生效。'), checkbox('autoBackup', '恢复/迁移前自动备份(推荐开启)', '执行备份恢复或存储迁移前自动创建一份安全备份。'), h('div', { style: styles.row }, h('div', { style: { ...styles.field, flex: '1 1 160px' } }, h('label', { style: styles.label }, '审核人名称(1–40 字,默认系统用户名)'), h('input', { style: styles.input, value: form.reviewerName ?? '', maxLength: 40, onChange: (event) => patch({ reviewerName: event.target.value }) })), h('div', { style: { ...styles.field, flex: '1 1 160px' } }, h('label', { style: styles.label }, '自动批准最大差异行数(1–200)'), h('input', { type: 'number', style: styles.input, value: form.autoApproveMaxDiffLines ?? 10, onChange: (event) => patch({ autoApproveMaxDiffLines: event.target.value }) }))), checkbox('autoApprove', '开启自动批准(默认关闭,仅低风险 Wiki 更新)', '只对「已批准 Wiki 的更新候选」生效:基线版本未变、差异行数不超过阈值时自动落库,并写入审核日志标记 auto_approve;其他类型仍必须人工审核。'), h('div', { style: styles.field }, h('label', { style: styles.label }, '额外排除目录(每行一个或逗号分隔,仅目录名)'), h('textarea', { style: styles.textarea, value: form.excludeDirsText ?? '', onChange: (event) => patch({ excludeDirsText: event.target.value }), placeholder: '例如:vendor\nthird_party' }), h('div', { style: styles.hint }, '默认已排除 .git / node_modules / dist / build / 缓存 / 密钥文件等;这里用于补充项目自定义目录。')), h('button', { type: 'button', style: busy === 'save' ? { ...styles.primary, ...styles.buttonDisabled } : styles.primary, disabled: busy !== '', onClick: save }, busy === 'save' ? '保存中…' : '保存设置')), h(SettingsInjectionCard, { form, patch }), h('div', { style: styles.card }, h('div', { style: styles.cardTitle }, '存储位置'), h('pre', { style: styles.pre }, '当前数据目录:' + storage.storageRoot + '\n来源:' + (storage.source === 'config' ? '插件配置项' : storage.source === 'pointer' ? '本地位置指针文件' : '默认位置(DSH_HOME)') + (storage.pointerFile ? '\n指针文件:' + storage.pointerFile + (storage.pointerStorageRoot ? '(指向 ' + storage.pointerStorageRoot + ')' : '') : '')), h('div', { style: styles.field }, h('label', { style: styles.label }, '迁移到新目录(绝对路径)'), h('div', { style: styles.row }, h('input', { style: { ...styles.input, flex: '1 1 320px' }, value: target, placeholder: '例如 D:\\KnowHubData', onChange: (event) => setTarget(event.target.value) }), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== '', onClick: () => migrate(true) }, busy === 'dry' ? '预检中…' : '预检'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== '', onClick: () => migrate(false) }, busy === 'migrate' ? '迁移中…' : '执行迁移')), h('div', { style: styles.hint }, '迁移会先自动备份、拷贝数据库与知识文件并校验计数,成功后写入位置指针;DSH 重启后生效,失败不会切换位置。'))), h('div', { style: styles.card }, h('div', { style: styles.cardTitle }, '备份与恢复', stats !== null ? h('span', { style: styles.badge }, '共 ' + stats.count + ' 份 / ' + formatBytes(stats.totalBytes)) : null), h('div', { style: styles.row, marginBottom: '8px' }, h('input', { style: { ...styles.input, flex: '1 1 260px' }, value: backupNote, placeholder: '备份备注(可选)', onChange: (event) => setBackupNote(event.target.value) }), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== '', onClick: createBackup }, busy === 'backup' ? '备份中…' : '立即备份'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: loadBackups }, '刷新列表')), (backups ?? []).length === 0 ? EmptyState({ text: '还没有备份。建议在升级或大改前手动创建一份。' }) : (backups ?? []).map((item) => h('div', { key: item.id, style: { borderTop: '1px solid var(--dsw-alias-border-l1)', padding: '8px 0' } }, h('div', { style: styles.cardTitle }, item.file, item.exists === false ? h('span', { style: styles.badgeAmber }, '文件缺失') : h('span', { style: styles.badgeGreen }, '可恢复')), h('div', { style: styles.meta }, h('span', null, item.kbCount + ' 个库 / ' + item.docCount + ' 条知识'), h('span', null, formatBytes(item.bytes)), h('span', null, formatTime(item.createdAt)), item.note ? h('span', null, item.note) : null), h('div', { style: styles.row, marginTop: '6px' }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== '' || item.exists === false, onClick: () => openPreview(item) }, busy === 'preview:' + item.id ? '读取中…' : '预览内容'), h('button', { type: 'button', style: styles.button, disabled: busy !== '' || item.exists === false, onClick: () => restore(item) }, '从该备份恢复(合并)'))))), h('div', { style: styles.card }, h('div', { style: styles.cardTitle }, '自动提炼记录'), h('div', { style: styles.meta, marginBottom: '6px' }, h('span', null, '自动提炼:' + (form.autoDistill ? '已开启' : '未开启')), h('span', null, '最短字符数:' + form.distillMinChars)), runs.length === 0 ? EmptyState({ text: '还没有提炼记录。开启自动提炼后,会话结束时会自动生成待审核候选。' }) : h('pre', { style: styles.pre }, runs.map((run) => formatTime(run.createdAt) + ' ' + run.status + ' 新建 ' + run.createdCount + ' / 跳过 ' + run.skippedCount + ' ' + (run.modelProvider ?? '') + '/' + (run.modelId ?? '') + (run.note ? ' ' + run.note : '')).join('\n'))), preview !== null ? h(Modal, { onClose: () => setPreview(null), maxWidth: '800px' }, h('div', { style: styles.dialog }, h('div', { style: styles.dialogHead }, h('div', { style: { minWidth: 0 } }, h('div', { style: styles.dialogTitle }, '备份预览:' + preview.file), h('div', { style: styles.dialogSub }, '创建于 ' + formatTime(preview.createdAt) + ' · 格式 ' + preview.format + ' v' + preview.formatVersion + ' · schema v' + preview.schemaVersion + (preview.note ? ' · ' + preview.note : ''))), h('button', { type: 'button', style: styles.dialogClose, className: 'kh-hover', title: '关闭', onClick: () => setPreview(null) }, '✕')), h('div', { style: styles.dialogBody }, h('div', { style: styles.meta, marginBottom: '8px' }, h('span', null, '知识库 ' + preview.kbCount + ' 个'), h('span', null, '知识 ' + preview.docCount + ' 条'), h('span', null, '版本 ' + preview.versionCount + ' 条'), h('span', null, formatBytes(preview.bytes))), (preview.kbs ?? []).length === 0 ? EmptyState({ text: '这个备份里没有知识库数据。' }) : (preview.kbs ?? []).map((kb) => h('div', { key: kb.id, style: { ...styles.card,
|
|
2767
|
+
return h('div', null, error !== null ? ErrorBox({ text: error }) : null, h('div', { style: styles.card }, h('div', { style: styles.cardTitle }, '知识库默认设置'), h('div', { style: styles.field }, h('label', { style: styles.label }, '默认模型使用策略(新建知识库时的默认值)'), h('select', { style: styles.input, value: form.defaultStrategy ?? 'balanced', onChange: (event) => patch({ defaultStrategy: event.target.value }) }, STRATEGIES.map((item) => h('option', { key: item.value, value: item.value }, item.label + '(' + item.desc + ')')))), h('div', { style: styles.row }, h('div', { style: { ...styles.field, flex: '1 1 160px' } }, h('label', { style: styles.label }, '自动提炼最短字符数(80–4000)'), h('input', { type: 'number', style: styles.input, value: form.distillMinChars ?? 200, onChange: (event) => patch({ distillMinChars: event.target.value }) })), h('div', { style: { ...styles.field, flex: '1 1 160px' } }, h('label', { style: styles.label }, '备份保留份数(1–50)'), h('input', { type: 'number', style: styles.input, value: form.backupKeep ?? 10, onChange: (event) => patch({ backupKeep: event.target.value }) }))), checkbox('autoDistill', '开启自动提炼(默认关闭)', '会话 turn 结束时自动提炼长期知识,结果进入待审核队列,不会直接生效。'), checkbox('autoBackup', '恢复/迁移前自动备份(推荐开启)', '执行备份恢复或存储迁移前自动创建一份安全备份。'), h('div', { style: styles.row }, h('div', { style: { ...styles.field, flex: '1 1 160px' } }, h('label', { style: styles.label }, '审核人名称(1–40 字,默认系统用户名)'), h('input', { style: styles.input, value: form.reviewerName ?? '', maxLength: 40, onChange: (event) => patch({ reviewerName: event.target.value }) })), h('div', { style: { ...styles.field, flex: '1 1 160px' } }, h('label', { style: styles.label }, '自动批准最大差异行数(1–200)'), h('input', { type: 'number', style: styles.input, value: form.autoApproveMaxDiffLines ?? 10, onChange: (event) => patch({ autoApproveMaxDiffLines: event.target.value }) }))), checkbox('autoApprove', '开启自动批准(默认关闭,仅低风险 Wiki 更新)', '只对「已批准 Wiki 的更新候选」生效:基线版本未变、差异行数不超过阈值时自动落库,并写入审核日志标记 auto_approve;其他类型仍必须人工审核。'), h('div', { style: styles.field }, h('label', { style: styles.label }, '额外排除目录(每行一个或逗号分隔,仅目录名)'), h('textarea', { style: styles.textarea, value: form.excludeDirsText ?? '', onChange: (event) => patch({ excludeDirsText: event.target.value }), placeholder: '例如:vendor\nthird_party' }), h('div', { style: styles.hint }, '默认已排除 .git / node_modules / dist / build / 缓存 / 密钥文件等;这里用于补充项目自定义目录。')), h('button', { type: 'button', style: busy === 'save' ? { ...styles.primary, ...styles.buttonDisabled } : styles.primary, disabled: busy !== '', onClick: save }, busy === 'save' ? '保存中…' : '保存设置')), h(SettingsInjectionCard, { form, patch }), h('div', { style: styles.card }, h('div', { style: styles.cardTitle }, '存储位置'), h('pre', { style: styles.pre }, '当前数据目录:' + storage.storageRoot + '\n来源:' + (storage.source === 'config' ? '插件配置项' : storage.source === 'pointer' ? '本地位置指针文件' : '默认位置(DSH_HOME)') + (storage.pointerFile ? '\n指针文件:' + storage.pointerFile + (storage.pointerStorageRoot ? '(指向 ' + storage.pointerStorageRoot + ')' : '') : '')), h('div', { style: styles.field }, h('label', { style: styles.label }, '迁移到新目录(绝对路径)'), h('div', { style: styles.row }, h('input', { style: { ...styles.input, flex: '1 1 320px' }, value: target, placeholder: '例如 D:\\KnowHubData', onChange: (event) => setTarget(event.target.value) }), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== '', onClick: () => migrate(true) }, busy === 'dry' ? '预检中…' : '预检'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== '', onClick: () => migrate(false) }, busy === 'migrate' ? '迁移中…' : '执行迁移')), h('div', { style: styles.hint }, '迁移会先自动备份、拷贝数据库与知识文件并校验计数,成功后写入位置指针;DSH 重启后生效,失败不会切换位置。'))), h('div', { style: styles.card }, h('div', { style: styles.cardTitle }, '备份与恢复', stats !== null ? h('span', { style: styles.badge }, '共 ' + stats.count + ' 份 / ' + formatBytes(stats.totalBytes)) : null), h('div', { style: styles.row, marginBottom: '8px' }, h('input', { style: { ...styles.input, flex: '1 1 260px' }, value: backupNote, placeholder: '备份备注(可选)', onChange: (event) => setBackupNote(event.target.value) }), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== '', onClick: createBackup }, busy === 'backup' ? '备份中…' : '立即备份'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: loadBackups }, '刷新列表')), (backups ?? []).length === 0 ? EmptyState({ text: '还没有备份。建议在升级或大改前手动创建一份。' }) : (backups ?? []).map((item) => h('div', { key: item.id, style: { borderTop: '1px solid var(--dsw-alias-border-l1)', padding: '8px 0' } }, h('div', { style: styles.cardTitle }, item.file, item.exists === false ? h('span', { style: styles.badgeAmber }, '文件缺失') : h('span', { style: styles.badgeGreen }, '可恢复')), h('div', { style: styles.meta }, h('span', null, item.kbCount + ' 个库 / ' + item.docCount + ' 条知识'), h('span', null, formatBytes(item.bytes)), h('span', null, formatTime(item.createdAt)), item.note ? h('span', null, item.note) : null), h('div', { style: styles.row, marginTop: '6px' }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== '' || item.exists === false, onClick: () => openPreview(item) }, busy === 'preview:' + item.id ? '读取中…' : '预览内容'), h('button', { type: 'button', style: styles.button, disabled: busy !== '' || item.exists === false, onClick: () => restore(item) }, '从该备份恢复(合并)'))))), h('div', { style: styles.card }, h('div', { style: styles.cardTitle }, '自动提炼记录'), h('div', { style: styles.meta, marginBottom: '6px' }, h('span', null, '自动提炼:' + (form.autoDistill ? '已开启' : '未开启')), h('span', null, '最短字符数:' + form.distillMinChars)), runs.length === 0 ? EmptyState({ text: '还没有提炼记录。开启自动提炼后,会话结束时会自动生成待审核候选。' }) : h('pre', { style: styles.pre }, runs.map((run) => formatTime(run.createdAt) + ' ' + run.status + ' 新建 ' + run.createdCount + ' / 跳过 ' + run.skippedCount + ' ' + (run.modelProvider ?? '') + '/' + (run.modelId ?? '') + (run.note ? ' ' + run.note : '')).join('\n'))), preview !== null ? h(Modal, { onClose: () => setPreview(null), maxWidth: '800px' }, h('div', { style: styles.dialog }, h('div', { style: styles.dialogHead }, h('div', { style: { minWidth: 0 } }, h('div', { style: styles.dialogTitle }, '备份预览:' + preview.file), h('div', { style: styles.dialogSub }, '创建于 ' + formatTime(preview.createdAt) + ' · 格式 ' + preview.format + ' v' + preview.formatVersion + ' · schema v' + preview.schemaVersion + (preview.note ? ' · ' + preview.note : ''))), h('button', { type: 'button', style: styles.dialogClose, className: 'kh-hover', title: '关闭', onClick: () => setPreview(null) }, '✕')), h('div', { style: styles.dialogBody, className: 'kh-scroll' }, h('div', { style: styles.meta, marginBottom: '8px' }, h('span', null, '知识库 ' + preview.kbCount + ' 个'), h('span', null, '知识 ' + preview.docCount + ' 条'), h('span', null, '版本 ' + preview.versionCount + ' 条'), h('span', null, formatBytes(preview.bytes))), (preview.kbs ?? []).length === 0 ? EmptyState({ text: '这个备份里没有知识库数据。' }) : (preview.kbs ?? []).map((kb) => h('div', { key: kb.id, style: { ...styles.card, marginBottom: '8px' } }, h('div', { style: styles.cardTitle }, kb.name, h(Badge, { text: kb.scope === 'user' ? '用户级' : '工作区级' }), kb.exists ? h(Badge, { text: '当前已存在', style: styles.badgeGreen }) : h(Badge, { text: '当前不存在', style: styles.badgeAmber })), h('div', { style: styles.meta }, h('span', null, '知识 ' + kb.documentCount + ' 条'), h('span', null, '版本 ' + kb.versionCount + ' 条'), h('span', null, '当前库同名知识 ' + kb.overlapCount + ' 条'), (kb.statusBreakdown ?? []).map((item) => h('span', { key: 's-' + item.status }, (DOC_STATUS_LABEL[item.status] ?? item.status) + ' ' + item.count)), (kb.kindBreakdown ?? []).map((item) => h('span', { key: 'k-' + item.kind }, (KIND_LABEL[item.kind] ?? item.kind) + ' ' + item.count))), (kb.sampleTitles ?? []).length > 0 ? h('div', { style: styles.hint }, '包含:' + (kb.sampleTitles ?? []).join('、')) : null, (kb.overlapTitles ?? []).length > 0 ? h('div', { style: styles.hint }, '恢复时只生成待审核候选(不覆盖现有正文):' + (kb.overlapTitles ?? []).slice(0, 10).join('、')) : null)), h('div', { style: styles.hint }, preview.note2 ?? '')), h('div', { style: styles.dialogFoot }, h('div', { style: styles.hint }, '预览只读,不会写入任何数据。'), h('div', { style: { flex: 1 } }), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setPreview(null) }, '关闭'), preview.kbs && preview.kbs.length > 0 ? h('button', {
|
|
2771
2768
|
type: 'button', style: styles.button, className: 'kh-hover',
|
|
2772
2769
|
onClick: () => { const item = (backups ?? []).find((entry) => entry.id === preview.backupId); setPreview(null); if (item)
|
|
2773
2770
|
restore(item); },
|
|
@@ -2800,6 +2797,8 @@ function Page() {
|
|
|
2800
2797
|
const [booting, setBooting] = R.useState(false);
|
|
2801
2798
|
const [reloadKey, setReloadKey] = R.useState(0);
|
|
2802
2799
|
const [reviewCount, setReviewCount] = R.useState(0);
|
|
2800
|
+
const [versionInfo, setVersionInfo] = R.useState(null);
|
|
2801
|
+
const [dismissedVersionPair, setDismissedVersionPair] = R.useState('');
|
|
2803
2802
|
R.useEffect(() => {
|
|
2804
2803
|
let alive = true;
|
|
2805
2804
|
setBooting(true);
|
|
@@ -2836,6 +2835,15 @@ function Page() {
|
|
|
2836
2835
|
})();
|
|
2837
2836
|
return () => { alive = false; };
|
|
2838
2837
|
}, [reloadKey]);
|
|
2838
|
+
// C-3:启动时轻量查一次版本对比;失败静默(无横幅、无报错),不打断主启动流程。
|
|
2839
|
+
R.useEffect(() => {
|
|
2840
|
+
let alive = true;
|
|
2841
|
+
request('/version').then((value) => { if (alive)
|
|
2842
|
+
setVersionInfo(value); }).catch(() => null);
|
|
2843
|
+
return () => { alive = false; };
|
|
2844
|
+
}, [reloadKey]);
|
|
2845
|
+
const versionPair = versionInfo && versionInfo.mismatch === true ? String(versionInfo.running) + '->' + String(versionInfo.installed) : '';
|
|
2846
|
+
const showVersionBanner = versionPair !== '' && dismissedVersionPair !== versionPair;
|
|
2839
2847
|
const notify = (text) => { pushToast(text, 'success'); };
|
|
2840
2848
|
const refreshReviewCount = async () => {
|
|
2841
2849
|
try {
|
|
@@ -2862,7 +2870,9 @@ function Page() {
|
|
|
2862
2870
|
key: item.key, type: 'button',
|
|
2863
2871
|
style: view === '' && tab === item.key ? styles.headerTabActive : styles.headerTab,
|
|
2864
2872
|
onClick: () => { setTab(item.key); setView(''); },
|
|
2865
|
-
}, item.label))), h('div', { style: styles.headerActions }, booting ? h('span', { style: styles.status }, '读取环境中…') : null, h('button', { type: 'button', style: styles.linkButton, className: 'kh-hover', onClick: () => goto('review') }, '待审核', reviewCount > 0 ? h('span', { style: styles.countBadge }, String(reviewCount)) : null), h('button', { type: 'button', style: styles.linkButton, className: 'kh-hover', onClick: () => goto('search') }, '检索'), h('button', { type: 'button', style: styles.linkButton, className: 'kh-hover', onClick: () => goto('settings') }, '设置'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => { setReloadKey(reloadKey + 1); void refreshReviewCount(); } }, '刷新'))),
|
|
2873
|
+
}, item.label))), h('div', { style: styles.headerActions }, booting ? h('span', { style: styles.status }, '读取环境中…') : null, h('button', { type: 'button', style: styles.linkButton, className: 'kh-hover', onClick: () => goto('review') }, '待审核', reviewCount > 0 ? h('span', { style: styles.countBadge }, String(reviewCount)) : null), h('button', { type: 'button', style: styles.linkButton, className: 'kh-hover', onClick: () => goto('search') }, '检索'), h('button', { type: 'button', style: styles.linkButton, className: 'kh-hover', onClick: () => goto('settings') }, '设置'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => { setReloadKey(reloadKey + 1); void refreshReviewCount(); } }, '刷新'))),
|
|
2874
|
+
// 横幅槽:常驻容器(无横幅时零高度),保证后面内容区与反馈层的位置恒定。
|
|
2875
|
+
h('div', null, bootError !== null ? h('div', { style: { ...styles.error, margin: '10px 24px 0' }, role: 'alert' }, bootError) : null, showVersionBanner ? h('div', { style: { ...styles.warn, margin: '10px 24px 0', display: 'flex', alignItems: 'flex-start', gap: '12px' }, role: 'alert' }, h('span', { style: { flex: 1 } }, '知枢版本不一致:已安装 ' + versionInfo.installed + ',但当前运行的仍是 ' + versionInfo.running + '。升级只替换了磁盘文件,完全退出并重新打开 DSH 后新版本才生效。'), h('button', { type: 'button', style: styles.linkButton, className: 'kh-hover', onClick: () => setDismissedVersionPair(versionPair) }, '关闭')) : null), view !== '' ? renderOverlay() : renderTab(), h(ToastHost, null), h(ConfirmHost, null));
|
|
2866
2876
|
}
|
|
2867
2877
|
/** sidebar.panellist 行图标:自绘 SVG(不依赖 @deepseek-ai/dsh-client-ui-primitives)。 */
|
|
2868
2878
|
function KnowHubIcon(props) {
|
package/lib/host/diagnostics.js
CHANGED
|
@@ -1,21 +1,73 @@
|
|
|
1
1
|
/** 组装 M0 诊断数据:运行时/工作区/模型/SQLite/存储/工具,全部来自真实调用。 */
|
|
2
|
-
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { promises as fs, readFileSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { dshHome, resolveStorageRoot } from './paths.js';
|
|
5
6
|
import { errorMessage } from './http.js';
|
|
6
7
|
import { listWorkspaces, resolveWorkspace } from './workspace.js';
|
|
7
8
|
import { readModelCatalog } from './model.js';
|
|
8
9
|
import { probeSqlite, storageProbe } from './storage.js';
|
|
10
|
+
const PACKAGE_URL = new URL('../../package.json', import.meta.url);
|
|
11
|
+
const FALLBACK_PACKAGE = { name: 'dsh-knowhub', version: '0.0.0' };
|
|
12
|
+
function parsePackage(text) {
|
|
13
|
+
const parsed = JSON.parse(text);
|
|
14
|
+
return { name: String(parsed.name ?? 'dsh-knowhub'), version: String(parsed.version ?? '0.0.0') };
|
|
15
|
+
}
|
|
9
16
|
export async function readOwnPackage() {
|
|
10
17
|
try {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return {
|
|
18
|
+
return parsePackage(await fs.readFile(PACKAGE_URL, 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return { ...FALLBACK_PACKAGE };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** 同步读磁盘 package.json;失败返回 null,把「读不到」与真实版本号区分开。 */
|
|
25
|
+
export function readOwnPackageSync() {
|
|
26
|
+
try {
|
|
27
|
+
return parsePackage(readFileSync(fileURLToPath(PACKAGE_URL), 'utf8'));
|
|
15
28
|
}
|
|
16
29
|
catch {
|
|
17
|
-
return
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** 异步读磁盘 package.json;失败返回 null(对比时记为未知,不产生假阳性)。 */
|
|
34
|
+
export async function readInstalledPackage() {
|
|
35
|
+
try {
|
|
36
|
+
return parsePackage(await fs.readFile(PACKAGE_URL, 'utf8'));
|
|
18
37
|
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// ---- 运行版本(进程启动时定格)与安装版本(磁盘现读)的对比(C-3) ----
|
|
43
|
+
let runningVersion = null;
|
|
44
|
+
/**
|
|
45
|
+
* 插件 apply() 启动时调用一次:把当时磁盘上的版本定格为本进程的「运行版本」。
|
|
46
|
+
* 升级只替换磁盘文件、宿主不重启时,运行版本仍是旧版——正是要提示的情形。
|
|
47
|
+
* 重复调用不覆盖(进程内版本终身不变);读取失败时保持 null。
|
|
48
|
+
*/
|
|
49
|
+
export function captureRunningVersion() {
|
|
50
|
+
if (runningVersion === null) {
|
|
51
|
+
runningVersion = readOwnPackageSync()?.version ?? null;
|
|
52
|
+
}
|
|
53
|
+
return runningVersion;
|
|
54
|
+
}
|
|
55
|
+
export function getRunningVersion() {
|
|
56
|
+
return runningVersion;
|
|
57
|
+
}
|
|
58
|
+
/** 仅供测试:直接指定运行版本(传 null 表示未捕获/读取失败)。 */
|
|
59
|
+
export function setRunningVersionForTest(version) {
|
|
60
|
+
runningVersion = version;
|
|
61
|
+
}
|
|
62
|
+
/** 纯对比:只看字符串相等与否(磁盘回滚同样算不一致);任一侧未知(null)一律不报不一致,宁可不提示也不误报。 */
|
|
63
|
+
export function compareVersions(running, installed) {
|
|
64
|
+
return { running, installed, mismatch: running !== null && installed !== null && running !== installed };
|
|
65
|
+
}
|
|
66
|
+
/** 组装一次版本对比:运行版本用启动时定格值,安装版本现读磁盘。 */
|
|
67
|
+
export async function buildVersionCheck() {
|
|
68
|
+
const pkg = await readInstalledPackage();
|
|
69
|
+
const check = compareVersions(getRunningVersion(), pkg?.version ?? null);
|
|
70
|
+
return { name: pkg?.name ?? FALLBACK_PACKAGE.name, ...check };
|
|
19
71
|
}
|
|
20
72
|
function dshVersion() {
|
|
21
73
|
try {
|
|
@@ -56,9 +108,16 @@ function toolsInfo(ctx) {
|
|
|
56
108
|
export async function buildDiagnose(ctx, config, input = {}) {
|
|
57
109
|
const section = typeof input.section === 'string' && input.section !== '' ? input.section : 'all';
|
|
58
110
|
const want = (name) => section === 'all' || section === name;
|
|
59
|
-
const
|
|
111
|
+
const versionInfo = await buildVersionCheck();
|
|
60
112
|
const result = {
|
|
61
|
-
plugin: {
|
|
113
|
+
plugin: {
|
|
114
|
+
name: versionInfo.name,
|
|
115
|
+
// version 语义为「运行版本」;未捕获到时回退到安装版本,再不行回退 0.0.0(与旧行为一致)。
|
|
116
|
+
version: versionInfo.running ?? versionInfo.installed ?? FALLBACK_PACKAGE.version,
|
|
117
|
+
installedVersion: versionInfo.installed,
|
|
118
|
+
versionMismatch: versionInfo.mismatch,
|
|
119
|
+
route: '/knowhub/api/*', section,
|
|
120
|
+
},
|
|
62
121
|
};
|
|
63
122
|
if (want('runtime'))
|
|
64
123
|
result.runtime = runtimeInfo();
|
package/lib/host/index.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* ctx.inject([name], cb) 的形式——服务就绪时回调,缺失时插件照常加载。
|
|
12
12
|
*/
|
|
13
13
|
import { createRouter } from './route.js';
|
|
14
|
+
import { captureRunningVersion } from './diagnostics.js';
|
|
14
15
|
import { registerKnowledgeTools } from './tool.js';
|
|
15
16
|
import { registerContextInjection } from './inject.js';
|
|
16
17
|
import { registerAutoDistill } from './distill.js';
|
|
@@ -19,7 +20,9 @@ export const name = 'dsh-knowhub/host';
|
|
|
19
20
|
export const inject = [];
|
|
20
21
|
export function apply(ctx, config = {}) {
|
|
21
22
|
const cfg = config ?? {};
|
|
22
|
-
|
|
23
|
+
// C-3:进程刚加载完本版代码,此刻把磁盘版本定格为「运行版本」,供 /version 与诊断对比。
|
|
24
|
+
const runningVersion = captureRunningVersion();
|
|
25
|
+
const router = createRouter(ctx, cfg, { runningVersion });
|
|
23
26
|
// HTTP 路由:webServer 就绪时才注册;没有该服务(桌面版)时静默跳过,工具能力不受影响。
|
|
24
27
|
ctx.inject(['webServer'], (webCtx) => {
|
|
25
28
|
webCtx.effect(() => webCtx.webServer.register({
|
package/lib/host/review/route.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* M3 审核 HTTP 接口(前缀 /knowhub/api)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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('
|
|
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 =
|
|
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
|
|
115
|
-
throw new ReviewError('
|
|
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
|
|
131
|
-
throw new ReviewError('
|
|
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
|
|
161
|
-
throw new ReviewError('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
226
|
-
throw new ReviewError('
|
|
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
|
|
257
|
-
throw new ReviewError('
|
|
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
|
|
285
|
-
throw new ReviewError('
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/lib/host/route.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** /knowhub HTTP 路由:前缀注册,原生 req/res,统一 {ok,value} / {ok,error} 信封。 */
|
|
2
2
|
import { errorMessage, readBody, writeError, writeOk } from './http.js';
|
|
3
|
-
import { buildDiagnose } from './diagnostics.js';
|
|
3
|
+
import { buildDiagnose, buildVersionCheck, compareVersions, readInstalledPackage } from './diagnostics.js';
|
|
4
4
|
import { listWorkspaces, resolveWorkspace } from './workspace.js';
|
|
5
5
|
import { probeModel, readModelCatalog } from './model.js';
|
|
6
6
|
import { scanProject } from './scan.js';
|
|
@@ -29,7 +29,14 @@ async function probeGit(path) {
|
|
|
29
29
|
: '宿主进程无法执行 git 或目录不是 Git 仓库,增量更新将使用哈希模式',
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
-
export function createRouter(ctx, config) {
|
|
32
|
+
export function createRouter(ctx, config, options = {}) {
|
|
33
|
+
/** 本路由的版本对比:显式注入了运行版本时用注入值,否则走全局组装(含定格值与包名)。 */
|
|
34
|
+
const versionCheck = async () => {
|
|
35
|
+
if (options.runningVersion === undefined)
|
|
36
|
+
return buildVersionCheck();
|
|
37
|
+
const pkg = await readInstalledPackage();
|
|
38
|
+
return compareVersions(options.runningVersion, pkg?.version ?? null);
|
|
39
|
+
};
|
|
33
40
|
return {
|
|
34
41
|
async handle(req, res) {
|
|
35
42
|
const url = new URL(String(req.url ?? '/'), 'http://knowhub.internal');
|
|
@@ -40,6 +47,7 @@ export function createRouter(ctx, config) {
|
|
|
40
47
|
if (method === 'GET' && (path === '/ping' || path === '/')) {
|
|
41
48
|
writeOk(res, { name: 'dsh-knowhub', time: new Date().toISOString(), endpoints: [
|
|
42
49
|
'GET /knowhub/api/ping',
|
|
50
|
+
'GET /knowhub/api/version',
|
|
43
51
|
'GET /knowhub/api/diagnose?section=all|runtime|storage|workspaces|models|sqlite|tools',
|
|
44
52
|
'GET /knowhub/api/workspaces?path=',
|
|
45
53
|
'GET /knowhub/api/models',
|
|
@@ -105,6 +113,10 @@ export function createRouter(ctx, config) {
|
|
|
105
113
|
writeOk(res, probe);
|
|
106
114
|
return;
|
|
107
115
|
}
|
|
116
|
+
if (method === 'GET' && path === '/version') {
|
|
117
|
+
writeOk(res, await versionCheck());
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
108
120
|
if (method === 'GET' && path === '/diagnose') {
|
|
109
121
|
const value = await buildDiagnose(ctx, config, {
|
|
110
122
|
section: url.searchParams.get('section'),
|