dsh-knowhub 0.8.3 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -116,11 +116,18 @@ cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\knowhub\node_modules\dsh-knowhu
116
116
  | Repo Wiki | 按知识库分组的 Wiki 文档树 + 阅读器:正文、来源清单、版本历史、编辑、标记过时、父级 / 子级知识(最多 5 层)、是否有帮助反馈、导出 Markdown;状态过滤与真实分页 |
117
117
  | 知识卡片 | 决策 / 约定 / 问题 / 待办四类知识,类型与状态过滤,列表 + 阅读器 |
118
118
  | 记忆 | 记忆 / 偏好两类知识,类型与状态过滤,列表 + 阅读器 |
119
- | 知识库 | 知识库列表 + 详情:新建与编辑配置、初始化与增量任务(进度 / 日志 / 取消 / 重试)、项目索引、模型调用记录、知识列表分页、导出全部、导入 Markdown、删除 |
119
+ | 知识库 | 知识库列表(固定列出全部:用户级 + 各工作区,不做工作区筛选)+ 详情:新建与编辑配置、初始化与增量任务(进度 / 日志 / 取消 / 重试)、项目索引、模型调用记录、知识列表分页、导出全部、导入 Markdown、删除 |
120
120
  | 待审核 | 候选 / 冲突 / 草稿 / 过时四类队列,真实计数、筛选、排序、批量审批;点「查看详情与差异」进入文档对比页;版本历史与审核日志 |
121
121
  | 检索 | 关键词 + 标签 + 知识库 / 类型 / 状态 / 排序;显示来源、作用域、审核状态与检索模式;结果分页加载 |
122
122
  | 设置 | 默认策略、提炼开关与阈值、审核人与自动批准、知识注入开关与预算、备份保留、排除目录、存储位置迁移、备份预览与恢复、诊断信息 |
123
123
 
124
+ **交互反馈**
125
+
126
+ - 操作结果以右下角浮层提示呈现:成功、失败、提示分色显示,失败提示停留更久,均可手动关闭,不占用页面布局。
127
+ - 标记过时、恢复历史版本、迁移存储、从备份恢复等破坏性操作先弹确认对话框,展示操作说明与影响范围;Esc 取消、Enter 确认。
128
+ - 弹窗统一使用 DSH 视觉规范:半透明遮罩 + 背景模糊、层级阴影、进出场动画;打开时锁定页面滚动,Esc 或点击遮罩关闭。
129
+ - 表单内的字段校验仍在输入框旁就地提示;接口错误既进入浮层提示,也在受影响的区域给出定位。
130
+
124
131
  ## 配置项
125
132
 
126
133
  设置保存在知识库存储的配置库中,面板「设置」页可修改。
@@ -364,6 +364,44 @@ Object.assign(styles, {
364
364
  seg: { height: '24px', padding: '0 10px', borderRadius: '6px', border: '0', background: 'transparent', color: 'var(--dsw-alias-label-tertiary)', fontSize: '12px', cursor: 'pointer' },
365
365
  segActive: { height: '24px', padding: '0 10px', borderRadius: '6px', border: '0', background: 'var(--dsw-alias-interactive-bg-hover)', color: 'var(--dsw-alias-label-primary)', fontSize: '12px', cursor: 'pointer' },
366
366
  });
367
+ // ---- 弹窗与浮层反馈(0.8.5:对齐 DSH 遮罩/阴影 token 与交互习惯)----
368
+ Object.assign(styles, {
369
+ modalBackdrop: {
370
+ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center',
371
+ padding: '24px', background: 'var(--dsw-alias-bg-mask-2, rgba(0,0,0,0.45))',
372
+ backdropFilter: 'blur(var(--dsw-mask-blur, 2px))', WebkitBackdropFilter: 'blur(var(--dsw-mask-blur, 2px))',
373
+ animation: 'kh-backdrop-in 120ms ease-out',
374
+ },
375
+ modalCard: {
376
+ width: 'min(640px, calc(100vw - 48px))', maxHeight: '84vh', overflow: 'hidden',
377
+ display: 'flex', flexDirection: 'column', minHeight: 0,
378
+ background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l2)',
379
+ borderRadius: '14px', boxShadow: 'var(--dsw-shadow-lv3, 0 24px 60px rgba(0,0,0,0.35))',
380
+ animation: 'kh-modal-in 140ms cubic-bezier(0.2, 0.9, 0.3, 1)',
381
+ },
382
+ confirmBody: {
383
+ padding: '2px 0 8px', fontSize: '13px', lineHeight: '21px',
384
+ color: 'var(--dsw-alias-label-secondary)', whiteSpace: 'pre-wrap',
385
+ },
386
+ toastHost: {
387
+ position: 'fixed', right: '20px', bottom: '20px', zIndex: 80, display: 'flex', flexDirection: 'column',
388
+ alignItems: 'flex-end', gap: '8px', maxWidth: 'min(420px, calc(100vw - 40px))', pointerEvents: 'none',
389
+ },
390
+ toast: {
391
+ pointerEvents: 'auto', display: 'flex', alignItems: 'flex-start', gap: '8px', padding: '9px 12px',
392
+ borderRadius: '10px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)',
393
+ boxShadow: 'var(--dsw-shadow-lv2, 0 8px 24px rgba(0,0,0,0.18))', color: 'var(--dsw-alias-label-primary)',
394
+ fontSize: '13px', lineHeight: '20px', animation: 'kh-toast-in 160ms ease-out',
395
+ },
396
+ toastError: { borderColor: 'color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent)' },
397
+ toastSuccess: { borderColor: 'color-mix(in srgb, var(--dsw-alias-state-business-primary) 42%, transparent)' },
398
+ toastIcon: { flexShrink: 0, width: '18px', textAlign: 'center', fontSize: '12px', fontWeight: 600, lineHeight: '20px' },
399
+ toastText: { flex: 1, minWidth: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' },
400
+ toastClose: {
401
+ flexShrink: 0, width: '20px', height: '20px', padding: 0, borderRadius: '6px', border: 0,
402
+ background: 'transparent', color: 'var(--dsw-alias-label-tertiary)', cursor: 'pointer', fontSize: '11px',
403
+ },
404
+ });
367
405
  const STYLE_ELEMENT_ID = 'knowhub-theme-styles';
