tavern-harness 0.1.25 → 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 +72 -0
- package/package.json +1 -1
- package/sandbox-server.mjs +22 -4
- package/src/components/CharacterBookEditor.tsx +11 -0
- package/src/components/CharactersView.tsx +30 -22
- package/src/components/FileDisplayModal.tsx +13 -8
- package/src/components/WorkspaceFileManagerModal.tsx +42 -1
- package/src/core/i18n.ts +12 -6
- package/src/core/toolDefinitions.ts +15 -11
- package/src/core/tools/generatedSkillExecutor.ts +55 -63
- package/src/core/tools/toolExecutor.ts +38 -7
- package/src/generated/changelog.json +107 -1
- package/src/store/store.ts +6 -11
- package/src/theme/global.css +1 -0
- package/src/theme/views.css +87 -2
- package/src/types/models.ts +1 -7
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,76 @@
|
|
|
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
|
+
|
|
50
|
+
## [0.1.26] - 2026-10-27
|
|
51
|
+
|
|
52
|
+
### 中文
|
|
53
|
+
|
|
54
|
+
#### 改进
|
|
55
|
+
|
|
56
|
+
- 角色卡与世界书的知识库编辑器现在支持 JSON 语法高亮,键名、字符串、数字等用不同颜色区分,长内容更容易扫读
|
|
57
|
+
- 知识库 JSON 格式有误时,提示会指出具体出错的第几行,方便直接定位修改
|
|
58
|
+
|
|
59
|
+
#### 修复
|
|
60
|
+
|
|
61
|
+
- 修复手机上编辑角色时,切换「角色 / 知识库」标签会收起键盘、底部按钮随之闪烁跳动的问题
|
|
62
|
+
- 修复技能卡片里过长的工具名在手机上撑出屏幕、把操作按钮挤到看不见的问题
|
|
63
|
+
|
|
64
|
+
### English
|
|
65
|
+
|
|
66
|
+
#### Changed
|
|
67
|
+
|
|
68
|
+
- The character-card and world-book knowledge editors now highlight JSON syntax, coloring keys, strings, and numbers so long entries are easier to scan
|
|
69
|
+
- When knowledge-base JSON is invalid, the error now names the exact line, so the problem can be found and fixed directly
|
|
70
|
+
|
|
71
|
+
#### Fixed
|
|
72
|
+
|
|
73
|
+
- Fixed an issue on mobile where switching between the Character and Knowledge tabs while editing a character dismissed the keyboard and made the bottom buttons flicker
|
|
74
|
+
- Fixed overly long tool names in skill cards overflowing the screen on mobile and pushing the action buttons out of view
|
|
75
|
+
|
|
6
76
|
## [0.1.25] - 2026-10-27
|
|
7
77
|
|
|
8
78
|
### 中文
|
|
@@ -716,6 +786,8 @@
|
|
|
716
786
|
- Vite + React + Zustand + Dexie stack with browser-side local persistence
|
|
717
787
|
|
|
718
788
|
<!-- 链接定义 -->
|
|
789
|
+
[0.1.27]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.26...v0.1.27
|
|
790
|
+
[0.1.26]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.25...v0.1.26
|
|
719
791
|
[0.1.25]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.24...v0.1.25
|
|
720
792
|
[0.1.24]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.23...v0.1.24
|
|
721
793
|
[0.1.23]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.22...v0.1.23
|
package/package.json
CHANGED
package/sandbox-server.mjs
CHANGED
|
@@ -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
|
|
1086
|
-
|
|
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(
|
|
1168
|
+
? createWriteStream(redirectWritePath(last.output.path, sessionBase), { flags: last.output.append ? 'a' : 'w' })
|
|
1151
1169
|
: null;
|
|
1152
1170
|
const errorOutput = last.errorOutput
|
|
1153
|
-
? createWriteStream(
|
|
1171
|
+
? createWriteStream(redirectWritePath(last.errorOutput.path, sessionBase), { flags: last.errorOutput.append ? 'a' : 'w' })
|
|
1154
1172
|
: null;
|
|
1155
1173
|
let stdout = '';
|
|
1156
1174
|
let stderr = '';
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { memo, useEffect, useRef } from 'react';
|
|
2
2
|
import { Compartment, EditorState } from '@codemirror/state';
|
|
3
3
|
import { EditorView, lineNumbers } from '@codemirror/view';
|
|
4
|
+
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
|
4
5
|
import { json } from '@codemirror/lang-json';
|
|
6
|
+
import { tags } from '@lezer/highlight';
|
|
7
|
+
|
|
8
|
+
const characterBookJsonHighlight = syntaxHighlighting(HighlightStyle.define([
|
|
9
|
+
{ tag: tags.propertyName, color: 'var(--primary-dim)', fontWeight: '600' },
|
|
10
|
+
{ tag: tags.string, color: 'var(--success)' },
|
|
11
|
+
{ tag: [tags.number, tags.bool, tags.null], color: 'var(--think)' },
|
|
12
|
+
{ tag: tags.keyword, color: 'var(--primary)' },
|
|
13
|
+
{ tag: tags.punctuation, color: 'var(--text-dim)' },
|
|
14
|
+
]));
|
|
5
15
|
|
|
6
16
|
const CharacterBookEditor = memo(function CharacterBookEditor({
|
|
7
17
|
value,
|
|
@@ -37,6 +47,7 @@ const CharacterBookEditor = memo(function CharacterBookEditor({
|
|
|
37
47
|
extensions: [
|
|
38
48
|
lineNumbers(),
|
|
39
49
|
json(),
|
|
50
|
+
characterBookJsonHighlight,
|
|
40
51
|
wrappingRef.current.of(lineWrapping ? EditorView.lineWrapping : []),
|
|
41
52
|
readOnlyRef.current.of(readOnly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []),
|
|
42
53
|
EditorView.updateListener.of((update) => {
|
|
@@ -35,6 +35,19 @@ const CharacterBookEditor = lazy(() => import('./CharacterBookEditor'));
|
|
|
35
35
|
|
|
36
36
|
type WorkshopTab = 'characters' | 'worldbooks' | 'skills';
|
|
37
37
|
|
|
38
|
+
function invalidJsonLine(value: string): number | null {
|
|
39
|
+
const trimmed = value.trim();
|
|
40
|
+
if (!trimmed) return null;
|
|
41
|
+
try {
|
|
42
|
+
JSON.parse(trimmed);
|
|
43
|
+
return null;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const position = /position (\d+)/i.exec(String(error))?.[1];
|
|
46
|
+
if (!position) return 1;
|
|
47
|
+
return trimmed.slice(0, Number(position)).split('\n').length;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
38
51
|
// 内置角色「酒馆老板」默认启用所有技能
|
|
39
52
|
const ALL_DEFAULT_SKILLS = [...ALL_BUILTIN_TOOL_NAMES];
|
|
40
53
|
|
|
@@ -274,13 +287,10 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
|
|
|
274
287
|
addToast(t('toast.nameRequired'), 'error');
|
|
275
288
|
return;
|
|
276
289
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
addToast(t('toast.characterBookInvalidJson'), 'error');
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
290
|
+
const characterBookInvalidLine = invalidJsonLine(characterBook);
|
|
291
|
+
if (characterBookInvalidLine != null) {
|
|
292
|
+
addToast(t('toast.characterBookInvalidJson', { line: characterBookInvalidLine }), 'error');
|
|
293
|
+
return;
|
|
284
294
|
}
|
|
285
295
|
// 清理空白/重复项:第一项作为主开场白,其余作为备用
|
|
286
296
|
const cleaned = greetings.map((g) => g.trim()).filter((g, i, arr) => g && arr.indexOf(g) === i);
|
|
@@ -318,9 +328,10 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
|
|
|
318
328
|
<div className="modal-head">
|
|
319
329
|
<span className="typo-dialog-title">{isNew ? t('workshop.newCharTitle') : t('workshop.editCharTitle')}</span>
|
|
320
330
|
<div className="raw-tabs">
|
|
321
|
-
<button className={`raw-tab ${editorTab === 'character' ? 'active' : ''}`} onClick={() => setEditorTab('character')}>{t('workshop.characterTab')}</button>
|
|
331
|
+
<button className={`raw-tab ${editorTab === 'character' ? 'active' : ''}`} onPointerDown={(event) => event.preventDefault()} onClick={() => setEditorTab('character')}>{t('workshop.characterTab')}</button>
|
|
322
332
|
<button
|
|
323
333
|
className={`raw-tab ${editorTab === 'knowledge' ? 'active' : ''}`}
|
|
334
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
324
335
|
onClick={() => setEditorTab('knowledge')}
|
|
325
336
|
>
|
|
326
337
|
{t('workshop.knowledgeTab')}
|
|
@@ -337,8 +348,8 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
|
|
|
337
348
|
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
|
|
338
349
|
</div>
|
|
339
350
|
</div>
|
|
340
|
-
<div className=
|
|
341
|
-
{editorTab === 'character'
|
|
351
|
+
<div className="modal-body character-editor-body">
|
|
352
|
+
<div className={`character-editor-tab-panel ${editorTab === 'character' ? 'active' : ''}`}>
|
|
342
353
|
<div style={{ display: 'flex', gap: 14, alignItems: 'center' }}>
|
|
343
354
|
<Avatar name={name || t('chat.speakerChar')} colorOrdinal={npc.avatarColorOrdinal} imageUrl={avatarDataUrl} size="sm" />
|
|
344
355
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
@@ -427,8 +438,8 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
|
|
|
427
438
|
</div>
|
|
428
439
|
)}
|
|
429
440
|
</div>
|
|
430
|
-
|
|
431
|
-
{editorTab === 'knowledge'
|
|
441
|
+
</div>
|
|
442
|
+
<div className={`character-editor-tab-panel knowledge-tab ${editorTab === 'knowledge' ? 'active' : ''}`}>
|
|
432
443
|
<div className="field prompt-field character-book-field">
|
|
433
444
|
<label>{t('workshop.characterBookLabel')}</label>
|
|
434
445
|
<Suspense fallback={<div className="character-book-editor-loading" />}>
|
|
@@ -439,7 +450,7 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
|
|
|
439
450
|
/>
|
|
440
451
|
</Suspense>
|
|
441
452
|
</div>
|
|
442
|
-
|
|
453
|
+
</div>
|
|
443
454
|
</div>
|
|
444
455
|
<div className="modal-foot">
|
|
445
456
|
{npc.isBuiltIn && (
|
|
@@ -448,7 +459,7 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
|
|
|
448
459
|
</button>
|
|
449
460
|
)}
|
|
450
461
|
<button className="btn" onClick={onClose}>{t('common.cancel')}</button>
|
|
451
|
-
<button className="btn btn-primary" onClick={save}>{t('common.save')}</button>
|
|
462
|
+
<button className="btn btn-primary" type="button" onPointerDown={(event) => event.preventDefault()} onClick={save}>{t('common.save')}</button>
|
|
452
463
|
</div>
|
|
453
464
|
</Modal>
|
|
454
465
|
);
|
|
@@ -501,13 +512,10 @@ function WorldBookList({ books, onChanged }: {
|
|
|
501
512
|
addToast(t('toast.wbNameContentRequired'), 'error');
|
|
502
513
|
return;
|
|
503
514
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
addToast(t('toast.knowledgeInvalidJson'), 'error');
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
515
|
+
const knowledgeInvalidLine = invalidJsonLine(knowledge);
|
|
516
|
+
if (knowledgeInvalidLine != null) {
|
|
517
|
+
addToast(t('toast.knowledgeInvalidJson', { line: knowledgeInvalidLine }), 'error');
|
|
518
|
+
return;
|
|
511
519
|
}
|
|
512
520
|
const data = {
|
|
513
521
|
name: name.trim(),
|
|
@@ -791,7 +799,7 @@ function WorldBookForm({
|
|
|
791
799
|
</button>
|
|
792
800
|
)}
|
|
793
801
|
<button className="btn" onClick={onClose}>{t('common.cancel')}</button>
|
|
794
|
-
<button className="btn btn-primary" onClick={() => onSave(name, content, characterBook)}>{t('common.save')}</button>
|
|
802
|
+
<button className="btn btn-primary" type="button" onPointerDown={(event) => event.preventDefault()} onClick={() => onSave(name, content, characterBook)}>{t('common.save')}</button>
|
|
795
803
|
</div>
|
|
796
804
|
</>
|
|
797
805
|
);
|
|
@@ -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
|
|
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
|
-
|
|
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=
|
|
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
|
-
{!
|
|
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': '关闭',
|
|
@@ -471,13 +472,13 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
471
472
|
'toast.importFailed': '导入失败: {msg}',
|
|
472
473
|
'toast.charDeleted': '已删除角色「{name}」',
|
|
473
474
|
'toast.nameRequired': '名称不能为空',
|
|
474
|
-
'toast.characterBookInvalidJson': '
|
|
475
|
+
'toast.characterBookInvalidJson': '角色卡知识库 JSON 格式无效(第 {line} 行),请修改后再保存',
|
|
475
476
|
'toast.charCreated': '已创建角色「{name}」',
|
|
476
477
|
'toast.charUpdated': '已更新角色「{name}」',
|
|
477
478
|
'toast.wbDraftImported': '已解析角色卡内的世界书「{name}」,请确认后保存',
|
|
478
479
|
'toast.wbNameRequired': '世界书名不能为空',
|
|
479
480
|
'toast.wbNameContentRequired': '世界书名不能为空,且内容与知识库至少填写一项',
|
|
480
|
-
'toast.knowledgeInvalidJson': '
|
|
481
|
+
'toast.knowledgeInvalidJson': '世界书知识库 JSON 格式无效(第 {line} 行),请修改后再保存',
|
|
481
482
|
'toast.wbImported': '已导入世界书「{name}」({total} 条知识,{enabled} 条启用),请确认后保存',
|
|
482
483
|
'toast.wbImportFailed': '世界书 JSON 导入失败: {msg}',
|
|
483
484
|
'toast.wbUpdated': '已更新世界书',
|
|
@@ -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': '關閉',
|
|
@@ -989,13 +992,13 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
989
992
|
'toast.importFailed': '匯入失敗: {msg}',
|
|
990
993
|
'toast.charDeleted': '已刪除角色「{name}」',
|
|
991
994
|
'toast.nameRequired': '名稱不能為空',
|
|
992
|
-
'toast.characterBookInvalidJson': 'Character Book
|
|
995
|
+
'toast.characterBookInvalidJson': 'Character Book JSON 格式無效(第 {line} 行),請修改後再儲存',
|
|
993
996
|
'toast.charCreated': '已建立角色「{name}」',
|
|
994
997
|
'toast.charUpdated': '已更新角色「{name}」',
|
|
995
998
|
'toast.wbDraftImported': '已解析角色卡內的世界書「{name}」,請確認後儲存',
|
|
996
999
|
'toast.wbNameRequired': '世界書名不能為空',
|
|
997
1000
|
'toast.wbNameContentRequired': '世界書名不能為空,且內容與知識庫至少填寫一項',
|
|
998
|
-
'toast.knowledgeInvalidJson': '
|
|
1001
|
+
'toast.knowledgeInvalidJson': '世界書知識庫 JSON 格式無效(第 {line} 行),請修改後再儲存',
|
|
999
1002
|
'toast.wbImported': '已匯入世界書「{name}」({total} 條知識,{enabled} 條啟用),請確認後儲存',
|
|
1000
1003
|
'toast.wbImportFailed': '世界書 JSON 匯入失敗: {msg}',
|
|
1001
1004
|
'toast.wbUpdated': '已更新世界書',
|
|
@@ -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',
|
|
@@ -1505,13 +1510,13 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1505
1510
|
'toast.importFailed': 'Import failed: {msg}',
|
|
1506
1511
|
'toast.charDeleted': 'Deleted character "{name}"',
|
|
1507
1512
|
'toast.nameRequired': 'Name cannot be empty',
|
|
1508
|
-
'toast.characterBookInvalidJson': 'Character Book
|
|
1513
|
+
'toast.characterBookInvalidJson': 'Character Book JSON is invalid (line {line}). Correct it before saving.',
|
|
1509
1514
|
'toast.charCreated': 'Created character "{name}"',
|
|
1510
1515
|
'toast.charUpdated': 'Updated character "{name}"',
|
|
1511
1516
|
'toast.wbDraftImported': 'Parsed the Lorebook "{name}" from the character card — review and save',
|
|
1512
1517
|
'toast.wbNameRequired': 'Lorebook name cannot be empty',
|
|
1513
1518
|
'toast.wbNameContentRequired': 'Lorebook name is required, and content or knowledge base must be filled in',
|
|
1514
|
-
'toast.knowledgeInvalidJson': '
|
|
1519
|
+
'toast.knowledgeInvalidJson': 'Lorebook knowledge-base JSON is invalid (line {line}). Correct it before saving.',
|
|
1515
1520
|
'toast.wbImported': 'Imported world book "{name}" ({total} entries, {enabled} enabled) — review and save',
|
|
1516
1521
|
'toast.wbImportFailed': 'World book JSON import failed: {msg}',
|
|
1517
1522
|
'toast.wbUpdated': 'Lorebook updated',
|
|
@@ -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
|
|
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', '
|
|
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 = {
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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,113 @@
|
|
|
1
1
|
{
|
|
2
|
-
"latest": "0.1.
|
|
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
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"version": "0.1.26",
|
|
73
|
+
"date": "2026-10-27",
|
|
74
|
+
"breaking": false,
|
|
75
|
+
"compare": "v0.1.25...v0.1.26",
|
|
76
|
+
"groups": {
|
|
77
|
+
"zh-CN": [
|
|
78
|
+
{
|
|
79
|
+
"kind": "changed",
|
|
80
|
+
"items": [
|
|
81
|
+
"角色卡与世界书的知识库编辑器现在支持 JSON 语法高亮,键名、字符串、数字等用不同颜色区分,长内容更容易扫读",
|
|
82
|
+
"知识库 JSON 格式有误时,提示会指出具体出错的第几行,方便直接定位修改"
|
|
83
|
+
]
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"kind": "fixed",
|
|
87
|
+
"items": [
|
|
88
|
+
"修复手机上编辑角色时,切换「角色 / 知识库」标签会收起键盘、底部按钮随之闪烁跳动的问题",
|
|
89
|
+
"修复技能卡片里过长的工具名在手机上撑出屏幕、把操作按钮挤到看不见的问题"
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"en": [
|
|
94
|
+
{
|
|
95
|
+
"kind": "changed",
|
|
96
|
+
"items": [
|
|
97
|
+
"The character-card and world-book knowledge editors now highlight JSON syntax, coloring keys, strings, and numbers so long entries are easier to scan",
|
|
98
|
+
"When knowledge-base JSON is invalid, the error now names the exact line, so the problem can be found and fixed directly"
|
|
99
|
+
]
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"kind": "fixed",
|
|
103
|
+
"items": [
|
|
104
|
+
"Fixed an issue on mobile where switching between the Character and Knowledge tabs while editing a character dismissed the keyboard and made the bottom buttons flicker",
|
|
105
|
+
"Fixed overly long tool names in skill cards overflowing the screen on mobile and pushing the action buttons out of view"
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
},
|
|
5
111
|
{
|
|
6
112
|
"version": "0.1.25",
|
|
7
113
|
"date": "2026-10-27",
|
package/src/store/store.ts
CHANGED
|
@@ -2322,13 +2322,13 @@ async function performAssistantTurn(
|
|
|
2322
2322
|
modelUsed: null,
|
|
2323
2323
|
attachments: [],
|
|
2324
2324
|
attachmentInfos: [],
|
|
2325
|
-
displayRef:
|
|
2325
|
+
displayRef: displayRefForResult(result, sessionId),
|
|
2326
2326
|
rawRequestBody: null,
|
|
2327
2327
|
rawResponseBody: null,
|
|
2328
2328
|
});
|
|
2329
2329
|
|
|
2330
|
-
//
|
|
2331
|
-
const displayPayload =
|
|
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
|
-
*
|
|
2682
|
-
*
|
|
2683
|
-
* 供对话流中的「查看」按钮回看);其他工具一律返回 null。
|
|
2681
|
+
* 展示工具与 JavaScript 的 $display 都以 DISPLAY_REF 前缀返回展示引用。
|
|
2682
|
+
* 解析成功后将最小展示信息持久化,供对话流的「查看」按钮回看。
|
|
2684
2683
|
*/
|
|
2685
|
-
function
|
|
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,
|
package/src/theme/global.css
CHANGED
package/src/theme/views.css
CHANGED
|
@@ -105,7 +105,23 @@
|
|
|
105
105
|
flex: 1;
|
|
106
106
|
min-height: 0;
|
|
107
107
|
}
|
|
108
|
-
.
|
|
108
|
+
.character-editor-body {
|
|
109
|
+
overflow: hidden;
|
|
110
|
+
}
|
|
111
|
+
.character-editor-tab-panel {
|
|
112
|
+
display: none;
|
|
113
|
+
min-height: 0;
|
|
114
|
+
}
|
|
115
|
+
.character-editor-tab-panel.active {
|
|
116
|
+
display: flex;
|
|
117
|
+
flex: 1;
|
|
118
|
+
min-height: 0;
|
|
119
|
+
flex-direction: column;
|
|
120
|
+
gap: 12px;
|
|
121
|
+
overflow-y: auto;
|
|
122
|
+
overscroll-behavior: contain;
|
|
123
|
+
}
|
|
124
|
+
.character-editor-tab-panel.knowledge-tab {
|
|
109
125
|
overflow: hidden;
|
|
110
126
|
}
|
|
111
127
|
.modal-editor .grow-textarea {
|
|
@@ -161,6 +177,11 @@
|
|
|
161
177
|
}
|
|
162
178
|
.character-book-editor .cm-content {
|
|
163
179
|
padding: 9px 12px;
|
|
180
|
+
caret-color: var(--text-primary);
|
|
181
|
+
}
|
|
182
|
+
/* CodeMirror 的默认光标颜色为黑色,不会随应用主题变量切换。 */
|
|
183
|
+
.character-book-editor .cm-cursor {
|
|
184
|
+
border-left-color: var(--text-primary) !important;
|
|
164
185
|
}
|
|
165
186
|
.character-book-editor .cm-gutters {
|
|
166
187
|
border-right: 1px solid var(--border-soft);
|
|
@@ -514,11 +535,14 @@
|
|
|
514
535
|
flex-shrink: 0;
|
|
515
536
|
}
|
|
516
537
|
.list-item .l-sub {
|
|
517
|
-
font-size: var(--font-size-
|
|
538
|
+
font-size: var(--font-size-caption);
|
|
518
539
|
color: var(--text-muted);
|
|
519
540
|
font-family: var(--font-family-mono);
|
|
520
541
|
}
|
|
521
542
|
.list-item.sortable-skill .l-sub {
|
|
543
|
+
min-width: 0;
|
|
544
|
+
overflow-wrap: anywhere;
|
|
545
|
+
word-break: break-word;
|
|
522
546
|
color: var(--text-secondary);
|
|
523
547
|
}
|
|
524
548
|
|
|
@@ -1388,10 +1412,41 @@
|
|
|
1388
1412
|
gap: 4px;
|
|
1389
1413
|
}
|
|
1390
1414
|
|
|
1415
|
+
.list-item.sortable-skill {
|
|
1416
|
+
min-width: 0;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1391
1419
|
.skill-item-actions .btn {
|
|
1392
1420
|
padding: 5px 7px;
|
|
1393
1421
|
}
|
|
1394
1422
|
|
|
1423
|
+
/* 保持隐藏知识库编辑器在布局树中:若用 display:none 隐藏一个仍有
|
|
1424
|
+
焦点的 CodeMirror,移动浏览器会收起键盘并重算动态视口,造成底部按钮闪烁。 */
|
|
1425
|
+
.character-editor-body {
|
|
1426
|
+
position: relative;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
.character-editor-tab-panel {
|
|
1430
|
+
position: absolute;
|
|
1431
|
+
inset: 16px 14px;
|
|
1432
|
+
display: flex;
|
|
1433
|
+
flex-direction: column;
|
|
1434
|
+
opacity: 0;
|
|
1435
|
+
pointer-events: none;
|
|
1436
|
+
overflow: hidden;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
.character-editor-tab-panel.active {
|
|
1440
|
+
position: absolute;
|
|
1441
|
+
display: flex;
|
|
1442
|
+
opacity: 1;
|
|
1443
|
+
pointer-events: auto;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
.character-editor-tab-panel:not(.active) {
|
|
1447
|
+
overflow-y: auto;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1395
1450
|
.character-editor-modal .modal-head {
|
|
1396
1451
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
1397
1452
|
}
|
|
@@ -1566,6 +1621,24 @@
|
|
|
1566
1621
|
gap: 6px;
|
|
1567
1622
|
margin-left: auto;
|
|
1568
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
|
+
}
|
|
1569
1642
|
.display-zoom-group {
|
|
1570
1643
|
display: flex;
|
|
1571
1644
|
align-items: center;
|
|
@@ -1861,11 +1934,23 @@
|
|
|
1861
1934
|
align-items: center;
|
|
1862
1935
|
gap: 4px;
|
|
1863
1936
|
}
|
|
1937
|
+
.ws-fm-download,
|
|
1864
1938
|
.ws-fm-delete {
|
|
1865
1939
|
width: 26px;
|
|
1866
1940
|
height: 26px;
|
|
1867
1941
|
color: var(--text-muted);
|
|
1868
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
|
+
}
|
|
1869
1954
|
.ws-fm-delete:hover {
|
|
1870
1955
|
color: var(--text-danger);
|
|
1871
1956
|
}
|
package/src/types/models.ts
CHANGED
|
@@ -368,19 +368,13 @@ export interface ToolConfirmationRequest {
|
|
|
368
368
|
}
|
|
369
369
|
|
|
370
370
|
export interface GeneratedSkillExecution {
|
|
371
|
-
type: 'template' | 'http_get' | 'javascript' | '
|
|
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;
|