tavern-harness 0.1.12 → 0.1.13

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +345 -0
  2. package/README.md +12 -4
  3. package/README.zh.md +12 -4
  4. package/bin/cli.js +16 -2
  5. package/index.html +3 -1
  6. package/package.json +18 -5
  7. package/persistence-server.mjs +303 -0
  8. package/public/sillytavern-logo.png +0 -0
  9. package/src/App.tsx +72 -8
  10. package/src/components/CharactersView.tsx +221 -68
  11. package/src/components/ChatView.tsx +98 -69
  12. package/src/components/FileDisplayModal.tsx +28 -17
  13. package/src/components/NewSessionMenu.tsx +2 -9
  14. package/src/components/PopoverMenu.tsx +54 -0
  15. package/src/components/SettingsView.tsx +21 -1
  16. package/src/components/Sidebar.tsx +133 -53
  17. package/src/components/VersionHistoryModal.tsx +114 -0
  18. package/src/components/shared.tsx +12 -3
  19. package/src/core/browserPreferences.ts +45 -0
  20. package/src/core/changelog.ts +43 -0
  21. package/src/core/characterBook.ts +32 -0
  22. package/src/core/clipboard.ts +32 -0
  23. package/src/core/editable.ts +26 -0
  24. package/src/core/gameplay.ts +2 -0
  25. package/src/core/i18n.ts +141 -10
  26. package/src/core/longPress.ts +209 -0
  27. package/src/core/longPressGesture.ts +110 -0
  28. package/src/core/mobileViewport.ts +362 -0
  29. package/src/core/openai.ts +39 -7
  30. package/src/core/sillyTavernWorldInfo.ts +159 -0
  31. package/src/core/sortableSensors.ts +21 -0
  32. package/src/core/stickyBottom.ts +45 -0
  33. package/src/core/tools/generatedSkillExecutor.ts +4 -20
  34. package/src/core/version.ts +46 -0
  35. package/src/core/viewport.ts +81 -0
  36. package/src/core/wechatTouch.ts +145 -0
  37. package/src/db/database.ts +313 -2
  38. package/src/generated/changelog.json +510 -0
  39. package/src/main.tsx +18 -2
  40. package/src/store/store.ts +99 -33
  41. package/src/theme/chat.css +174 -49
  42. package/src/theme/global.css +391 -10
  43. package/src/theme/theme.ts +9 -2
  44. package/src/theme/views.css +341 -8
  45. package/src/types/models.ts +9 -0
  46. package/src/vite-env.d.ts +4 -1
  47. package/vite.config.ts +184 -17
@@ -1,21 +1,17 @@
1
1
  import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  DndContext,
4
- PointerSensor,
5
- KeyboardSensor,
6
4
  closestCenter,
7
- useSensor,
8
- useSensors,
9
5
  type DragEndEvent,
10
6
  } from '@dnd-kit/core';
11
7
  import {
12
8
  SortableContext,
13
9
  arrayMove,
14
- sortableKeyboardCoordinates,
15
10
  verticalListSortingStrategy,
16
11
  useSortable,
17
12
  } from '@dnd-kit/sortable';
18
13
  import { CSS } from '@dnd-kit/utilities';
14
+ import { useSortableSensors } from '../core/sortableSensors';
19
15
  import { useStore } from '../store/store';