368
406
  const THEME_STYLESHEET = [
369
407
  '.knowhub-page :focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}',
@@ -373,7 +411,14 @@ const THEME_STYLESHEET = [
373
411
  '.knowhub-page .kh-danger:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)!important}',
374
412
  '.knowhub-page .kh-scroll{scrollbar-gutter:stable;--dsh-scrollbar-width:9px;--dsh-scrollbar-thumb-border:2px}',
375
413
  '.knowhub-page input[type=checkbox],.knowhub-page input[type=radio]{accent-color:var(--dsw-alias-state-business-primary)}',
376
- '@media (prefers-reduced-motion: reduce){.knowhub-page *{transition:none!important}}',
414
+ '.knowhub-page button{transition:background-color 120ms ease,border-color 120ms ease,color 120ms ease,opacity 120ms ease}',
415
+ '.knowhub-page button:not(:disabled):active{transform:translateY(0.5px)}',
416
+ '.knowhub-page button:disabled{cursor:not-allowed}',
417
+ '.knowhub-page [role=alert]{scroll-margin-top:12px}',
418
+ '@keyframes kh-backdrop-in{from{opacity:0}to{opacity:1}}',
419
+ '@keyframes kh-modal-in{from{opacity:0;transform:translateY(6px) scale(0.985)}to{opacity:1;transform:none}}',
420
+ '@keyframes kh-toast-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}',
421
+ '@media (prefers-reduced-motion: reduce){.knowhub-page *,.knowhub-page *::before,.knowhub-page *::after{transition:none!important;animation-duration:1ms!important}}',
377
422
  ].join('\n');
378
423
  /** 注入一次主题样式表;卸载时移除。 */
379
424
  function injectStyles() {
@@ -402,7 +447,10 @@ async function request(path, options = {}) {
402
447
  throw new Error('HTTP ' + response.status + ':响应不是合法 JSON');
403
448
  }
404
449
  if (!payload || payload.ok !== true) {
405
- throw new Error(payload?.error?.message ?? ('HTTP ' + response.status));
450
+ const failure = new Error(payload?.error?.message ?? ('HTTP ' + response.status));
451
+ failure.status = response.status;
452
+ failure.code = payload?.error?.code;
453
+ throw failure;
406
454
  }
407
455
  return payload.value;
408
456
  }
@@ -424,6 +472,170 @@ function message(error) {
424
472
  function Badge(props) {
425
473
  return h('span', { style: props.style ?? styles.badge }, props.text);
426
474
  }
475
+ const toastState = { items: [], listeners: new Set(), seq: 0 };
476
+ const TOAST_LIMIT = 4;
477
+ const TOAST_TIMEOUT = { success: 4000, info: 5000, error: 8000 };
478
+ function emitToasts() {
479
+ for (const listener of [...toastState.listeners]) {
480
+ try {
481
+ listener();
482
+ }
483
+ catch { /* 订阅方异常不影响其它 */ }
484
+ }
485
+ }
486
+ function removeToast(id) {
487
+ if (!toastState.items.some((item) => item.id === id))
488
+ return;
489
+ toastState.items = toastState.items.filter((item) => item.id !== id);
490
+ emitToasts();
491
+ }
492
+ /** 推入一条浮层提示(右下角、自动消失、可手动关闭)。 */
493
+ function pushToast(text, kind = 'success') {
494
+ const content = String(text ?? '').trim();
495
+ if (content === '')
496
+ return 0;
497
+ toastState.seq += 1;
498
+ const item = { id: toastState.seq, text: content, kind };
499
+ // 同文案不堆叠:先移除旧条目,只保留最新一条并重新计时
500
+ const rest = toastState.items.filter((entry) => entry.text !== content);
501
+ toastState.items = rest.concat([item]).slice(-TOAST_LIMIT);
502
+ emitToasts();
503
+ if (typeof setTimeout === 'function') {
504
+ const timer = setTimeout(() => removeToast(item.id), TOAST_TIMEOUT[kind] ?? 4000);
505
+ // 不拖住宿主进程退出(Node 下 unref,浏览器里 timer 没有这个方法)
506
+ if (typeof timer?.unref === 'function')
507
+ timer.unref();
508
+ }
509
+ return item.id;
510
+ }
511
+ const TOAST_ICON = { success: '✓', error: '!', info: 'i' };
512
+ function ToastHost() {
513
+ const [items, setItems] = R.useState(toastState.items);
514
+ R.useEffect(() => {
515
+ const listener = () => setItems(toastState.items.slice());
516
+ toastState.listeners.add(listener);
517
+ listener();
518
+ return () => { toastState.listeners.delete(listener); };
519
+ }, []);
520
+ if (items.length === 0)
521
+ return null;
522
+ return h('div', { style: styles.toastHost }, items.map((item) => h('div', {
523
+ key: item.id,
524
+ role: item.kind === 'error' ? 'alert' : 'status',
525
+ 'aria-live': item.kind === 'error' ? 'assertive' : 'polite',
526
+ style: { ...styles.toast, ...(item.kind === 'error' ? styles.toastError : item.kind === 'success' ? styles.toastSuccess : {}) },
527
+ }, h('span', {
528
+ style: {
529
+ ...styles.toastIcon,
530
+ color: item.kind === 'error' ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-label-secondary)',
531
+ },
532
+ }, TOAST_ICON[item.kind]), h('span', { style: styles.toastText }, item.text), h('button', {
533
+ type: 'button', style: styles.toastClose, title: '关闭提示', 'aria-label': '关闭提示',
534
+ onClick: () => removeToast(item.id),
535
+ }, '✕'))));
536
+ }
537
+ /** 组件内的临时 notice 文本统一转成 toast:不占布局、自动消失。 */
538
+ function useNoticeToast(notice, clear) {
539
+ R.useEffect(() => {
540
+ const text = String(notice ?? '').trim();
541
+ if (text === '')
542
+ return;
543
+ pushToast(text, 'success');
544
+ clear();
545
+ // eslint-disable-next-line react-hooks/exhaustive-deps
546
+ }, [notice]);
547
+ }
548
+ const confirmState = { current: null, listeners: new Set() };
549
+ function emitConfirm() {
550
+ for (const listener of [...confirmState.listeners]) {
551
+ try {
552
+ listener();
553
+ }
554
+ catch { /* 忽略 */ }
555
+ }
556
+ }
557
+ /** Promise 化的确认框:await askConfirm({ text }) 返回 true / false。 */
558
+ function askConfirm(options) {
559
+ return new Promise((resolve) => {
560
+ if (confirmState.current !== null) {
561
+ const previous = confirmState.current;
562
+ confirmState.current = null;
563
+ try {
564
+ previous.resolve(false);
565
+ }
566
+ catch { /* 忽略 */ }
567
+ }
568
+ confirmState.current = { ...options, resolve };
569
+ emitConfirm();
570
+ });
571
+ }
572
+ function settleConfirm(ok) {
573
+ const current = confirmState.current;
574
+ if (current === null)
575
+ return;
576
+ confirmState.current = null;
577
+ emitConfirm();
578
+ try {
579
+ current.resolve(ok);
580
+ }
581
+ catch { /* 忽略 */ }
582
+ }
583
+ /** DSH 风格弹窗外壳:统一遮罩模糊、圆角阴影、进出场动画、Esc 关闭与页面滚动锁。 */
584
+ function Modal(props) {
585
+ const { onClose, maxWidth, children, closeOnMask = true } = props;
586
+ R.useEffect(() => {
587
+ const doc = globalThis.document;
588
+ if (!doc || !doc.body)
589
+ return undefined;
590
+ const previous = doc.body.style.overflow;
591
+ doc.body.style.overflow = 'hidden';
592
+ return () => { doc.body.style.overflow = previous; };
593
+ }, []);
594
+ R.useEffect(() => {
595
+ if (typeof window === 'undefined' || typeof window.addEventListener !== 'function')
596
+ return undefined;
597
+ const onKey = (event) => { if (event.key === 'Escape')
598
+ onClose(); };
599
+ window.addEventListener('keydown', onKey);
600
+ return () => window.removeEventListener('keydown', onKey);
601
+ }, [onClose]);
602
+ return h('div', {
603
+ style: styles.modalBackdrop,
604
+ onMouseDown: (event) => { if (closeOnMask && event.target === event.currentTarget)
605
+ onClose(); },
606
+ }, h('div', {
607
+ style: maxWidth ? { ...styles.modalCard, maxWidth } : styles.modalCard,
608
+ role: 'dialog', 'aria-modal': 'true',
609
+ }, children));
610
+ }
611
+ function ConfirmHost() {
612
+ const [pending, setPending] = R.useState(confirmState.current);
613
+ R.useEffect(() => {
614
+ const listener = () => setPending(confirmState.current);
615
+ confirmState.listeners.add(listener);
616
+ listener();
617
+ return () => { confirmState.listeners.delete(listener); };
618
+ }, []);
619
+ R.useEffect(() => {
620
+ if (pending === null || typeof window === 'undefined' || typeof window.addEventListener !== 'function')
621
+ return undefined;
622
+ const onKey = (event) => {
623
+ if (event.key !== 'Enter')
624
+ return;
625
+ if (typeof event.preventDefault === 'function')
626
+ event.preventDefault();
627
+ settleConfirm(true);
628
+ };
629
+ window.addEventListener('keydown', onKey);
630
+ return () => window.removeEventListener('keydown', onKey);
631
+ }, [pending]);
632
+ if (pending === null)
633
+ return null;
634
+ const confirmStyle = pending.danger
635
+ ? { ...styles.primary, border: '1px solid var(--dsw-alias-state-error-primary)', background: 'var(--dsw-alias-state-error-primary)' }
636
+ : 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 ?? '确认')))));
638
+ }
427
639
  // ---------- 知识库 ----------
428
640
  function CreateKbForm(props) {
429
641
  const { models, workspaces, currentWorkspaceId, onCancel, onCreated } = props;
@@ -712,12 +924,14 @@ function KbView(props) {
712
924
  // 增量状态读取失败不影响主流程
713
925
  }
714
926
  }, []);
