tavern-harness 0.1.10 → 0.1.12

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
@@ -10,6 +10,10 @@
10
10
  <a href="https://www.npmjs.com/package/tavern-harness"><img alt="npm" src="https://img.shields.io/npm/v/tavern-harness?style=flat-square" /></a>
11
11
  </p>
12
12
 
13
+ <p align="center">
14
+ <strong>English</strong> | <a href="README.zh.md">简体中文</a>
15
+ </p>
16
+
13
17
  # Tavern Harness · An AI-Native Role-Playing Sandbox
14
18
 
15
19
  <a id="demo"></a>
@@ -74,9 +78,11 @@ After starting the app, open **Settings → Model Service Provider**:
74
78
  2. **Test the connection**: the app fetches the model list automatically. If the target service does not expose CORS headers, requests are forwarded through the built-in same-origin proxy for private-network or LAN addresses. On success, the Base URL is rewritten to its proxy form and saved.
75
79
  3. **Select a model**: click the model name at the top of the chat page, then return to the conversation and start chatting.
76
80
 
77
- ### Automatic Tool Approval (YOLO Mode)
81
+ ### Import a Gameplay and Start Playing
78
82
 
79
- Enable **Settings → Tool execution → YOLO mode** to automatically approve tool changes and Shell commands during conversations. It is off by default, is saved locally, and can be disabled at any time. Only enable it for characters and skills you trust. Agent execution limits still require manual continuation.
83
+ Download the gameplay file below. Click **Import** in the upper-left corner, then import the file you downloaded to create a complete game environment—including characters, lorebooks, skills, and more—and start playing right away:
84
+
85
+ [th-demo-affinity-gameplay-en.json](./docs/assets/gameplay/th-demo-affinity-gameplay-en.json)
80
86
 
81
87
  ### Data Privacy
82
88
 
@@ -110,11 +116,13 @@ Compatible with the **Tavern ecosystem**, including one-click PNG character-card
110
116
 
111
117
  You can also export and share an entire game: recipients can import it and play immediately without manually creating characters, skills, or conversation settings.
112
118
 
113
- ### 🍃 Fully Local and Clean
119
+ ### 🍃 Fully Local / High Cache Hit Rates / Clean
114
120
 
115
121
  The app supports **OpenAI-format APIs**. Use a hosted API plan or run a model locally; your data remains on your device.
116
122
 
117
- The project has no external harness dependency, and its UI is designed for a clean, focused experience.
123
+ Tavern Harness is optimized to improve cache hit rates, significantly reducing token costs.
124
+
125
+ Tavern Harness has no external harness dependency, and its UI is designed for a clean, focused experience.
118
126
 
119
127
  <a id="feedback"></a>
120
128
  ## 4 · Community and Feedback
package/README.zh.md CHANGED
@@ -10,6 +10,10 @@
10
10
  <a href="https://www.npmjs.com/package/tavern-harness"><img alt="npm" src="https://img.shields.io/npm/v/tavern-harness?style=flat-square" /></a>
11
11
  </p>
12
12
 
13
+ <p align="center">
14
+ <a href="README.md">English</a> | <strong>简体中文</strong>
15
+ </p>
16
+
13
17
  # Tavern Harness · AI 原生的角色扮演沙盒
14
18
 
15
19
  <a id="demo"></a>
@@ -75,9 +79,11 @@ npm run dev # 开发服务器默认端口 5173,被占用时自动顺延
75
79
  2. **测试连接**:自动拉取模型列表。若目标服务未开 CORS 头,会自动经内置同源代理转发(仅限内网 / 局域网地址),成功后 Base URL 自动改写为代理形式保存
76
80
  3. **选模型**:聊天页顶部点击模型名即可,回到对话开聊!
77
81
 
78
- ### 自动审批工具(YOLO 模式)
82
+ ### 导入游戏并开始
79
83
 
80
- 在「**设置 → 工具执行 → YOLO 模式**」开启后,对话中的工具修改和 Shell 执行会自动批准,无需逐次确认。默认关闭,设置保存在本地,可随时关闭。仅在信任角色和技能时开启;Agent 执行限额仍需手动确认继续。
84
+ 下载以下游戏文件,点击左上角的「导入」按钮,然后导入刚刚下载的文件,即可创建一整套游戏环境(包括角色、世界书、技能等),直接体验:
85
+
86
+ [小咕嘎-gameplay.json](./docs/assets/gameplay/小咕嘎-gameplay.json)
81
87
 
