dsh-knowhub 0.8.3 → 0.8.5

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
@@ -121,6 +121,13 @@ cmd /c mklink /J "$env:USERPROFILE\.dsh\profiles\knowhub\node_modules\dsh-knowhu
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;
@@ -667,6 +879,7 @@ function KbView(props) {
667
879
  const [error, setError] = R.useState(null);
668
880
  const [showForm, setShowForm] = R.useState(false);
669
881
  const [kbQuery, setKbQuery] = R.useState('');
882
+ const [listWorkspace, setListWorkspace] = R.useState('all');
670
883
  const [detail, setDetail] = R.useState(null);
671
884
  const [detailLoading, setDetailLoading] = R.useState(false);
672
885
  const [pendingDelete, setPendingDelete] = R.useState(null);
@@ -712,11 +925,15 @@ function KbView(props) {
712
925
  // 增量状态读取失败不影响主流程
713
926
  }
714
927
  }, []);
715
- const load = R.useCallback(async () => {
928
+ // 列表范围:'all' 看全部(用户级 + 所有工作区),或指定某个工作区。
929
+ // 不能只按 cwd 反查出的「当前工作区」过滤:知识库允许建在任意工作区,
930
+ // 建完看不见、同名又报已存在,就是这样产生的。
931
+ const load = R.useCallback(async (workspaceOverride) => {
716
932
  setLoading(true);
717
933
  setError(null);
934
+ const target = typeof workspaceOverride === 'string' ? workspaceOverride : listWorkspace;
718
935
  try {
719
- const query = currentWorkspace && currentWorkspace.workspaceId ? '?workspaceId=' + encodeURIComponent(currentWorkspace.workspaceId) : '';
936
+ const query = target === 'all' ? '?all=1' : '?workspaceId=' + encodeURIComponent(target);
720
937
  const value = await request('/kb' + query);
721
938
  setList(value.knowledgeBases ?? []);
722
939
  }
@@ -726,7 +943,7 @@ function KbView(props) {
726
943
  finally {
727
944
  setLoading(false);
728
945
  }
729
- }, [currentWorkspace]);
946
+ }, [listWorkspace]);
730
947
  R.useEffect(() => { void load(); }, [load]);
731
948
  R.useEffect(() => { void load(); }, [props.refreshKey]);
732
949
  // 打开详情后每 1.5s 轮询任务;succeeded 时刷新文档与索引摘要(真实数据,无本地伪造)。
@@ -938,30 +1155,42 @@ function KbView(props) {
938
1155
  }), h('button', {
939
1156
  type: 'button', style: styles.primary, className: 'kh-primary',
940
1157
  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 ? '当前作用范围还没有知识库。点击「+ 新建」创建。' : '没有匹配的知识库名称。')
1158
+ }, showForm ? '收起' : '+ 新建')), h('div', { style: { padding: '0 12px 8px', display: 'flex', flexDirection: 'column', gap: '6px' } }, h('select', {
1159
+ style: { ...styles.input, height: '30px' },
1160
+ value: listWorkspace,
1161
+ onChange: (event) => { setListWorkspace(event.target.value); setDetail(null); },
1162
+ }, h('option', { value: 'all' }, '全部工作区(用户级 + 各工作区)'), (workspaces ?? []).map((workspace) => h('option', { key: workspace.id, value: workspace.id }, (workspace.title ?? workspace.path ?? workspace.id) + (workspace.pathState === 'missing' ? '(目录不可用)' : '')))), h('div', { style: styles.hint }, listWorkspace === 'all'
1163
+ ? '显示用户级知识库与所有工作区的知识库。'
1164
+ : '只显示该工作区的知识库,以及用户级知识库。')), error !== null ? h('div', { style: { ...styles.error, margin: '0 10px 10px' } }, error) : null, h('div', { style: styles.listScroll, className: 'kh-scroll' }, list === null ? (loading ? h('div', { style: styles.listEmpty }, '正在加载知识库列表…') : null)
1165
+ : filteredList.length === 0 ? h('div', { style: styles.listEmpty }, list.length === 0 ? (listWorkspace === 'all' ? '还没有任何知识库。点击「+ 新建」创建。' : '该范围还没有知识库。') : '没有匹配的知识库名称。')
943
1166
  : filteredList.map((kb) => h('button', {
944
1167
  key: kb.id, type: 'button',
945
1168
  style: detail !== null && detail.knowledgeBase.id === kb.id ? styles.treeRowActive : styles.treeRow,
946
1169
  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
1170
+ }, 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
1171
  ? 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, {
1172
+ : 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
1173
  models, workspaces, currentWorkspaceId: currentWorkspace?.workspaceId ?? '',
952
1174
  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, {
1175
+ onCreated: async (kb) => {
1176
+ setShowForm(false);
1177
+ notify('知识库「' + kb.name + '」已创建。下一步:在详情里点「开始初始化」。');
1178
+ // 新建的库可能绑在别的工作区:直接把列表切过去并打开详情,避免「建完了却看不见」。
1179
+ const target = kb.scope === 'workspace' && kb.workspaceId ? String(kb.workspaceId) : undefined;
1180
+ if (target !== undefined)
1181
+ setListWorkspace(target);
1182
+ await load(target);
1183
+ await openDetail(kb.id);
1184
+ },
1185
+ })) : null, importOpen && detail !== null ? h(Modal, { onClose: () => setImportOpen(false), maxWidth: '760px' }, h(ImportMarkdownForm, {
956
1186
  kbId: detail.knowledgeBase.id, kbName: detail.knowledgeBase.name,
957
1187
  onCancel: () => setImportOpen(false),
958
1188
  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, {
1189
+ })) : null, editOpen && detail !== null ? h(Modal, { onClose: () => setEditOpen(false), maxWidth: '680px' }, h(EditKbForm, {
961
1190
  kb: detail.knowledgeBase, models,
962
1191
  onCancel: () => setEditOpen(false),
963
1192
  onSaved: async (kb) => { setEditOpen(false); notify('知识库「' + kb.name + '」配置已更新'); await openDetail(kb.id); await load(); },
964
- }))) : null);
1193
+ })) : null);
965
1194
  }