927
+ // 列表范围:始终展示全部知识库(用户级 + 所有工作区),不按工作区筛选。
928
+ // 知识库允许建在任意工作区;若按 cwd 反查的「当前工作区」过滤,会出现建完看不见、
929
+ // 同名却报已存在的错觉,所以这里固定请求 all=1。
715
930
  const load = R.useCallback(async () => {
716
931
  setLoading(true);
717
932
  setError(null);
718
933
  try {
719
- const query = currentWorkspace && currentWorkspace.workspaceId ? '?workspaceId=' + encodeURIComponent(currentWorkspace.workspaceId) : '';
720
- const value = await request('/kb' + query);
934
+ const value = await request('/kb?all=1');
721
935
  setList(value.knowledgeBases ?? []);
722
936
  }
723
937
  catch (failure) {
@@ -726,7 +940,7 @@ function KbView(props) {
726
940
  finally {
727
941
  setLoading(false);
728
942
  }
729
- }, [currentWorkspace]);
943
+ }, []);
730
944
  R.useEffect(() => { void load(); }, [load]);
731
945
  R.useEffect(() => { void load(); }, [props.refreshKey]);
732
946
  // 打开详情后每 1.5s 轮询任务;succeeded 时刷新文档与索引摘要(真实数据,无本地伪造)。
@@ -938,30 +1152,33 @@ function KbView(props) {
938
1152
  }), h('button', {
939
1153
  type: 'button', style: styles.primary, className: 'kh-primary',
940
1154
  onClick: () => { setShowForm(!showForm); setDetail(null); },
941
- }, showForm ? '收起' : '+ 新建')), 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)
942
- : filteredList.length === 0 ? h('div', { style: styles.listEmpty }, list.length === 0 ? '当前作用范围还没有知识库。点击「+ 新建」创建。' : '没有匹配的知识库名称。')
1155
+ }, 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)
1156
+ : filteredList.length === 0 ? h('div', { style: styles.listEmpty }, list.length === 0 ? '还没有任何知识库。点击「+ 新建」创建。' : '没有匹配的知识库名称。')
943
1157
  : filteredList.map((kb) => h('button', {
944
1158
  key: kb.id, type: 'button',
945
1159
  style: detail !== null && detail.knowledgeBase.id === kb.id ? styles.treeRowActive : styles.treeRow,
946
1160
  onClick: () => void openDetail(kb.id),
947
- }, h('span', { style: styles.treeTitle }, h('span', { style: styles.treeName }, kb.name), h('span', { style: styles.treeMeta }, (kb.scope === 'user' ? '用户级' : '工作区级') + ' · ' + (STATUS_LABEL[kb.status] ?? kb.status) + ' · ' + (kb.documentCount ?? 0) + ' 条')))))), h('div', { style: styles.readerCol }, h('div', { style: styles.readerHead }, h('div', { style: { flex: 1, minWidth: 0 } }, h('h2', { style: styles.readerTitle }, detail !== null ? detail.knowledgeBase.name : '知识库'), detail !== null
1161
+ }, h('span', { style: styles.treeTitle }, h('span', { style: styles.treeName }, kb.name), h('span', { style: styles.treeMeta }, (kb.scope === 'user' ? '用户级' : '工作区级 · ' + (kb.workspaceTitle ?? kb.workspaceId ?? '未知工作区')) + ' · ' + (STATUS_LABEL[kb.status] ?? kb.status) + ' · ' + (kb.documentCount ?? 0) + ' 条')))))), h('div', { style: styles.readerCol }, h('div', { style: styles.readerHead }, h('div', { style: { flex: 1, minWidth: 0 } }, h('h2', { style: styles.readerTitle }, detail !== null ? detail.knowledgeBase.name : '知识库'), detail !== null
948
1162
  ? h('div', { style: styles.readerMeta }, h(Badge, { text: detail.knowledgeBase.scope === 'user' ? '用户级' : '工作区级', style: styles.badgeBlue }), h(Badge, { text: STATUS_LABEL[detail.knowledgeBase.status] ?? detail.knowledgeBase.status, style: styles.badgeGreen }), h('span', null, '模型:' + detail.knowledgeBase.modelId + '(' + detail.knowledgeBase.modelProvider + ')'), h('span', null, '策略:' + detail.knowledgeBase.modelStrategy), h('span', null, detail.documentCount + ' 条知识'), detail.knowledgeBase.workspacePath ? h('span', null, '工作区:' + detail.knowledgeBase.workspacePath) : null)
949
- : h('div', { style: styles.readerMeta }, '知识库列表与详情')), h('div', { style: styles.headerActions }, detail !== null ? h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: exportAll }, '导出全部') : null, detail !== null ? h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setImportOpen(true) }, '导入 Markdown') : null, detail !== null ? h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setEditOpen(true) }, '编辑配置') : null, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading, onClick: () => void load() }, loading ? '刷新中…' : '刷新'))), h('div', { style: styles.readerScroll, className: 'kh-scroll' }, h('div', { style: styles.readerInner }, detailSections()))), showForm ? h('div', { style: styles.modalBackdrop, onClick: (event) => { if (event.target === event.currentTarget)
950
- setShowForm(false); } }, h('div', { style: styles.modalCard }, h(CreateKbForm, {
1163
+ : h('div', { style: styles.readerMeta }, '知识库列表与详情')), h('div', { style: styles.headerActions }, detail !== null ? h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: exportAll }, '导出全部') : null, detail !== null ? h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setImportOpen(true) }, '导入 Markdown') : null, detail !== null ? h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setEditOpen(true) }, '编辑配置') : null, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading, onClick: () => void load() }, loading ? '刷新中…' : '刷新'))), h('div', { style: styles.readerScroll, className: 'kh-scroll' }, h('div', { style: styles.readerInner }, detailSections()))), showForm ? h(Modal, { onClose: () => setShowForm(false), maxWidth: '680px' }, h(CreateKbForm, {
951
1164
  models, workspaces, currentWorkspaceId: currentWorkspace?.workspaceId ?? '',
952
1165
  onCancel: () => setShowForm(false),
953
- onCreated: async (kb) => { setShowForm(false); notify('知识库「' + kb.name + '」已创建。下一步:在详情里点「开始初始化」。'); await load(); },
954
- }))) : null, importOpen && detail !== null ? h('div', { style: styles.modalBackdrop, onClick: (event) => { if (event.target === event.currentTarget)
955
- setImportOpen(false); } }, h('div', { style: { ...styles.modalCard, maxWidth: '720px' } }, h(ImportMarkdownForm, {
1166
+ onCreated: async (kb) => {
1167
+ setShowForm(false);
1168
+ notify('知识库「' + kb.name + '」已创建。下一步:在详情里点「开始初始化」。');
1169
+ // 列表固定展示全部知识库,新建后直接刷新并打开详情即可。
1170
+ await load();
1171
+ await openDetail(kb.id);
1172
+ },
1173
+ })) : null, importOpen && detail !== null ? h(Modal, { onClose: () => setImportOpen(false), maxWidth: '760px' }, h(ImportMarkdownForm, {
956
1174
  kbId: detail.knowledgeBase.id, kbName: detail.knowledgeBase.name,
957
1175
  onCancel: () => setImportOpen(false),
958
1176
  onDone: async () => { await openDetail(detail.knowledgeBase.id); await load(); },
959
- }))) : null, editOpen && detail !== null ? h('div', { style: styles.modalBackdrop, onClick: (event) => { if (event.target === event.currentTarget)
960
- setEditOpen(false); } }, h('div', { style: styles.modalCard }, h(EditKbForm, {
1177
+ })) : null, editOpen && detail !== null ? h(Modal, { onClose: () => setEditOpen(false), maxWidth: '680px' }, h(EditKbForm, {
961
1178
  kb: detail.knowledgeBase, models,
962
1179
  onCancel: () => setEditOpen(false),
963
1180
  onSaved: async (kb) => { setEditOpen(false); notify('知识库「' + kb.name + '」配置已更新'); await openDetail(kb.id); await load(); },
964
- }))) : null);
1181
+ })) : null);
965
1182
  }
966
1183
  // ---------- 检索 ----------