82
88
  ### 数据隐私须知
83
89
 
@@ -111,11 +117,13 @@ npm run dev # 开发服务器默认端口 5173,被占用时自动顺延
111
117
 
112
118
  同时支持**游戏一键打包分享**:对方直接导入即可游玩,无需手动创建角色、技能、配置对话。
113
119
 
114
- ### 🍃 完全本地 / 简洁干净
120
+ ### 🍃 完全本地 / 高缓存命中率 / 简洁干净
115
121
 
116
122
  目前支持 **OpenAI 格式的 API 接口**:你可以使用你的 token plan,也可以自己本地部署模型,一切数据都保存在本地。
117
123
 
118
- 项目不依赖任何外部 harness,UI 也经过了精细打磨,提供简洁干净的体验。
124
+ Tavern Harness 针对提高缓存命中率进行了优化,显著降低了 Token 成本。
125
+
126
+ 并且 Tavern Harness 不依赖任何外部 harness,UI 也经过了精细打磨,提供简洁干净的体验。
119
127
 
120
128
  <a id="feedback"></a>
121
129
  ## 4 · 交流反馈
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tavern-harness",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Tavern Harness — 酒馆模式下的本地 AI Agent 助手(MyAgent-Android 的 Web 复刻)",
5
5
  "private": false,
6
6
  "type": "module",
@@ -23,6 +23,8 @@
23
23
  "scripts": {
24
24
  "start": "vite",
25
25
  "dev": "vite",
26
+ "test": "node --test *.test.mjs",
27
+ "test:character-book": "node --test character-book.test.mjs",
26
28
  "test:command-service": "node sandbox-server.test.mjs",
27
29
  "test:yolo-mode": "node yolo-mode.test.mjs",
28
30
  "test:tool-arguments": "node tool-arguments.test.mjs",
@@ -30,6 +32,9 @@
30
32
  "preview": "vite preview"
31
33
  },
