tavern-harness 0.1.26 → 0.1.27

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/CHANGELOG.md CHANGED
@@ -3,6 +3,50 @@
3
3
  > 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
4
4
  > 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
5
5
 
6
+ ## [0.1.27] - 2026-10-28
7
+
8
+ ### 中文
9
+
10
+ #### 新增
11
+
12
+ - 新增 `get_skill_info` 工具:模型可以先查看技能的完整定义(参数、实现方式、执行配置),再决定如何调用
13
+ - 技能里的 JavaScript 执行新增 `$display` 助手:生成报告、图表或网页后可以直接在弹窗中展示给用户(每次执行只展示一个文件)
14
+
15
+ #### 改进
16
+
17
+ - 工作区文件管理里每个文件都多了下载按钮,可以直接保存到本地
18
+ - 文件预览会按文件记住缩放比例,切到别的文件再回来不用重新调整
19
+ - 技能的 shell 脚本允许把输出重定向到 /dev/null(Windows 为 NUL),常见的静默输出写法不再被误判为越权
20
+
21
+ #### 修复
22
+
23
+ - 修复画中画中的 HTML 预览不会随文件更新而自动刷新、必须关闭重开才能看到最新内容的问题
24
+
25
+ #### 内部
26
+
27
+ - 技能的文件读写统一走 JavaScript 助手(`$read` / `$write` / `$append` / `$list`),移除 `file_read` / `file_write` 执行类型
28
+
29
+ ### English
30
+
31
+ #### Added
32
+
33
+ - Added `get_skill_info`, so the model can inspect a skill's full definition (parameters, implementation, execution config) before deciding how to call it
34
+ - JavaScript skills can now call `$display` to show a generated report, chart, or page in a popup for the user (one file per execution)
35
+
36
+ #### Changed
37
+
38
+ - Every file in the workspace file manager can now be downloaded directly
39
+ - File previews remember the zoom level per file, so switching back no longer resets the view
40
+ - Skill shell scripts may redirect output to /dev/null (NUL on Windows), so common quiet-command patterns are no longer rejected as unsafe writes
41
+
42
+ #### Fixed
43
+
44
+ - Fixed HTML previews in picture-in-picture not refreshing when the file changed; the latest content now appears without closing and reopening the preview
45
+
46
+ #### Internal
47
+
48
+ - Skill file access now goes through the JavaScript helpers (`$read` / `$write` / `$append` / `$list`); the `file_read` and `file_write` execution types were removed
49
+
6
50
  ## [0.1.26] - 2026-10-27
7
51
 
8
52
  ### 中文
@@ -742,6 +786,7 @@
742
786
  - Vite + React + Zustand + Dexie stack with browser-side local persistence
743
787
 
744
788
  <!-- 链接定义 -->
789
+ [0.1.27]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.26...v0.1.27
745
790
  [0.1.26]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.25...v0.1.26
746
791
  [0.1.25]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.24...v0.1.25
747
792
  [0.1.24]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.23...v0.1.24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tavern-harness",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Tavern Harness — 酒馆模式下的本地 AI Agent 助手(MyAgent-Android 的 Web 复刻)",
5
5
  "private": false,
6
6
  "type": "module",
@@ -877,6 +877,18 @@ function isPublicLinkReadPath(value, sessionBase) {
877
877
  return normalized === 'public' || normalized.startsWith('public/');
878
878
  }
879
879
 