967
1184
  function SearchView(props) {
@@ -1492,7 +1709,7 @@ function DocumentDiffPage(props) {
1492
1709
  ? h('span', null, base.version === null || base.version === undefined ? '没有历史版本可对比,当前正文全部显示为新增' : '左为上一版内容,右为当前内容')
1493
1710
  : h('span', null, doc ? '左为当前正式内容,右为候选内容' : '该候选是新增知识,下方内容全部为新增'), diff.truncated ? h('span', null, '内容过长,差异已截断展示') : null), h('div', { style: { ...styles.row, alignItems: 'center', marginTop: '10px' } }, h('span', { style: { ...styles.diffStat, ...styles.diffStatAdd } }, '新增 ' + String(stats.added) + ' 行'), h('span', { style: { ...styles.diffStat, ...styles.diffStatDel } }, '删除 ' + String(stats.removed) + ' 行'), h('span', { style: styles.diffStat }, '未变 ' + String(stats.unchanged) + ' 行'), h('div', { style: styles.segGroup }, h('button', { type: 'button', style: viewMode === 'unified' ? styles.segActive : styles.seg, onClick: () => { setViewMode('unified'); setActiveChange(0); } }, '统一视图'), h('button', { type: 'button', style: viewMode === 'split' ? styles.segActive : styles.seg, onClick: () => { setViewMode('split'); setActiveChange(0); } }, '并排视图')), h('div', { style: styles.segGroup }, viewMode === 'split' ? null : h('button', { type: 'button', style: wrap ? styles.segActive : styles.seg, onClick: () => setWrap(!wrap) }, '自动换行'), h('button', { type: 'button', style: expandAll ? styles.segActive : styles.seg, onClick: () => { setExpandAll(!expandAll); setFolded({}); } }, '展开全部上下文'))), h('div', { style: { ...styles.row, alignItems: 'center', marginTop: '6px' } }, changeCount > 0
1494
1711
  ? h('span', { style: styles.diffStat }, '第 ' + String(Math.min(activeChange, changeCount - 1) + 1) + ' / ' + String(changeCount) + ' 处改动')
1495
- : h('span', { style: styles.hint }, '两侧内容一致,没有改动可跳转'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: changeCount === 0 || activeChange <= 0, onClick: () => jumpToChange(activeChange - 1) }, '↑ 上一处改动'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: changeCount === 0 || activeChange >= changeCount - 1, onClick: () => jumpToChange(activeChange + 1) }, '↓ 下一处改动'), h('span', { style: styles.hint }, '改动块左侧有蓝色标线;未变内容超过 7 行会自动折叠。')), diffPane, busy !== null ? h('div', { style: styles.hint }, busy + '…') : null, error !== null ? h('div', { style: styles.error }, error) : null, notice !== '' ? h('div', { style: styles.success }, notice) : null, actions);
1712
+ : h('span', { style: styles.hint }, '两侧内容一致,没有改动可跳转'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: changeCount === 0 || activeChange <= 0, onClick: () => jumpToChange(activeChange - 1) }, '↑ 上一处改动'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: changeCount === 0 || activeChange >= changeCount - 1, onClick: () => jumpToChange(activeChange + 1) }, '↓ 下一处改动'), h('span', { style: styles.hint }, '改动块左侧有蓝色标线;未变内容超过 7 行会自动折叠。')), diffPane, busy !== null ? h('div', { style: styles.hint }, busy + '…') : null, error !== null ? h('div', { style: styles.error, role: 'alert' }, error) : null, actions);
1496
1713
  }