32
34
  "dependencies": {
35
+ "@codemirror/lang-json": "^6.0.2",
36
+ "@codemirror/state": "^6.7.6",
37
+ "@codemirror/view": "^6.43.14",
33
38
  "@dnd-kit/core": "^6.3.1",
34
39
  "@dnd-kit/sortable": "^10.0.0",
35
40
  "@dnd-kit/utilities": "^3.2.2",
@@ -0,0 +1,77 @@
1
+ import { memo, useEffect, useRef } from 'react';
2
+ import { Compartment, EditorState } from '@codemirror/state';
3
+ import { EditorView, lineNumbers } from '@codemirror/view';
4
+ import { json } from '@codemirror/lang-json';
5
+
6
+ const CharacterBookEditor = memo(function CharacterBookEditor({
7
+ value,
8
+ onChange,
9
+ placeholder,
10
+ readOnly = false,
11
+ lineWrapping = true,
12
+ className,
13
+ }: {
14
+ value: string;
15
+ onChange?: (value: string) => void;
16
+ placeholder: string;
17
+ readOnly?: boolean;
18
+ lineWrapping?: boolean;
19
+ className?: string;
20
+ }) {
21
+ const containerRef = useRef<HTMLDivElement>(null);
22
+ const viewRef = useRef<EditorView | null>(null);
23
+ const onChangeRef = useRef(onChange);
24
+ const wrappingRef = useRef(new Compartment());
25
+ const readOnlyRef = useRef(new Compartment());
26
+
27
+ useEffect(() => {
28
+ onChangeRef.current = onChange;
29
+ }, [onChange]);
30
+
31
+ useEffect(() => {
32
+ if (!containerRef.current) return;
33
+
34
+ const view = new EditorView({
35
+ state: EditorState.create({
36
+ doc: value,
37
+ extensions: [
38
+ lineNumbers(),
39
+ json(),
40
+ wrappingRef.current.of(lineWrapping ? EditorView.lineWrapping : []),
41
+ readOnlyRef.current.of(readOnly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []),
42
+ EditorView.updateListener.of((update) => {
43
+ if (update.docChanged) onChangeRef.current?.(update.state.doc.toString());
44
+ }),
45
+ ],
46
+ }),
47
+ parent: containerRef.current,
48
+ });
49
+ viewRef.current = view;
50
+
51
+ return () => view.destroy();
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ }, []);
54
+
55
+ useEffect(() => {
56
+ const view = viewRef.current;
57
+ if (view) view.dispatch({ effects: wrappingRef.current.reconfigure(lineWrapping ? EditorView.lineWrapping : []) });
58
+ }, [lineWrapping]);
59
+
60
+ useEffect(() => {
61
+ const view = viewRef.current;
62
+ if (view) view.dispatch({
63
+ effects: readOnlyRef.current.reconfigure(readOnly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []),
64
+ });
65
+ }, [readOnly]);
66
+
67
+ useEffect(() => {
68
+ const view = viewRef.current;
69
+ if (view && value !== view.state.doc.toString()) {
70
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
71
+ }
72
+ }, [value]);
73
+
74
+ return <div ref={containerRef} className={`character-book-editor ${className ?? ''}`.trim()} aria-label={placeholder} />;
75
+ });
76
+
77
+ export default CharacterBookEditor;
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react';
1
+ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  DndContext,
4
4
  PointerSensor,
@@ -26,16 +26,18 @@ import {
26
26
  import type { NpcCharacter, WorldBook, McpTool } from '../types/models';
27
27
  import { Avatar, Icon, MasonryGrid, Markdown, Modal } from './shared';
28
28
  import { DeleteConfirmDialog } from './DeleteConfirmDialog';
29
- import { parseSillyTavernCardFile, type ParsedSillyTavernCard } from '../core/sillyTavernImporter';
29
+ import { parseSillyTavernCardFile } from '../core/sillyTavernImporter';
30
30
  import { ALL_BUILTIN_TOOL_NAMES } from '../core/toolDefinitions';
31
31
  import { useT } from '../core/i18n';
32
32
 
33
+ const CharacterBookEditor = lazy(() => import('./CharacterBookEditor'));
34
+
33
35
  // 内置角色「酒馆老板」默认启用所有技能
34
36
  const ALL_DEFAULT_SKILLS = [...ALL_BUILTIN_TOOL_NAMES];
35
37
 
36
38
  // ============================================================
37
39
  // 角色工坊(NPC 管理 / Lorebook / 技能表 / PNG 导入)
38
- // 说明:PNG 导入只解析不落库,打开「新建角色 / 新建Lorebook」表单预填,
40
+ // 说明:PNG 导入只解析不落库,打开「新建角色」表单预填,
39
41
  // 由用户手动点保存
40
42
  // ============================================================
41
43
 
@@ -47,8 +49,6 @@ export function CharactersView() {
47
49
  const [tab, setTab] = useState<'characters' | 'worldbooks' | 'skills'>('characters');
48
50
  const [editing, setEditing] = useState<NpcCharacter | null>(null);
49
51
  const [isNew, setIsNew] = useState(false);
50
- // PNG 解析出的草稿:角色 + 内嵌Lorebook
51
- const [importDraft, setImportDraft] = useState<ParsedSillyTavernCard | null>(null);
52
52
  const fileRef = useRef<HTMLInputElement>(null);
53
53
 
54
54
  const openFilePicker = () => fileRef.current?.click();
@@ -68,18 +68,10 @@ export function CharactersView() {
68
68
  const handleImport = async (file: File) => {
69
69
  try {
70
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
- }
71
+ // 预填新建角色表单,由用户确认后保存
72
+ setEditing({ ...draft.character, id: undefined });
73
+ setIsNew(true);
74
+ addToast(t('toast.importedChar', { name: draft.character.name, version: draft.version }));
83
75
  } catch (e) {
84
76
  addToast(t('toast.importFailed', { msg: (e as Error).message }), 'error');
85
77
  }
@@ -110,20 +102,11 @@ export function CharactersView() {
110
102
  onEdit={(n, isNew) => {
111
103
  setEditing(n);
112
104
  setIsNew(isNew);
113
- setImportDraft(null);
114
105
  }}
115
106
  onImportPng={openFilePicker}
116
107
  />
117
108
  )}
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
- )}
109
+ {tab === 'worldbooks' && <WorldBookList books={worldBooks} onChanged={refreshWb} />}
127
110
  {tab === 'skills' && <SkillList />}
128
111
  </div>
129
112
  <input
@@ -144,10 +127,8 @@ export function CharactersView() {
144
127
  isNew={isNew}
145
128
  onClose={() => {
146
129
  setEditing(null);
147
- setImportDraft(null);
148
130
  }}
149
131
  onSaved={() => {
150
- setImportDraft(null);
151
132
  refresh();
152
133
  setEditing(null);
153
134
  }}
@@ -217,7 +198,7 @@ function CharacterGrid({ npcs, onEdit, onImportPng }: { npcs: NpcCharacter[]; on
217
198
  ))}
218
199
  </div>
219
200
  <div className="cactions">
220
- <button className="btn btn-sm" onClick={() => onEdit(n, false)}><Icon name="settings" size={12} /> {t('common.edit')}</button>
201
+ <button className="btn btn-sm" onClick={() => onEdit(n, false)}><Icon name="settings" size={12} /> {t('workshop.browseEdit')}</button>
221
202
  <button className="btn btn-sm btn-danger" onClick={() => setPendingDelete(n)} disabled={n.isBuiltIn} title={n.isBuiltIn ? t('workshop.protectedTip') : t('common.delete')}>
222
203
  <Icon name="trash" size={12} />
223
204
  </button>
@@ -243,6 +224,9 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
243
224
  const t = useT();
244
225
  const [name, setName] = useState(npc.name);
245
226
  const [prompt, setPrompt] = useState(npc.prompt);
227
+ const [characterBook, setCharacterBook] = useState(npc.characterBook ?? '');
228
+ const [editorTab, setEditorTab] = useState<'character' | 'knowledge'>('character');
229
+ const [renderMarkdown, setRenderMarkdown] = useState(false);
246
230
  // 多个开场白:第一个保存在 greeting,其余保存在 alternateGreetings
247
231
  const [greetings, setGreetings] = useState<string[]>([npc.greeting, ...(npc.alternateGreetings ?? [])]);
248
232
  const [avatarDataUrl, setAvatarDataUrl] = useState<string | null>(npc.avatarDataUrl ?? null);
@@ -273,11 +257,20 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
273
257
  addToast(t('toast.nameRequired'), 'error');
274
258
  return;
275
259
  }
