tavern-harness 0.1.10 → 0.1.11
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 +8 -4
- package/README.zh.md +8 -4
- package/package.json +6 -1
- package/src/components/CharacterBookEditor.tsx +77 -0
- package/src/components/CharactersView.tsx +111 -97
- package/src/components/ChatView.tsx +53 -24
- package/src/components/Dashboard.tsx +3 -0
- package/src/core/characterBook.ts +243 -0
- package/src/core/i18n.ts +24 -9
- package/src/core/sillyTavernImporter.ts +52 -42
- package/src/db/database.ts +16 -1
- package/src/store/store.ts +12 -2
- package/src/theme/chat.css +4 -0
- package/src/theme/global.css +12 -2
- package/src/theme/views.css +97 -14
- package/src/types/models.ts +2 -0
package/README.md
CHANGED
|
@@ -74,9 +74,11 @@ After starting the app, open **Settings → Model Service Provider**:
|
|
|
74
74
|
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
75
|
3. **Select a model**: click the model name at the top of the chat page, then return to the conversation and start chatting.
|
|
76
76
|
|
|
77
|
-
###
|
|
77
|
+
### Import a Game and Start Playing
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
Download the game 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:
|
|
80
|
+
|
|
81
|
+
[th-demo-affinity-gameplay-en.json](./docs/assets/gameplay/th-demo-affinity-gameplay-en.json)
|
|
80
82
|
|
|
81
83
|
### Data Privacy
|
|
82
84
|
|
|
@@ -110,11 +112,13 @@ Compatible with the **Tavern ecosystem**, including one-click PNG character-card
|
|
|
110
112
|
|
|
111
113
|
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
114
|
|
|
113
|
-
### 🍃 Fully Local
|
|
115
|
+
### 🍃 Fully Local / High Cache Hit Rates / Clean
|
|
114
116
|
|
|
115
117
|
The app supports **OpenAI-format APIs**. Use a hosted API plan or run a model locally; your data remains on your device.
|
|
116
118
|
|
|
117
|
-
|
|
119
|
+
Tavern Harness is optimized to improve cache hit rates, significantly reducing token costs.
|
|
120
|
+
|
|
121
|
+
Tavern Harness has no external harness dependency, and its UI is designed for a clean, focused experience.
|
|
118
122
|
|
|
119
123
|
<a id="feedback"></a>
|
|
120
124
|
## 4 · Community and Feedback
|
package/README.zh.md
CHANGED
|
@@ -75,9 +75,11 @@ npm run dev # 开发服务器默认端口 5173,被占用时自动顺延
|
|
|
75
75
|
2. **测试连接**:自动拉取模型列表。若目标服务未开 CORS 头,会自动经内置同源代理转发(仅限内网 / 局域网地址),成功后 Base URL 自动改写为代理形式保存
|
|
76
76
|
3. **选模型**:聊天页顶部点击模型名即可,回到对话开聊!
|
|
77
77
|
|
|
78
|
-
###
|
|
78
|
+
### 导入游戏并开始
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
下载以下游戏文件,点击左上角的「导入」按钮,然后导入刚刚下载的文件,即可创建一整套游戏环境(包括角色、世界书、技能等),直接体验:
|
|
81
|
+
|
|
82
|
+
[小咕嘎-gameplay.json](./docs/assets/gameplay/小咕嘎-gameplay.json)
|
|
81
83
|
|
|
82
84
|
### 数据隐私须知
|
|
83
85
|
|
|
@@ -111,11 +113,13 @@ npm run dev # 开发服务器默认端口 5173,被占用时自动顺延
|
|
|
111
113
|
|
|
112
114
|
同时支持**游戏一键打包分享**:对方直接导入即可游玩,无需手动创建角色、技能、配置对话。
|
|
113
115
|
|
|
114
|
-
### 🍃 完全本地 / 简洁干净
|
|
116
|
+
### 🍃 完全本地 / 高缓存命中率 / 简洁干净
|
|
115
117
|
|
|
116
118
|
目前支持 **OpenAI 格式的 API 接口**:你可以使用你的 token plan,也可以自己本地部署模型,一切数据都保存在本地。
|
|
117
119
|
|
|
118
|
-
|
|
120
|
+
Tavern Harness 针对提高缓存命中率进行了优化,显著降低了 Token 成本。
|
|
121
|
+
|
|
122
|
+
并且 Tavern Harness 不依赖任何外部 harness,UI 也经过了精细打磨,提供简洁干净的体验。
|
|
119
123
|
|
|
120
124
|
<a id="feedback"></a>
|
|
121
125
|
## 4 · 交流反馈
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tavern-harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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('
|
|
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
|
-
<
|
|
307
|
-
<div className="modal-
|
|
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
|
-
<
|
|
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=
|
|
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
|
-
|
|
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
|
|
360
|
+
<span> {t('workshop.greetingRandomHint')}</span>
|
|
345
361
|
</label>
|
|
346
362
|
{greetings.map((g, i) => (
|
|
347
363
|
<div key={i} className="greeting-input">
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
364
|
-
<
|
|
365
|
-
|
|
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
|
-
|
|
394
|
-
</div>
|
|
395
|
-
</div>
|
|
432
|
+
</Modal>
|
|
396
433
|
);
|
|
397
434
|
}
|
|
398
435
|
|
|
399
436
|
// ---------------- Lorebook ----------------
|
|
400
437
|
|
|
401
|
-
function WorldBookList({ books, onChanged
|
|
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
|
-
<
|
|
511
|
-
<div className="modal-
|
|
512
|
-
<
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
<
|
|
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
|
-
<
|
|
540
|
+
<button className="icon-btn" onClick={closeEditor}><Icon name="x" /></button>
|
|
518
541
|
</div>
|
|
519
542
|
</div>
|
|
520
|
-
|
|
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
|
-
<
|
|
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
|
) : (
|