1497
1714
  function ReviewView(props) {
1498
1715
  const { knowledgeBases } = props;
@@ -1501,6 +1718,7 @@ function ReviewView(props) {
1501
1718
  const [loading, setLoading] = R.useState(false);
1502
1719
  const [error, setError] = R.useState(null);
1503
1720
  const [notice, setNotice] = R.useState('');
1721
+ useNoticeToast(notice, () => setNotice(''));
1504
1722
  const [busy, setBusy] = R.useState(null);
1505
1723
  const [diffPage, setDiffPage] = R.useState(null);
1506
1724
  const [proposalEdit, setProposalEdit] = R.useState(null);
@@ -1760,7 +1978,7 @@ function ReviewView(props) {
1760
1978
  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);
1761
1979
  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')
1762
1980
  applyFilters(); } }), h('input', { style: { ...styles.input, width: '160px' }, value: sourceInput, placeholder: '来源,如 pipeline', onChange: (e) => setSourceInput(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter')
1763
- 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 }, error) : null, notice !== '' ? h('div', { style: styles.success }, notice) : 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 () => {
1981
+ applyFilters(); } }), h('select', { style: { ...styles.input, width: '150px' }, value: sort, onChange: (e) => { setSort(e.target.value); setPage(1); } }, h('option', { value: 'newest' }, '最新提交优先'), h('option', { value: 'oldest' }, '最早提交优先'), h('option', { value: 'updated' }, '最近更新优先')), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: loading, onClick: applyFilters }, '筛选'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: resetFilters }, '清空'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading, onClick: () => void load() }, loading ? '刷新中…' : '刷新'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void openLog() }, '审核日志')), h('div', { style: { ...styles.row, alignItems: 'center', marginTop: '8px' } }, h('span', { style: styles.badgeAmber }, '待审核合计 ' + pendingTotal + ' 项'), h('span', { style: styles.hint }, filterHint + ' · 第 ' + page + ' / ' + maxPages + ' 页(每页最多 ' + LIMIT + ' 条)'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading || page <= 1, onClick: () => setPage(page - 1) }, '上一页'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading || page >= maxPages, onClick: () => setPage(page + 1) }, '下一页')), selected.length > 0 ? h('div', { style: { ...styles.card, marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' } }, h('span', null, '已选 ' + selected.length + ' 条'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void bulkRun('approve') }, '批量批准'), h('button', { type: 'button', style: styles.danger, className: 'kh-danger', disabled: busy !== null, onClick: () => void bulkRun('reject') }, '批量拒绝'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: clearSelection }, '清空选择'), h('span', { style: styles.hint }, '单次最多 ' + LIMIT + ' 条,执行前会二次确认')) : null, busy !== null ? h('div', { style: styles.hint }, busy + '…') : null, error !== null ? h('div', { style: styles.error, role: 'alert' }, error) : null, queue === null ? h('div', { style: styles.empty }, loading ? '正在读取待审核数据…' : '暂无数据') : h('div', null, sectionHead('候选知识', counts.proposals, 'proposal', queue.proposals ?? []), (queue.proposals ?? []).length === 0 ? h('div', { style: styles.empty }, '没有符合条件的候选。Agent 或面板提交的知识会先进入这里。') : (queue.proposals ?? []).map(proposalCard), sectionHead('版本冲突', counts.conflicts), (queue.conflicts ?? []).length === 0 ? h('div', { style: styles.empty }, '没有待处理冲突。') : (queue.conflicts ?? []).map((item) => h('div', { key: item.id, style: styles.card }, h('div', { style: styles.cardTitle }, '冲突:' + (item.proposedTitle ?? item.docId), h(Badge, { text: '待处理', style: styles.badgeAmber })), h('div', { style: styles.meta }, h('span', null, '知识库:' + (item.kbName ?? item.kbId)), h('span', null, '来源:' + (item.source ?? '—')), h('span', null, '基础版本:第 ' + item.baseVersion + ' 版 / 当前:第 ' + item.currentVersion + ' 版'), h('span', null, '检测于 ' + new Date(item.detectedAt).toLocaleString('zh-CN'))), h('div', { style: styles.snippet }, '提议内容:'), h('pre', { style: styles.pre }, String(item.proposedBody ?? '').slice(0, 1600)), h('div', { style: styles.row, marginTop: '8px' }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => void openDiff('conflict', item.id) }, '查看差异'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void run('保留当前', () => request('/review/conflicts/' + item.id + '/resolve', { method: 'POST', body: { mode: 'keep_current' } })) }, '保留当前知识'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('采用新提议', () => request('/review/conflicts/' + item.id + '/resolve', { method: 'POST', body: { mode: 'use_proposed' } })) }, '采用新提议(追加合并版本)'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => setConflictEdit({ id: item.id, title: String(item.proposedTitle ?? ''), body: String(item.proposedBody ?? '') }) }, '人工合并…')), conflictEdit !== null && conflictEdit.id === item.id ? h('div', { style: styles.field, marginTop: '10px' }, h('label', { style: styles.label }, '合并后标题'), h('input', { style: styles.input, value: conflictEdit.title, onChange: (e) => setConflictEdit({ ...conflictEdit, title: e.target.value }) }), h('label', { style: { ...styles.label, marginTop: '8px' } }, '合并后正文(预填新提议,可自由编辑)'), h('textarea', { style: { ...styles.textarea, minHeight: '180px' }, value: conflictEdit.body, onChange: (e) => setConflictEdit({ ...conflictEdit, body: e.target.value }) }), h('div', { style: styles.row }, h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('保存人工合并', () => request('/review/conflicts/' + item.id + '/resolve', { method: 'POST', body: { mode: 'merge', title: conflictEdit.title, body: conflictEdit.body } }), () => setConflictEdit(null)) }, '保存合并结果'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setConflictEdit(null) }, '取消合并'))) : null)), sectionHead('待审核草稿', counts.drafts, 'doc', queue.drafts ?? []), (queue.drafts ?? []).length === 0 ? h('div', { style: styles.empty }, '没有符合条件的待审核草稿。') : (queue.drafts ?? []).map((item) => h('div', { key: item.id, style: styles.card }, h('div', { style: styles.cardTitle }, checkBox('doc:' + item.id), item.title, h(Badge, { text: KIND_LABEL[item.kind] ?? item.kind }), h(Badge, { text: VERIFY_LABEL[item.verifyState] ?? item.verifyState }), h(Badge, { text: '第 ' + item.currentVersion + ' 版' })), h('div', { style: styles.meta }, h('span', null, '知识库:' + (item.kbName ?? item.kbId)), h('span', null, '更新于 ' + new Date(item.updatedAt).toLocaleString('zh-CN')), h('span', null, item.path)), h('div', { style: { ...styles.row, marginTop: '8px' } }, h('div', { style: styles.field, flex: '1 1 260px' }, noteInput(item.id))), h('div', { style: styles.row }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void openDiff('document', item.id) }, '查看详情与差异'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => void openVersions(item.kbId, item.id) }, '版本历史'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('批准草稿', () => request('/kb/' + item.kbId + '/docs/' + item.id + '/review', { method: 'POST', body: { action: 'approve', note: noteOf(item.id) || null } })) }, '批准'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void openDraftEdit(item.kbId, item.id) }, '编辑后批准'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void run('标记过时', () => request('/kb/' + item.kbId + '/docs/' + item.id + '/review', { method: 'POST', body: { action: 'mark_stale', note: noteOf(item.id) || null } })) }, '标记过时'), h('button', { type: 'button', style: styles.danger, className: 'kh-danger', disabled: busy !== null, onClick: () => void run('拒绝草稿', () => request('/kb/' + item.kbId + '/docs/' + item.id + '/review', { method: 'POST', body: { action: 'reject', note: noteOf(item.id) || null } })) }, '拒绝(归档)')))), sectionHead('过时知识', counts.stale), (queue.staleDocuments ?? []).length === 0 ? h('div', { style: styles.empty }, '没有符合条件的过时知识。') : (queue.staleDocuments ?? []).map((item) => h('div', { key: item.id, style: styles.card }, h('div', { style: styles.cardTitle }, item.title, h(Badge, { text: '过时', style: styles.badgeAmber })), h('div', { style: styles.meta }, h('span', null, '知识库:' + (item.kbName ?? item.kbId)), h('span', null, '更新于 ' + new Date(item.updatedAt).toLocaleString('zh-CN'))), h('div', { style: styles.row, marginTop: '8px' }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: busy !== null, onClick: () => void openDiff('document', item.id) }, '查看详情与差异'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => void openVersions(item.kbId, item.id) }, '版本历史'), h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('恢复为已批准', () => request('/kb/' + item.kbId + '/docs/' + item.id + '/review', { method: 'POST', body: { action: 'approve', note: '确认内容仍有效' } })) }, '确认仍有效(恢复已批准)')))), maxPages > 1 ? h('div', { style: { ...styles.row, marginTop: '10px' } }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading || page <= 1, onClick: () => setPage(page - 1) }, '上一页'), h('span', { style: styles.hint }, '第 ' + page + ' / ' + maxPages + ' 页'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', disabled: loading || page >= maxPages, onClick: () => setPage(page + 1) }, '下一页')) : null), draftEdit !== null ? h('div', { style: styles.divider }, h('div', { style: styles.sectionTitle }, '编辑后批准草稿(将在第 ' + Number(draftEdit.version ?? 0) + ' 版基础上生成审核版本)'), h('label', { style: styles.label }, '标题'), h('input', { style: styles.input, value: draftEdit.title, onChange: (e) => setDraftEdit({ ...draftEdit, title: e.target.value }) }), h('label', { style: { ...styles.label, marginTop: '8px' } }, '正文(Markdown)'), h('textarea', { style: { ...styles.textarea, minHeight: '220px' }, value: draftEdit.body, onChange: (e) => setDraftEdit({ ...draftEdit, body: e.target.value }) }), h('div', { style: styles.row, marginTop: '8px' }, h('button', { type: 'button', style: styles.primary, className: 'kh-primary', disabled: busy !== null, onClick: () => void run('编辑并批准草稿', () => request('/kb/' + draftEdit.kbId + '/docs/' + draftEdit.docId + '/review', { method: 'POST', body: { action: 'approve', title: draftEdit.title, body: draftEdit.body, note: '审核时编辑后批准' } }), () => setDraftEdit(null)) }, '保存并批准'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setDraftEdit(null) }, '取消'))) : null, docPanel !== null ? h('div', { style: styles.divider }, h('div', { style: styles.sectionTitle }, '版本历史(共 ' + docPanel.versions.length + ' 版,当前第 ' + docPanel.currentVersion + ' 版)'), docPanel.versions.map((item) => h('div', { key: item.version, style: styles.card }, h('div', { style: styles.cardTitle }, '第 ' + item.version + ' 版', h(Badge, { text: CHANGE_KIND_LABEL[item.changeKind] ?? item.changeKind }), item.version === docPanel.currentVersion ? h(Badge, { text: '当前', style: styles.badgeGreen }) : null), h('div', { style: styles.meta }, h('span', null, '来源:' + (item.source ?? '—')), h('span', null, '操作人:' + (item.actor ?? '—')), h('span', null, '时间:' + new Date(item.createdAt).toLocaleString('zh-CN'))), item.reason ? h('div', { style: styles.snippet }, '说明:' + item.reason) : null, h('div', { style: styles.row, marginTop: '8px' }, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: async () => {
1764
1982
  setError(null);
1765
1983
  try {
1766
1984
  const value = await request('/kb/' + encodeURIComponent(docPanel.kbId) + '/docs/' + encodeURIComponent(docPanel.docId) + '/versions/' + item.version);
@@ -1797,7 +2015,7 @@ function EmptyState(props) {
1797
2015
  return h('div', { style: styles.empty }, props.text ?? '暂无数据');
1798
2016
  }
1799
2017
  function ErrorBox(props) {
1800
- return h('div', { style: styles.error }, props.text);
2018
+ return h('div', { style: styles.error, role: 'alert' }, h('span', { style: { marginRight: '6px', fontWeight: 600 } }, '⚠'), h('span', null, String(props.text ?? '')));
1801
2019
  }
1802
2020
  /** M5 总览:知识库/知识/审核/任务/最近活动一屏可见,点击可直接跳到对应页签。 */
1803
2021
  function OverviewView(props) {
@@ -1995,6 +2213,7 @@ function DocsExplorer(props) {
1995
2213
  const [loading, setLoading] = R.useState(false);
1996
2214
  const [error, setError] = R.useState(null);
1997
2215
  const [notice, setNotice] = R.useState('');
2216
+ useNoticeToast(notice, () => setNotice(''));
1998
2217
  const [selectedId, setSelectedId] = R.useState('');
1999
2218
  const [detail, setDetail] = R.useState(null);
2000
2219
  const [detailError, setDetailError] = R.useState(null);
@@ -2096,7 +2315,12 @@ function DocsExplorer(props) {
2096
2315
  const markStale = async () => {
2097
2316
  if (detail === null)
2098
2317
  return;
2099
- if (!window.confirm('确认把这条知识标记为过时?标记后仍保留正文与历史版本,可在审核里恢复。'))
2318
+ const ok = await askConfirm({
2319
+ title: '标记为过时',
2320
+ text: '确认把这条知识标记为过时?标记后仍保留正文与历史版本,可在审核里恢复。',
2321
+ confirmText: '确认标记过时',
2322
+ });
2323
+ if (!ok)
2100
2324
  return;
2101
2325
  setBusy(true);
2102
2326
  setDetailError(null);
@@ -2156,7 +2380,12 @@ function DocsExplorer(props) {
2156
2380
  const restoreVersion = async (version) => {
2157
2381
  if (docBasePath() === '')
2158
2382
  return;
2159
- if (!window.confirm('确认恢复为第 ' + version + ' 版内容?恢复会以新版本方式记录,不会静默覆盖当前内容。'))
2383
+ const ok = await askConfirm({
2384
+ title: '恢复历史版本',
2385
+ text: '确认恢复为第 ' + version + ' 版内容?恢复会以新版本方式记录,不会静默覆盖当前内容。',
2386
+ confirmText: '确认恢复此版本',
2387
+ });
2388
+ if (!ok)
2160
2389
  return;
2161
2390
  setDetailError(null);
2162
2391
  setVersionBusy('恢复第 ' + version + ' 版…');
@@ -2302,7 +2531,7 @@ function DocsExplorer(props) {
2302
2531
  : h('div', { style: styles.readerMeta }, '从左侧列表选择一条知识,右侧显示正文、来源与版本历史。')), detail !== null
2303
2532
  ? h('div', { style: styles.headerActions }, detail !== null && detail.document ? h('div', { style: styles.segGroup }, h('button', { type: 'button', style: mdView ? styles.segActive : styles.seg, onClick: () => setMdView(true) }, '渲染'), h('button', { type: 'button', style: mdView ? styles.seg : styles.segActive, onClick: () => setMdView(false) }, '源码')) : null, h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => setEditing(!editing) }, editing ? '取消编辑' : '编辑'), h('button', { type: 'button', style: { ...styles.primary, ...(busy || !editing ? styles.buttonDisabled : {}) }, className: 'kh-primary', disabled: busy || !editing, onClick: () => void saveEdit() }, busy ? '保存中…' : '保存修改'), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: () => { if (detail !== null && detail.document)
2304
2533
  downloadFile('/kb/' + encodeURIComponent(detail.document.kbId) + '/docs/' + encodeURIComponent(detail.document.id) + '/export'); } }, '导出 Markdown'), h('button', { type: 'button', style: { ...styles.danger, ...(busy ? styles.buttonDisabled : {}) }, className: 'kh-danger', disabled: busy, onClick: () => void markStale() }, '标记过时'))
2305
- : null), h('div', { style: styles.readerScroll, className: 'kh-scroll' }, h('div', { style: styles.readerInner }, notice !== '' ? h('div', { style: styles.success }, notice) : null, detailError !== null ? ErrorBox({ text: detailError }) : null, detail === null
2534
+ : null), h('div', { style: styles.readerScroll, className: 'kh-scroll' }, h('div', { style: styles.readerInner }, detailError !== null ? ErrorBox({ text: detailError }) : null, detail === null
2306
2535
  ? (selectedId === '' ? EmptyState({ text: '从左侧选择一条知识查看正文。' }) : null)
2307
2536
  : h('div', null, editing
2308
2537
  ? h('div', null, h('div', { style: styles.field }, h('label', { style: styles.label }, '标题'), h('input', { style: styles.input, value: draftTitle, onChange: (event) => setDraftTitle(event.target.value) })), h('div', { style: styles.field }, h('label', { style: styles.label }, '正文(Markdown)'), h('textarea', { style: { ...styles.textarea, minHeight: '260px' }, value: draftBody, onChange: (event) => setDraftBody(event.target.value) })))
@@ -2353,7 +2582,6 @@ function SettingsView() {
2353
2582
  const [stats, setStats] = R.useState(null);
2354
2583
  const [runs, setRuns] = R.useState([]);
2355
2584
  const [error, setError] = R.useState(null);
2356
- const [notice, setNotice] = R.useState('');
2357
2585
  const [busy, setBusy] = R.useState('');
2358
2586
  const [target, setTarget] = R.useState('');
2359
2587
  const [backupNote, setBackupNote] = R.useState('');
@@ -2390,68 +2618,113 @@ function SettingsView() {
2390
2618
  };
2391
2619
  setBusy('save');
2392
2620
  setError(null);
2393
- setNotice('');
2394
2621
  request('/settings', { method: 'PATCH', body }).then((value) => {
2395
2622
  setSnapshot(value);
2396
2623
  setForm({ ...value.settings, excludeDirsText: (value.settings?.excludeDirs ?? []).join('\n') });
2397
- setNotice('设置已保存');
2624
+ pushToast('设置已保存', 'success');
2398
2625
  }).catch((failure) => setError(message(failure))).then(() => setBusy(''));
2399
2626
  };
2400
- const migrate = (dryRun) => {
2627
+ const migrate = async (dryRun) => {
2401
2628
  if (target.trim() === '') {
2402
2629
  setError('请先填写目标目录绝对路径');
2403
2630
  return;
2404
2631
  }
2405
- if (!dryRun && !window.confirm('确认把知识中心数据迁移到:' + target + '?\n迁移前会自动创建备份,校验失败时不会切换位置。'))
2406
- return;
2632
+ if (!dryRun) {
2633
+ const ok = await askConfirm({
2634
+ title: '迁移知识中心数据',
2635
+ text: '确认把知识中心数据迁移到:' + target + '?\n迁移前会自动创建备份,校验失败时不会切换位置。',
2636
+ confirmText: '开始迁移',
2637
+ });
2638
+ if (!ok)
2639
+ return;
2640
+ }
2407
2641
  setBusy(dryRun ? 'dry' : 'migrate');
2408
2642
  setError(null);
2409
- setNotice('');
2410
- request('/settings/storage/migrate', { method: 'POST', body: { target: target.trim(), dryRun } }).then((value) => {
2411
- setNotice(dryRun ? ('预检通过:将复制 ' + (value.willCopy?.files ?? 0) + ' 个文件,共 ' + formatBytes(value.willCopy?.bytes ?? 0)) : ('已迁移到 ' + value.to + ';请重启 DSH 后生效(原数据仍在 ' + value.from + ')'));
2412
- }).catch((failure) => setError(message(failure))).then(() => setBusy(''));
2643
+ try {
2644
+ const value = await request('/settings/storage/migrate', { method: 'POST', body: { target: target.trim(), dryRun } });
2645
+ pushToast(dryRun
2646
+ ? ('预检通过:将复制 ' + (value.willCopy?.files ?? 0) + ' 个文件,共 ' + formatBytes(value.willCopy?.bytes ?? 0))
2647
+ : ('已迁移到 ' + value.to + ';请重启 DSH 后生效(原数据仍在 ' + value.from + ')'), dryRun ? 'info' : 'success');
2648
+ }
2649
+ catch (failure) {
2650
+ pushToast(message(failure), 'error');
2651
+ }
2652
+ finally {
2653
+ setBusy('');
2654
+ }
2413
2655
  };
2414
- const createBackup = () => {
2656
+ const createBackup = async () => {
2415
2657
  setBusy('backup');
2416
2658
  setError(null);
2417
- setNotice('');
2418
- request('/backups', { method: 'POST', body: { note: backupNote.trim() } }).then((value) => {
2419
- setNotice('备份完成:' + value.kbCount + ' 个知识库 / ' + value.docCount + ' 条知识 / ' + formatBytes(value.bytes));
2659
+ try {
2660
+ const value = await request('/backups', { method: 'POST', body: { note: backupNote.trim() } });
2661
+ pushToast('备份完成:' + value.kbCount + ' 个知识库 / ' + value.docCount + ' 条知识 / ' + formatBytes(value.bytes), 'success');
2420
2662
  setBackupNote('');
2421
2663
  loadBackups();
2422
- }).catch((failure) => setError(message(failure))).then(() => setBusy(''));
2664
+ }
2665
+ catch (failure) {
2666
+ pushToast(message(failure), 'error');
2667
+ }
2668
+ finally {
2669
+ setBusy('');
2670
+ }
2423
2671
  };
2424
- const openPreview = (item) => {
2672
+ const openPreview = async (item) => {
2425
2673
  if (busy !== '')
2426
2674
  return;
2427
2675
  setBusy('preview:' + item.id);
2428
2676
  setError(null);
2429
- setNotice('');
2430
- request('/backups/' + encodeURIComponent(item.id) + '/preview').then((value) => setPreview(value))
2431
- .catch((failure) => setError(message(failure))).then(() => setBusy(''));
2677
+ try {
2678
+ setPreview(await request('/backups/' + encodeURIComponent(item.id) + '/preview'));
2679
+ }
2680
+ catch (failure) {
2681
+ pushToast(message(failure), 'error');
2682
+ }
2683
+ finally {
2684
+ setBusy('');
2685
+ }
2432
2686
  };
2433
- const restore = (item) => {
2434
- if (!window.confirm('确认从备份 ' + item.file + ' 恢复?\n恢复采用合并语义:缺失的库与知识会重建,正文不同的知识只生成待审核候选,不会覆盖现有内容。'))
2687
+ const restore = async (item) => {
2688
+ const ok = await askConfirm({
2689
+ title: '从备份恢复',
2690
+ text: '确认从备份 ' + item.file + ' 恢复?\n恢复采用合并语义:缺失的库与知识会重建,正文不同的知识只生成待审核候选,不会覆盖现有内容。',
2691
+ confirmText: '开始恢复',
2692
+ });
2693
+ if (!ok)
2435
2694
  return;
2436
2695
  setBusy(item.id);
2437
2696
  setError(null);
2438
- setNotice('');
2439
- request('/backups/' + encodeURIComponent(item.id) + '/restore', { method: 'POST', body: {} }).then((value) => {
2440
- setNotice('恢复完成:重建知识库 ' + value.createdKbs + ' 个 / 知识 ' + value.createdDocs + ' 条 / 新候选 ' + value.proposals + ' 条 / 跳过 ' + value.skipped + ' 条');
2697
+ try {
2698
+ const value = await request('/backups/' + encodeURIComponent(item.id) + '/restore', { method: 'POST', body: {} });
2699
+ pushToast('恢复完成:重建知识库 ' + value.createdKbs + ' 个 / 知识 ' + value.createdDocs + ' 条 / 新候选 ' + value.proposals + ' 条 / 跳过 ' + value.skipped + ' 条', 'success');
2441
2700
  loadBackups();
2442
- }).catch((failure) => setError(message(failure))).then(() => setBusy(''));
2701
+ }
2702
+ catch (failure) {
2703
+ pushToast(message(failure), 'error');
2704
+ }
2705
+ finally {
2706
+ setBusy('');
2707
+ }
2443
2708
  };
2709
+ // 备份预览是独立浮层:补 Esc 关闭(与其它弹窗一致)。
2710
+ R.useEffect(() => {
2711
+ if (preview === null || typeof window === 'undefined' || typeof window.addEventListener !== 'function')
2712
+ return undefined;
2713
+ const onKey = (event) => { if (event.key === 'Escape')
2714
+ setPreview(null); };
2715
+ window.addEventListener('keydown', onKey);
2716
+ return () => window.removeEventListener('keydown', onKey);
2717
+ }, [preview]);
2444
2718
  if (snapshot === null || form === null) {
2445
2719
  return error !== null ? h('div', null, ErrorBox({ text: error }), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: loadSettings }, '重试')) : Loading({ text: '正在读取设置…' });
2446
2720
  }
2447
2721
  const storage = snapshot.storage ?? {};
2448
2722
  const checkbox = (key, label, hintText) => settingsCheckbox(form, patch, key, label, hintText);
2449
- return h('div', null, error !== null ? ErrorBox({ text: error }) : null, notice !== '' ? h('div', { style: styles.success }, notice) : 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('div', { style: styles.modalBackdrop, onClick: (event) => { if (event.target === event.currentTarget)
2450
- setPreview(null); } }, h('div', { style: { ...styles.modalCard, maxWidth: '760px' } }, 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, background: 'var(--dsw-alias-bg-layer-1)', 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', {
2723
+ 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, background: 'var(--dsw-alias-bg-layer-1)', 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', {
2451
2724
  type: 'button', style: styles.button, className: 'kh-hover',
2452
2725
  onClick: () => { const item = (backups ?? []).find((entry) => entry.id === preview.backupId); setPreview(null); if (item)
2453
2726
  restore(item); },
2454
- }, '按合并语义恢复…') : null)))) : null, h('div', { style: styles.sectionTitle }, '环境与接口诊断'), h(DiagnosticsView, null));
2727
+ }, '按合并语义恢复…') : null))) : null, h('div', { style: styles.sectionTitle }, '环境与接口诊断'), h(DiagnosticsView, null));
2455
2728
  }
2456
2729
  /**
2457
2730
  * 知枢知识中心主面板。
@@ -2478,7 +2751,6 @@ function Page() {
2478
2751
  const [kbList, setKbList] = R.useState(null);
2479
2752
  const [bootError, setBootError] = R.useState(null);
2480
2753
  const [booting, setBooting] = R.useState(false);
2481
- const [notice, setNotice] = R.useState('');
2482
2754
  const [reloadKey, setReloadKey] = R.useState(0);
2483
2755
  const [reviewCount, setReviewCount] = R.useState(0);
2484
2756
  R.useEffect(() => {
@@ -2517,7 +2789,7 @@ function Page() {
2517
2789
  })();
2518
2790
  return () => { alive = false; };
2519
2791
  }, [reloadKey]);
2520
- const notify = (text) => setNotice(text);
2792
+ const notify = (text) => { pushToast(text, 'success'); };
2521
2793
  const refreshReviewCount = async () => {
2522
2794
  try {
2523
2795
  const value = await request('/review?limit=1');
@@ -2527,7 +2799,7 @@ function Page() {
2527
2799
  catch { /* 徽标失败不打断界面 */ }
2528
2800
  };