260
+ if (characterBook.trim()) {
261
+ try {
262
+ JSON.parse(characterBook);
263
+ } catch {
264
+ addToast(t('toast.characterBookInvalidJson'), 'error');
265
+ return;
266
+ }
267
+ }
276
268
  // 清理空白/重复项:第一项作为主开场白,其余作为备用
277
269
  const cleaned = greetings.map((g) => g.trim()).filter((g, i, arr) => g && arr.indexOf(g) === i);
278
270
  const npcData = {
279
271
  name: npc.isBuiltIn ? npc.name : name.trim(),
280
272
  prompt,
273
+ characterBook: characterBook.trim() || undefined,
281
274
  greeting: cleaned[0] ?? '',
282
275
  alternateGreetings: cleaned.slice(1),
283
276
  avatarDataUrl,
@@ -303,14 +296,31 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
303
296
  };
304
297
 
305
298
  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">
299
+ <Modal onClose={onClose} className="modal-editor character-editor-modal">
300
+ <div className="modal-head">
310
301
  <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>
302
+ <div className="raw-tabs">
303
+ <button className={`raw-tab ${editorTab === 'character' ? 'active' : ''}`} onClick={() => setEditorTab('character')}>{t('workshop.characterTab')}</button>
304
+ <button
305
+ className={`raw-tab ${editorTab === 'knowledge' ? 'active' : ''}`}
306
+ onClick={() => setEditorTab('knowledge')}
307
+ >
308
+ {t('workshop.knowledgeTab')}
309
+ </button>
310
+ </div>
311
+ <div className="modal-head-actions">
312
+ <div className="raw-wrap-toggle">
313
+ <span className="raw-wrap-label">{t('workshop.wbMarkdownRender')}</span>
314
+ <label className="switch">
315
+ <input type="checkbox" checked={renderMarkdown} onChange={(e) => setRenderMarkdown(e.target.checked)} />
316
+ <span className="switch-slider" />
317
+ </label>
318
+ </div>
319
+ <button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
320
+ </div>
312
321
  </div>
