tavern-harness 0.1.0

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 (56) hide show
  1. package/README.md +161 -0
  2. package/README.zh.md +123 -0
  3. package/bin/cli.js +48 -0
  4. package/index.html +35 -0
  5. package/package.json +50 -0
  6. package/public/favicon.png +0 -0
  7. package/public/tavern-harness-logo.png +0 -0
  8. package/sandbox-server.mjs +997 -0
  9. package/src/App.tsx +268 -0
  10. package/src/assets/tavern-handbook.md +39 -0
  11. package/src/components/AchievementModal.tsx +158 -0
  12. package/src/components/CharactersView.tsx +904 -0
  13. package/src/components/ChatView.tsx +1639 -0
  14. package/src/components/ConfirmationDialog.tsx +56 -0
  15. package/src/components/Dashboard.tsx +180 -0
  16. package/src/components/DeleteConfirmDialog.tsx +48 -0
  17. package/src/components/FileDisplayModal.tsx +556 -0
  18. package/src/components/GameplayDialogs.tsx +191 -0
  19. package/src/components/NewSessionMenu.tsx +362 -0
  20. package/src/components/SettingsView.tsx +517 -0
  21. package/src/components/Sidebar.tsx +392 -0
  22. package/src/components/StatsView.tsx +189 -0
  23. package/src/components/Toasts.tsx +20 -0
  24. package/src/components/WorkspaceFileManagerModal.tsx +216 -0
  25. package/src/components/shared.tsx +369 -0
  26. package/src/core/achievements.ts +85 -0
  27. package/src/core/fileDownload.ts +67 -0
  28. package/src/core/gameplay.ts +521 -0
  29. package/src/core/horizontalWheel.ts +93 -0
  30. package/src/core/i18n.ts +1403 -0
  31. package/src/core/linkedScroll.ts +236 -0
  32. package/src/core/markdown.ts +170 -0
  33. package/src/core/openai.ts +451 -0
  34. package/src/core/openingGreeting.ts +41 -0
  35. package/src/core/prompts.ts +254 -0
  36. package/src/core/proxy.ts +58 -0
  37. package/src/core/sillyTavernImporter.ts +317 -0
  38. package/src/core/stats.ts +91 -0
  39. package/src/core/strings.ts +19 -0
  40. package/src/core/toolDefinitions.ts +400 -0
  41. package/src/core/tools/builtinTools.ts +38 -0
  42. package/src/core/tools/generatedSkillExecutor.ts +706 -0
  43. package/src/core/tools/generatedWorkspace.ts +13 -0
  44. package/src/core/tools/toolExecutor.ts +718 -0
  45. package/src/core/turnLoop.ts +211 -0
  46. package/src/db/database.ts +647 -0
  47. package/src/main.tsx +23 -0
  48. package/src/store/store.ts +2419 -0
  49. package/src/theme/chat.css +1227 -0
  50. package/src/theme/global.css +1377 -0
  51. package/src/theme/theme.ts +143 -0
  52. package/src/theme/views.css +1430 -0
  53. package/src/types/models.ts +364 -0
  54. package/src/vite-env.d.ts +1 -0
  55. package/tsconfig.json +21 -0
  56. package/vite.config.ts +273 -0