2529
2801
  const goto = (next) => { setView(next); void refreshReviewCount(); };
2530
- const renderOverlay = () => h('div', { style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } }, h('div', { style: styles.backBar }, h('button', { type: 'button', style: styles.linkButton, className: 'kh-hover', onClick: () => { setView(''); setNotice(''); } }, '← 返回知识中心'), h('h2', { style: styles.headerTitle }, OVERLAY_TITLES[view] ?? view)), h('div', { style: styles.overlayScroll, className: 'kh-scroll' }, h('div', { style: styles.overlayInner }, notice !== '' ? h('div', { style: styles.success }, notice) : null, view === 'review' ? h(ReviewView, { knowledgeBases: kbList ?? [] }) : null, view === 'search' ? h('div', null, h(SearchView, { knowledgeBases: kbList ?? [] }), h('div', { style: styles.hint }, '说明:检索作用域由宿主根据当前工作目录推导,前端无法越过作用域读取其他项目私有知识。')) : null, view === 'settings' ? h(SettingsView, null) : null)));
2802
+ const renderOverlay = () => h('div', { style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } }, h('div', { style: styles.backBar }, h('button', { type: 'button', style: styles.linkButton, className: 'kh-hover', onClick: () => setView('') }, '← 返回知识中心'), h('h2', { style: styles.headerTitle }, OVERLAY_TITLES[view] ?? view)), h('div', { style: styles.overlayScroll, className: 'kh-scroll' }, h('div', { style: styles.overlayInner }, view === 'review' ? h(ReviewView, { knowledgeBases: kbList ?? [] }) : null, view === 'search' ? h('div', null, h(SearchView, { knowledgeBases: kbList ?? [] }), h('div', { style: styles.hint }, '说明:检索作用域由宿主根据当前工作目录推导,前端无法越过作用域读取其他项目私有知识。')) : null, view === 'settings' ? h(SettingsView, null) : null)));
2531
2803
  const renderTab = () => {
2532
2804
  if (tab === 'kb') {
2533
2805
  return h('div', { style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } }, h('div', { style: styles.subBar }, booting ? h('span', null, '正在读取 DSH 环境…') : null, currentWorkspace && currentWorkspace.workspaceId
@@ -2542,8 +2814,8 @@ function Page() {
2542
2814
  return h('div', { style: styles.shell, className: 'knowhub-page' }, h('div', { style: styles.headerBar }, h('h1', { style: styles.headerTitle }, '知识中心'), h('div', { style: styles.headerTabs }, MAIN_TABS.map((item) => h('button', {
2543
2815
  key: item.key, type: 'button',
2544
2816
  style: view === '' && tab === item.key ? styles.headerTabActive : styles.headerTab,
2545
- onClick: () => { setTab(item.key); setView(''); setNotice(''); },
2546
- }, 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(); } }, '刷新'))), bootError !== null ? h('div', { style: { ...styles.error, margin: '10px 24px 0' } }, bootError) : null, notice !== '' && view === '' ? h('div', { style: { ...styles.success, margin: '10px 24px 0' } }, notice) : null, view !== '' ? renderOverlay() : renderTab());
2817
+ onClick: () => { setTab(item.key); setView(''); },
2818
+ }, 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(); } }, '刷新'))), bootError !== null ? h('div', { style: { ...styles.error, margin: '10px 24px 0' }, role: 'alert' }, bootError) : null, view !== '' ? renderOverlay() : renderTab(), h(ToastHost, null), h(ConfirmHost, null));
2547
2819
  }