313
- <div className="modal-body">
322
+ <div className={`modal-body character-editor-body ${editorTab === 'knowledge' ? 'knowledge-tab' : ''}`}>
323
+ {editorTab === 'character' && <>
314
324
  <div style={{ display: 'flex', gap: 14, alignItems: 'center' }}>
315
325
  <Avatar name={name || t('chat.speakerChar')} colorOrdinal={npc.avatarColorOrdinal} imageUrl={avatarDataUrl} size="sm" />
316
326
  <div style={{ display: 'flex', gap: 8 }}>
@@ -336,33 +346,49 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
336
346
  </div>
337
347
  <div className="field prompt-field">
338
348
  <label>{t('workshop.promptLabel')}</label>
339
- <textarea className="textarea grow-textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder={t('workshop.promptPh')} />
349
+ {!renderMarkdown ? (
350
+ <textarea className="textarea grow-textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder={t('workshop.promptPh')} />
351
+ ) : (
352
+ <div className="bubble preview-scroll wb-render-pane char-prompt-render-pane">
353
+ {prompt ? <Markdown text={prompt} /> : <span style={{ color: 'var(--text-faint)', fontSize: 13 }}>{t('workshop.promptPh')}</span>}
354
+ </div>
355
+ )}
340
356
  </div>
341
357
  <div className="field" style={{ flexShrink: 0 }}>
342
358
  <label>
343
359
  {t('workshop.greetingLabel')}
344
- <span className="field-hint"> {t('workshop.greetingRandomHint')}</span>
360
+ <span> {t('workshop.greetingRandomHint')}</span>
345
361
  </label>
346
362
  {greetings.map((g, i) => (
347
363
  <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>
364
+ {!renderMarkdown ? (
365
+ <textarea
366
+ className="textarea grow-textarea greeting-grow"
367
+ value={g}
368
+ onChange={(e) => setGreetings((prev) => prev.map((x, j) => (j === i ? e.target.value : x)))}
369
+ placeholder={t('workshop.greetingPh')}
370
+ />
371
+ ) : (
372
+ <div className="bubble preview-scroll wb-render-pane greeting-render-pane">
373
+ {g ? <Markdown text={g} /> : <span style={{ color: 'var(--text-faint)', fontSize: 13 }}>{t('workshop.greetingPh')}</span>}
374
+ </div>
375
+ )}
376
+ {!renderMarkdown && (
377
+ <button
378
+ className="icon-btn greeting-del"
379
+ title={t('common.delete')}
380
+ onClick={() => setGreetings((prev) => prev.filter((_, j) => j !== i))}
381
+ >
382
+ <Icon name="x" size={13} />
383
+ </button>
384
+ )}
361
385
  </div>
362
386
  ))}
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>
387
+ {!renderMarkdown && (
388
+ <button className="btn btn-sm btn-ghost" style={{ alignSelf: 'flex-start' }} onClick={() => setGreetings((prev) => [...prev, ''])}>
389
+ <Icon name="plus" size={12} /> {t('workshop.addAltGreeting')}
390
+ </button>
391
+ )}
366
392
  </div>
367
393
  {/* 启用技能:从卡片移入编辑页 */}
368
394
  <div className="field" style={{ flexShrink: 0 }}>
@@ -380,6 +406,19 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
380
406
  </div>
381
407
  )}
382
408
  </div>
409
+ </>}
410
+ {editorTab === 'knowledge' && (
411
+ <div className="field prompt-field character-book-field">
412
+ <label>{t('workshop.characterBookLabel')}</label>
413
+ <Suspense fallback={<div className="character-book-editor-loading" />}>
414
+ <CharacterBookEditor
415
+ value={characterBook}
416
+ onChange={setCharacterBook}
417
+ placeholder={t('workshop.characterBookPh')}
418
+ />
419
+ </Suspense>
420
+ </div>
421
+ )}
383
422
  </div>
384
423
  <div className="modal-foot">
385
424
  {npc.isBuiltIn && (
@@ -390,40 +429,24 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
390
429
  <button className="btn" onClick={onClose}>{t('common.cancel')}</button>
391
430
  <button className="btn btn-primary" onClick={save}>{t('common.save')}</button>
392
431
  </div>
393
- </div>
394
- </div>
395
- </div>
432
+ </Modal>
396
433
  );
397
434
  }