880
+ /** Only the platform null device may be used outside the workspace, and only as a redirect sink. */
881
+ function isNullDeviceSink(value) {
882
+ const candidate = extractPathCandidate(value);
883
+ if (!candidate) return false;
884
+ const normalized = candidate.replace(/\\/g, '/').trim();
885
+ return IS_WIN ? normalized.toUpperCase() === 'NUL' : normalized === '/dev/null';
886
+ }
887
+
888
+ function redirectWritePath(value, sessionBase) {
889
+ return isNullDeviceSink(value) ? (IS_WIN ? 'NUL' : '/dev/null') : join(sessionBase, value);
890
+ }
891
+
880
892
  function resolvesOutsideSession(value, sessionBase) {
881
893
  const candidate = extractPathCandidate(value);
882
894
  if (!candidate) return false;
@@ -1082,8 +1094,14 @@ function validateScript(script, sessionBase) {
1082
1094
  if (commandCount > MAX_LINES) return 'Script contains more than 20 commands';
1083
1095
  for (const { cmd, args, input, output, errorOutput } of commands) {
1084
1096
  if (!SHELL_ALLOWED_REAL.has(cmd)) hasNonAllowlistedCommand = true;
1085
- const writeTargets = [...shellWriteTargets(cmd, args), ...(output ? [output.path] : []), ...(errorOutput ? [errorOutput.path] : [])];
1086
- if (writeTargets.some((arg) => resolvesOutsideSession(arg, sessionBase) || isPublicLinkReadPath(arg, sessionBase))) {
1097
+ const commandWriteTargets = shellWriteTargets(cmd, args);
1098
+ const redirectWriteTargets = [...(output ? [output.path] : []), ...(errorOutput ? [errorOutput.path] : [])];
1099
+ if (
1100
+ commandWriteTargets.some((arg) => resolvesOutsideSession(arg, sessionBase) || isPublicLinkReadPath(arg, sessionBase))
1101
+ || redirectWriteTargets.some((arg) => (
1102
+ !isNullDeviceSink(arg) && (resolvesOutsideSession(arg, sessionBase) || isPublicLinkReadPath(arg, sessionBase))
1103
+ ))
1104
+ ) {
1087
1105
  hasExternalWrite = true;
1088
1106
  }
1089
1107
  const readTargets = [...shellReadTargets(cmd, args), ...(input ? [input] : [])];
@@ -1147,10 +1165,10 @@ function runCommandGroup(group, sessionBase) {
1147
1165
  : null;
1148
1166
  const last = commands.at(-1);
1149
1167
  const output = last.output
1150
- ? createWriteStream(join(sessionBase, last.output.path), { flags: last.output.append ? 'a' : 'w' })
1168
+ ? createWriteStream(redirectWritePath(last.output.path, sessionBase), { flags: last.output.append ? 'a' : 'w' })
1151
1169
  : null;
1152
1170
  const errorOutput = last.errorOutput
1153
- ? createWriteStream(join(sessionBase, last.errorOutput.path), { flags: last.errorOutput.append ? 'a' : 'w' })
1171
+ ? createWriteStream(redirectWritePath(last.errorOutput.path, sessionBase), { flags: last.errorOutput.append ? 'a' : 'w' })
1154
1172
  : null;
1155
1173
  let stdout = '';
1156
1174
  let stderr = '';
@@ -79,7 +79,8 @@ export function FileDisplayModal() {
79
79
  const [loadState, setLoadState] = useState<'loading' | 'ok' | 'error'>('loading');
80
80
  const [imgError, setImgError] = useState(false);
81
81
  const [htmlZoom, setHtmlZoom] = useState(1);
82
- const lastModalHtmlZoomRef = useRef(1);
82
+ const [htmlPreviewVersion, setHtmlPreviewVersion] = useState(0);
83
+ const htmlZoomByFileRef = useRef(new Map<string, number>());
83
84
  const [textWrap, setTextWrap] = useState(true);
84
85
  const [isPictureInPicture, setIsPictureInPicture] = useState(true);
85
86
  const [pipRect, setPipRect] = useState<PipRect>(createPipRect);
@@ -128,12 +129,10 @@ export function FileDisplayModal() {
128
129
  setTextWrap(true);
129
130
  if (isNewDisplay) {
130
131
  const opensInPictureInPicture = active.presentation === 'pip' || active.presentation == null;
131
- setHtmlZoom(opensInPictureInPicture ? lastModalHtmlZoomRef.current : 1);
132
132
  setPipRect(createPipRect());
133
133
  setIsPictureInPicture(opensInPictureInPicture);
134
- } else {
135
- setHtmlZoom(1);
136
134
  }
135
+ setHtmlZoom(htmlZoomByFileRef.current.get(active.path) ?? 1);
137
136
  }, [active]);
138
137
 
139
138
  useEffect(() => {
@@ -152,6 +151,7 @@ export function FileDisplayModal() {
152
151
  if (contentRef.current !== text) {
153
152
  contentRef.current = text;
154
153
  setContent(text);
154
+ if (active.kind === 'html') setHtmlPreviewVersion((version) => version + 1);
155
155
  }
156
156
  setLoadState('ok');
157
157
  setImgError(false);
@@ -324,7 +324,7 @@ export function FileDisplayModal() {
324
324
  const updateHtmlZoom = (update: (zoom: number) => number) => {
325
325
  setHtmlZoom((zoom) => {
326
326
  const nextZoom = update(zoom);
327
- if (!isPictureInPicture) lastModalHtmlZoomRef.current = nextZoom;
327
+ htmlZoomByFileRef.current.set(active.path, nextZoom);
328
328
  return nextZoom;
329
329
  });
330
330
  };
@@ -402,7 +402,7 @@ export function FileDisplayModal() {
402
402
  className="display-iframe ready"
403
403
  sandbox="allow-scripts allow-pointer-lock"
404
404
  title={title}
405
- src={previewUrl(active.path, workspaceDir)}
405
+ src={`${previewUrl(active.path, workspaceDir)}?v=${htmlPreviewVersion}`}
406
406
  style={{
407
407
  width: `calc(${100 / htmlZoom}% + ${2 / htmlZoom}px)`,
408
408
  height: `calc(${100 / htmlZoom}% + ${2 / htmlZoom}px)`,
@@ -466,10 +466,15 @@ export function FileDisplayModal() {
466
466
  >
467
467
  <span className="display-modal-title">
468
468
  <Icon name="file" size={15} />
469
- <span className="mono typo-body-sm text-secondary">{active.path}</span>
470
469
  <span className="display-title-text">{title}</span>
471
470
  </span>
472
471
  <div className="display-head-actions">
472
+ {active.kind === 'html' && isPictureInPicture && (
473
+ <span className="display-auto-refresh-badge" role="status">
474
+ <span className="display-auto-refresh-dot" aria-hidden="true" />
475
+ {t('display.autoRefreshing')}
476
+ </span>
477
+ )}
473
478
  {active.kind === 'image' && loadState === 'ok' && !imgError && (
474
479
  <span className="display-zoom-group">
475
480
  <button className="icon-btn" title={t('display.zoomOut')} onClick={zoomOut}><Icon name="zoom-out" size={15} /></button>
@@ -504,7 +509,7 @@ export function FileDisplayModal() {
504
509
  aria-label={t(isPictureInPicture ? 'display.exitPictureInPicture' : 'display.pictureInPicture')}
505
510
  onClick={() => isPictureInPicture ? exitPictureInPicture() : enterPictureInPicture()}
506
511
  >
507
- <Icon name={isPictureInPicture ? 'pip-exit' : 'pip'} size={17} />
512
+ <Icon name="pip" size={17} />
508
513
  </button>
509
514
  <button className="icon-btn" title={t('common.close')} onClick={close}><Icon name="x" size={17} /></button>
510
515
  </div>
@@ -56,6 +56,7 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
56
56
  const usesPublicWorkspace = session?.mode === 'NPC' && associatedNpc?.isBuiltIn === true;
57
57
  const rootLabel = usesPublicWorkspace ? t('header.workspaceRoot') : session?.workspaceDir?.trim() || `session-${sessionId}`;
58
58
  const [files, setFiles] = useState<string[] | null>(null);
59
+ const [workspaceDir, setWorkspaceDir] = useState<string | null>(null);
59
60
  const [loadState, setLoadState] = useState<'loading' | 'ok' | 'empty' | 'error'>('loading');
60
61
  const [pendingDelete, setPendingDelete] = useState<Node | null>(null);
61
62
  const [deleting, setDeleting] = useState(false);
@@ -73,6 +74,7 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
73
74
  const workspaceDir = await applySessionWorkspace(sessionId);
74
75
  const list = await listSessionWorkspaceFiles(workspaceDir);
75
76
  if (cancelled) return;
77
+ setWorkspaceDir(workspaceDir);
76
78
  setFiles(list);
77
79
  setLoadState(list.length === 0 ? 'empty' : 'ok');
78
80
  } catch {
@@ -116,6 +118,15 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
116
118
  });
117
119
  };
118
120
 
121
+ const downloadFile = (node: Node) => {
122
+ if (!workspaceDir) return;
123
+ const url = `/api-v2/preview/${encodeURIComponent(workspaceDir)}/${node.path.split('/').map(encodeURIComponent).join('/')}`;
124
+ const anchor = document.createElement('a');
125
+ anchor.href = url;
126
+ anchor.download = node.name;
127
+ anchor.click();
128
+ };
129
+
119
130
  const confirmDelete = async () => {
120
131
  if (!pendingDelete || deleting) return;
121
132
  setDeleting(true);
@@ -217,8 +228,17 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
217
228
  <span className="ws-fm-arrow" />
218
229
  <Icon name={n.isDir ? 'folder' : 'file'} size={14} />
219
230
  <span className={`ws-fm-name${n.isDir ? '' : ' mono'}`}>{n.name}{n.isDir ? '/' : ''}</span>
220
- {!isPublicLink && (
231
+ {!n.isDir && (
221
232
  <span className="ws-fm-actions">
233
+ <button
234
+ className="icon-btn ws-fm-download"
235
+ title={t('common.download')}
236
+ aria-label={t('common.download')}
237
+ onClick={(event) => {
238
+ event.stopPropagation();
239
+ downloadFile(n);
240
+ }}
241
+ ><span aria-hidden="true">↓</span></button>
222
242
  <button
223
243
  className="icon-btn ws-fm-delete"
224
244
  title={t('common.delete')}
@@ -230,6 +250,27 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
230
250
  ><Icon name="trash" size={13} /></button>
231
251
  </span>
232
252
  )}
253
+ {n.isDir && (
254
+ <span className="ws-fm-actions">
255
+ <button
256
+ className="icon-btn ws-fm-download"
257
+ title={t('common.download')}
258
+ aria-label={t('common.download')}
259
+ disabled
260
+ ><span aria-hidden="true">↓</span></button>
261
+ {!isPublicLink && (
262
+ <button
263
+ className="icon-btn ws-fm-delete"
264
+ title={t('common.delete')}
265
+ aria-label={t('common.delete')}
266
+ onClick={(event) => {
267
+ event.stopPropagation();
268
+ setPendingDelete(n);
269
+ }}
270
+ ><Icon name="trash" size={13} /></button>
271
+ )}
272
+ </span>
273
+ )}
233
274
  </div>
234
275
  );
235
276
  })}
package/src/core/i18n.ts CHANGED
@@ -20,6 +20,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
20
20
  'common.cancel': '取消',
21
21
  'common.delete': '删除',
22
22
  'common.save': '保存',
23
+ 'common.download': '下载',
23
24
  'common.ok': '确定',
24
25
  'common.clear': '清除',
25
26
  'common.close': '关闭',
@@ -538,6 +539,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
538
539
  'display.pictureInPicture': '画中画',
539
540
  'display.exitPictureInPicture': '退出画中画',
540
541
  'display.resizePictureInPicture': '调整画中画大小',
542
+ 'display.autoRefreshing': '自动刷新中',
541
543
  'display.htmlSandboxNote': 'HTML 应用在受限沙箱中运行,并可加载同目录资源',
542
544
  'display.openHtmlInNewPage': '在新页面打开 HTML',
543
545
 
@@ -552,6 +554,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
552
554
  'common.cancel': '取消',
553
555
  'common.delete': '刪除',
554
556
  'common.save': '儲存',
557
+ 'common.download': '下載',
555
558
  'common.ok': '確定',
556
559
  'common.clear': '清除',
557
560
  'common.close': '關閉',
@@ -1055,6 +1058,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1055
1058
  'display.pictureInPicture': '畫中畫',
1056
1059
  'display.exitPictureInPicture': '退出畫中畫',
1057
1060
  'display.resizePictureInPicture': '調整畫中畫大小',
1061
+ 'display.autoRefreshing': '自動重新整理中',
1058
1062
  'display.htmlSandboxNote': 'HTML 應用程式在受限沙箱中執行,並可載入同目錄資源',
1059
1063
  'display.openHtmlInNewPage': '在新頁面開啟 HTML',
1060
1064
 
@@ -1068,6 +1072,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1068
1072
  'common.cancel': 'Cancel',
1069
1073
  'common.delete': 'Delete',
1070
1074
  'common.save': 'Save',
1075
+ 'common.download': 'Download',
1071
1076
  'common.ok': 'OK',
1072
1077
  'common.clear': 'Clear',
1073
1078
  'common.close': 'Close',
@@ -1571,6 +1576,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1571
1576
  'display.pictureInPicture': 'Picture in picture',
1572
1577
  'display.exitPictureInPicture': 'Exit picture in picture',
1573
1578
  'display.resizePictureInPicture': 'Resize picture in picture',
1579
+ 'display.autoRefreshing': 'Auto-refreshing',
1574
1580
  'display.htmlSandboxNote': 'HTML application runs in a restricted sandbox with same-directory resources available',
1575
1581
  'display.openHtmlInNewPage': 'Open HTML in a new page',
1576
1582
 
@@ -18,6 +18,7 @@ export const BUILTIN_TOOL_NAMES = [
18
18
  'get_tavern_info',
19
19
  'get_character_info',
20
20
  'get_lorebook_info',
21
+ 'get_skill_info',
21
22
  'file_display',
22
23
  'create_character',
23
24
  'update_character',
@@ -197,7 +198,7 @@ export const BUILTIN_TOOLS: ChatCompletionTool[] = [
197
198
  ),
198
199
  fn(
199
200
  'get_tavern_info',
200
- 'Read-only snapshot of the tavern: characters, Lorebooks, skills and career statistics.',
201
+ 'Read a read-only tavern overview: characters, Lorebooks, skill summaries, and career statistics.',
201
202
  {
202
203
  type: 'object',
203
204
  properties: {
@@ -233,6 +234,16 @@ export const BUILTIN_TOOLS: ChatCompletionTool[] = [
233
234
  additionalProperties: false,
234
235
  }
235
236
  ),
237
+ fn(
238
+ 'get_skill_info',
239
+ 'Get the full definition of a skill by name, including generated skill parameters and execution configuration.',
240
+ {
241
+ type: 'object',
242
+ properties: { name: { type: 'string', description: 'Existing skill name' } },
243
+ required: ['name'],
244
+ additionalProperties: false,
245
+ }
246
+ ),
236
247
  fn(
237
248
  'file_display',
238
249
  'Display any file from the current workspace to the user in a popup. Useful for inspecting existing files, reviewing generated output (reports, dashboards, images, HTML pages), or browsing the workspace content. Only shows the file in the UI — it does NOT return the file content; use file_read to actually read the content.',
@@ -371,15 +382,10 @@ export const BUILTIN_TOOLS: ChatCompletionTool[] = [
371
382
 
372
383
  function executionProperties(): Record<string, unknown> {
373
384
  return {
374
- type: { type: 'string', enum: ['template', 'http_get', 'javascript', 'file_read', 'file_write', 'shell'] },
385
+ type: { type: 'string', enum: ['template', 'http_get', 'javascript', 'shell'] },
375
386
  template: { type: 'string', description: 'template type: result template with {{param}} placeholders' },
376
387
  url: { type: 'string', description: "http_get type: public https URL with {{param}} placeholders" },
377
- code: { type: 'string', maxLength: 20000, description: "javascript type: JS code. Reads 'input' (args object), assigns JSON-safe 'result'. Supports async/await. Injected helpers: await $read(path)->string, $write(path, content), $append(path, content), $list()->[paths]. Helper paths must be relative to the current session workspace; absolute paths and parent traversal (..) are rejected." },
378
- path: { type: 'string', description: 'file_read/file_write: relative path inside the current session workspace' },
379
- content: { type: 'string', description: 'file_write: text content with {{param}} placeholders' },
380
- json_content: { type: 'object', description: 'file_write: JSON content, interpolated recursively' },
381
- append: { type: 'boolean', description: 'file_write: append instead of overwrite' },
382
- append_newline: { type: 'boolean', description: 'file_write: append a trailing newline to the written content' },
388
+ code: { type: 'string', maxLength: 20000, description: "javascript type: JS code. Reads 'input' (args object), assigns JSON-safe 'result'. Supports async/await. Injected helpers: await $read(path)->string, $write(path, content), $append(path, content), $list()->[paths], and $display(path, { title? }) to open a workspace file in a popup. Helper paths must be relative to the current session workspace; absolute paths and parent traversal (..) are rejected. Only one file may be displayed per execution." },
383
389
  script: { type: 'string', maxLength: 8000, description: 'shell: local command script (max 20 commands) executed in the current session workspace. Unsupported: shell expansion, command substitution, heredocs, background execution, arbitrary file-descriptor operations, and interactive commands.' },
384
390
  };
385
391
  }
@@ -389,9 +395,7 @@ function getExecutionDescription(): string {
389
395
  'Declarative implementation of the skill. One of:',
390
396
  '- template: `{template: "As of {{date}}, the price is {{price}}"}`, placeholders interpolated from args',
391
397
  '- http_get: `{url: "https://public.example.com/api?q={{q}}"}`, public HTTPS hostname required',
392
- '- javascript: `{code: "result = { sum: input.a + input.b }"}`, reads `input`, assigns JSON-safe `result`; supports async/await and can read or write workspace files via `await $read/$write/$append/$list`. These helpers only accept relative paths in the current session workspace; absolute paths and `..` are rejected',
393
- '- file_read: `{path: "notes.md"}` — read a file from the current session workspace through the required local workspace service (100KB cap)',
394
- '- file_write: `{path: "notes.jsonl", json_content: {...}, append: true, append_newline: true}` — write to the current session workspace through the required local workspace service',
398
+ '- javascript: `{code: "await $display(\'dashboard.html\', { title: \'Dashboard\' }); result = { ok: true };"}`, reads `input`, assigns JSON-safe `result`; supports async/await and can access the workspace through `await $read/$write/$append/$list/$display`. `$display` opens one existing text, image, or HTML file in a popup. Helpers only accept relative paths in the current session workspace; absolute paths and `..` are rejected',
395
399
  '- shell: `{script: "..."}` — execute a local command script in the current session workspace (max 20 commands). Unsupported: shell expansion, command substitution, heredocs, background execution, arbitrary file-descriptor operations, and interactive commands.',
396
400
  ].join(' ');
397
401
  }
@@ -108,10 +108,6 @@ export async function executeGeneratedSkill(
108
108
  return execHttpGet(execution, args);
109
109
  case 'javascript':
110
110
  return execJavaScript(execution, args, workspaceDir);
111
- case 'file_read':
112
- return execFileRead(execution, args, workspaceDir);
113
- case 'file_write':
114
- return execFileWrite(execution, args, workspaceDir);
115
111
  case 'shell':
116
112
  return execShell(execution, args, confirm, workspaceDir);
117
113
  default:
@@ -186,9 +182,10 @@ self.__run = async (ev) => {
186
182
  const $write = async (p, c) => (await self.__req('write', { path: p, content: c })).result;
187
183
  const $append = async (p, c) => (await self.__req('append', { path: p, content: c })).result;
188
184
  const $list = async () => (await self.__req('list', {})).files;
185
+ const $display = async (p, options = {}) => (await self.__req('display', { path: p, options })).result;
189
186
  try {
190
- const fn = new Function('input', '$read', '$write', '$append', '$list', CODE_FN);
191
- const result = await fn(input, $read, $write, $append, $list);
187
+ const fn = new Function('input', '$read', '$write', '$append', '$list', '$display', CODE_FN);
188
+ const result = await fn(input, $read, $write, $append, $list, $display);
192
189
  let safe;
193
190
  try {
194
191
  safe = JSON.parse(JSON.stringify(result ?? null));
@@ -223,6 +220,16 @@ function createSandboxWorker(code: string): Worker {
223
220
  return new Worker(URL.createObjectURL(blob));
224
221
  }
225
222
 
223
+ const DISPLAY_REF_PREFIX = 'DISPLAY_REF: ';
224
+
225
+ type DisplayKind = 'text' | 'image' | 'html';
226
+
227
+ interface DisplayPayload {
228
+ path: string;
229
+ kind: DisplayKind;
230
+ title?: string;
231
+ }
232
+
226
233
  async function execJavaScript(
227
234
  execution: GeneratedSkillExecution,
228
235
  args: Record<string, unknown>,
@@ -234,6 +241,7 @@ async function execJavaScript(
234
241
 
235
242
  return new Promise((resolve) => {
236
243
  let worker: Worker;
244
+ let displayPayload: DisplayPayload | null = null;
237
245
  try {
238
246
  worker = createSandboxWorker(code);
239
247
  } catch (e) {
@@ -250,8 +258,11 @@ async function execJavaScript(
250
258
  const d = ev.data;
251
259
  if (!d) return;
252
260
  if (d.__bridge__) {
253
- void handleBridgeCall(d, workspaceDir).then(
254
- (result) => worker.postMessage({ __bridge_resp__: true, id: d.id, ok: true, result }),
261
+ void handleBridgeCall(d, workspaceDir, displayPayload).then(
262
+ (result) => {
263
+ if (d.method === 'display' && result.ok === true && result.payload) displayPayload = result.payload;
264
+ worker.postMessage({ __bridge_resp__: true, id: d.id, ok: true, result });
265
+ },
255
266
  (err) => worker.postMessage({ __bridge_resp__: true, id: d.id, ok: false, error: String((err && err.message) || err) })
256
267
  );
257
268
  return;
@@ -265,7 +276,8 @@ async function execJavaScript(
265
276
  if ('__result__' in d) {
266
277
  clearTimeout(timer);
267
278
  worker.terminate();
268
- resolve(truncateToolOutput(JSON.stringify(d.__result__ ?? null)));
279
+ const result = truncateToolOutput(JSON.stringify(d.__result__ ?? null));
280
+ resolve(displayPayload ? `${DISPLAY_REF_PREFIX}${JSON.stringify(displayPayload)}\n${result}` : result);
269
281
  return;
270
282
  }
271
283
  };
@@ -278,15 +290,25 @@ async function execJavaScript(
278
290
  });
279
291
  }
280
292
 
293
+ interface BridgeResponse {
294
+ ok: boolean;
295
+ content?: string;
296
+ result?: string;
297
+ files?: string[];
298
+ payload?: DisplayPayload;
299
+ error?: string;
300
+ }
301
+
281
302
  /**
282
303
  * 主线程侧桥接实现:全部复用已验证的文件沙箱逻辑。
283
- * 统一返回 { ok, content?/result?/files?, error? },Worker 内 $read 等
304
+ * 统一返回 { ok, content?/result?/files?/payload?, error? },Worker 内 $read 等
284
305
  * 在失败时 reject 成 Error,技能代码用 try/catch 接住。
285
306
  */
286
307
  async function handleBridgeCall(
287
308
  req: { method: string; payload: Record<string, unknown> },
288
309
  workspaceDir?: string | null,
289
- ): Promise<unknown> {
310
+ existingDisplay?: DisplayPayload | null,
311
+ ): Promise<BridgeResponse> {
290
312
  const { method, payload } = req;
291
313
  switch (method) {
292
314
  case 'read': {
@@ -313,6 +335,26 @@ async function handleBridgeCall(
313
335
  const files = await diskFileList(workspaceDir!);
314
336
  return { ok: true, files: files.slice(0, 500) };
315
337
  }
338
+ case 'display': {
339
+ if (existingDisplay) return { ok: false, error: 'Only one file can be displayed per script execution' };
340
+ const path = sanitizeRelativePath(String(payload.path ?? ''));
341
+ await requireFileServer(workspaceDir);
342
+ if (await diskFileRead(path, workspaceDir!) === null) return { ok: false, error: `File not found: ${path}` };
343
+ const rawOptions = payload.options;
344
+ const options = rawOptions && typeof rawOptions === 'object' && !Array.isArray(rawOptions)
345
+ ? rawOptions as Record<string, unknown>
346
+ : {};
347
+ const rawTitle = options.title;
348
+ const title = typeof rawTitle === 'string' && rawTitle.trim() ? rawTitle.trim() : undefined;
349
+ const extension = /\.([A-Za-z0-9]+)$/.exec(path)?.[1]?.toLowerCase() ?? '';
350
+ const kind: DisplayKind = /^(html?)$/.test(extension)
351
+ ? 'html'
352
+ : /^(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(extension)
353
+ ? 'image'
354
+ : 'text';
355
+ const displayPayload: DisplayPayload = { path, kind, title };
356
+ return { ok: true, result: `Displayed ${path} in a popup`, payload: displayPayload };
357
+ }
316
358
  default:
317
359
  return { ok: false, error: `Unknown bridge method ${method}` };
318
360
  }
@@ -455,11 +497,12 @@ export async function writeWorkspaceFileTextFor(
455
497
  path: string,
456
498
  content: string,
457
499
  workspaceDir: string,
500
+ append = false,
458
501
  ): Promise<string> {
459
502
  const normalizedPath = normalizePath(path);
460
503
  if (!normalizedPath) throw new Error('Invalid path');
461
504
  await requireFileServer(workspaceDir);
462
- return diskFileWrite(normalizedPath, content, false, workspaceDir);
505
+ return diskFileWrite(normalizedPath, content, append, workspaceDir);
463
506
  }
464
507
 
465
508
  /** 删除工作区中的文件或目录。 */
@@ -480,57 +523,6 @@ export async function listSessionWorkspaceFiles(workspaceDir: string): Promise<s
480
523
  return (await diskFileList(workspaceDir)).slice(0, 500);
481
524
  }
482
525
 
483
- async function execFileRead(
484
- execution: GeneratedSkillExecution,
485
- args: Record<string, unknown>,
486
- workspaceDir?: string | null,
487
- ): Promise<string> {
488
- try {
489
- const path = normalizePath(interpolate(execution.path ?? '', args));
490
- if (!path) throw new Error('Invalid path');
491
- const text = await readWorkspaceFileText(path, normalizeWorkspaceDir(workspaceDir) ?? '');
492
- if (text === null) return `ERROR: File not found: ${path}`;
493
- return truncateToolOutput(text);
494
- } catch (e) {
495
- return `ERROR: ${(e as Error).message}`;
496
- }
497
- }
498
-
499
- // ---------- file_write ----------
500
- async function execFileWrite(
501
- execution: GeneratedSkillExecution,
502
- args: Record<string, unknown>,
503
- workspaceDir?: string | null,
504
- ): Promise<string> {
505
- try {
506
- const path = normalizePath(interpolate(execution.path ?? '', args));
507
- if (!path) throw new Error('Invalid path');
508
- let content: string;
509
- if (execution.json_content != null) {
510
- content = JSON.stringify(interpolateDeep(execution.json_content, args), null, 2);
511
- } else {
512
- content = interpolate(execution.content ?? '', args);
513
- }
514
- const safeWorkspaceDir = normalizeWorkspaceDir(workspaceDir);
515
- await requireFileServer(safeWorkspaceDir);
516
- const diskContent = execution.append && execution.append_newline ? `${content}\n` : content;
517
- return await diskFileWrite(path, diskContent, execution.append ?? false, safeWorkspaceDir!);
518
- } catch (e) {
519
- return `ERROR: ${(e as Error).message}`;
520
- }
521
- }
522
-
523
- function interpolateDeep(value: unknown, args: Record<string, unknown>): unknown {
524
- if (typeof value === 'string') return interpolate(value, args);
525
- if (Array.isArray(value)) return value.map((v) => interpolateDeep(v, args));
526
- if (value && typeof value === 'object') {
527
- const out: Record<string, unknown> = {};
528
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = interpolateDeep(v, args);
529
- return out;
530
- }
531
- return value;
532
- }
533
-
534
526
  // ---------- shell(真实执行:白名单直执 + 其它命令确认)----------
535
527
 
536
528
  /** 按服务端规则统计换行、连接符和管道分隔的命令,忽略引号和转义内的连接符。 */
@@ -131,6 +131,8 @@ async function runNativeTool(
131
131
 
132
132
  case 'get_tavern_info':
133
133
  return await handleGetTavernInfo(args);
134
+ case 'get_skill_info':
135
+ return await handleGetSkillInfo(args);
134
136
  case 'get_character_info':
135
137
  return await handleGetCharacterInfo(args);
136
138
  case 'get_lorebook_info':
@@ -222,6 +224,34 @@ async function handleGetTavernInfo(args: Record<string, unknown>): Promise<strin
222
224
  return JSON.stringify(out, null, 2);
223
225
  }
224
226
 
227
+ async function handleGetSkillInfo(args: Record<string, unknown>): Promise<string> {
228
+ const name = String(args.name ?? '').trim();
229
+ if (!name) return 'ERROR: Missing name';
230
+ const tool = await db.tools.where('name').equals(name).first();
231
+ if (!tool) return `ERROR: Skill ${name} does not exist`;
232
+
233
+ const definition = safeJsonParse<ChatCompletionTool>(tool.jsonContent);
234
+ if (!definition?.function) return `ERROR: Skill ${name} has an invalid definition`;
235
+
236
+ const result: Record<string, unknown> = {
237
+ name: tool.name,
238
+ description: definition.function.description ?? '',
239
+ parameters: definition.function.parameters,
240
+ is_default: tool.isBuiltIn,
241
+ };
242
+
243
+ if (tool.isBuiltIn) {
244
+ result.implementation_type = 'native';
245
+ } else {
246
+ const execution = safeJsonParse<GeneratedSkillExecution>(tool.executionJson);
247
+ if (!execution) return `ERROR: Skill ${name} has an invalid implementation`;
248
+ result.implementation_type = execution.type;
249
+ result.execution = execution;
250
+ }
251
+
252
+ return JSON.stringify(result, null, 2);
253
+ }
254
+
225
255
  async function handleGetCharacterInfo(args: Record<string, unknown>): Promise<string> {
226
256
  const name = String(args.name ?? '').trim();
227
257
  if (!name) return 'ERROR: Missing name';
@@ -322,13 +352,14 @@ async function handleFileRead(args: Record<string, unknown>, ctx: ToolExecutionC
322
352
  }
323
353
 
324
354
  async function handleFileWrite(args: Record<string, unknown>, ctx: ToolExecutionContext): Promise<string> {
325
- const workspaceDir = await applySessionWorkspace(ctx.sessionId, ctx.npcId);
326
- return executeGeneratedSkill(
327
- { type: 'file_write', path: String(args.path ?? ''), content: String(args.content ?? ''), append: args.append === true },
328
- args,
329
- ctx.requestConfirmation,
330
- workspaceDir,
331
- );
355
+ try {
356
+ const workspaceDir = await applySessionWorkspace(ctx.sessionId, ctx.npcId);
357
+ const path = String(args.path ?? '').replace(/\\/g, '/').trim();
358
+ if (!path) return 'ERROR: Invalid path';
359
+ return await writeWorkspaceFileTextFor(path, String(args.content ?? ''), workspaceDir, args.append === true);
360
+ } catch (e) {
361
+ return `ERROR: ${(e as Error).message}`;
362
+ }
332
363
  }
333
364
 
334
365
  async function handleFileEdit(args: Record<string, unknown>, ctx: ToolExecutionContext): Promise<string> {
@@ -1,7 +1,73 @@
1
1
  {
2
- "latest": "0.1.26",
2
+ "latest": "0.1.27",
3
3
  "repo": "https://github.com/Rannichan/Tavern-Harness",
4
4
  "entries": [
5
+ {
6
+ "version": "0.1.27",
7
+ "date": "2026-10-28",
8
+ "breaking": false,
9
+ "compare": "v0.1.26...v0.1.27",
10
+ "groups": {
11
+ "zh-CN": [
12
+ {
13
+ "kind": "added",
14
+ "items": [
15
+ "新增 `get_skill_info` 工具:模型可以先查看技能的完整定义(参数、实现方式、执行配置),再决定如何调用",
16
+ "技能里的 JavaScript 执行新增 `$display` 助手:生成报告、图表或网页后可以直接在弹窗中展示给用户(每次执行只展示一个文件)"
17
+ ]
18
+ },
19
+ {
20
+ "kind": "changed",
21
+ "items": [
22
+ "工作区文件管理里每个文件都多了下载按钮,可以直接保存到本地",
23
+ "文件预览会按文件记住缩放比例,切到别的文件再回来不用重新调整",
24
+ "技能的 shell 脚本允许把输出重定向到 /dev/null(Windows 为 NUL),常见的静默输出写法不再被误判为越权"
25
+ ]
26
+ },
27
+ {
28
+ "kind": "fixed",
29
+ "items": [
30
+ "修复画中画中的 HTML 预览不会随文件更新而自动刷新、必须关闭重开才能看到最新内容的问题"
31
+ ]
32
+ },
33
+ {
34
+ "kind": "internal",
35
+ "items": [
36
+ "技能的文件读写统一走 JavaScript 助手(`$read` / `$write` / `$append` / `$list`),移除 `file_read` / `file_write` 执行类型"
37
+ ]
38
+ }
39
+ ],
40
+ "en": [
41
+ {
42
+ "kind": "added",
43
+ "items": [
44
+ "Added `get_skill_info`, so the model can inspect a skill's full definition (parameters, implementation, execution config) before deciding how to call it",
45
+ "JavaScript skills can now call `$display` to show a generated report, chart, or page in a popup for the user (one file per execution)"
46
+ ]
47
+ },
48
+ {
49
+ "kind": "changed",
50
+ "items": [
51
+ "Every file in the workspace file manager can now be downloaded directly",
52
+ "File previews remember the zoom level per file, so switching back no longer resets the view",
53
+ "Skill shell scripts may redirect output to /dev/null (NUL on Windows), so common quiet-command patterns are no longer rejected as unsafe writes"
54
+ ]
55
+ },
56
+ {
57
+ "kind": "fixed",
58
+ "items": [
59
+ "Fixed HTML previews in picture-in-picture not refreshing when the file changed; the latest content now appears without closing and reopening the preview"
60
+ ]
61
+ },
62
+ {
63
+ "kind": "internal",
64
+ "items": [
65
+ "Skill file access now goes through the JavaScript helpers (`$read` / `$write` / `$append` / `$list`); the `file_read` and `file_write` execution types were removed"
66
+ ]
67
+ }
68
+ ]
69
+ }
70
+ },
5
71
  {
6
72
  "version": "0.1.26",
7
73
  "date": "2026-10-27",
@@ -2322,13 +2322,13 @@ async function performAssistantTurn(
2322
2322
  modelUsed: null,
2323
2323
  attachments: [],
2324
2324
  attachmentInfos: [],
2325
- displayRef: displayRefForToolName(tc.name, result),
2325
+ displayRef: displayRefForResult(result, sessionId),
2326
2326
  rawRequestBody: null,
2327
2327
  rawResponseBody: null,
2328
2328
  });
2329
2329
 
2330
- // 展示类工具(file_display):执行成功后自动打开展示弹窗
2331
- const displayPayload = tc.name === 'file_display' ? parseDisplayRef(result) : null;
2330
+ // 展示工具与 JavaScript 的 $display:执行成功后自动打开展示弹窗
2331
+ const displayPayload = parseDisplayRef(result);
2332
2332
  if (displayPayload) {
2333
2333
  useStore.getState().setActiveDisplay({
2334
2334
  path: displayPayload.path,
@@ -2678,17 +2678,12 @@ function requestLimitConfirmation(
2678
2678
  }
2679
2679
 
2680
2680
  /**
2681
- * 展示类工具(file_display)的结果会携带展示引用(DISPLAY_REF 前缀)。
2682
- * 解析成功 → 返回序列化后的 DisplayFileRef(写入工具结果消息的 displayRef 字段,
2683
- * 供对话流中的「查看」按钮回看);其他工具一律返回 null。
2681
+ * 展示工具与 JavaScript 的 $display 都以 DISPLAY_REF 前缀返回展示引用。
2682
+ * 解析成功后将最小展示信息持久化,供对话流的「查看」按钮回看。
2684
2683
  */
2685
- function displayRefForToolName(toolName: string, result: string): string | null {
2686
- if (toolName !== 'file_display') return null;
2684
+ function displayRefForResult(result: string, sessionId: number): string | null {
2687
2685
  const payload = parseDisplayRef(result);
2688
2686
  if (!payload) return null;
2689
- // path 前面去展示前缀的剩余文本是给模型看的(留在 content 中)。
2690
- // displayRef 只保存展示所需的最小信息;sessionId 用于回看时定位会话工作目录。
2691
- const sessionId = useStore.getState().activeSessionId;
2692
2687
  return JSON.stringify({
2693
2688
  path: payload.path,
2694
2689
  kind: payload.kind,
@@ -1621,6 +1621,24 @@
1621
1621
  gap: 6px;
1622
1622
  margin-left: auto;
1623
1623
  }
1624
+ .display-auto-refresh-badge {
1625
+ display: inline-flex;
1626
+ align-items: center;
1627
+ gap: 5px;
1628
+ padding: 3px 7px;
1629
+ border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-soft));
1630
+ border-radius: 999px;
1631
+ color: var(--text-secondary);
1632
+ font-size: var(--font-size-caption);
1633
+ font-weight: var(--font-weight-semibold);
1634
+ white-space: nowrap;
1635
+ }
1636
+ .display-auto-refresh-dot {
1637
+ width: 6px;
1638
+ height: 6px;
1639
+ border-radius: 50%;
1640
+ background: var(--primary);
1641
+ }
1624
1642
  .display-zoom-group {
1625
1643
  display: flex;
1626
1644
  align-items: center;
@@ -1916,11 +1934,23 @@
1916
1934
  align-items: center;
1917
1935
  gap: 4px;
1918
1936
  }
1937
+ .ws-fm-download,
1919
1938
  .ws-fm-delete {
1920
1939
  width: 26px;
1921
1940
  height: 26px;
1922
1941
  color: var(--text-muted);
1923
1942
  }
1943
+ .ws-fm-download:hover {
1944
+ color: var(--text-accent);
1945
+ }
1946
+ .ws-fm-download:disabled {
1947
+ color: var(--text-muted);
1948
+ opacity: 0.35;
1949
+ cursor: not-allowed;
1950
+ }
1951
+ .ws-fm-download:disabled:hover {
1952
+ color: var(--text-muted);
1953
+ }
1924
1954
  .ws-fm-delete:hover {
1925
1955
  color: var(--text-danger);
1926
1956
  }
@@ -368,19 +368,13 @@ export interface ToolConfirmationRequest {
368
368
  }
369
369
 
370
370
  export interface GeneratedSkillExecution {
371
- type: 'template' | 'http_get' | 'javascript' | 'file_read' | 'file_write' | 'shell';
371
+ type: 'template' | 'http_get' | 'javascript' | 'shell';
372
372
  // template
373
373
  template?: string;
374
374
  // http_get
375
375
  url?: string;
376
376
  // javascript
377
377
  code?: string;
378
- // file_read / file_write
379
- path?: string;
380
- content?: string;
381
- json_content?: unknown;
382
- append?: boolean;
383
- append_newline?: boolean;
384
378
  // shell
385
379
  script?: string;
386
380
  [k: string]: unknown;