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.
- package/CHANGELOG.md +345 -0
- package/README.md +12 -4
- package/README.zh.md +12 -4
- package/bin/cli.js +16 -2
- package/index.html +3 -1
- package/package.json +18 -5
- package/persistence-server.mjs +303 -0
- package/public/sillytavern-logo.png +0 -0
- package/src/App.tsx +72 -8
- package/src/components/CharactersView.tsx +221 -68
- package/src/components/ChatView.tsx +98 -69
- package/src/components/FileDisplayModal.tsx +28 -17
- package/src/components/NewSessionMenu.tsx +2 -9
- package/src/components/PopoverMenu.tsx +54 -0
- package/src/components/SettingsView.tsx +21 -1
- package/src/components/Sidebar.tsx +133 -53
- package/src/components/VersionHistoryModal.tsx +114 -0
- package/src/components/shared.tsx +12 -3
- package/src/core/browserPreferences.ts +45 -0
- package/src/core/changelog.ts +43 -0
- package/src/core/characterBook.ts +32 -0
- package/src/core/clipboard.ts +32 -0
- package/src/core/editable.ts +26 -0
- package/src/core/gameplay.ts +2 -0
- package/src/core/i18n.ts +141 -10
- package/src/core/longPress.ts +209 -0
- package/src/core/longPressGesture.ts +110 -0
- package/src/core/mobileViewport.ts +362 -0
- package/src/core/openai.ts +39 -7
- package/src/core/sillyTavernWorldInfo.ts +159 -0
- package/src/core/sortableSensors.ts +21 -0
- package/src/core/stickyBottom.ts +45 -0
- package/src/core/tools/generatedSkillExecutor.ts +4 -20
- package/src/core/version.ts +46 -0
- package/src/core/viewport.ts +81 -0
- package/src/core/wechatTouch.ts +145 -0
- package/src/db/database.ts +313 -2
- package/src/generated/changelog.json +510 -0
- package/src/main.tsx +18 -2
- package/src/store/store.ts +99 -33
- package/src/theme/chat.css +174 -49
- package/src/theme/global.css +391 -10
- package/src/theme/theme.ts +9 -2
- package/src/theme/views.css +341 -8
- package/src/types/models.ts +9 -0
- package/src/vite-env.d.ts +4 -1
- 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
|
-
<
|
|
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'
|
|
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<
|
|
445
|
-
const [
|
|
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
|
|
453
|
-
|
|
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,
|
|
488
|
+
await db.worldBooks.update(editing.id, data);
|
|
459
489
|
addToast(t('toast.wbUpdated'));
|
|
460
490
|
} else {
|
|
461
|
-
await db.worldBooks.add({
|
|
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-
|
|
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">{
|
|
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={() =>
|
|
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
|
-
<
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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({
|
|
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-
|
|
586
|
-
<
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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="
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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={
|
|
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 =
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
691
|
-
if (
|
|
692
|
-
|
|
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[]>> }[] = [
|