2548
2820
  /** sidebar.panellist 行图标:自绘 SVG(不依赖 @deepseek-ai/dsh-client-ui-primitives)。 */
2549
2821
  function KnowHubIcon(props) {
@@ -2555,7 +2827,7 @@ function KnowHubIcon(props) {
2555
2827
  }, h('path', { d: 'M4 5.5A2.5 2.5 0 0 1 6.5 3H19v15.5H6.5A2.5 2.5 0 0 0 4 21V5.5Z' }), h('path', { d: 'M4 18.5A2.5 2.5 0 0 1 6.5 16H19' }), h('path', { d: 'M8.5 7.5h7' }), h('path', { d: 'M8.5 11h4.5' }));
2556
2828
  }
2557
2829
  /** 仅供构建期渲染冒烟测试使用(宿主不使用)。 */
2558
- exports.__smoke = { Page, DocsExplorer, KbView, CreateKbForm, EditKbForm, ImportMarkdownForm, ReviewView, SearchView, SettingsView, SettingsInjectionCard, DocumentDiffPage, diffUnifiedRows, diffSplitRows, renderMarkdown };
2830
+ exports.__smoke = { Page, DocsExplorer, KbView, CreateKbForm, EditKbForm, ImportMarkdownForm, ReviewView, SearchView, SettingsView, SettingsInjectionCard, DocumentDiffPage, diffUnifiedRows, diffSplitRows, renderMarkdown, Modal, ToastHost, ConfirmHost, pushToast, askConfirm, settleConfirm, useNoticeToast, toastSnapshot: () => toastState.items.map((item) => item.text) };
2559
2831
  exports.inject = ['slots'];
