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 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 and Clean
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
- The project has no external harness dependency, and its UI is designed for a clean, focused experience.
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
- 项目不依赖任何外部 harness,UI 也经过了精细打磨,提供简洁干净的体验。
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.9",
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
  }
@@ -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
- // 项目根目录下 sandbox_workspace/,仅允许读写该目录内文件(虚拟磁盘)。
303
- // 会话隔离:每次请求必须携带会话工作目录(session 字段,如 "session-12"),
304
- // 所有读写/执行都锁定在该目录内,不允许回退到共享根工作区。
305
- const WORKSPACE_ROOT = resolve(__dirname, 'sandbox_workspace');
306
- const PUBLIC_WORKSPACE = join(WORKSPACE_ROOT, 'public');
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(WORKSPACE_ROOT, raw);
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;