dsh-knowhub 0.8.5 → 0.8.7
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 +23 -4
- package/lib/client/index.js +59 -24
- package/lib/host/pipeline/incremental.js +47 -24
- package/lib/host/pipeline/initialize.js +46 -35
- package/lib/host/pipeline/model-output.js +64 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
- 用户级知识库(跨工作区共享)与工作区级知识库(仅当前项目可见),检索、注入、审核都按作用域隔离。
|
|
14
14
|
- 每个知识库可绑定不同的模型与生成策略(economy / balanced / quality),初始化、提炼、增量都使用库绑定的模型。
|
|
15
|
-
-
|
|
15
|
+
- 创建后可修改名称、模型与策略,不影响已有知识与版本历史;编辑配置里可先「测试模型」确认该模型当前可调通。
|
|
16
|
+
- 模型输出被截断(推理模型把 token 预算消耗在思考过程)时,任务会自动加倍预算重试一次;仍失败会明确提示换用非推理模型或调高「模型使用策略」。
|
|
16
17
|
|
|
17
18
|
**Repo Wiki 与增量更新**
|
|
18
19
|
|
|
@@ -80,7 +81,7 @@ dsh --profile knowhub --port 29961 --no-open
|
|
|
80
81
|
|
|
81
82
|
```powershell
|
|
82
83
|
# tarball
|
|
83
|
-
& dsh plugin --profile knowhub add .\dsh-knowhub-0.8.
|
|
84
|
+
& dsh plugin --profile knowhub add .\dsh-knowhub-0.8.7.tgz
|
|
84
85
|
|
|
85
86
|
# 开发期:软链到源码目录,构建后重启即生效
|
|
86
87
|
cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\knowhub\node_modules\dsh-knowhub" "C:\path\to\KnowHub"
|
|
@@ -116,7 +117,7 @@ cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\knowhub\node_modules\dsh-knowhu
|
|
|
116
117
|
| Repo Wiki | 按知识库分组的 Wiki 文档树 + 阅读器:正文、来源清单、版本历史、编辑、标记过时、父级 / 子级知识(最多 5 层)、是否有帮助反馈、导出 Markdown;状态过滤与真实分页 |
|
|
117
118
|
| 知识卡片 | 决策 / 约定 / 问题 / 待办四类知识,类型与状态过滤,列表 + 阅读器 |
|
|
118
119
|
| 记忆 | 记忆 / 偏好两类知识,类型与状态过滤,列表 + 阅读器 |
|
|
119
|
-
| 知识库 |
|
|
120
|
+
| 知识库 | 知识库列表(固定列出全部:用户级 + 各工作区,不做工作区筛选)+ 详情:新建与编辑配置(含「测试模型」)、初始化与增量任务(进度 / 日志 / 取消 / 重试)、项目索引、模型调用记录、知识列表分页、导出全部、导入 Markdown、删除 |
|
|
120
121
|
| 待审核 | 候选 / 冲突 / 草稿 / 过时四类队列,真实计数、筛选、排序、批量审批;点「查看详情与差异」进入文档对比页;版本历史与审核日志 |
|
|
121
122
|
| 检索 | 关键词 + 标签 + 知识库 / 类型 / 状态 / 排序;显示来源、作用域、审核状态与检索模式;结果分页加载 |
|
|
122
123
|
| 设置 | 默认策略、提炼开关与阈值、审核人与自动批准、知识注入开关与预算、备份保留、排除目录、存储位置迁移、备份预览与恢复、诊断信息 |
|
|
@@ -128,6 +129,24 @@ cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\knowhub\node_modules\dsh-knowhu
|
|
|
128
129
|
- 弹窗统一使用 DSH 视觉规范:半透明遮罩 + 背景模糊、层级阴影、进出场动画;打开时锁定页面滚动,Esc 或点击遮罩关闭。
|
|
129
130
|
- 表单内的字段校验仍在输入框旁就地提示;接口错误既进入浮层提示,也在受影响的区域给出定位。
|
|
130
131
|
|
|
132
|
+
## 常见问题
|
|
133
|
+
|
|
134
|
+
**初始化或增量任务失败,提示模型调用失败怎么办?**
|
|
135
|
+
|
|
136
|
+
在知识库详情点右上「编辑配置」→「测试模型」,先用一句 ping 确认该 provider / model 当前能否调通;不通说明是模型配置问题(凭据、baseURL、模型名),与知识库无关。若提示「模型输出被截断」,说明该模型把输出预算消耗在思考过程上:换一个非推理模型,或把「模型使用策略」调高(economy 1600 → balanced 2200 → quality 3200 tokens,截断时还会自动加倍重试一次)。
|
|
137
|
+
|
|
138
|
+
**为什么生成的知识都是「待审核」?**
|
|
139
|
+
|
|
140
|
+
自动生成、增量更新、Agent 提议、自动提炼、Markdown 导入的结果一律先落为草稿或候选,必须人工批准后才成为正式知识。这是为了避免模型臆断污染长期记忆。
|
|
141
|
+
|
|
142
|
+
**已经批准的知识会被增量更新悄悄改掉吗?**
|
|
143
|
+
|
|
144
|
+
不会。已批准知识只会生成「更新候选」进入待审核队列,批准后才以新版本追加,旧版本仍可查看与恢复。
|
|
145
|
+
|
|
146
|
+
**恢复备份会覆盖当前知识吗?**
|
|
147
|
+
|
|
148
|
+
不会。恢复是合并语义:备份里有、当前库里没有的知识会按历史版本重建;当前库里已有的知识不会被删除或覆盖。恢复前还会自动创建一个恢复点,并先显示预览(知识库数、知识数、同名重叠)。
|
|
149
|
+
|
|
131
150
|
## 配置项
|
|
132
151
|
|
|
133
152
|
设置保存在知识库存储的配置库中,面板「设置」页可修改。
|
|
@@ -151,7 +170,7 @@ cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\knowhub\node_modules\dsh-knowhu
|
|
|
151
170
|
|
|
152
171
|
## HTTP 接口
|
|
153
172
|
|
|
154
|
-
共
|
|
173
|
+
共 57 条,均挂在 `/knowhub/api` 下,返回 `{ok:true,value}` 或 `{ok:false,error:{code,message}}`。
|
|
155
174
|
|
|
156
175
|
| 分组 | 端点 |
|
|
157
176
|
|---|---|
|
package/lib/client/index.js
CHANGED
|
@@ -731,7 +731,36 @@ function EditKbForm(props) {
|
|
|
731
731
|
const [strategy, setStrategy] = R.useState(kb.modelStrategy ?? 'balanced');
|
|
732
732
|
const [busy, setBusy] = R.useState(false);
|
|
733
733
|
const [error, setError] = R.useState(null);
|
|
734
|
+
const [probing, setProbing] = R.useState(false);
|
|
735
|
+
const [probeResult, setProbeResult] = R.useState(null);
|
|
734
736
|
const nameLength = [...name].length;
|
|
737
|
+
// 直接向宿主发一次最小模型调用,确认这个模型当前真的能调通(不写任何知识)。
|
|
738
|
+
const testModel = async () => {
|
|
739
|
+
if (modelKey === '') {
|
|
740
|
+
setError('请先选择知识处理模型');
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const parts = modelKey.split('|');
|
|
744
|
+
setProbing(true);
|
|
745
|
+
setProbeResult(null);
|
|
746
|
+
setError(null);
|
|
747
|
+
try {
|
|
748
|
+
const probe = await request('/probe', { method: 'POST', body: { provider: parts[0], model: parts[1], prompt: 'ping' } });
|
|
749
|
+
if (probe && probe.ok === false) {
|
|
750
|
+
const reason = probe.failure ? String(probe.failure.code ?? '') + ':' + String(probe.failure.message ?? '') : '模型不可用';
|
|
751
|
+
setProbeResult({ ok: false, text: reason });
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
setProbeResult({ ok: true, text: '模型可用(' + Number(probe?.elapsedMs ?? 0) + ' ms,返回 ' + JSON.stringify(String(probe?.text ?? '')).slice(1, 41) + ')' });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
catch (failure) {
|
|
758
|
+
setProbeResult({ ok: false, text: message(failure) });
|
|
759
|
+
}
|
|
760
|
+
finally {
|
|
761
|
+
setProbing(false);
|
|
762
|
+
}
|
|
763
|
+
};
|
|
735
764
|
const submit = async () => {
|
|
736
765
|
const trimmed = name.trim();
|
|
737
766
|
if (trimmed === '') {
|
|
@@ -780,7 +809,23 @@ function EditKbForm(props) {
|
|
|
780
809
|
onChange: (event) => setName(event.target.value),
|
|
781
810
|
}), h('div', { style: styles.dialogCounter }, nameLength + ' / 50')), h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, '生效范围(创建后不可修改)'), h('div', { style: styles.hint }, (kb.scope === 'user' ? '用户级:跨工作区共享' : '工作区级:仅当前项目可见') + (kb.workspacePath ? ';绑定工作区:' + kb.workspacePath : ''))), h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, '知识处理模型', h('span', { style: styles.labelRequired }, '*')), (models ?? []).length === 0
|
|
782
811
|
? h('div', { style: styles.hint }, '当前 DSH 没有已配置模型。请先在 DSH 设置中配置模型,知枢不会要求重新填写 API Key。')
|
|
783
|
-
: h('select', {
|
|
812
|
+
: h('select', {
|
|
813
|
+
style: styles.dialogInput, value: modelKey,
|
|
814
|
+
onChange: (event) => { setModelKey(event.target.value); setProbeResult(null); },
|
|
815
|
+
}, h('option', { value: '' }, '请选择模型…'), (models ?? []).map((model) => h('option', { key: model.provider + '|' + model.id, value: model.provider + '|' + model.id }, model.name + '(' + model.id + ')· ' + model.provider))), h('div', { style: styles.hint }, '模型列表来自 DSH 当前真实配置(ctx.llm.listProviders/listModels);只保存 provider 与 model 标识,不存密钥。'), h('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', marginTop: '8px', flexWrap: 'wrap' } }, h('button', {
|
|
816
|
+
type: 'button',
|
|
817
|
+
style: probing || modelKey === '' ? { ...styles.button, ...styles.buttonDisabled } : styles.button,
|
|
818
|
+
className: 'kh-hover',
|
|
819
|
+
disabled: probing || modelKey === '',
|
|
820
|
+
onClick: () => void testModel(),
|
|
821
|
+
}, probing ? '测试中…' : '测试模型'), probeResult === null
|
|
822
|
+
? h('span', { style: styles.hint }, '建议先点一次,确认该模型当前可调通(只发一句 ping,不写入任何知识)。')
|
|
823
|
+
: h('span', {
|
|
824
|
+
style: {
|
|
825
|
+
fontSize: '12px', lineHeight: '18px', wordBreak: 'break-all',
|
|
826
|
+
color: probeResult.ok ? 'var(--dsw-alias-state-success-label)' : 'var(--dsw-alias-state-error-label)',
|
|
827
|
+
},
|
|
828
|
+
}, (probeResult.ok ? '✓ ' : '✗ ') + String(probeResult.text)))), h('div', { style: styles.field }, h('label', { style: styles.dialogLabel }, '模型使用策略', h('span', { style: styles.labelRequired }, '*')), h('div', { style: styles.choiceColumn }, strategyChoices)), error !== null ? h('div', { style: { ...styles.error, margin: '4px 0 12px' } }, error) : null), h('div', { style: styles.dialogFoot }, h('div', { style: styles.dialogFootHint }, '下一次初始化或增量更新会使用新的模型与策略;已经生成的 Wiki 不受影响。'), h('div', { style: { display: 'flex', gap: '8px', flexShrink: 0 } }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: onCancel }, '取消'), h('button', { type: 'button', style: busy ? { ...styles.primary, ...styles.buttonDisabled } : styles.primary, className: 'kh-primary', disabled: busy, onClick: () => void submit() }, busy ? '保存中…' : '保存配置'))));
|
|
784
829
|
}
|
|
785
830
|
function AddDocForm(props) {
|
|
786
831
|
const { kbId, onDone } = props;
|
|
@@ -879,7 +924,6 @@ function KbView(props) {
|
|
|
879
924
|
const [error, setError] = R.useState(null);
|
|
880
925
|
const [showForm, setShowForm] = R.useState(false);
|
|
881
926
|
const [kbQuery, setKbQuery] = R.useState('');
|
|
882
|
-
const [listWorkspace, setListWorkspace] = R.useState('all');
|
|
883
927
|
const [detail, setDetail] = R.useState(null);
|
|
884
928
|
const [detailLoading, setDetailLoading] = R.useState(false);
|
|
885
929
|
const [pendingDelete, setPendingDelete] = R.useState(null);
|
|
@@ -925,16 +969,14 @@ function KbView(props) {
|
|
|
925
969
|
// 增量状态读取失败不影响主流程
|
|
926
970
|
}
|
|
927
971
|
}, []);
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
const load = R.useCallback(async (
|
|
972
|
+
// 列表范围:始终展示全部知识库(用户级 + 所有工作区),不按工作区筛选。
|
|
973
|
+
// 知识库允许建在任意工作区;若按 cwd 反查的「当前工作区」过滤,会出现建完看不见、
|
|
974
|
+
// 同名却报已存在的错觉,所以这里固定请求 all=1。
|
|
975
|
+
const load = R.useCallback(async () => {
|
|
932
976
|
setLoading(true);
|
|
933
977
|
setError(null);
|
|
934
|
-
const target = typeof workspaceOverride === 'string' ? workspaceOverride : listWorkspace;
|
|
935
978
|
try {
|
|
936
|
-
const
|
|
937
|
-
const value = await request('/kb' + query);
|
|
979
|
+
const value = await request('/kb?all=1');
|
|
938
980
|
setList(value.knowledgeBases ?? []);
|
|
939
981
|
}
|
|
940
982
|
catch (failure) {
|
|
@@ -943,7 +985,7 @@ function KbView(props) {
|
|
|
943
985
|
finally {
|
|
944
986
|
setLoading(false);
|
|
945
987
|
}
|
|
946
|
-
}, [
|
|
988
|
+
}, []);
|
|
947
989
|
R.useEffect(() => { void load(); }, [load]);
|
|
948
990
|
R.useEffect(() => { void load(); }, [props.refreshKey]);
|
|
949
991
|
// 打开详情后每 1.5s 轮询任务;succeeded 时刷新文档与索引摘要(真实数据,无本地伪造)。
|
|
@@ -1140,7 +1182,9 @@ function KbView(props) {
|
|
|
1140
1182
|
}
|
|
1141
1183
|
return h('div', null, h('div', { style: styles.divider }), h('div', { style: styles.sectionTitle }, '项目初始化与 Repo Wiki'), h('div', { style: styles.hint }, '初始化会用该知识库绑定的模型(' + detail.knowledgeBase.modelProvider + ' / ' + detail.knowledgeBase.modelId + ',策略 ' + detail.knowledgeBase.modelStrategy + ')分析仓库,生成 Wiki 候选草稿;扫描不执行仓库内脚本,自动排除 node_modules、.git、构建产物与密钥文件。'), detail.knowledgeBase.scope === 'user' ? h('div', { style: styles.field, marginTop: '10px' }, h('label', { style: styles.label }, '选择要分析的工作区', h('span', { style: styles.labelRequired }, '*')), h('select', { style: styles.input, value: initWorkspaceId, onChange: (event) => setInitWorkspaceId(event.target.value) }, h('option', { value: '' }, '请选择工作区'), workspaces.map((workspace) => h('option', { key: workspace.id, value: workspace.id }, (workspace.title ?? workspace.path) + '(' + workspace.id + ')')))) : null, h('div', { style: { ...styles.row, marginTop: '10px' } }, h('button', { type: 'button', style: { ...styles.primary, ...(initBusy || activeTask !== null ? styles.buttonDisabled : {}) }, disabled: initBusy || activeTask !== null, onClick: () => void startInitialize() }, activeTask !== null ? '初始化进行中…' : initBusy ? '提交中…' : '开始初始化'), activeTask !== null ? h('button', { type: 'button', style: styles.danger, className: 'kh-danger', onClick: () => void cancelTask(activeTask.id) }, '取消任务') : null, latestTask !== null && latestTask.status === 'failed' ? h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: initBusy, onClick: () => void retryTask(latestTask.id) }, '重试失败任务') : null, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => { if (detail)
|
|
1142
1184
|
void loadTaskData(detail.knowledgeBase.id); } }, '刷新任务状态')), taskError !== null ? h('div', { style: { ...styles.error, margin: '10px 0 0' } }, message(taskError)) : null, tasks.length === 0 ? h('div', { style: styles.hint }, '尚未运行初始化。点击「开始初始化」后,会先建立项目索引,再按选择的模型生成 Wiki 候选。')
|
|
1143
|
-
: tasks.slice(0, 3).map((task) => h('div', { key: task.id, style: { ...styles.card, marginTop: '10px', background: 'var(--dsw-alias-bg-layer-1)' } }, h('div', { style: styles.cardTitle }, (TASK_KIND_LABEL[task.kind] ?? task.kind) + '任务 #' + task.id.slice(-8), h(Badge, { text: TASK_STATUS_LABEL[task.status] ?? task.status, style: task.status === 'failed' ? styles.badgeAmber : styles.badgeGreen }), h('span', { style: styles.hint }, '尝试 ' + (task.attempt ?? 1) + ' 次 · 创建于 ' + new Date(task.createdAt).toLocaleString('zh-CN'))), h('div', { style: styles.progressTrack }, h('div', { style: { ...styles.progressBar, width: String(Math.max(0, Math.min(100, Number(task.progress ?? 0)))) + '%', background: task.status === 'failed' ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-business-primary)' } })), h('div', { style: { ...styles.meta } }, h('span', null, '进度:' + Number(task.progress ?? 0) + '%'), h('span', null, '阶段:' + (task.stage ?? '—')), task.errorCode ? h('span', null, '错误码:' + task.errorCode) : null, task.errorDetail ? h('span', null, '错误:' + task.errorDetail) : null), task.
|
|
1185
|
+
: tasks.slice(0, 3).map((task) => h('div', { key: task.id, style: { ...styles.card, marginTop: '10px', background: 'var(--dsw-alias-bg-layer-1)' } }, h('div', { style: styles.cardTitle }, (TASK_KIND_LABEL[task.kind] ?? task.kind) + '任务 #' + task.id.slice(-8), h(Badge, { text: TASK_STATUS_LABEL[task.status] ?? task.status, style: task.status === 'failed' ? styles.badgeAmber : styles.badgeGreen }), h('span', { style: styles.hint }, '尝试 ' + (task.attempt ?? 1) + ' 次 · 创建于 ' + new Date(task.createdAt).toLocaleString('zh-CN'))), h('div', { style: styles.progressTrack }, h('div', { style: { ...styles.progressBar, width: String(Math.max(0, Math.min(100, Number(task.progress ?? 0)))) + '%', background: task.status === 'failed' ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-business-primary)' } })), h('div', { style: { ...styles.meta } }, h('span', null, '进度:' + Number(task.progress ?? 0) + '%'), h('span', null, '阶段:' + (task.stage ?? '—')), task.errorCode ? h('span', null, '错误码:' + task.errorCode) : null, task.errorDetail ? h('span', null, '错误:' + task.errorDetail) : null), task.status === 'failed'
|
|
1186
|
+
? h('div', { style: { ...styles.hint, marginTop: '4px' } }, '提示:模型调用失败时,点右上角「编辑配置」→「测试模型」可直接确认该模型当前能否调通;若提示输出被截断,换一个非推理模型或把「模型使用策略」调高。')
|
|
1187
|
+
: null, task.logs && task.logs.length > 0 ? h('pre', { style: styles.logs }, task.logs.slice(-12).map((item) => '[' + new Date(item.at).toLocaleTimeString('zh-CN') + '] ' + item.level + ' ' + item.message).join('\n')) : null)), h('div', { style: styles.sectionTitle }, '项目索引状态'), indexInfo === null ? h('div', { style: styles.hint }, '还没有索引数据(初始化完成后自动建立)')
|
|
1144
1188
|
: h('div', { style: styles.meta }, h('span', null, '文件数:' + indexInfo.fileCount), h('span', null, '已哈希:' + (indexInfo.hashedCount ?? 0)), h('span', null, '最近索引:' + (indexInfo.lastIndexedAt ? new Date(indexInfo.lastIndexedAt).toLocaleString('zh-CN') : '—')), indexInfo.byLanguage && indexInfo.byLanguage.length > 0 ? h('span', null, '语言:' + indexInfo.byLanguage.slice(0, 6).map((item) => item.language + ' ' + item.count).join('、')) : null), h('div', { style: styles.sectionTitle }, '模型调用记录(证明实际使用的模型)'), calls.length === 0 ? h('div', { style: styles.hint }, '还没有模型调用记录')
|
|
1145
1189
|
: h('div', null, calls.slice(0, 10).map((call) => h('div', { key: call.id, style: { ...styles.meta, borderTop: '1px dashed var(--dsw-alias-border-l1)', paddingTop: '6px', marginTop: '0' } }, h('span', null, (call.provider ?? '') + ' / ' + (call.model ?? '')), h('span', null, '用途:' + (call.purpose ?? '—')), h('span', null, 'tokens:' + (call.promptTokens ?? 0) + ' → ' + (call.completionTokens ?? 0)), h('span', null, '耗时:' + (call.latencyMs ?? 0) + 'ms'), call.failureCode ? h('span', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, '失败码:' + call.failureCode) : null))), h('div', { style: styles.divider }), h('div', { style: styles.sectionTitle }, 'Git 变更与增量更新(M4)'), h('div', { style: styles.hint }, '变更检测优先使用只读 Git 命令,不是 Git 仓库或无法执行 git 时自动降级为文件内容哈希比对;只有受影响文档会重新分析,已批准的知识不会被自动覆盖,而是生成「修改候选」进入待审核。'), h('div', { style: styles.meta }, gitState && gitState.checkpoint ? h('span', null, '检查点模式:' + (gitState.checkpoint.mode === 'git' ? 'Git' : '哈希')) : h('span', null, '检查点:尚未运行增量更新'), gitState && gitState.checkpoint && gitState.checkpoint.head ? h('span', null, 'HEAD:' + String(gitState.checkpoint.head).slice(0, 10)) : null, gitState && gitState.git ? h('span', null, 'Git 状态:' + (gitState.git.available ? (gitState.git.isRepo ? '可用(分支 ' + (gitState.git.branch ?? '—') + ',未提交变更 ' + (gitState.git.dirtyCount ?? 0) + ' 项)' : '目录不是 Git 仓库') : '宿主无法执行 git')) : null, gitState && gitState.checkpoint && gitState.checkpoint.lastScanAt ? h('span', null, '最近检测:' + new Date(gitState.checkpoint.lastScanAt).toLocaleString('zh-CN')) : null, changes.length > 0 ? h('span', null, '最近变更:' + changes.length + ' 条') : null), h('div', { style: { ...styles.row, marginTop: '10px' } }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: incBusy || activeTask !== null, onClick: () => void incrementalRequest(true) }, incBusy ? '检测中…' : '检测变更(仅预览)'), h('button', { type: 'button', style: { ...styles.primary, ...(incBusy || activeTask !== null ? styles.buttonDisabled : {}) }, disabled: incBusy || activeTask !== null, onClick: () => void incrementalRequest(false) }, activeTask !== null ? '任务进行中…' : incBusy ? '提交中…' : '开始增量更新'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => { if (detail)
|
|
1146
1190
|
void loadTaskData(detail.knowledgeBase.id); } }, '刷新增量状态')), incError !== null ? h('div', { style: { ...styles.error, margin: '10px 0 0' } }, message(incError)) : null, incPlan !== null ? h('div', { style: { ...styles.card, marginTop: '10px', background: 'var(--dsw-alias-bg-layer-1)' } }, h('div', { style: styles.cardTitle }, '变更预览(未写入任何数据)'), h('div', { style: styles.meta }, h('span', null, '模式:' + (incPlan.mode === 'git' ? 'Git' : '哈希')), h('span', null, '基线:' + (incPlan.baseline ? '首次/重置(只建立检查点,不调用模型)' : '已有检查点')), h('span', null, '变更文件:' + incPlan.changeCount), h('span', null, '受影响文档:' + incPlan.affectedCount), h('span', null, '预计模型调用:' + incPlan.plannedModelCalls), incPlan.plan && incPlan.plan.length > 0 ? h('span', null, '计划:' + incPlan.plan.map((item) => item.title + '(' + (item.action === 'stale-proposal' ? '标记过时候选' : item.action === 'update-proposal' ? '修改候选' : '草稿直更') + ')').join(';')) : null), incPlan.structural && incPlan.structural.length > 0 ? h('div', { style: { ...styles.hint, color: 'var(--dsw-alias-state-warn-label)' } }, '结构性变更(需要人工重新检查):' + incPlan.structural.join('、')) : null, incPlan.unmatchedPaths && incPlan.unmatchedPaths.length > 0 ? h('div', { style: styles.hint }, '未关联任何文档的变更:' + incPlan.unmatchedPaths.slice(0, 8).join('、')) : null, incPlan.changes && incPlan.changes.length > 0 ? h('pre', { style: styles.logs }, incPlan.changes.slice(0, 12).map((item) => (CHANGE_LABEL[item.change] ?? item.change) + ' ' + item.path).join('\n')) : null, incPlan.note ? h('div', { style: styles.hint }, incPlan.note) : null) : null, changes.length > 0 ? h('div', { style: { marginTop: '10px' } }, h('div', { style: styles.hint }, '已记录的变更(来自 file_changes 表)'), h('pre', { style: styles.logs }, changes.slice(0, 10).map((item) => (CHANGE_LABEL[item.change] ?? item.change) + ' ' + item.path + ' [' + item.mode + ']').join('\n'))) : null, recheck.length > 0 ? h('div', { style: { marginTop: '10px' } }, h('div', { style: { ...styles.hint, color: 'var(--dsw-alias-state-warn-label)' } }, '需要重新检查的文档(' + recheck.length + ')'), h('pre', { style: styles.logs }, recheck.slice(0, 10).map((item) => (item.title ?? item.docTitle ?? item.docId) + ' ← ' + item.reason).join('\n'))) : null, h('div', { style: styles.divider }), h('div', { style: styles.sectionTitle }, '知识条目(Markdown + SQLite 索引;草稿需审核后生效)'), h('div', { style: styles.hint }, '共 ' + docsTotal + ' 条,已显示 ' + docs.length + ' 条(每页 50 条)'), docs.length === 0 ? h('div', { style: styles.empty }, '该知识库还没有知识条目') : docs.map((doc) => h('div', { key: doc.id, style: { ...styles.card, marginBottom: '8px' } }, h('div', { style: styles.cardTitle }, doc.title, h(Badge, { text: KIND_LABEL[doc.kind] ?? doc.kind }), h(Badge, { text: DOC_STATUS_LABEL[doc.status] ?? doc.status, style: styles.badgeAmber }), h(Badge, { text: VERIFY_LABEL[doc.verifyState] ?? doc.verifyState })), h('div', { style: styles.snippet }, String(doc.body ?? '').slice(0, 180)), h('div', { style: styles.path }, doc.path))), docsTotal > docs.length
|
|
@@ -1155,14 +1199,8 @@ function KbView(props) {
|
|
|
1155
1199
|
}), h('button', {
|
|
1156
1200
|
type: 'button', style: styles.primary, className: 'kh-primary',
|
|
1157
1201
|
onClick: () => { setShowForm(!showForm); setDetail(null); },
|
|
1158
|
-
}, showForm ? '收起' : '+ 新建')), h('div', { style: { padding: '0 12px 8px',
|
|
1159
|
-
|
|
1160
|
-
value: listWorkspace,
|
|
1161
|
-
onChange: (event) => { setListWorkspace(event.target.value); setDetail(null); },
|
|
1162
|
-
}, h('option', { value: 'all' }, '全部工作区(用户级 + 各工作区)'), (workspaces ?? []).map((workspace) => h('option', { key: workspace.id, value: workspace.id }, (workspace.title ?? workspace.path ?? workspace.id) + (workspace.pathState === 'missing' ? '(目录不可用)' : '')))), h('div', { style: styles.hint }, listWorkspace === 'all'
|
|
1163
|
-
? '显示用户级知识库与所有工作区的知识库。'
|
|
1164
|
-
: '只显示该工作区的知识库,以及用户级知识库。')), error !== null ? h('div', { style: { ...styles.error, margin: '0 10px 10px' } }, error) : null, h('div', { style: styles.listScroll, className: 'kh-scroll' }, list === null ? (loading ? h('div', { style: styles.listEmpty }, '正在加载知识库列表…') : null)
|
|
1165
|
-
: filteredList.length === 0 ? h('div', { style: styles.listEmpty }, list.length === 0 ? (listWorkspace === 'all' ? '还没有任何知识库。点击「+ 新建」创建。' : '该范围还没有知识库。') : '没有匹配的知识库名称。')
|
|
1202
|
+
}, showForm ? '收起' : '+ 新建')), h('div', { style: { padding: '0 12px 8px' } }, h('div', { style: styles.hint }, '已列出全部知识库(用户级 + 各工作区)' + (list !== null ? ',共 ' + list.length + ' 个' : '') + '。')), error !== null ? h('div', { style: { ...styles.error, margin: '0 10px 10px' } }, error) : null, h('div', { style: styles.listScroll, className: 'kh-scroll' }, list === null ? (loading ? h('div', { style: styles.listEmpty }, '正在加载知识库列表…') : null)
|
|
1203
|
+
: filteredList.length === 0 ? h('div', { style: styles.listEmpty }, list.length === 0 ? '还没有任何知识库。点击「+ 新建」创建。' : '没有匹配的知识库名称。')
|
|
1166
1204
|
: filteredList.map((kb) => h('button', {
|
|
1167
1205
|
key: kb.id, type: 'button',
|
|
1168
1206
|
style: detail !== null && detail.knowledgeBase.id === kb.id ? styles.treeRowActive : styles.treeRow,
|
|
@@ -1175,11 +1213,8 @@ function KbView(props) {
|
|
|
1175
1213
|
onCreated: async (kb) => {
|
|
1176
1214
|
setShowForm(false);
|
|
1177
1215
|
notify('知识库「' + kb.name + '」已创建。下一步:在详情里点「开始初始化」。');
|
|
1178
|
-
//
|
|
1179
|
-
|
|
1180
|
-
if (target !== undefined)
|
|
1181
|
-
setListWorkspace(target);
|
|
1182
|
-
await load(target);
|
|
1216
|
+
// 列表固定展示全部知识库,新建后直接刷新并打开详情即可。
|
|
1217
|
+
await load();
|
|
1183
1218
|
await openDetail(kb.id);
|
|
1184
1219
|
},
|
|
1185
1220
|
})) : null, importOpen && detail !== null ? h(Modal, { onClose: () => setImportOpen(false), maxWidth: '760px' }, h(ImportMarkdownForm, {
|
|
@@ -16,6 +16,7 @@ import { getDocument, listDocuments } from '../search.js';
|
|
|
16
16
|
import { appendVersion, diffLines } from '../docvers.js';
|
|
17
17
|
import { autoApproveIfEligible, createProposal } from '../review.js';
|
|
18
18
|
import { runModelCall } from '../model.js';
|
|
19
|
+
import { MAX_OUTPUT_ATTEMPTS, retryTokenBudget } from './model-output.js';
|
|
19
20
|
import { readSettings } from '../settings.js';
|
|
20
21
|
import { listWorkspaces } from '../workspace.js';
|
|
21
22
|
import { indexProject, saveProjectIndex } from './index.js';
|
|
@@ -413,33 +414,55 @@ export async function runIncremental(input) {
|
|
|
413
414
|
}
|
|
414
415
|
const snippets = await collectSnippets(analysis.root, deltaChanges.filter((change) => change.change !== 'deleted').map((change) => change.path), analysis.limits);
|
|
415
416
|
const prompt = buildUpdatePrompt({ doc, changes: deltaChanges, snippets, scan: analysis.scan, mode: analysis.mode });
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
417
|
+
// 推理模型可能把 token 预算耗在思考上导致输出被截断:识别出来并加倍预算重试一次。
|
|
418
|
+
let budget = analysis.limits.maxTokens;
|
|
419
|
+
let parsed = null;
|
|
420
|
+
let failureText = null;
|
|
421
|
+
for (let attempt = 1;; attempt += 1) {
|
|
422
|
+
if (signal.aborted)
|
|
423
|
+
throw new IncrementalError('TASK_CANCELLED', '任务已取消');
|
|
424
|
+
let result;
|
|
425
|
+
try {
|
|
426
|
+
result = await runModelCall(input.ctx, {
|
|
427
|
+
provider: kb.modelProvider ?? '',
|
|
428
|
+
model: kb.modelId ?? '',
|
|
429
|
+
system: UPDATE_SYSTEM,
|
|
430
|
+
prompt,
|
|
431
|
+
maxTokens: budget,
|
|
432
|
+
temperature: 0.2,
|
|
433
|
+
timeoutMs: 120000,
|
|
434
|
+
signal,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
failureText = errorMessage(error);
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
recordLlmCall(db, kb, taskId, 'wiki-update:' + doc.slug, result);
|
|
442
|
+
summary.modelCalls += 1;
|
|
443
|
+
if (!result.ok) {
|
|
444
|
+
failureText = result.failure ? String(result.failure.code) + ':' + String(result.failure.message) : '模型未返回内容';
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
parsed = parseUpdatePayload(result.text, secrets);
|
|
448
|
+
if (parsed !== null)
|
|
449
|
+
break;
|
|
450
|
+
if (result.finishKind === 'max-tokens' && attempt < MAX_OUTPUT_ATTEMPTS) {
|
|
451
|
+
const nextBudget = retryTokenBudget(budget);
|
|
452
|
+
log('warn', '文档「' + doc.title + '」的模型输出被截断(' + budget + ' tokens 上限),改用 ' + nextBudget + ' tokens 重试一次');
|
|
453
|
+
budget = nextBudget;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
failureText = result.finishKind === 'max-tokens'
|
|
457
|
+
? '模型输出被截断(' + budget + ' tokens 上限,finish=max-tokens,思考过程 ' + String(result.reasoningChars ?? 0) + ' 字符),未产出完整 JSON;建议换用非推理模型或把策略调高'
|
|
458
|
+
: '模型输出结构不合法';
|
|
459
|
+
break;
|
|
433
460
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const failure = result.failure ? String(result.failure.code) + ':' + String(result.failure.message) : '模型未返回内容';
|
|
438
|
-
summary.failures.push(doc.title + ' → ' + failure);
|
|
439
|
-
log('error', '文档「' + doc.title + '」增量分析失败(' + failure + '),已保留原文档');
|
|
461
|
+
if (failureText !== null) {
|
|
462
|
+
summary.failures.push(doc.title + ' → ' + failureText);
|
|
463
|
+
log('error', '文档「' + doc.title + '」增量分析失败(' + failureText + '),已保留原文档');
|
|
440
464
|
continue;
|
|
441
465
|
}
|
|
442
|
-
const parsed = parseUpdatePayload(result.text, secrets);
|
|
443
466
|
if (parsed === null) {
|
|
444
467
|
summary.failures.push(doc.title + ' → 模型输出结构不合法');
|
|
445
468
|
log('warn', '文档「' + doc.title + '」的模型输出未通过结构校验,已保留原文档');
|
|
@@ -17,6 +17,7 @@ import { contentHash } from '../kb/store.js';
|
|
|
17
17
|
import { createDocument, getDocument, slugify } from '../search.js';
|
|
18
18
|
import { recordConflict } from '../review.js';
|
|
19
19
|
import { readModelCatalog, runModelCall } from '../model.js';
|
|
20
|
+
import { MAX_OUTPUT_ATTEMPTS, parseModelJson, retryTokenBudget } from './model-output.js';
|
|
20
21
|
import { listWorkspaces } from '../workspace.js';
|
|
21
22
|
import { indexProject, saveProjectIndex } from './index.js';
|
|
22
23
|
import { createTask, runTask } from '../tasks.js';
|
|
@@ -122,22 +123,6 @@ function buildPrompt(topics, context) {
|
|
|
122
123
|
context,
|
|
123
124
|
].join('\n');
|
|
124
125
|
}
|
|
125
|
-
function extractJson(text) {
|
|
126
|
-
let cleaned = text.trim();
|
|
127
|
-
const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
128
|
-
if (fence && fence[1])
|
|
129
|
-
cleaned = fence[1].trim();
|
|
130
|
-
const first = cleaned.indexOf('{');
|
|
131
|
-
const last = cleaned.lastIndexOf('}');
|
|
132
|
-
if (first < 0 || last <= first)
|
|
133
|
-
throw new InitError('MODEL_OUTPUT_INVALID', '模型输出中没有 JSON 对象');
|
|
134
|
-
try {
|
|
135
|
-
return JSON.parse(cleaned.slice(first, last + 1));
|
|
136
|
-
}
|
|
137
|
-
catch (error) {
|
|
138
|
-
throw new InitError('MODEL_OUTPUT_INVALID', '模型输出的 JSON 解析失败:' + errorMessage(error));
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
126
|
function validateDocuments(raw, knownPaths, secrets) {
|
|
142
127
|
const documents = [];
|
|
143
128
|
const rejected = [];
|
|
@@ -241,6 +226,7 @@ export async function runInitialize(deps) {
|
|
|
241
226
|
let documentsSeen = 0;
|
|
242
227
|
const callSummaries = [];
|
|
243
228
|
const secrets = { root, storageRoot };
|
|
229
|
+
currentStage = '生成 Wiki 候选';
|
|
244
230
|
for (let position = 0; position < calls.length; position += 1) {
|
|
245
231
|
const planned = calls[position];
|
|
246
232
|
if (!planned)
|
|
@@ -250,26 +236,51 @@ export async function runInitialize(deps) {
|
|
|
250
236
|
progress(percent, '调用模型生成:' + planned.topics.join('、'));
|
|
251
237
|
const prompt = buildPrompt(planned.topics, context);
|
|
252
238
|
log('info', '调用模型(' + planned.purpose + ',主题:' + planned.topics.join('、') + ',提示长度 ' + prompt.length + ' 字符)');
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
239
|
+
// 推理模型可能把 token 预算耗在思考上导致输出被截断:识别出来并加倍预算重试一次。
|
|
240
|
+
let budget = limits.maxTokens;
|
|
241
|
+
let parsed = null;
|
|
242
|
+
for (let attempt = 1;; attempt += 1) {
|
|
243
|
+
ensureNotAborted(signal);
|
|
244
|
+
const result = await runModelCall(ctx, {
|
|
245
|
+
provider: kb.modelProvider,
|
|
246
|
+
model: kb.modelId,
|
|
247
|
+
system: '你是严谨的代码仓库文档分析器,只依据给定事实输出严格 JSON。',
|
|
248
|
+
prompt,
|
|
249
|
+
maxTokens: budget,
|
|
250
|
+
temperature: 0.2,
|
|
251
|
+
timeoutMs: 150000,
|
|
252
|
+
signal,
|
|
253
|
+
});
|
|
254
|
+
recordLlmCall(db, kb, taskId, planned.purpose, result);
|
|
255
|
+
callSummaries.push({ purpose: planned.purpose, provider: result.provider, model: result.model, ok: result.ok, finishKind: result.finishKind, failure: result.failure, elapsedMs: result.elapsedMs, usage: result.usage, attempt, maxTokens: budget });
|
|
256
|
+
if (result.aborted || signal.aborted)
|
|
257
|
+
throw new InitError('TASK_CANCELLED', '任务已取消(模型调用被中断)');
|
|
258
|
+
if (!result.ok) {
|
|
259
|
+
const failureCode = result.failure?.code ?? 'MODEL_CALL_FAILED';
|
|
260
|
+
throw new InitError('MODEL_CALL_FAILED', '模型调用失败(' + kb.modelProvider + ' / ' + kb.modelId + ',' + failureCode + '):' + (result.failure?.message ?? '未知错误'));
|
|
261
|
+
}
|
|
262
|
+
log('info', '模型返回 ' + result.text.length + ' 字符(finish=' + String(result.finishKind) + ',预算 ' + budget + ' tokens,耗时 ' + result.elapsedMs + 'ms)');
|
|
263
|
+
const output = parseModelJson(result.text, { finishKind: result.finishKind, maxTokens: budget, attempt, reasoningChars: result.reasoningChars });
|
|
264
|
+
const truncated = result.finishKind === 'max-tokens';
|
|
265
|
+
if (output.ok) {
|
|
266
|
+
const docCount = Array.isArray(output.value?.documents) ? output.value.documents.length : 0;
|
|
267
|
+
if (docCount === 0 && truncated && attempt < MAX_OUTPUT_ATTEMPTS) {
|
|
268
|
+
const nextBudget = retryTokenBudget(budget);
|
|
269
|
+
log('warn', '模型在 ' + budget + ' tokens 预算内没有写出任何文档(finish=max-tokens,思考 ' + String(result.reasoningChars ?? 0) + ' 字符),改用 ' + nextBudget + ' tokens 重试一次');
|
|
270
|
+
budget = nextBudget;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
parsed = output.value;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
if (output.truncated && attempt < MAX_OUTPUT_ATTEMPTS) {
|
|
277
|
+
const nextBudget = retryTokenBudget(budget);
|
|
278
|
+
log('warn', '模型输出被截断(' + budget + ' tokens 上限),改用 ' + nextBudget + ' tokens 重试一次:' + planned.purpose);
|
|
279
|
+
budget = nextBudget;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
throw new InitError(output.code, output.message);
|
|
270
283
|
}
|
|
271
|
-
log('info', '模型返回 ' + result.text.length + ' 字符(finish=' + String(result.finishKind) + ',耗时 ' + result.elapsedMs + 'ms)');
|
|
272
|
-
const parsed = extractJson(result.text);
|
|
273
284
|
const validated = validateDocuments(parsed, knownPaths, secrets);
|
|
274
285
|
rejected.push(...validated.rejected);
|
|
275
286
|
documentsSeen += validated.documents.length;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型输出解析与「输出被截断」诊断(0.8.7)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:推理模型会把 token 预算耗在思考过程上,finish_kind = max-tokens 时正文还没开始写,
|
|
5
|
+
* 之前统一报「模型输出中没有 JSON 对象」,既看不出是预算不足,也没有自动补救的机会。
|
|
6
|
+
* 这里把「截断」识别出来,让流水线可以加倍预算重试一次,并把可操作的建议写进错误信息。
|
|
7
|
+
*/
|
|
8
|
+
/** 每个生成步骤最多尝试次数(首次 + 截断后加倍预算重试一次)。 */
|
|
9
|
+
export const MAX_OUTPUT_ATTEMPTS = 2;
|
|
10
|
+
export function isTruncatedModelOutput(meta) {
|
|
11
|
+
return meta.finishKind === 'max-tokens';
|
|
12
|
+
}
|
|
13
|
+
/** 截断重试时的预算:翻倍,但不超过 runModelCall 的上限 8192。 */
|
|
14
|
+
export function retryTokenBudget(maxTokens) {
|
|
15
|
+
const base = Number.isFinite(maxTokens) ? Math.max(1, Math.trunc(maxTokens)) : 1;
|
|
16
|
+
return Math.min(8192, base * 2);
|
|
17
|
+
}
|
|
18
|
+
function compactHead(text) {
|
|
19
|
+
const compact = text.replace(/\s+/g, ' ').trim();
|
|
20
|
+
if (compact === '')
|
|
21
|
+
return '(空输出)';
|
|
22
|
+
return compact.slice(0, 120);
|
|
23
|
+
}
|
|
24
|
+
function truncationMessage(meta, textLength, detail) {
|
|
25
|
+
const thinking = typeof meta.reasoningChars === 'number' && meta.reasoningChars > 0 ? ',其中思考过程 ' + meta.reasoningChars + ' 字符' : '';
|
|
26
|
+
const suffix = detail === null ? '' : '(' + detail + ')';
|
|
27
|
+
return '模型输出被截断:达到 ' + meta.maxTokens + ' tokens 上限(finish=max-tokens,输出 ' + textLength + ' 字符' + thinking + suffix + '),没有产出完整 JSON。'
|
|
28
|
+
+ '请在「编辑配置」里换用非推理模型(可先点「测试模型」确认可用),或把「模型使用策略」调高后重试。';
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 解析模型输出里的第一个 JSON 对象。
|
|
32
|
+
* - 允许 Markdown 代码围栏与前后解释文字;
|
|
33
|
+
* - 输出被截断(finish=max-tokens)时返回 MODEL_OUTPUT_TRUNCATED,调用方可以加倍预算重试;
|
|
34
|
+
* - 其它情况返回 MODEL_OUTPUT_INVALID,并带上输出开头方便排查。
|
|
35
|
+
*/
|
|
36
|
+
export function parseModelJson(text, meta) {
|
|
37
|
+
const raw = typeof text === 'string' ? text : '';
|
|
38
|
+
let cleaned = raw.trim();
|
|
39
|
+
const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
40
|
+
if (fence && fence[1])
|
|
41
|
+
cleaned = fence[1].trim();
|
|
42
|
+
const truncated = isTruncatedModelOutput(meta);
|
|
43
|
+
const first = cleaned.indexOf('{');
|
|
44
|
+
const last = cleaned.lastIndexOf('}');
|
|
45
|
+
if (first < 0 || last <= first) {
|
|
46
|
+
if (truncated)
|
|
47
|
+
return { ok: false, code: 'MODEL_OUTPUT_TRUNCATED', truncated: true, message: truncationMessage(meta, raw.length, null) };
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
code: 'MODEL_OUTPUT_INVALID',
|
|
51
|
+
truncated: false,
|
|
52
|
+
message: '模型输出中没有 JSON 对象(finish=' + String(meta.finishKind ?? '未知') + ',输出 ' + raw.length + ' 字符):' + compactHead(raw),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return { ok: true, value: JSON.parse(cleaned.slice(first, last + 1)) };
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
const detail = 'JSON 解析失败:' + String(error instanceof Error ? error.message : error);
|
|
60
|
+
if (truncated)
|
|
61
|
+
return { ok: false, code: 'MODEL_OUTPUT_TRUNCATED', truncated: true, message: truncationMessage(meta, raw.length, detail) };
|
|
62
|
+
return { ok: false, code: 'MODEL_OUTPUT_INVALID', truncated: false, message: '模型输出的 ' + detail };
|
|
63
|
+
}
|
|
64
|
+
}
|