2560
2832
  function apply(ctx) {
2561
2833
  const slots = (() => { try {
@@ -8,7 +8,7 @@
8
8
  import { errorMessage, readBody, writeError, writeOk, writeText } from '../http.js';
9
9
  import { all, openDatabase } from '../db/db.js';
10
10
  import { resolveStorageRoot } from '../paths.js';
11
- import { createKnowledgeBase, deleteKnowledgeBase, getKnowledgeBase, KbError, listKnowledgeBases, updateKnowledgeBase, } from './store.js';
11
+ import { createKnowledgeBase, deleteKnowledgeBase, getKnowledgeBase, KbError, listAllKnowledgeBases, listKnowledgeBases, updateKnowledgeBase, } from './store.js';
12
12
  import { countDocumentList, countDocuments, createDocument, DocError, getDocument, listDocuments, resolveParentId, searchDocuments } from '../search.js';
13
13
  import { addFeedback, FeedbackError, feedbackSummary, feedbackSummaries } from '../feedback.js';
14
14
  import { exportKbMarkdown, importKbMarkdown, renderDocMarkdown, TransferError } from '../transfer.js';
@@ -69,6 +69,7 @@ export async function handleKbRequest(request) {
69
69
  }
70
70
  if (method === 'GET' && path === '/kb') {
71
71
  const requested = str(url.searchParams.get('workspaceId'));
72
+ const wantAll = ['1', 'true', 'yes'].includes(str(url.searchParams.get('all')).toLowerCase());
72
73
  let workspaceId;
73
74
  if (requested !== '') {
74
75
  const listed = await listWorkspaces(ctx);
@@ -83,11 +84,21 @@ export async function handleKbRequest(request) {
83
84
  }
84
85
  const listed = await listWorkspaces(ctx);
85
86
  const workspaces = listed.workspaces.map((workspace) => ({ id: workspace.id, title: workspace.title, path: workspace.path }));
86
- const rows = listKnowledgeBases(db(), scopeKeysFor(workspaceId)).map((row) => ({
87
+ // 管理面全量视图:知识库可能建在任意工作区,只按「当前工作区」过滤会导致刚建好的库看不见
88
+ // (同名却报已存在)。all=1 返回所有未删除知识库,并带 workspaceTitle 便于辨认。
89
+ const source = wantAll ? listAllKnowledgeBases(db()) : listKnowledgeBases(db(), scopeKeysFor(workspaceId));
90
+ const rows = source.map((row) => ({
87
91
  ...withWorkspaceInfo(row, workspaces),
88
92
  documentCount: countDocuments(db(), row.id),
89
93
  }));
90
- writeOk(res, { workspaceId, scopeKeys: scopeKeysFor(workspaceId), count: rows.length, knowledgeBases: rows });
94
+ writeOk(res, {
95
+ all: wantAll,
96
+ workspaceId,
97
+ currentWorkspaceId: workspaceId,
98
+ scopeKeys: scopeKeysFor(workspaceId),
99
+ count: rows.length,
100
+ knowledgeBases: rows,
101
+ });
91
102
  return true;
92
103
  }
93
104
  if (method === 'POST' && path === '/kb') {
@@ -96,6 +96,16 @@ function findByName(db, scopeKey, name) {
96
96
  const raw = get(db, SELECT + ' AND scope_key = ? AND name = ?', [scopeKey, name]);
97
97
  return raw ? toRow(raw) : null;
98
98
  }
99
+ /**
100
+ * 同名冲突提示必须说清「已有的那条在哪个作用范围」:
101
+ * 面板默认只列一个工作区,用户很容易以为知识库没建成,实际上它建在别的工作区里。
102
+ */
103
+ function duplicateMessage(existing, workspaceTitle) {
104
+ const where = existing.scope === 'user'
105
+ ? '用户级(所有工作区共享)'
106
+ : '工作区级 · ' + (workspaceTitle ?? existing.workspaceId ?? '未知工作区');
107
+ return '同一作用范围内已存在同名知识库:' + existing.name + '(' + where + ',id ' + existing.id + ')。可换个名称,或在「知识库」页把列表切到该工作区查看。';
108
+ }
99
109
  export async function createKnowledgeBase(db, input, options) {
100
110
  const errors = validateKbInput(input);
101
111
  if (errors.length > 0)
@@ -104,9 +114,9 @@ export async function createKnowledgeBase(db, input, options) {
104
114
  const scope = str(input.scope);
105
115
  const workspaceId = scope === 'workspace' ? str(input.workspaceId) : null;
106
116
  const scopeKey = scopeKeyOf(scope, workspaceId);
107
- if (findByName(db, scopeKey, name)) {
108
- throw new KbError('KB_NAME_DUPLICATED', 409, '同一作用范围内已存在同名知识库:' + name);
109
- }
117
+ const existing = findByName(db, scopeKey, name);
118
+ if (existing)
119
+ throw new KbError('KB_NAME_DUPLICATED', 409, duplicateMessage(existing, options.workspaceTitle ?? null));
110
120
  const timestamp = Date.now();
111
121
  const row = {
112
122
  id: newId('kb'),
@@ -135,7 +145,8 @@ export async function createKnowledgeBase(db, input, options) {
135
145
  catch (error) {
136
146
  const message = error instanceof Error ? error.message : String(error);
137
147
  if (/UNIQUE constraint failed/i.test(message)) {
138
- throw new KbError('KB_NAME_DUPLICATED', 409, '同一作用范围内已存在同名知识库:' + name);
148
+ const dup = findByName(db, scopeKey, name);
149
+ throw new KbError('KB_NAME_DUPLICATED', 409, dup ? duplicateMessage(dup, options.workspaceTitle ?? null) : '同一作用范围内已存在同名知识库:' + name);
139
150
  }
140
151
  throw error;
141
152
  }
package/lib/host/route.js CHANGED
@@ -49,6 +49,7 @@ export function createRouter(ctx, config) {
49
49
  'POST /knowhub/api/storage-probe {value}',
50
50
  'GET /knowhub/api/current-workspace',
51
51
  'GET|POST /knowhub/api/kb',
52
+ 'GET /knowhub/api/kb?workspaceId=&all=1',
52
53
  'GET|PATCH|DELETE /knowhub/api/kb/:id',
53
54
  'GET|POST /knowhub/api/kb/:id/docs',
54
55
  'GET /knowhub/api/search?q=&tag=&kbId=&kind=&status=&sort=',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-knowhub",
3
- "version": "0.8.3",
3
+ "version": "0.8.6",
4
4
  "description": "知枢 KnowHub — DSH 全局知识中心与项目长期记忆:知识库、Repo Wiki、审核与版本、增量更新、检索与上下文注入、自动提炼、备份恢复、中文面板",
5
5
  "license": "MIT",
6
6
  "type": "module",