966
1195
  // ---------- 检索 ----------
967
1196
  function SearchView(props) {
@@ -1492,7 +1721,7 @@ function DocumentDiffPage(props) {
1492
1721
  ? h('span', null, base.version === null || base.version === undefined ? '没有历史版本可对比,当前正文全部显示为新增' : '左为上一版内容,右为当前内容')
1493
1722
  : 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
1723
  ? 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);
1724
+ : 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
1725
  }
1497
1726
  function ReviewView(props) {
1498
1727
  const { knowledgeBases } = props;
@@ -1501,6 +1730,7 @@ function ReviewView(props) {
1501
1730
  const [loading, setLoading] = R.useState(false);
1502
1731
  const [error, setError] = R.useState(null);
1503
1732
  const [notice, setNotice] = R.useState('');
1733
+ useNoticeToast(notice, () => setNotice(''));
1504
1734
  const [busy, setBusy] = R.useState(null);
1505
1735
  const [diffPage, setDiffPage] = R.useState(null);
1506
1736
  const [proposalEdit, setProposalEdit] = R.useState(null);
@@ -1760,7 +1990,7 @@ function ReviewView(props) {
1760
1990
  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
1991
  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
1992
  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 () => {
1993
+ 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
1994
  setError(null);
1765
1995
  try {
1766
1996
  const value = await request('/kb/' + encodeURIComponent(docPanel.kbId) + '/docs/' + encodeURIComponent(docPanel.docId) + '/versions/' + item.version);
@@ -1797,7 +2027,7 @@ function EmptyState(props) {
1797
2027
  return h('div', { style: styles.empty }, props.text ?? '暂无数据');
1798
2028
  }
1799
2029
  function ErrorBox(props) {
1800
- return h('div', { style: styles.error }, props.text);
2030
+ return h('div', { style: styles.error, role: 'alert' }, h('span', { style: { marginRight: '6px', fontWeight: 600 } }, '⚠'), h('span', null, String(props.text ?? '')));
1801
2031
  }
1802
2032
  /** M5 总览:知识库/知识/审核/任务/最近活动一屏可见,点击可直接跳到对应页签。 */
1803
2033
  function OverviewView(props) {
@@ -1995,6 +2225,7 @@ function DocsExplorer(props) {
1995
2225
  const [loading, setLoading] = R.useState(false);
1996
2226
  const [error, setError] = R.useState(null);
1997
2227
  const [notice, setNotice] = R.useState('');
2228
+ useNoticeToast(notice, () => setNotice(''));
1998
2229
  const [selectedId, setSelectedId] = R.useState('');
1999
2230
  const [detail, setDetail] = R.useState(null);
2000
2231
  const [detailError, setDetailError] = R.useState(null);
@@ -2096,7 +2327,12 @@ function DocsExplorer(props) {
2096
2327
  const markStale = async () => {
2097
2328
  if (detail === null)
2098
2329
  return;
2099
- if (!window.confirm('确认把这条知识标记为过时?标记后仍保留正文与历史版本,可在审核里恢复。'))
2330
+ const ok = await askConfirm({
2331
+ title: '标记为过时',
2332
+ text: '确认把这条知识标记为过时?标记后仍保留正文与历史版本,可在审核里恢复。',
2333
+ confirmText: '确认标记过时',
2334
+ });
2335
+ if (!ok)
2100
2336
  return;
2101
2337
  setBusy(true);
2102
2338
  setDetailError(null);
@@ -2156,7 +2392,12 @@ function DocsExplorer(props) {
2156
2392
  const restoreVersion = async (version) => {
2157
2393
  if (docBasePath() === '')
2158
2394
  return;
2159
- if (!window.confirm('确认恢复为第 ' + version + ' 版内容?恢复会以新版本方式记录,不会静默覆盖当前内容。'))
2395
+ const ok = await askConfirm({
2396
+ title: '恢复历史版本',
2397
+ text: '确认恢复为第 ' + version + ' 版内容?恢复会以新版本方式记录,不会静默覆盖当前内容。',
2398
+ confirmText: '确认恢复此版本',
2399
+ });
2400
+ if (!ok)
2160
2401
  return;
2161
2402
  setDetailError(null);
2162
2403
  setVersionBusy('恢复第 ' + version + ' 版…');
@@ -2302,7 +2543,7 @@ function DocsExplorer(props) {
2302
2543
  : h('div', { style: styles.readerMeta }, '从左侧列表选择一条知识,右侧显示正文、来源与版本历史。')), detail !== null
2303
2544
  ? 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
2545
  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
2546
+ : null), h('div', { style: styles.readerScroll, className: 'kh-scroll' }, h('div', { style: styles.readerInner }, detailError !== null ? ErrorBox({ text: detailError }) : null, detail === null
2306
2547
  ? (selectedId === '' ? EmptyState({ text: '从左侧选择一条知识查看正文。' }) : null)
2307
2548
  : h('div', null, editing
2308
2549
  ? 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 +2594,6 @@ function SettingsView() {
2353
2594
  const [stats, setStats] = R.useState(null);
2354
2595
  const [runs, setRuns] = R.useState([]);
2355
2596
  const [error, setError] = R.useState(null);
2356
- const [notice, setNotice] = R.useState('');
2357
2597
  const [busy, setBusy] = R.useState('');
2358
2598
  const [target, setTarget] = R.useState('');
2359
2599
  const [backupNote, setBackupNote] = R.useState('');
@@ -2390,68 +2630,113 @@ function SettingsView() {
2390
2630
  };
2391
2631
  setBusy('save');
2392
2632
  setError(null);
2393
- setNotice('');
2394
2633
  request('/settings', { method: 'PATCH', body }).then((value) => {
2395
2634
  setSnapshot(value);
2396
2635
  setForm({ ...value.settings, excludeDirsText: (value.settings?.excludeDirs ?? []).join('\n') });
2397
- setNotice('设置已保存');
2636
+ pushToast('设置已保存', 'success');
2398
2637
  }).catch((failure) => setError(message(failure))).then(() => setBusy(''));
2399
2638
  };
2400
- const migrate = (dryRun) => {
2639
+ const migrate = async (dryRun) => {
2401
2640
  if (target.trim() === '') {
2402
2641
  setError('请先填写目标目录绝对路径');
2403
2642
  return;
2404
2643
  }
2405
- if (!dryRun && !window.confirm('确认把知识中心数据迁移到:' + target + '?\n迁移前会自动创建备份,校验失败时不会切换位置。'))
2406
- return;
2644
+ if (!dryRun) {
2645
+ const ok = await askConfirm({
2646
+ title: '迁移知识中心数据',
2647
+ text: '确认把知识中心数据迁移到:' + target + '?\n迁移前会自动创建备份,校验失败时不会切换位置。',
2648
+ confirmText: '开始迁移',
2649
+ });
2650
+ if (!ok)
2651
+ return;
2652
+ }
2407
2653
  setBusy(dryRun ? 'dry' : 'migrate');
2408
2654
  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(''));
2655
+ try {
2656
+ const value = await request('/settings/storage/migrate', { method: 'POST', body: { target: target.trim(), dryRun } });
2657
+ pushToast(dryRun
2658
+ ? ('预检通过:将复制 ' + (value.willCopy?.files ?? 0) + ' 个文件,共 ' + formatBytes(value.willCopy?.bytes ?? 0))
2659
+ : ('已迁移到 ' + value.to + ';请重启 DSH 后生效(原数据仍在 ' + value.from + ')'), dryRun ? 'info' : 'success');
2660
+ }
2661
+ catch (failure) {
2662
+ pushToast(message(failure), 'error');
2663
+ }
2664
+ finally {
2665
+ setBusy('');
2666
+ }
2413
2667
  };
2414
- const createBackup = () => {
2668
+ const createBackup = async () => {
2415
2669
  setBusy('backup');
2416
2670
  setError(null);
2417
- setNotice('');
2418
- request('/backups', { method: 'POST', body: { note: backupNote.trim() } }).then((value) => {
2419
- setNotice('备份完成:' + value.kbCount + ' 个知识库 / ' + value.docCount + ' 条知识 / ' + formatBytes(value.bytes));
2671
+ try {
2672
+ const value = await request('/backups', { method: 'POST', body: { note: backupNote.trim() } });
2673
+ pushToast('备份完成:' + value.kbCount + ' 个知识库 / ' + value.docCount + ' 条知识 / ' + formatBytes(value.bytes), 'success');
2420
2674
  setBackupNote('');
2421
2675
  loadBackups();
2422
- }).catch((failure) => setError(message(failure))).then(() => setBusy(''));
2676
+ }
2677
+ catch (failure) {
2678
+ pushToast(message(failure), 'error');
2679
+ }
2680
+ finally {
2681
+ setBusy('');
2682
+ }
2423
2683
  };
2424
- const openPreview = (item) => {
2684
+ const openPreview = async (item) => {
2425
2685
  if (busy !== '')
2426
2686
  return;
2427
2687
  setBusy('preview:' + item.id);
2428
2688
  setError(null);
2429
- setNotice('');
2430
- request('/backups/' + encodeURIComponent(item.id) + '/preview').then((value) => setPreview(value))
2431
- .catch((failure) => setError(message(failure))).then(() => setBusy(''));
2689
+ try {
2690
+ setPreview(await request('/backups/' + encodeURIComponent(item.id) + '/preview'));
2691
+ }
2692
+ catch (failure) {
2693
+ pushToast(message(failure), 'error');
2694
+ }
2695
+ finally {
2696
+ setBusy('');
2697
+ }
2432
2698
  };
2433
- const restore = (item) => {
2434
- if (!window.confirm('确认从备份 ' + item.file + ' 恢复?\n恢复采用合并语义:缺失的库与知识会重建,正文不同的知识只生成待审核候选,不会覆盖现有内容。'))
2699
+ const restore = async (item) => {
2700
+ const ok = await askConfirm({
2701
+ title: '从备份恢复',
2702
+ text: '确认从备份 ' + item.file + ' 恢复?\n恢复采用合并语义:缺失的库与知识会重建,正文不同的知识只生成待审核候选,不会覆盖现有内容。',
2703
+ confirmText: '开始恢复',
2704
+ });
2705
+ if (!ok)
2435
2706
  return;
2436
2707
  setBusy(item.id);
2437
2708
  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 + ' 条');
2709
+ try {
2710
+ const value = await request('/backups/' + encodeURIComponent(item.id) + '/restore', { method: 'POST', body: {} });
2711
+ pushToast('恢复完成:重建知识库 ' + value.createdKbs + ' 个 / 知识 ' + value.createdDocs + ' 条 / 新候选 ' + value.proposals + ' 条 / 跳过 ' + value.skipped + ' 条', 'success');
2441
2712
  loadBackups();
2442
- }).catch((failure) => setError(message(failure))).then(() => setBusy(''));
2713
+ }
2714
+ catch (failure) {
2715
+ pushToast(message(failure), 'error');
2716
+ }
2717
+ finally {
2718
+ setBusy('');
2719
+ }
2443
2720
  };
2721
+ // 备份预览是独立浮层:补 Esc 关闭(与其它弹窗一致)。
2722
+ R.useEffect(() => {
2723
+ if (preview === null || typeof window === 'undefined' || typeof window.addEventListener !== 'function')
2724
+ return undefined;
2725
+ const onKey = (event) => { if (event.key === 'Escape')
2726
+ setPreview(null); };
2727
+ window.addEventListener('keydown', onKey);
2728
+ return () => window.removeEventListener('keydown', onKey);
2729
+ }, [preview]);
2444
2730
  if (snapshot === null || form === null) {
2445
2731
  return error !== null ? h('div', null, ErrorBox({ text: error }), h('button', { type: 'button', style: styles.button, className: 'kh-hover', onClick: loadSettings }, '重试')) : Loading({ text: '正在读取设置…' });
2446
2732
  }
2447
2733
  const storage = snapshot.storage ?? {};
2448
2734
  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', {
2735
+ 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
2736
  type: 'button', style: styles.button, className: 'kh-hover',
2452
2737
  onClick: () => { const item = (backups ?? []).find((entry) => entry.id === preview.backupId); setPreview(null); if (item)
2453
2738
  restore(item); },
2454
- }, '按合并语义恢复…') : null)))) : null, h('div', { style: styles.sectionTitle }, '环境与接口诊断'), h(DiagnosticsView, null));
2739
+ }, '按合并语义恢复…') : null))) : null, h('div', { style: styles.sectionTitle }, '环境与接口诊断'), h(DiagnosticsView, null));
2455
2740
  }
