tavern-harness 0.1.9 → 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 +11 -2
- package/README.zh.md +11 -2
- package/bin/cli.js +5 -0
- package/package.json +9 -1
- package/sandbox-server.mjs +38 -9
- 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/components/SettingsView.tsx +41 -31
- package/src/core/characterBook.ts +243 -0
- package/src/core/i18n.ts +34 -10
- package/src/core/openai.ts +48 -37
- package/src/core/sillyTavernImporter.ts +52 -42
- package/src/db/database.ts +21 -2
- package/src/store/store.ts +21 -4
- package/src/theme/chat.css +4 -0
- package/src/theme/global.css +12 -2
- package/src/theme/views.css +107 -15
- package/src/types/models.ts +4 -0
package/README.md
CHANGED
|
@@ -33,6 +33,7 @@ The first run downloads the package, starts the local service, and opens your br
|
|
|
33
33
|
```bash
|
|
34
34
|
npx tavern-harness --no-open
|
|
35
35
|
npx tavern-harness --port 5273
|
|
36
|
+
npx tavern-harness --workspace /absolute/path/to/workspace
|
|
36
37
|
```
|
|
37
38
|
|
|
38
39
|
### Option 2: Global Installation (Recommended for Regular Use)
|
|
@@ -73,6 +74,12 @@ After starting the app, open **Settings → Model Service Provider**:
|
|
|
73
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.
|
|
74
75
|
3. **Select a model**: click the model name at the top of the chat page, then return to the conversation and start chatting.
|
|
75
76
|
|
|
77
|
+
### Import a Game and Start Playing
|
|
78
|
+
|
|
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)
|
|
82
|
+
|
|
76
83
|
### Data Privacy
|
|
77
84
|
|
|
78
85
|
- **All data is stored in browser IndexedDB**: conversations, character cards, lorebooks, skills, statistics, and API keys are not uploaded to any server.
|
|
@@ -105,11 +112,13 @@ Compatible with the **Tavern ecosystem**, including one-click PNG character-card
|
|
|
105
112
|
|
|
106
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.
|
|
107
114
|
|
|
108
|
-
### 🍃 Fully Local
|
|
115
|
+
### 🍃 Fully Local / High Cache Hit Rates / Clean
|
|
109
116
|
|
|
110
117
|
The app supports **OpenAI-format APIs**. Use a hosted API plan or run a model locally; your data remains on your device.
|
|
111
118
|
|
|
112
|
-
|
|
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.
|
|
113
122
|
|
|
114
123
|
<a id="feedback"></a>
|
|
115
124
|
## 4 · Community and Feedback
|
package/README.zh.md
CHANGED
|
@@ -33,6 +33,7 @@ npx tavern-harness
|
|
|
33
33
|
```bash
|
|
34
34
|
npx tavern-harness --no-open
|
|
35
35
|
npx tavern-harness --port 5273
|
|
36
|
+
npx tavern-harness --workspace /absolute/path/to/workspace
|
|
36
37
|
```
|
|
37
38
|
|
|
38
39
|
|
|
@@ -74,6 +75,12 @@ npm run dev # 开发服务器默认端口 5173,被占用时自动顺延
|
|
|
74
75
|
2. **测试连接**:自动拉取模型列表。若目标服务未开 CORS 头,会自动经内置同源代理转发(仅限内网 / 局域网地址),成功后 Base URL 自动改写为代理形式保存
|
|
75
76
|
3. **选模型**:聊天页顶部点击模型名即可,回到对话开聊!
|
|
76
77
|
|
|
78
|
+
### 导入游戏并开始
|
|
79
|
+
|
|
80
|
+
下载以下游戏文件,点击左上角的「导入」按钮,然后导入刚刚下载的文件,即可创建一整套游戏环境(包括角色、世界书、技能等),直接体验:
|
|
81
|
+
|
|
82
|
+
[小咕嘎-gameplay.json](./docs/assets/gameplay/小咕嘎-gameplay.json)
|
|
83
|
+
|
|
77
84
|
### 数据隐私须知
|
|
78
85
|
|
|
79
86
|
- **全部数据存在浏览器 IndexedDB 里**:会话、角色卡、世界书、技能、统计、API Key,均**不上传任何服务器**。
|
|
@@ -106,11 +113,13 @@ npm run dev # 开发服务器默认端口 5173,被占用时自动顺延
|
|
|
106
113
|
|
|
107
114
|
同时支持**游戏一键打包分享**:对方直接导入即可游玩,无需手动创建角色、技能、配置对话。
|
|
108
115
|
|
|
109
|
-
### 🍃 完全本地 / 简洁干净
|
|
116
|
+
### 🍃 完全本地 / 高缓存命中率 / 简洁干净
|
|
110
117
|
|
|
111
118
|
目前支持 **OpenAI 格式的 API 接口**:你可以使用你的 token plan,也可以自己本地部署模型,一切数据都保存在本地。
|
|
112
119
|
|
|
113
|
-
|
|
120
|
+
Tavern Harness 针对提高缓存命中率进行了优化,显著降低了 Token 成本。
|
|
121
|
+
|
|
122
|
+
并且 Tavern Harness 不依赖任何外部 harness,UI 也经过了精细打磨,提供简洁干净的体验。
|
|
114
123
|
|
|
115
124
|
<a id="feedback"></a>
|
|
116
125
|
## 4 · 交流反馈
|
package/bin/cli.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// tavern-harness --no-open 只启动,不打开浏览器
|
|
6
6
|
// tavern-harness --port 5273 指定端口(5173 被占用时 vite 会自动顺延,
|
|
7
7
|
// 会打开顺延后的实际端口)
|
|
8
|
+
// tavern-harness --workspace /path 指定生成技能文件工作区
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
9
10
|
import { dirname, join } from 'node:path';
|
|
10
11
|
import { createServer } from 'vite';
|
|
@@ -17,15 +18,19 @@ const configFile = join(root, 'vite.config.ts'); // 复用现有配置(cors
|
|
|
17
18
|
const args = process.argv.slice(2);
|
|
18
19
|
let open = true; // 默认自动打开浏览器
|
|
19
20
|
let port;
|
|
21
|
+
let workspace;
|
|
20
22
|
for (let i = 0; i < args.length; i++) {
|
|
21
23
|
const a = args[i];
|
|
22
24
|
if (a === '--no-open') open = false;
|
|
23
25
|
else if (a === '--open') open = true;
|
|
24
26
|
else if (a === '--port') port = Number(args[++i]);
|
|
25
27
|
else if (a.startsWith('--port=')) port = Number(a.slice('--port='.length));
|
|
28
|
+
else if (a === '--workspace') workspace = args[++i];
|
|
29
|
+
else if (a.startsWith('--workspace=')) workspace = a.slice('--workspace='.length);
|
|
26
30
|
}
|
|
27
31
|
const serverOptions = { open };
|
|
28
32
|
if (Number.isInteger(port) && port > 0 && port < 65536) serverOptions.port = port;
|
|
33
|
+
if (workspace) process.env.TAVERN_HARNESS_WORKSPACE = workspace;
|
|
29
34
|
|
|
30
35
|
try {
|
|
31
36
|
// inlineConfig 与 vite.config.ts 合并(inline 优先),open 会覆盖配置文件里的 open:false
|
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,11 +23,18 @@
|
|
|
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",
|
|
29
|
+
"test:yolo-mode": "node yolo-mode.test.mjs",
|
|
30
|
+
"test:tool-arguments": "node tool-arguments.test.mjs",
|
|
27
31
|
"build": "tsc --noEmit && vite build",
|
|
28
32
|
"preview": "vite preview"
|
|
29
33
|
},
|
|
30
34
|
"dependencies": {
|
|
35
|
+
"@codemirror/lang-json": "^6.0.2",
|
|
36
|
+
"@codemirror/state": "^6.7.6",
|
|
37
|
+
"@codemirror/view": "^6.43.14",
|
|
31
38
|
"@dnd-kit/core": "^6.3.1",
|
|
32
39
|
"@dnd-kit/sortable": "^10.0.0",
|
|
33
40
|
"@dnd-kit/utilities": "^3.2.2",
|
|
@@ -45,6 +52,7 @@
|
|
|
45
52
|
"@types/katex": "^0.16.7",
|
|
46
53
|
"@types/react": "^18.3.5",
|
|
47
54
|
"@types/react-dom": "^18.3.0",
|
|
55
|
+
"fake-indexeddb": "^6.2.5",
|
|
48
56
|
"typescript": "^5.5.4"
|
|
49
57
|
}
|
|
50
58
|
}
|
package/sandbox-server.mjs
CHANGED
|
@@ -22,10 +22,10 @@ import { createServer } from 'node:http';
|
|
|
22
22
|
import { spawn, spawnSync } from 'node:child_process';
|
|
23
23
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
24
24
|
import {
|
|
25
|
-
writeFileSync, existsSync, lstatSync, readlinkSync, readFile, writeFile, mkdirSync, mkdtempSync, readdir, statSync, symlinkSync, realpathSync, rmSync, unlink,
|
|
25
|
+
writeFileSync, existsSync, lstatSync, readlinkSync, readFile, writeFile, mkdirSync, mkdtempSync, readdir, statSync, symlinkSync, realpathSync, rmSync, unlink, cpSync,
|
|
26
26
|
} from 'node:fs';
|
|
27
|
-
import { tmpdir } from 'node:os';
|
|
28
|
-
import { join, dirname, resolve, sep, delimiter } from 'node:path';
|
|
27
|
+
import { tmpdir, homedir } from 'node:os';
|
|
28
|
+
import { join, dirname, resolve, sep, delimiter, isAbsolute } from 'node:path';
|
|
29
29
|
import { fileURLToPath } from 'node:url';
|
|
30
30
|
|
|
31
31
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -299,11 +299,40 @@ function consumeApprovedConfirmation(requestId, operation, sessionBase) {
|
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
// ---- 真实文件工作区(generate_skill 的 file_read / file_write 落盘区) ----
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
302
|
+
// 默认放在用户数据目录,避免 npm 更新或重装删除用户创建的文件。
|
|
303
|
+
// 可通过 TAVERN_HARNESS_WORKSPACE 指向自定义绝对路径。
|
|
304
|
+
function defaultWorkspaceRoot() {
|
|
305
|
+
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', 'Tavern Harness', 'sandbox_workspace');
|
|
306
|
+
if (process.platform === 'win32') return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'Tavern Harness', 'sandbox_workspace');
|
|
307
|
+
return join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'tavern-harness', 'sandbox_workspace');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function resolveWorkspaceRoot() {
|
|
311
|
+
const configured = process.env.TAVERN_HARNESS_WORKSPACE?.trim();
|
|
312
|
+
if (configured) {
|
|
313
|
+
if (!isAbsolute(configured)) throw new Error('TAVERN_HARNESS_WORKSPACE must be an absolute path');
|
|
314
|
+
return resolve(configured);
|
|
315
|
+
}
|
|
316
|
+
return defaultWorkspaceRoot();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const WORKSPACE_ROOT = resolveWorkspaceRoot();
|
|
320
|
+
const LEGACY_WORKSPACE_ROOT = resolve(__dirname, 'sandbox_workspace');
|
|
321
|
+
|
|
322
|
+
function migrateLegacyWorkspace() {
|
|
323
|
+
if (WORKSPACE_ROOT === LEGACY_WORKSPACE_ROOT || existsSync(WORKSPACE_ROOT) || !existsSync(LEGACY_WORKSPACE_ROOT)) return;
|
|
324
|
+
try {
|
|
325
|
+
mkdirSync(dirname(WORKSPACE_ROOT), { recursive: true });
|
|
326
|
+
cpSync(LEGACY_WORKSPACE_ROOT, WORKSPACE_ROOT, { recursive: true, dereference: false, errorOnExist: true });
|
|
327
|
+
console.log(`[command-service] migrated workspace to ${WORKSPACE_ROOT}`);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
console.warn(`[command-service] could not migrate legacy workspace: ${error.message}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
migrateLegacyWorkspace();
|
|
334
|
+
const RESOLVED_WORKSPACE_ROOT = existsSync(WORKSPACE_ROOT) ? realpathSync(WORKSPACE_ROOT) : WORKSPACE_ROOT;
|
|
335
|
+
const PUBLIC_WORKSPACE = join(RESOLVED_WORKSPACE_ROOT, 'public');
|
|
307
336
|
const MAX_FILE_READ_CHARS = 100_000; // 单文件读取上限(与前端虚拟工作区一致)
|
|
308
337
|
const MAX_FILE_WRITE_BYTES = 400 * 1024; // 单文件写入上限 400KB
|
|
309
338
|
const MAX_LIST_ENTRIES = 500;
|
|
@@ -320,7 +349,7 @@ function resolveSessionBase(session) {
|
|
|
320
349
|
if (!raw || raw.startsWith('/') || raw === '.' || raw === '..' || !SESSION_DIR_RE.test(raw)) {
|
|
321
350
|
throw new Error('A valid session workspace is required');
|
|
322
351
|
}
|
|
323
|
-
return join(
|
|
352
|
+
return join(RESOLVED_WORKSPACE_ROOT, raw);
|
|
324
353
|
}
|
|
325
354
|
|
|
326
355
|
/** 创建会话目录,并为普通会话提供固定的只读公共目录入口。 */
|
|
@@ -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;
|