398
435
 
399
436
  // ---------------- Lorebook ----------------
400
437
 
401
- function WorldBookList({ books, onChanged, onImportPng, importDraft, onImportDraftConsumed }: {
438
+ function WorldBookList({ books, onChanged }: {
402
439
  books: WorldBook[];
403
440
  onChanged: () => void;
404
- onImportPng: () => void;
405
- /** PNG 解析出的角色卡草稿(含内嵌Lorebook) */
406
- importDraft: ParsedSillyTavernCard | null;
407
- /** 草稿已被消费(保存/取消)后通知父组件清空 */
408
- onImportDraftConsumed: () => void;
409
441
  }) {
410
442
  const addToast = useStore((s) => s.addToast);
411
443
  const t = useT();
412
444
  const [editing, setEditing] = useState<WorldBook | { name: string; content: string; imageUri: null; createdAt: number } | null>(null);
445
+ const [renderMarkdown, setRenderMarkdown] = useState(false);
413
446
  const [pendingDelete, setPendingDelete] = useState<WorldBook | null>(null);
414
447
 
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
448
  const closeEditor = () => {
425
449
  setEditing(null);
426
- onImportDraftConsumed();
427
450
  };
428
451
 
429
452
  const saveWb = async (name: string, content: string) => {
@@ -440,7 +463,6 @@ function WorldBookList({ books, onChanged, onImportPng, importDraft, onImportDra
440
463
  }
441
464
  setEditing(null);
442
465
  onChanged();
443
- onImportDraftConsumed();
444
466
  };
445
467
 
446
468
  const deleteWb = async (b: WorldBook) => {
@@ -455,9 +477,6 @@ function WorldBookList({ books, onChanged, onImportPng, importDraft, onImportDra
455
477
  <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
456
478
  <span className="group-label" style={{ margin: 0 }}>{t('workshop.wbCount', { n: books.length })}</span>
457
479
  <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
480
  <button className="btn btn-primary btn-sm" onClick={() => setEditing({ name: '', content: '', imageUri: null, createdAt: Date.now() })}>
462
481
  <Icon name="plus" size={13} /> {t('workshop.newWb')}
463
482
  </button>
@@ -507,17 +526,22 @@ function WorldBookList({ books, onChanged, onImportPng, importDraft, onImportDra
507
526
  )}
508
527
 
509
528
  {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>
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>
516
539
  </div>
517
- <WorldBookForm initial={editing} onSave={saveWb} onCancel={closeEditor} />
540
+ <button className="icon-btn" onClick={closeEditor}><Icon name="x" /></button>
518
541
  </div>
519
542
  </div>
520
- </div>
543
+ <WorldBookForm initial={editing} renderMarkdown={renderMarkdown} onSave={saveWb} onCancel={closeEditor} />
544
+ </Modal>
521
545
  )}
522
546
  </div>
523
547
  );
@@ -546,11 +570,10 @@ function ScrollingCardName({ name }: { name: string }) {
546
570
  );
547
571
  }
548
572
 
549
- function WorldBookForm({ initial, onSave, onCancel }: { initial: WorldBook | { name: string; content: string; imageUri: null; createdAt: number }; onSave: (n: string, c: string) => void; onCancel: () => void }) {
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 }) {
550
574
  const t = useT();
551
575
  const [name, setName] = useState(initial.name);
552
576
  const [content, setContent] = useState(initial.content);
553
- const [renderMarkdown, setRenderMarkdown] = useState(false);
554
577
  const isBuiltin = isBuiltinTavernHandbook(initial);
555
578
 
556
579
  const resetToDefault = () => {
@@ -572,16 +595,7 @@ function WorldBookForm({ initial, onSave, onCancel }: { initial: WorldBook | { n
572
595
  />}
573
596
  </div>
574
597
  <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>
598
+ <label>{t('workshop.wbContentLabel')}</label>
585
599
  {!renderMarkdown ? (
586
600
  <textarea className="textarea grow-textarea" value={content} onChange={(e) => setContent(e.target.value)} placeholder={t('workshop.wbContentPh')} />
587
601
  ) : (