2456
2741
  /**
2457
2742
  * 知枢知识中心主面板。
@@ -2478,7 +2763,6 @@ function Page() {
2478
2763
  const [kbList, setKbList] = R.useState(null);
2479
2764
  const [bootError, setBootError] = R.useState(null);
2480
2765
  const [booting, setBooting] = R.useState(false);
2481
- const [notice, setNotice] = R.useState('');
2482
2766
  const [reloadKey, setReloadKey] = R.useState(0);
2483
2767
  const [reviewCount, setReviewCount] = R.useState(0);
2484
2768
  R.useEffect(() => {
@@ -2517,7 +2801,7 @@ function Page() {
2517
2801
  })();
2518
2802
  return () => { alive = false; };
2519
2803
  }, [reloadKey]);
2520
- const notify = (text) => setNotice(text);
2804
+ const notify = (text) => { pushToast(text, 'success'); };
2521
2805
  const refreshReviewCount = async () => {
2522
2806
  try {
2523
2807
  const value = await request('/review?limit=1');
@@ -2527,7 +2811,7 @@ function Page() {
2527
2811
  catch { /* 徽标失败不打断界面 */ }
2528
2812
  };
2529
2813
  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)));
2814
+ 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
2815
  const renderTab = () => {
2532
2816
  if (tab === 'kb') {
2533
2817
  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 +2826,8 @@ function Page() {
2542
2826
  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
2827
  key: item.key, type: 'button',
2544
2828
  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());
2829
+ onClick: () => { setTab(item.key); setView(''); },
2830
+ }, 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
2831
  }
2548
2832
  /** sidebar.panellist 行图标:自绘 SVG(不依赖 @deepseek-ai/dsh-client-ui-primitives)。 */
2549
2833
  function KnowHubIcon(props) {
@@ -2555,7 +2839,7 @@ function KnowHubIcon(props) {
2555
2839
  }, 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
2840
  }
2557
2841
  /** 仅供构建期渲染冒烟测试使用(宿主不使用)。 */
2558
- exports.__smoke = { Page, DocsExplorer, KbView, CreateKbForm, EditKbForm, ImportMarkdownForm, ReviewView, SearchView, SettingsView, SettingsInjectionCard, DocumentDiffPage, diffUnifiedRows, diffSplitRows, renderMarkdown };
2842
+ 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
2843
  exports.inject = ['slots'];
2560
2844
  function apply(ctx) {
2561
2845
  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.5",
4
4
  "description": "知枢 KnowHub — DSH 全局知识中心与项目长期记忆:知识库、Repo Wiki、审核与版本、增量更新、检索与上下文注入、自动提炼、备份恢复、中文面板",
5
5
  "license": "MIT",
6
6
  "type": "module",