20
16
  import {
21
17
  BUILTIN_TAVERN_HANDBOOK_CONTENT,
@@ -27,6 +23,8 @@ import type { NpcCharacter, WorldBook, McpTool } from '../types/models';
27
23
  import { Avatar, Icon, MasonryGrid, Markdown, Modal } from './shared';
28
24
  import { DeleteConfirmDialog } from './DeleteConfirmDialog';
29
25
  import { parseSillyTavernCardFile } from '../core/sillyTavernImporter';
26
+ import { parseSillyTavernWorldInfoFile } from '../core/sillyTavernWorldInfo';
27
+ import { characterBookEntryStats } from '../core/characterBook';
30
28
  import { ALL_BUILTIN_TOOL_NAMES } from '../core/toolDefinitions';
31
29
  import { useT } from '../core/i18n';
32
30
 
@@ -157,7 +155,7 @@ function CharacterGrid({ npcs, onEdit, onImportPng }: { npcs: NpcCharacter[]; on
157
155
  <span className="group-label" style={{ margin: 0 }}>{t('workshop.charCount', { n: npcs.length })}</span>
158
156
  <div style={{ display: 'flex', gap: 8 }}>
159
157
  <button className="btn btn-sm" onClick={onImportPng} title={t('workshop.pngImportTip')}>
160
- <Icon name="import" size={13} /> {t('workshop.pngImport')}
158
+ <img className="btn-logo-icon" src="/sillytavern-logo.png" alt="" /> {t('workshop.pngImport')}
161
159
  </button>
162
160
  <button
163
161
  className="btn btn-primary btn-sm"
@@ -296,9 +294,9 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
296
294
  };
297
295
 
298
296
  return (
299
- <Modal onClose={onClose} className="modal-editor character-editor-modal">
297
+ <Modal onClose={onClose} className="modal-editor editor-modal-with-tabs character-editor-modal">
300
298
  <div className="modal-head">
301
- <span style={{ fontWeight: 800, fontSize: 15 }}>{isNew ? t('workshop.newCharTitle') : t('workshop.editCharTitle', { name: npc.name })}</span>
299
+ <span style={{ fontWeight: 800, fontSize: 15 }}>{isNew ? t('workshop.newCharTitle') : t('workshop.editCharTitle')}</span>
302
300
  <div className="raw-tabs">
303
301
  <button className={`raw-tab ${editorTab === 'character' ? 'active' : ''}`} onClick={() => setEditorTab('character')}>{t('workshop.characterTab')}</button>
304
302
  <button
@@ -435,36 +433,89 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
435
433
 
436
434
  // ---------------- Lorebook ----------------
437
435
 
436
+ type WorldBookDraft = WorldBook | {
437
+ name: string;
438
+ content: string;
439
+ characterBook?: string;
440
+ imageUri: null;
441
+ createdAt: number;
442
+ };
443
+
438
444
  function WorldBookList({ books, onChanged }: {
439
445
  books: WorldBook[];
440
446
  onChanged: () => void;
441
447
  }) {
442
448
  const addToast = useStore((s) => s.addToast);
443
449
  const t = useT();
444
- const [editing, setEditing] = useState<WorldBook | { name: string; content: string; imageUri: null; createdAt: number } | null>(null);
445
- const [renderMarkdown, setRenderMarkdown] = useState(false);
450
+ const [editing, setEditing] = useState<WorldBookDraft | null>(null);
451
+ const [editorTab, setEditorTab] = useState<'worldbook' | 'knowledge'>('worldbook');
446
452
  const [pendingDelete, setPendingDelete] = useState<WorldBook | null>(null);
453
+ const jsonFileRef = useRef<HTMLInputElement>(null);
447
454
 
448
455
  const closeEditor = () => {
449
456
  setEditing(null);
450
457
  };
451
458
 
452
- const saveWb = async (name: string, content: string) => {
453
- if (!name.trim() || !content.trim()) {
459
+ const openEditor = (draft: WorldBookDraft, tab: 'worldbook' | 'knowledge' = 'worldbook') => {
460
+ setEditorTab(tab);
461
+ setEditing(draft);
462
+ };
463
+
464
+ const saveWb = async (name: string, content: string, characterBook: string) => {
465
+ const knowledge = characterBook.trim();
466
+ if (!name.trim()) {
467
+ addToast(t('toast.wbNameRequired'), 'error');
468
+ return;
469
+ }
470
+ if (!content.trim() && !knowledge) {
454
471
  addToast(t('toast.wbNameContentRequired'), 'error');
455
472
  return;
456
473
  }
474
+ if (knowledge) {
475
+ try {
476
+ JSON.parse(knowledge);
477
+ } catch {
478
+ addToast(t('toast.knowledgeInvalidJson'), 'error');
479
+ return;
480
+ }
481
+ }
482
+ const data = {
483
+ name: name.trim(),
484
+ content,
485
+ characterBook: knowledge || undefined,
486
+ };
457
487
  if (editing && 'id' in editing && editing.id != null) {
458
- await db.worldBooks.update(editing.id, { name: name.trim(), content });
488
+ await db.worldBooks.update(editing.id, data);
459
489
  addToast(t('toast.wbUpdated'));
460
490
  } else {
461
- await db.worldBooks.add({ name: name.trim(), content, imageUri: null, createdAt: Date.now() });
491
+ await db.worldBooks.add({ ...data, imageUri: null, createdAt: Date.now() });
462
492
  addToast(t('toast.wbCreated'));
463
493
  }
464
494
  setEditing(null);
465
495
  onChanged();
466
496
  };
467
497
 
498
+ const importWorldInfo = async (file: File) => {
499
+ try {
500
+ const imported = await parseSillyTavernWorldInfoFile(file, t('workshop.importedWbName'));
501
+ // 与 PNG 角色卡导入一致:只预填草稿,由用户确认后保存。
502
+ openEditor({
503
+ name: imported.name,
504
+ content: imported.content,
505
+ characterBook: imported.characterBook,
506
+ imageUri: null,
507
+ createdAt: Date.now(),
508
+ }, 'knowledge');
509
+ addToast(t('toast.wbImported', {
510
+ name: imported.name,
511
+ total: imported.entryCount,
512
+ enabled: imported.enabledEntryCount,
513
+ }));
514
+ } catch (e) {
515
+ addToast(t('toast.wbImportFailed', { msg: (e as Error).message }), 'error');
516
+ }
517
+ };
518
+
468
519
  const deleteWb = async (b: WorldBook) => {
469
520
  if (isBuiltinTavernHandbook(b)) return;
470
521
  await db.worldBooks.delete(b.id!);
@@ -477,11 +528,25 @@ function WorldBookList({ books, onChanged }: {
477
528
  <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
478
529
  <span className="group-label" style={{ margin: 0 }}>{t('workshop.wbCount', { n: books.length })}</span>
479
530
  <div style={{ display: 'flex', gap: 8 }}>
480
- <button className="btn btn-primary btn-sm" onClick={() => setEditing({ name: '', content: '', imageUri: null, createdAt: Date.now() })}>
531
+ <button className="btn btn-sm" onClick={() => jsonFileRef.current?.click()} title={t('workshop.jsonImportTip')}>
532
+ <img className="btn-logo-icon" src="/sillytavern-logo.png" alt="" /> {t('workshop.jsonImport')}
533
+ </button>
534
+ <button className="btn btn-primary btn-sm" onClick={() => openEditor({ name: '', content: '', imageUri: null, createdAt: Date.now() })}>
481
535
  <Icon name="plus" size={13} /> {t('workshop.newWb')}
482
536
  </button>
483
537
  </div>
484
538
  </div>
539
+ <input
540
+ ref={jsonFileRef}
541
+ type="file"
542
+ accept="application/json,.json"
543
+ hidden
544
+ onChange={(e) => {
545
+ const f = e.target.files?.[0];
546
+ if (f) void importWorldInfo(f);
547
+ e.target.value = '';
548
+ }}
549
+ />
485
550
  {books.length === 0 && (
486
551
  <div className="empty-state">
487
552
  <div className="big">📖</div>
@@ -491,6 +556,11 @@ function WorldBookList({ books, onChanged }: {
491
556
  <MasonryGrid>
492
557
  {books.map((b) => {
493
558
  const isBuiltin = isBuiltinTavernHandbook(b);
559
+ const knowledge = characterBookEntryStats(b.characterBook);
560
+ const meta = [
561
+ b.content ? t('workshop.wbChars', { n: b.content.length }) : '',
562
+ knowledge.total > 0 ? t('workshop.wbKnowledgeMeta', { enabled: knowledge.enabled, total: knowledge.total }) : '',
563
+ ].filter(Boolean).join(' · ');
494
564
  return (
495
565
  <div key={b.id} className="char-card wb-card fade-up">
496
566
  <div className="cname">
@@ -498,10 +568,10 @@ function WorldBookList({ books, onChanged }: {
498
568
  <ScrollingCardName name={b.name} />
499
569
  {isBuiltin && <span className="tag">{t('common.builtin')}</span>}
500
570
  </div>
501
- <div className="wcontent">{b.content}</div>
502
- <div className="cmeta">{t('workshop.wbChars', { n: b.content.length })}</div>
571
+ <div className="wcontent">{b.content || t('workshop.wbNoContent')}</div>
572
+ <div className="cmeta">{meta}</div>
503
573
  <div className="cactions">
504
- <button className="btn btn-sm" onClick={() => setEditing(b)}><Icon name="settings" size={12} /> {t('workshop.browseEdit')}</button>
574
+ <button className="btn btn-sm" onClick={() => openEditor(b)}><Icon name="settings" size={12} /> {t('workshop.browseEdit')}</button>
505
575
  <button
506
576
  className="btn btn-sm btn-danger"
507
577
  onClick={() => setPendingDelete(b)}
@@ -526,21 +596,14 @@ function WorldBookList({ books, onChanged }: {
526
596
  )}
527
597
 
528
598
  {editing && (
529
- <Modal onClose={closeEditor} className="modal-editor">
530
- <div className="modal-head">
531
- <span style={{ fontWeight: 800, fontSize: 15 }}>{'id' in editing && editing.id != null ? t('workshop.editWbTitle') : t('workshop.newWbTitle')}</span>
532
- <div className="modal-head-actions">
533
- <div className="raw-wrap-toggle">
534
- <span className="raw-wrap-label">{t('workshop.wbMarkdownRender')}</span>
535
- <label className="switch">
536
- <input type="checkbox" checked={renderMarkdown} onChange={(e) => setRenderMarkdown(e.target.checked)} />
537
- <span className="switch-slider" />
538
- </label>
539
- </div>
540
- <button className="icon-btn" onClick={closeEditor}><Icon name="x" /></button>
541
- </div>
542
- </div>
543
- <WorldBookForm initial={editing} renderMarkdown={renderMarkdown} onSave={saveWb} onCancel={closeEditor} />
599
+ <Modal onClose={closeEditor} className="modal-editor editor-modal-with-tabs">
600
+ <WorldBookForm
601
+ initial={editing}
602
+ tab={editorTab}
603
+ onTabChange={setEditorTab}
604
+ onSave={saveWb}
605
+ onClose={closeEditor}
606
+ />
544
607
  </Modal>
545
608
  )}
546
609
  </div>
@@ -570,49 +633,134 @@ function ScrollingCardName({ name }: { name: string }) {
570
633
  );
571
634
  }
572
635
 
573
- function WorldBookForm({ initial, renderMarkdown, onSave, onCancel }: { initial: WorldBook | { name: string; content: string; imageUri: null; createdAt: number }; renderMarkdown: boolean; onSave: (n: string, c: string) => void; onCancel: () => void }) {
636
+ function WorldBookForm({
637
+ initial,
638
+ tab,
639
+ onTabChange,
640
+ onSave,
641
+ onClose,
642
+ }: {
643
+ initial: WorldBookDraft;
644
+ tab: 'worldbook' | 'knowledge';
645
+ onTabChange: (tab: 'worldbook' | 'knowledge') => void;
646
+ onSave: (n: string, c: string, k: string) => void;
647
+ onClose: () => void;
648
+ }) {
574
649
  const t = useT();
575
650
  const [name, setName] = useState(initial.name);
576
651
  const [content, setContent] = useState(initial.content);
652
+ const [characterBook, setCharacterBook] = useState(initial.characterBook ?? '');
653
+ const [renderMarkdown, setRenderMarkdown] = useState(false);
577
654
  const isBuiltin = isBuiltinTavernHandbook(initial);
655
+ const knowledge = characterBookEntryStats(characterBook);
656
+ const isEditing = 'id' in initial && initial.id != null;
578
657
 
579
658
  const resetToDefault = () => {
580
659
  setName(builtinTavernHandbookName());
581
660
  setContent(BUILTIN_TAVERN_HANDBOOK_CONTENT);
661
+ setCharacterBook('');
662
+ };
663
+
664
+ /** 批量启用/停用知识库条目(SillyTavern 导入的条目可能默认停用)。 */
665
+ const setAllEntriesEnabled = (enabled: boolean) => {
666
+ try {
667
+ const parsed: unknown = JSON.parse(characterBook);
668
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
669
+ const record = parsed as Record<string, unknown>;
670
+ if (!Array.isArray(record.entries)) return;
671
+ const entries = record.entries.map((entry) => (
672
+ entry && typeof entry === 'object' && !Array.isArray(entry)
673
+ ? { ...entry as Record<string, unknown>, enabled }
674
+ : entry
675
+ ));
676
+ setCharacterBook(JSON.stringify({ ...record, entries }, null, 2));
677
+ } catch {
678
+ // 无效 JSON 在保存时统一提示
679
+ }
582
680
  };
583
681
  return (
584
682
  <>
585
- <div className="modal-body">
586
- <div className="field">
587
- <label>{t('workshop.wbName')}</label>
588
- {isBuiltinTavernHandbook(initial)
589
- ? <div className="skill-detail-text">{name}</div>
590
- : <input
591
- className="input"
592
- value={name}
593
- onChange={(e) => setName(e.target.value)}
594
- placeholder={t('workshop.wbNamePh')}
595
- />}
683
+ <div className="modal-head">
684
+ <span style={{ fontWeight: 800, fontSize: 15 }}>
685
+ {isEditing ? t('workshop.editWbTitle') : t('workshop.newWbTitle')}
686
+ </span>
687
+ <div className="raw-tabs">
688
+ <button className={`raw-tab ${tab === 'worldbook' ? 'active' : ''}`} onClick={() => onTabChange('worldbook')}>
689
+ {t('workshop.worldBookTab')}
690
+ </button>
691
+ <button className={`raw-tab ${tab === 'knowledge' ? 'active' : ''}`} onClick={() => onTabChange('knowledge')}>
692
+ {t('workshop.knowledgeTab')}
693
+ </button>
596
694
  </div>
597
- <div className="field" style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
598
- <label>{t('workshop.wbContentLabel')}</label>
599
- {!renderMarkdown ? (
600
- <textarea className="textarea grow-textarea" value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('workshop.wbContentPh')} />
601
- ) : (
602
- <div className="bubble preview-scroll wb-render-pane">
603
- {content ? <Markdown text={content} /> : <span style={{ color: 'var(--text-faint)', fontSize: 13 }}>{t('workshop.wbContentPh')}</span>}
695
+ <div className="modal-head-actions">
696
+ {tab === 'worldbook' && (
697
+ <div className="raw-wrap-toggle">
698
+ <span className="raw-wrap-label">{t('workshop.wbMarkdownRender')}</span>
699
+ <label className="switch">
700
+ <input type="checkbox" checked={renderMarkdown} onChange={(e) => setRenderMarkdown(e.target.checked)} />
701
+ <span className="switch-slider" />
702
+ </label>
604
703
  </div>
605
704
  )}
705
+ <button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
606
706
  </div>
607
707
  </div>
708
+ <div className={`modal-body ${tab === 'knowledge' ? 'knowledge-tab' : ''}`}>
709
+ {tab === 'worldbook' && <>
710
+ <div className="field">
711
+ <label>{t('workshop.wbName')}</label>
712
+ {isBuiltin
713
+ ? <div className="skill-detail-text">{name}</div>
714
+ : <input
715
+ className="input"
716
+ value={name}
717
+ onChange={(e) => setName(e.target.value)}
718
+ placeholder={t('workshop.wbNamePh')}
719
+ />}
720
+ </div>
721
+ <div className="field" style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
722
+ <label>{t('workshop.wbContentLabel')}</label>
723
+ {!renderMarkdown ? (
724
+ <textarea className="textarea grow-textarea" value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('workshop.wbContentPh')} />
725
+ ) : (
726
+ <div className="bubble preview-scroll wb-render-pane">
727
+ {content ? <Markdown text={content} /> : <span style={{ color: 'var(--text-faint)', fontSize: 13 }}>{t('workshop.wbContentPh')}</span>}
728
+ </div>
729
+ )}
730
+ </div>
731
+ </>}
732
+ {tab === 'knowledge' && (
733
+ <div className="field prompt-field character-book-field">
734
+ <label>{t('workshop.wbKnowledgeLabel')}</label>
735
+ {knowledge.total > 0 && (
736
+ <div className="knowledge-bulk-actions">
737
+ <span>{t('workshop.wbKnowledgeMeta', { enabled: knowledge.enabled, total: knowledge.total })}</span>
738
+ <button type="button" className="btn btn-sm btn-ghost" onClick={() => setAllEntriesEnabled(true)}>
739
+ {t('workshop.knowledgeEnableAll')}
740
+ </button>
741
+ <button type="button" className="btn btn-sm btn-ghost" onClick={() => setAllEntriesEnabled(false)}>
742
+ {t('workshop.knowledgeDisableAll')}
743
+ </button>
744
+ </div>
745
+ )}
746
+ <Suspense fallback={<div className="character-book-editor-loading" />}>
747
+ <CharacterBookEditor
748
+ value={characterBook}
749
+ onChange={setCharacterBook}
750
+ placeholder={t('workshop.wbKnowledgePh')}
751
+ />
752
+ </Suspense>
753
+ </div>
754
+ )}
755
+ </div>
608
756
  <div className="modal-foot">
609
757
  {isBuiltin && (
610
758
  <button className="btn" type="button" onClick={resetToDefault} title={t('workshop.resetWbTip')}>
611
759
  <Icon name="refresh" size={12} /> {t('workshop.resetWb')}
612
760
  </button>
613
761
  )}
614
- <button className="btn" onClick={onCancel}>{t('common.cancel')}</button>
615
- <button className="btn btn-primary" onClick={() => onSave(name, content)}>{t('common.save')}</button>
762
+ <button className="btn" onClick={onClose}>{t('common.cancel')}</button>
763
+ <button className="btn btn-primary" onClick={() => onSave(name, content, characterBook)}>{t('common.save')}</button>
616
764
  </div>
617
765
  </>
618
766
  );
@@ -654,10 +802,7 @@ function SkillList() {
654
802
  refresh();
655
803
  }, [refresh]);
656
804
 
657
- const sensors = useSensors(
658
- useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
659
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
660
- );
805
+ const sensors = useSortableSensors();
661
806
 
662
807
  const deleteTool = async (tt: McpTool) => {
663
808
  await db.tools.delete(tt.id!);
@@ -673,25 +818,33 @@ function SkillList() {
673
818
  const toId = over.id as string;
674
819
 
675
820
  /** 在指定分组内搬移并持久化 displayOrder(0..n-1) */
676
- const apply = (group: McpTool[], setter: React.Dispatch<React.SetStateAction<McpTool[]>>): boolean => {
821
+ const apply = async (group: McpTool[], setter: React.Dispatch<React.SetStateAction<McpTool[]>>): Promise<boolean> => {
677
822
  const from = group.findIndex((x) => x.name === fromId);
678
823
  const to = group.findIndex((x) => x.name === toId);
679
824
  if (from < 0 || to < 0) return false;
680
825
  const next = arrayMove(group, from, to);
681
826
  setter(next);
682
- for (let i = 0; i < next.length; i++) {
683
- const item = next[i];
684
- if (item.id != null) db.tools.update(item.id, { displayOrder: i });
827
+ try {
828
+ // 必须先等所有写入落库再 refresh:持久化层是远程 SQLite(每次 update
829
+ // 还要先取写锁),立即 refresh 会读到旧顺序,再用 store 的旧数据把刚
830
+ // 拖好的界面覆盖回去,表现就是松手后弹回原位。
831
+ await Promise.all(next.map((item, index) => (
832
+ item.id == null ? Promise.resolve() : db.tools.update(item.id, { displayOrder: index })
833
+ )));
834
+ } catch (error) {
835
+ addToast(t('toast.skillOrderFailed', { msg: (error as Error).message }), 'error');
836
+ } finally {
837
+ // 失败时也刷新:让界面回到数据库里的真实顺序,而不是停留在乐观结果。
838
+ await refresh();
685
839
  }
686
- refresh();
687
840
  return true;
688
841
  };
689
842
 
690
- if (!apply(custom, setCustom)) {
691
- if (!apply(imported, setImported)) {
692
- if (builtin.some((x) => x.name === fromId)) apply(builtin, setBuiltin);
693
- }
694
- }
843
+ void (async () => {
844
+ if (await apply(custom, setCustom)) return;
845
+ if (await apply(imported, setImported)) return;
846
+ if (builtin.some((x) => x.name === fromId)) await apply(builtin, setBuiltin);
847
+ })();
695
848
  };
696
849
 
697
850
  const groupConfig: { key: 'builtin' | 'custom' | 'imported'; labelKey: string; items: McpTool[]; setter: React.Dispatch<React.SetStateAction<McpTool[]>> }[] = [