@@ -0,0 +1,904 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ DndContext,
4
+ PointerSensor,
5
+ KeyboardSensor,
6
+ closestCenter,
7
+ useSensor,
8
+ useSensors,
9
+ type DragEndEvent,
10
+ } from '@dnd-kit/core';
11
+ import {
12
+ SortableContext,
13
+ arrayMove,
14
+ sortableKeyboardCoordinates,
15
+ verticalListSortingStrategy,
16
+ useSortable,
17
+ } from '@dnd-kit/sortable';
18
+ import { CSS } from '@dnd-kit/utilities';
19
+ import { useStore } from '../store/store';
20
+ import {
21
+ BUILTIN_TAVERN_HANDBOOK_CONTENT,
22
+ builtinTavernHandbookName,
23
+ db,
24
+ isBuiltinTavernHandbook,
25
+ } from '../db/database';
26
+ import type { NpcCharacter, WorldBook, McpTool } from '../types/models';
27
+ import { Avatar, Icon, MasonryGrid, Markdown, Modal } from './shared';
28
+ import { DeleteConfirmDialog } from './DeleteConfirmDialog';
29
+ import { parseSillyTavernCardFile, type ParsedSillyTavernCard } from '../core/sillyTavernImporter';
30
+ import { ALL_BUILTIN_TOOL_NAMES } from '../core/toolDefinitions';
31
+ import { useT } from '../core/i18n';
32
+
33
+ // 内置角色「酒馆老板」默认启用所有技能
34
+ const ALL_DEFAULT_SKILLS = [...ALL_BUILTIN_TOOL_NAMES];
35
+
36
+ // ============================================================
37
+ // 角色工坊(NPC 管理 / Lorebook / 技能表 / PNG 导入)
38
+ // 说明:PNG 导入只解析不落库,打开「新建角色 / 新建Lorebook」表单预填,
39
+ // 由用户手动点保存
40
+ // ============================================================
41
+
42
+ export function CharactersView() {
43
+ const npcs = useStore((s) => s.npcs);
44
+ const worldBooks = useStore((s) => s.worldBooks);
45
+ const addToast = useStore((s) => s.addToast);
46
+ const t = useT();
47
+ const [tab, setTab] = useState<'characters' | 'worldbooks' | 'skills'>('characters');
48
+ const [editing, setEditing] = useState<NpcCharacter | null>(null);
49
+ const [isNew, setIsNew] = useState(false);
50
+ // PNG 解析出的草稿:角色 + 内嵌Lorebook
51
+ const [importDraft, setImportDraft] = useState<ParsedSillyTavernCard | null>(null);
52
+ const fileRef = useRef<HTMLInputElement>(null);
53
+
54
+ const openFilePicker = () => fileRef.current?.click();
55
+
56
+ const refresh = useStore((s) => s.refreshNpcs);
57
+ const refreshWb = useStore((s) => s.refreshWorldBooks);
58
+ const refreshTools = useStore((s) => s.refreshTools);
59
+
60
+ // 每次进入角色工坊都刷新一次,确保聊天中通过技能创建/修改的数据即时可见
61
+ useEffect(() => {
62
+ refresh();
63
+ refreshWb();
64
+ refreshTools();
65
+ // eslint-disable-next-line react-hooks/exhaustive-deps
66
+ }, []);
67
+
68
+ const handleImport = async (file: File) => {
69
+ try {
70
+ const draft = await parseSillyTavernCardFile(file);
71
+ setImportDraft(draft);
72
+ if (tab === 'characters' || tab === 'skills') {
73
+ // 角色卡 tab:预填新建角色表单,由用户确认后保存
74
+ setEditing({ ...draft.character, id: undefined });
75
+ setIsNew(true);
76
+ addToast(t('toast.importedChar', { name: draft.character.name, version: draft.version }));
77
+ } else {
78
+ // Lorebook tab:草稿交给 WorldBookList,由 useEffect 打开预填表单(含提示)
79
+ if (!draft.worldBook) {
80
+ addToast(t('toast.noEmbeddedWb', { name: draft.character.name }), 'error');
81
+ }
82
+ }
83
+ } catch (e) {
84
+ addToast(t('toast.importFailed', { msg: (e as Error).message }), 'error');
85
+ }
86
+ };
87
+
88
+ return (
89
+ <div className="view-page workshop-page">
90
+ <div className="view-col">
91
+ <div>
92
+ <h2 className="view-title">{t('workshop.title')}</h2>
93
+ <p className="view-sub">{t('workshop.sub')}</p>
94
+ </div>
95
+ <div className="side-nav" data-hscroll>
96
+ <button className={`nav-chip ${tab === 'characters' ? 'active' : ''}`} onClick={() => setTab('characters')}>
97
+ {t('workshop.tabCharacters')}
98
+ </button>
99
+ <button className={`nav-chip ${tab === 'worldbooks' ? 'active' : ''}`} onClick={() => setTab('worldbooks')}>
100
+ {t('workshop.tabWorldbooks')}
101
+ </button>
102
+ <button className={`nav-chip ${tab === 'skills' ? 'active' : ''}`} onClick={() => setTab('skills')}>
103
+ {t('workshop.tabSkills')}
104
+ </button>
105
+ </div>
106
+
107
+ {tab === 'characters' && (
108
+ <CharacterGrid
109
+ npcs={npcs}
110
+ onEdit={(n, isNew) => {
111
+ setEditing(n);
112
+ setIsNew(isNew);
113
+ setImportDraft(null);
114
+ }}
115
+ onImportPng={openFilePicker}
116
+ />
117
+ )}
118
+ {tab === 'worldbooks' && (
119
+ <WorldBookList
120
+ books={worldBooks}
121
+ onChanged={refreshWb}
122
+ onImportPng={openFilePicker}
123
+ importDraft={tab === 'worldbooks' ? importDraft : null}
124
+ onImportDraftConsumed={() => setImportDraft(null)}
125
+ />
126
+ )}
127
+ {tab === 'skills' && <SkillList />}
128
+ </div>
129
+ <input
130
+ ref={fileRef}
131
+ type="file"
132
+ accept="image/png"
133
+ hidden
134
+ onChange={(e) => {
135
+ const f = e.target.files?.[0];
136
+ if (f) handleImport(f);
137
+ e.target.value = '';
138
+ }}
139
+ />
140
+
141
+ {editing && (
142
+ <CharacterEditorModal
143
+ npc={editing}
144
+ isNew={isNew}
145
+ onClose={() => {
146
+ setEditing(null);
147
+ setImportDraft(null);
148
+ }}
149
+ onSaved={() => {
150
+ setImportDraft(null);
151
+ refresh();
152
+ setEditing(null);
153
+ }}
154
+ />
155
+ )}
156
+ </div>
157
+ );
158
+ }
159
+
160
+ // ---------------- 角色网格 ----------------
161
+
162
+ function CharacterGrid({ npcs, onEdit, onImportPng }: { npcs: NpcCharacter[]; onEdit: (n: NpcCharacter, isNew: boolean) => void; onImportPng: () => void }) {
163
+ const addToast = useStore((s) => s.addToast);
164
+ const t = useT();
165
+ const [pendingDelete, setPendingDelete] = useState<NpcCharacter | null>(null);
166
+
167
+ const deleteNpc = async (n: NpcCharacter) => {
168
+ await db.npcs.delete(n.id!);
169
+ useStore.getState().refreshNpcs();
170
+ addToast(t('toast.charDeleted', { name: n.name }));
171
+ };
172
+
173
+ return (
174
+ <div>
175
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
176
+ <span className="group-label" style={{ margin: 0 }}>{t('workshop.charCount', { n: npcs.length })}</span>
177
+ <div style={{ display: 'flex', gap: 8 }}>
178
+ <button className="btn btn-sm" onClick={onImportPng} title={t('workshop.pngImportTip')}>
179
+ <Icon name="import" size={13} /> {t('workshop.pngImport')}
180
+ </button>
181
+ <button
182
+ className="btn btn-primary btn-sm"
183
+ onClick={() =>
184
+ onEdit(
185
+ {
186
+ name: '',
187
+ prompt: '',
188
+ greeting: '',
189
+ alternateGreetings: [],
190
+ avatarColorOrdinal: Math.floor(Math.random() * 6),
191
+ avatarDataUrl: null,
192
+ enabledToolNames: [],
193
+ isBuiltIn: false,
194
+ createdAt: Date.now(),
195
+ },
196
+ true
197
+ )
198
+ }
199
+ >
200
+ <Icon name="plus" size={13} /> {t('workshop.newChar')}
201
+ </button>
202
+ </div>
203
+ </div>
204
+ <MasonryGrid>
205
+ {npcs.map((n) => (
206
+ <div key={n.id} className="char-card fade-up">
207
+ <div className="cname">
208
+ <Avatar name={n.name} colorOrdinal={n.avatarColorOrdinal} imageUrl={n.avatarDataUrl} size="xs" />
209
+ <ScrollingCardName name={n.name} />
210
+ {n.isBuiltIn && <span className="tag">{t('common.builtin')}</span>}
211
+ </div>
212
+ <div className="cgreet">{n.greeting || t('workshop.noGreeting')}</div>
213
+ {/* 技能区固定渲染(哪怕无技能),保证所有卡片高度一致 */}
214
+ <div className="cskil">
215
+ {n.enabledToolNames.map((s) => (
216
+ <span key={s} className="skill-tag">{s}</span>
217
+ ))}
218
+ </div>
219
+ <div className="cactions">
220
+ <button className="btn btn-sm" onClick={() => onEdit(n, false)}><Icon name="settings" size={12} /> {t('common.edit')}</button>
221
+ <button className="btn btn-sm btn-danger" onClick={() => setPendingDelete(n)} disabled={n.isBuiltIn} title={n.isBuiltIn ? t('workshop.protectedTip') : t('common.delete')}>
222
+ <Icon name="trash" size={12} />
223
+ </button>
224
+ </div>
225
+ </div>
226
+ ))}
227
+ </MasonryGrid>
228
+ {pendingDelete && (
229
+ <DeleteConfirmDialog
230
+ title={t('workshop.deleteCharTitle')}
231
+ itemName={pendingDelete.name}
232
+ onCancel={() => setPendingDelete(null)}
233
+ onConfirm={() => deleteNpc(pendingDelete)}
234
+ />
235
+ )}
236
+ </div>
237
+ );
238
+ }
239
+
240
+ // ---------------- 角色编辑器 ----------------
241
+
242
+ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharacter; isNew: boolean; onClose: () => void; onSaved: () => void }) {
243
+ const t = useT();
244
+ const [name, setName] = useState(npc.name);
245
+ const [prompt, setPrompt] = useState(npc.prompt);
246
+ // 多个开场白:第一个保存在 greeting,其余保存在 alternateGreetings
247
+ const [greetings, setGreetings] = useState<string[]>([npc.greeting, ...(npc.alternateGreetings ?? [])]);
248
+ const [avatarDataUrl, setAvatarDataUrl] = useState<string | null>(npc.avatarDataUrl ?? null);
249
+ // 技能启用状态(内置角色「酒馆老板」默认启用所有内置技能,同时保留已有自定义技能)
250
+ const [enabledToolNames, setEnabledToolNames] = useState<string[]>(() => {
251
+ if (!npc.isBuiltIn) return npc.enabledToolNames;
252
+ const set = new Set(npc.enabledToolNames);
253
+ for (const s of ALL_DEFAULT_SKILLS) set.add(s);
254
+ return [...set];
255
+ });
256
+ const [tools, setTools] = useState<McpTool[]>([]);
257
+ const addToast = useStore((s) => s.addToast);
258
+ const storeTools = useStore((s) => s.tools);
259
+
260
+ useEffect(() => {
261
+ setTools(storeTools);
262
+ }, [storeTools]);
263
+
264
+ const resetToDefault = () => {
265
+ setName(npc.name);
266
+ setPrompt(t('builtinNpc.prompt'));
267
+ setGreetings([t('builtinNpc.greeting')]);
268
+ setEnabledToolNames([...ALL_DEFAULT_SKILLS]);
269
+ };
270
+
271
+ const save = async () => {
272
+ if (!name.trim()) {
273
+ addToast(t('toast.nameRequired'), 'error');
274
+ return;
275
+ }
276
+ // 清理空白/重复项:第一项作为主开场白,其余作为备用
277
+ const cleaned = greetings.map((g) => g.trim()).filter((g, i, arr) => g && arr.indexOf(g) === i);
278
+ const npcData = {
279
+ name: npc.isBuiltIn ? npc.name : name.trim(),
280
+ prompt,
281
+ greeting: cleaned[0] ?? '',
282
+ alternateGreetings: cleaned.slice(1),
283
+ avatarDataUrl,
284
+ enabledToolNames,
285
+ };
286
+ if (isNew) {
287
+ await db.npcs.add({
288
+ ...npc,
289
+ ...npcData,
290
+ });
291
+ addToast(t('toast.charCreated', { name }));
292
+ } else {
293
+ await db.npcs.update(npc.id!, npcData);
294
+ addToast(t('toast.charUpdated', { name }));
295
+ }
296
+ onSaved();
297
+ };
298
+
299
+ const toggleSkill = (skill: string) => {
300
+ setEnabledToolNames((prev) =>
301
+ prev.includes(skill) ? prev.filter((s) => s !== skill) : [...prev, skill]
302
+ );
303
+ };
304
+
305
+ return (
306
+ <div className="overlay" onClick={onClose}>
307
+ <div className="modal-root" onClick={(e) => e.stopPropagation()}>
308
+ <div className="modal card modal-editor">
309
+ <div className="modal-head">
310
+ <span style={{ fontWeight: 800, fontSize: 15 }}>{isNew ? t('workshop.newCharTitle') : t('workshop.editCharTitle', { name: npc.name })}</span>
311
+ <button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
312
+ </div>
313
+ <div className="modal-body">
314
+ <div style={{ display: 'flex', gap: 14, alignItems: 'center' }}>
315
+ <Avatar name={name || t('chat.speakerChar')} colorOrdinal={npc.avatarColorOrdinal} imageUrl={avatarDataUrl} size="sm" />
316
+ <div style={{ display: 'flex', gap: 8 }}>
317
+ <label className="btn btn-sm" style={{ cursor: 'pointer', position: 'relative' }}>
318
+ {t('workshop.uploadAvatar')}
319
+ <input type="file" accept="image/*" hidden onChange={(e) => {
320
+ const f = e.target.files?.[0];
321
+ if (f) {
322
+ const reader = new FileReader();
323
+ reader.onload = () => setAvatarDataUrl(reader.result as string);
324
+ reader.readAsDataURL(f);
325
+ }
326
+ }} />
327
+ </label>
328
+ {avatarDataUrl && <button className="btn btn-sm btn-ghost" onClick={() => setAvatarDataUrl(null)}>{t('common.clear')}</button>}
329
+ </div>
330
+ </div>
331
+ <div className="field">
332
+ <label>{t('workshop.name')}</label>
333
+ {npc.isBuiltIn
334
+ ? <div className="skill-detail-text">{name}</div>
335
+ : <input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder={t('workshop.charNamePh')} />}
336
+ </div>
337
+ <div className="field prompt-field">
338
+ <label>{t('workshop.promptLabel')}</label>
339
+ <textarea className="textarea grow-textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder={t('workshop.promptPh')} />
340
+ </div>
341
+ <div className="field" style={{ flexShrink: 0 }}>
342
+ <label>
343
+ {t('workshop.greetingLabel')}
344
+ <span className="field-hint"> {t('workshop.greetingRandomHint')}</span>
345
+ </label>
346
+ {greetings.map((g, i) => (
347
+ <div key={i} className="greeting-input">
348
+ <textarea
349
+ className="textarea grow-textarea greeting-grow"
350
+ value={g}
351
+ onChange={(e) => setGreetings((prev) => prev.map((x, j) => (j === i ? e.target.value : x)))}
352
+ placeholder={t('workshop.greetingPh')}
353
+ />
354
+ <button
355
+ className="icon-btn greeting-del"
356
+ title={t('common.delete')}
357
+ onClick={() => setGreetings((prev) => prev.filter((_, j) => j !== i))}
358
+ >
359
+ <Icon name="x" size={13} />
360
+ </button>
361
+ </div>
362
+ ))}
363
+ <button className="btn btn-sm btn-ghost" style={{ alignSelf: 'flex-start' }} onClick={() => setGreetings((prev) => [...prev, ''])}>
364
+ <Icon name="plus" size={12} /> {t('workshop.addAltGreeting')}
365
+ </button>
366
+ </div>
367
+ {/* 启用技能:从卡片移入编辑页 */}
368
+ <div className="field" style={{ flexShrink: 0 }}>
369
+ <label>{t('workshop.enableSkills', { a: enabledToolNames.filter((s) => tools.some((t) => t.name === s)).length, b: tools.length })}</label>
370
+ {tools.length === 0 ? (
371
+ <div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('workshop.noSkills')}</div>
372
+ ) : (
373
+ <div className="skill-list-scroll">
374
+ {tools.map((t) => (
375
+ <label key={t.name} className={`skill-tag ${enabledToolNames.includes(t.name) ? '' : 'locked'}`} style={{ cursor: 'pointer', display: 'inline-flex', gap: 4, alignItems: 'center' }}>
376
+ <input type="checkbox" checked={enabledToolNames.includes(t.name)} onChange={() => toggleSkill(t.name)} style={{ accentColor: 'var(--primary)' }} />
377
+ {t.name}
378
+ </label>
379
+ ))}
380
+ </div>
381
+ )}
382
+ </div>
383
+ </div>
384
+ <div className="modal-foot">
385
+ {npc.isBuiltIn && (
386
+ <button className="btn" type="button" onClick={resetToDefault} title={t('workshop.resetCharTip')}>
387
+ <Icon name="refresh" size={12} /> {t('workshop.resetChar')}
388
+ </button>
389
+ )}
390
+ <button className="btn" onClick={onClose}>{t('common.cancel')}</button>
391
+ <button className="btn btn-primary" onClick={save}>{t('common.save')}</button>
392
+ </div>
393
+ </div>
394
+ </div>
395
+ </div>
396
+ );
397
+ }
398
+
399
+ // ---------------- Lorebook ----------------
400
+
401
+ function WorldBookList({ books, onChanged, onImportPng, importDraft, onImportDraftConsumed }: {
402
+ books: WorldBook[];
403
+ onChanged: () => void;
404
+ onImportPng: () => void;
405
+ /** PNG 解析出的角色卡草稿(含内嵌Lorebook) */
406
+ importDraft: ParsedSillyTavernCard | null;
407
+ /** 草稿已被消费(保存/取消)后通知父组件清空 */
408
+ onImportDraftConsumed: () => void;
409
+ }) {
410
+ const addToast = useStore((s) => s.addToast);
411
+ const t = useT();
412
+ const [editing, setEditing] = useState<WorldBook | { name: string; content: string; imageUri: null; createdAt: number } | null>(null);
413
+ const [pendingDelete, setPendingDelete] = useState<WorldBook | null>(null);
414
+
415
+ // PNG 导入产生草稿后:预填「新建Lorebook」表单(不落库,由用户手动保存)
416
+ useEffect(() => {
417
+ if (!importDraft) return;
418
+ if (importDraft.worldBook) {
419
+ setEditing({ ...importDraft.worldBook, imageUri: null, createdAt: Date.now() });
420
+ addToast(t('toast.wbDraftImported', { name: importDraft.worldBook.name }));
421
+ }
422
+ }, [importDraft]); // eslint-disable-line react-hooks/exhaustive-deps
423
+
424
+ const closeEditor = () => {
425
+ setEditing(null);
426
+ onImportDraftConsumed();
427
+ };
428
+
429
+ const saveWb = async (name: string, content: string) => {
430
+ if (!name.trim() || !content.trim()) {
431
+ addToast(t('toast.wbNameContentRequired'), 'error');
432
+ return;
433
+ }
434
+ if (editing && 'id' in editing && editing.id != null) {
435
+ await db.worldBooks.update(editing.id, { name: name.trim(), content });
436
+ addToast(t('toast.wbUpdated'));
437
+ } else {
438
+ await db.worldBooks.add({ name: name.trim(), content, imageUri: null, createdAt: Date.now() });
439
+ addToast(t('toast.wbCreated'));
440
+ }
441
+ setEditing(null);
442
+ onChanged();
443
+ onImportDraftConsumed();
444
+ };
445
+
446
+ const deleteWb = async (b: WorldBook) => {
447
+ if (isBuiltinTavernHandbook(b)) return;
448
+ await db.worldBooks.delete(b.id!);
449
+ addToast(t('toast.wbDeleted'));
450
+ onChanged();
451
+ };
452
+
453
+ return (
454
+ <div>
455
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
456
+ <span className="group-label" style={{ margin: 0 }}>{t('workshop.wbCount', { n: books.length })}</span>
457
+ <div style={{ display: 'flex', gap: 8 }}>
458
+ <button className="btn btn-sm" onClick={onImportPng} title={t('workshop.pngImportWbTip')}>
459
+ <Icon name="import" size={13} /> {t('workshop.pngImport')}
460
+ </button>
461
+ <button className="btn btn-primary btn-sm" onClick={() => setEditing({ name: '', content: '', imageUri: null, createdAt: Date.now() })}>
462
+ <Icon name="plus" size={13} /> {t('workshop.newWb')}
463
+ </button>
464
+ </div>
465
+ </div>
466
+ {books.length === 0 && (
467
+ <div className="empty-state">
468
+ <div className="big">📖</div>
469
+ {t('workshop.wbEmpty')}
470
+ </div>
471
+ )}
472
+ <MasonryGrid>
473
+ {books.map((b) => {
474
+ const isBuiltin = isBuiltinTavernHandbook(b);
475
+ return (
476
+ <div key={b.id} className="char-card wb-card fade-up">
477
+ <div className="cname">
478
+ <span style={{ fontSize: 16 }}>📖</span>
479
+ <ScrollingCardName name={b.name} />
480
+ {isBuiltin && <span className="tag">{t('common.builtin')}</span>}
481
+ </div>
482
+ <div className="wcontent">{b.content}</div>
483
+ <div className="cmeta">{t('workshop.wbChars', { n: b.content.length })}</div>
484
+ <div className="cactions">
485
+ <button className="btn btn-sm" onClick={() => setEditing(b)}><Icon name="settings" size={12} /> {t('workshop.browseEdit')}</button>
486
+ <button
487
+ className="btn btn-sm btn-danger"
488
+ onClick={() => setPendingDelete(b)}
489
+ disabled={isBuiltin}
490
+ title={isBuiltin ? t('workshop.protectedTip') : t('common.delete')}
491
+ >
492
+ <Icon name="trash" size={12} />
493
+ </button>
494
+ </div>
495
+ </div>
496
+ );
497
+ })}
498
+ </MasonryGrid>
499
+
500
+ {pendingDelete && (
501
+ <DeleteConfirmDialog
502
+ title={t('workshop.deleteWbTitle')}
503
+ itemName={pendingDelete.name}
504
+ onCancel={() => setPendingDelete(null)}
505
+ onConfirm={() => deleteWb(pendingDelete)}
506
+ />
507
+ )}
508
+
509
+ {editing && (
510
+ <div className="overlay" onClick={closeEditor}>
511
+ <div className="modal-root" onClick={(e) => e.stopPropagation()}>
512
+ <div className="modal card modal-editor">
513
+ <div className="modal-head">
514
+ <span style={{ fontWeight: 800, fontSize: 15 }}>{'id' in editing && editing.id != null ? t('workshop.editWbTitle') : t('workshop.newWbTitle')}</span>
515
+ <button className="icon-btn" onClick={closeEditor}><Icon name="x" /></button>
516
+ </div>
517
+ <WorldBookForm initial={editing} onSave={saveWb} onCancel={closeEditor} />
518
+ </div>
519
+ </div>
520
+ </div>
521
+ )}
522
+ </div>
523
+ );
524
+ }
525
+
526
+ function ScrollingCardName({ name }: { name: string }) {
527
+ const containerRef = useRef<HTMLSpanElement>(null);
528
+ const textRef = useRef<HTMLSpanElement>(null);
529
+ const [overflowing, setOverflowing] = useState(false);
530
+
531
+ useEffect(() => {
532
+ const updateOverflow = () => {
533
+ setOverflowing((textRef.current?.scrollWidth ?? 0) > (containerRef.current?.clientWidth ?? 0));
534
+ };
535
+ updateOverflow();
536
+ const observer = new ResizeObserver(updateOverflow);
537
+ if (containerRef.current) observer.observe(containerRef.current);
538
+ return () => observer.disconnect();
539
+ }, [name]);
540
+
541
+ return (
542
+ <span ref={containerRef} className={`cname-text card-name-scroll${overflowing ? ' is-overflowing' : ''}`}>
543
+ <span ref={textRef} className="card-name-scroll-text">{name}</span>
544
+ {overflowing && <span className="card-name-scroll-text card-name-scroll-copy" aria-hidden="true">{name}</span>}
545
+ </span>
546
+ );
547
+ }
548
+
549
+ function WorldBookForm({ initial, onSave, onCancel }: { initial: WorldBook | { name: string; content: string; imageUri: null; createdAt: number }; onSave: (n: string, c: string) => void; onCancel: () => void }) {
550
+ const t = useT();
551
+ const [name, setName] = useState(initial.name);
552
+ const [content, setContent] = useState(initial.content);
553
+ const [renderMarkdown, setRenderMarkdown] = useState(false);
554
+ const isBuiltin = isBuiltinTavernHandbook(initial);
555
+
556
+ const resetToDefault = () => {
557
+ setName(builtinTavernHandbookName());
558
+ setContent(BUILTIN_TAVERN_HANDBOOK_CONTENT);
559
+ };
560
+ return (
561
+ <>
562
+ <div className="modal-body">
563
+ <div className="field">
564
+ <label>{t('workshop.wbName')}</label>
565
+ {isBuiltinTavernHandbook(initial)
566
+ ? <div className="skill-detail-text">{name}</div>
567
+ : <input
568
+ className="input"
569
+ value={name}
570
+ onChange={(e) => setName(e.target.value)}
571
+ placeholder={t('workshop.wbNamePh')}
572
+ />}
573
+ </div>
574
+ <div className="field" style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
575
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
576
+ <label>{t('workshop.wbContentLabel')}</label>
577
+ <div className="raw-wrap-toggle">
578
+ <span className="raw-wrap-label">{t('workshop.wbMarkdownRender')}</span>
579
+ <label className="switch">
580
+ <input type="checkbox" checked={renderMarkdown} onChange={(e) => setRenderMarkdown(e.target.checked)} />
581
+ <span className="switch-slider" />
582
+ </label>
583
+ </div>
584
+ </div>
585
+ {!renderMarkdown ? (
586
+ <textarea className="textarea grow-textarea" value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('workshop.wbContentPh')} />
587
+ ) : (
588
+ <div className="bubble preview-scroll wb-render-pane">
589
+ {content ? <Markdown text={content} /> : <span style={{ color: 'var(--text-faint)', fontSize: 13 }}>{t('workshop.wbContentPh')}</span>}
590
+ </div>
591
+ )}
592
+ </div>
593
+ </div>
594
+ <div className="modal-foot">
595
+ {isBuiltin && (
596
+ <button className="btn" type="button" onClick={resetToDefault} title={t('workshop.resetWbTip')}>
597
+ <Icon name="refresh" size={12} /> {t('workshop.resetWb')}
598
+ </button>
599
+ )}
600
+ <button className="btn" onClick={onCancel}>{t('common.cancel')}</button>
601
+ <button className="btn btn-primary" onClick={() => onSave(name, content)}>{t('common.save')}</button>
602
+ </div>
603
+ </>
604
+ );
605
+ }
606
+
607
+ // ---------------- 技能表 ----------------
608
+
609
+ /** 技能来源排序:内置 → 自定义 → 导入(组内保持 DB 顺序) */
610
+ function toolOrigin(t: McpTool): 'builtin' | 'custom' | 'imported' {
611
+ return t.origin ?? (t.isBuiltIn ? 'builtin' : 'custom');
612
+ }
613
+
614
+ /** 保留在分组内的横向(拖动过程中,卡片实际拖动时不会离开组) */
615
+ function SkillList() {
616
+ const tools = useStore((s) => s.tools);
617
+ const addToast = useStore((s) => s.addToast);
618
+ const t = useT();
619
+ const refresh = useStore((s) => s.refreshTools);
620
+ const [pendingDelete, setPendingDelete] = useState<McpTool | null>(null);
621
+ const [detail, setDetail] = useState<McpTool | null>(null);
622
+
623
+ const [custom, setCustom] = useState<McpTool[]>([]);
624
+ const [imported, setImported] = useState<McpTool[]>([]);
625
+ const [builtin, setBuiltin] = useState<McpTool[]>([]);
626
+
627
+ // 从 store 同步分组(store 中 tools 已按 displayOrder 排序)
628
+ useEffect(() => {
629
+ const groups: Record<'custom' | 'imported' | 'builtin', McpTool[]> = { custom: [], imported: [], builtin: [] };
630
+ for (const tt of tools) {
631
+ const g = toolOrigin(tt);
632
+ if (groups[g]) groups[g].push(tt);
633
+ }
634
+ setCustom(groups.custom);
635
+ setImported(groups.imported);
636
+ setBuiltin(groups.builtin);
637
+ }, [tools]);
638
+
639
+ useEffect(() => {
640
+ refresh();
641
+ }, [refresh]);
642
+
643
+ const sensors = useSensors(
644
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
645
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
646
+ );
647
+
648
+ const deleteTool = async (tt: McpTool) => {
649
+ await db.tools.delete(tt.id!);
650
+ await refresh();
651
+ addToast(t('toast.skillDeleted'));
652
+ };
653
+
654
+ /** 拖拽结束:仅在同一分组内重排(各组用独立的 SortableContext),随后持久化 */
655
+ const onDragEnd = (event: DragEndEvent) => {
656
+ const { active, over } = event;
657
+ if (!over || active.id === over.id) return;
658
+ const fromId = active.id as string;
659
+ const toId = over.id as string;
660
+
661
+ /** 在指定分组内搬移并持久化 displayOrder(0..n-1) */
662
+ const apply = (group: McpTool[], setter: React.Dispatch<React.SetStateAction<McpTool[]>>): boolean => {
663
+ const from = group.findIndex((x) => x.name === fromId);
664
+ const to = group.findIndex((x) => x.name === toId);
665
+ if (from < 0 || to < 0) return false;
666
+ const next = arrayMove(group, from, to);
667
+ setter(next);
668
+ for (let i = 0; i < next.length; i++) {
669
+ const item = next[i];
670
+ if (item.id != null) db.tools.update(item.id, { displayOrder: i });
671
+ }
672
+ refresh();
673
+ return true;
674
+ };
675
+
676
+ if (!apply(custom, setCustom)) {
677
+ if (!apply(imported, setImported)) {
678
+ if (builtin.some((x) => x.name === fromId)) apply(builtin, setBuiltin);
679
+ }
680
+ }
681
+ };
682
+
683
+ const groupConfig: { key: 'builtin' | 'custom' | 'imported'; labelKey: string; items: McpTool[]; setter: React.Dispatch<React.SetStateAction<McpTool[]>> }[] = [
684
+ { key: 'custom', labelKey: 'workshop.skillGroupCustom', items: custom, setter: setCustom },
685
+ { key: 'imported', labelKey: 'workshop.skillGroupImported', items: imported, setter: setImported },
686
+ { key: 'builtin', labelKey: 'workshop.skillGroupBuiltin', items: builtin, setter: setBuiltin },
687
+ ];
688
+
689
+ const renderRow = (tt: McpTool) => {
690
+ let desc = '';
691
+ try {
692
+ desc = (JSON.parse(tt.jsonContent) as { function?: { description?: string } }).function?.description ?? '';
693
+ } catch { /* ignore */ }
694
+ let impl = 'native';
695
+ if (tt.executionJson) {
696
+ try {
697
+ impl = (JSON.parse(tt.executionJson) as { type?: string }).type ?? 'invalid';
698
+ } catch { impl = 'invalid'; }
699
+ }
700
+ return (
701
+ <SkillSortItem key={tt.name} tool={tt} desc={desc} impl={impl} onDetail={() => setDetail(tt)} onDelete={() => setPendingDelete(tt)} />
702
+ );
703
+ };
704
+
705
+ return (
706
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
707
+ <div className="skill-sort-hint">
708
+ <Icon name="sort" size={12} /> {t('workshop.sortHint')}
709
+ </div>
710
+ {groupConfig.map((g) => (
711
+ <div key={g.key} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
712
+ <span className="group-label">{t(g.labelKey, { n: g.items.length })}</span>
713
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
714
+ <SortableContext items={g.items.map((x) => x.name)} strategy={verticalListSortingStrategy}>
715
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
716
+ {g.items.map(renderRow)}
717
+ </div>
718
+ </SortableContext>
719
+ </DndContext>
720
+ </div>
721
+ ))}
722
+ {detail && <SkillDetailModal tool={detail} onClose={() => setDetail(null)} />}
723
+ {pendingDelete && (
724
+ <DeleteConfirmDialog
725
+ title={t('workshop.deleteSkillTitle')}
726
+ itemName={pendingDelete.name}
727
+ onCancel={() => setPendingDelete(null)}
728
+ onConfirm={() => deleteTool(pendingDelete)}
729
+ />
730
+ )}
731
+ </div>
732
+ );
733
+ }
734
+
735
+ /** 单个技能卡片:复用 .list-item 样式,整卡可拖拽排序(无手柄) */
736
+ function SkillSortItem({
737
+ tool,
738
+ desc,
739
+ impl,
740
+ onDetail,
741
+ onDelete,
742
+ }: {
743
+ tool: McpTool;
744
+ desc: string;
745
+ impl: string;
746
+ onDetail: () => void;
747
+ onDelete: () => void;
748
+ }) {
749
+ const t = useT();
750
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tool.name });
751
+ const style: React.CSSProperties = {
752
+ transform: CSS.Translate.toString(transform),
753
+ transition,
754
+ zIndex: isDragging ? 10 : undefined,
755
+ opacity: isDragging ? 0.85 : undefined,
756
+ };
757
+ return (
758
+ <div
759
+ ref={setNodeRef}
760
+ style={style}
761
+ className={`list-item sortable-skill ${isDragging ? 'dragging' : ''}`}
762
+ {...attributes}
763
+ {...listeners}
764
+ >
765
+ <div style={{ fontSize: 18 }}>{tool.isBuiltIn ? '🧰' : '⚙️'}</div>
766
+ <div className="l-main">
767
+ <div className="l-title">
768
+ {tool.name}
769
+ {tool.isBuiltIn && <span className="tag" style={{ marginLeft: 8 }}>{t('common.builtin')}</span>}
770
+ </div>
771
+ <div className="l-sub">{desc}</div>
772
+ {tool.isBuiltIn && impl !== 'native' && <div style={{ fontSize: 10.5, marginTop: 2, color: 'var(--warn)' }}>{t('workshop.implType', { t: impl })}</div>}
773
+ </div>
774
+ <div className="skill-item-actions">
775
+ <button className="btn btn-sm" onClick={(e) => { e.stopPropagation(); onDetail(); }} title={t('workshop.viewDetails')}>
776
+ <Icon name="file" size={12} /> {t('workshop.viewDetails')}
777
+ </button>
778
+ <button className="btn btn-sm btn-danger" onClick={(e) => { e.stopPropagation(); onDelete(); }} disabled={tool.isBuiltIn}>
779
+ <Icon name="trash" size={12} />
780
+ </button>
781
+ </div>
782
+ </div>
783
+ );
784
+ }
785
+
786
+ // ---------------- 技能详情 ----------------
787
+
788
+ function SkillDetailModal({ tool, onClose }: { tool: McpTool; onClose: () => void }) {
789
+ const t = useT();
790
+ const refreshTools = useStore((s) => s.refreshTools);
791
+ const editable = !tool.isBuiltIn;
792
+ let parsed: { function?: { name?: string; description?: string; parameters?: unknown } } | null = null;
793
+ try {
794
+ parsed = JSON.parse(tool.jsonContent) as { function?: { name?: string; description?: string; parameters?: unknown } };
795
+ } catch { /* 保持 null */ }
796
+ let exec: unknown = null;
797
+ let execType = 'native';
798
+ if (tool.executionJson) {
799
+ try {
800
+ exec = JSON.parse(tool.executionJson);
801
+ execType = (exec as { type?: string }).type ?? 'unknown';
802
+ } catch { exec = null; execType = 'invalid'; }
803
+ }
804
+
805
+ const initialParameters = parsed?.function?.parameters ?? { type: 'object', properties: {} };
806
+ const [name, setName] = useState(parsed?.function?.name ?? tool.name);
807
+ const [description, setDescription] = useState(parsed?.function?.description ?? '');
808
+ const [parametersText, setParametersText] = useState(() => JSON.stringify(initialParameters, null, 2));
809
+ const [error, setError] = useState<string | null>(null);
810
+ const [saving, setSaving] = useState(false);
811
+
812
+ const schema = useMemo(() => {
813
+ try {
814
+ const value: unknown = JSON.parse(parametersText);
815
+ if (!value || Array.isArray(value) || typeof value !== 'object' || (value as { type?: unknown }).type !== 'object') return { error: t('workshop.skillSchemaObject') };
816
+ return { value: value as Record<string, unknown> };
817
+ } catch {
818
+ return { error: t('workshop.skillSchemaJson') };
819
+ }
820
+ }, [parametersText, t]);
821
+ const rawJson = schema.value
822
+ ? JSON.stringify({ type: 'function', function: { name, description, parameters: schema.value } }, null, 2)
823
+ : null;
824
+
825
+ const save = async () => {
826
+ if (!schema.value) {
827
+ setError(schema.error ?? t('workshop.skillSchemaJson'));
828
+ return;
829
+ }
830
+ if (!/^[a-z][a-z0-9_]{2,39}$/.test(name)) {
831
+ setError(t('workshop.skillNameInvalid'));
832
+ return;
833
+ }
834
+ if (description.length > 500) {
835
+ setError(t('workshop.skillDescriptionLong'));
836
+ return;
837
+ }
838
+ setSaving(true);
839
+ setError(null);
840
+ try {
841
+ const existing = await db.tools.where('name').equals(name).first();
842
+ if (existing && existing.id !== tool.id) {
843
+ setError(t('workshop.skillNameExists'));
844
+ return;
845
+ }
846
+ const updated = { ...tool, name, jsonContent: rawJson! };
847
+ await db.transaction('rw', db.tools, db.npcs, async () => {
848
+ await db.tools.put(updated);
849
+ if (name !== tool.name) {
850
+ const npcs = await db.npcs.toArray();
851
+ await Promise.all(npcs
852
+ .filter((npc) => npc.enabledToolNames.includes(tool.name))
853
+ .map((npc) => db.npcs.update(npc.id!, {
854
+ enabledToolNames: npc.enabledToolNames.map((toolName) => toolName === tool.name ? name : toolName),
855
+ })));
856
+ }
857
+ });
858
+ await refreshTools();
859
+ onClose();
860
+ } finally {
861
+ setSaving(false);
862
+ }
863
+ };
864
+
865
+ return (
866
+ <Modal onClose={onClose} width={620}>
867
+ <div className="modal-head">
868
+ <span style={{ fontWeight: 800, fontSize: 15 }}>
869
+ {t('workshop.skillDetails')}
870
+ {tool.isBuiltIn && <span className="tag" style={{ marginLeft: 8 }}>{t('common.builtin')}</span>}
871
+ {!tool.isBuiltIn && execType !== 'native' && <span className="tag" style={{ marginLeft: 8 }}>{t('workshop.implType', { t: execType })}</span>}
872
+ </span>
873
+ <button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
874
+ </div>
875
+ <div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
876
+ <div className="field">
877
+ <label>{t('workshop.skillName')}</label>
878
+ {editable ? <input className="input" value={name} onChange={(e) => setName(e.target.value)} /> : <div className="skill-detail-text">{name}</div>}
879
+ </div>
880
+ <div className="field">
881
+ <label>{t('workshop.skillDescription')}</label>
882
+ {editable ? <textarea className="textarea skill-detail-textarea" value={description} maxLength={500} wrap="soft" onChange={(e) => setDescription(e.target.value)} /> : <div className="skill-detail-text">{description || '—'}</div>}
883
+ </div>
884
+ <div className="field">
885
+ <label>{t('workshop.skillParameters')}</label>
886
+ {editable ? <textarea className="textarea mono skill-detail-textarea" value={parametersText} wrap="soft" spellCheck={false} onChange={(e) => setParametersText(e.target.value)} /> : parametersText ? <pre className="skill-detail-json">{parametersText}</pre> : <div className="skill-detail-text" style={{ color: 'var(--text-faint)' }}>{t('workshop.skillNoParams')}</div>}
887
+ </div>
888
+ <div className="field">
889
+ <label>{t('workshop.skillExecution')}</label>
890
+ {exec ? <pre className="skill-detail-json">{JSON.stringify(exec, null, 2)}</pre> : <div className="skill-detail-text" style={{ color: 'var(--text-faint)' }}>{t('workshop.skillNoExecution')}</div>}
891
+ </div>
892
+ <div className="field">
893
+ <label>{t('workshop.skillRawJson')}</label>
894
+ <pre className={`skill-detail-json${rawJson ? '' : ' err'}`}>{rawJson ?? schema.error}</pre>
895
+ </div>
896
+ {error && <div className="field-hint" style={{ color: 'var(--danger)' }}>{error}</div>}
897
+ </div>
898
+ <div className="modal-foot">
899
+ <button className="btn" onClick={onClose}>{t('common.cancel')}</button>
900
+ <button className="btn btn-primary" disabled={!editable || saving} onClick={() => void save()}>{t('common.save')}</button>
901
+ </div>
902
+ </Modal>
903
+ );
904
+ }