tavern-harness 0.1.23 → 0.1.24
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 +47 -0
- package/package.json +1 -1
- package/sandbox-server.mjs +3 -2
- package/src/App.tsx +13 -3
- package/src/components/CharactersView.tsx +3 -3
- package/src/components/NewSessionMenu.tsx +48 -43
- package/src/components/Sidebar.tsx +18 -10
- package/src/components/WorkspaceFileManagerModal.tsx +19 -21
- package/src/core/i18n.ts +12 -6
- package/src/core/tools/builtinTools.ts +3 -5
- package/src/core/tools/generatedSkillExecutor.ts +3 -3
- package/src/core/tools/toolExecutor.ts +13 -14
- package/src/generated/changelog.json +65 -1
- package/src/store/store.ts +42 -32
- package/src/theme/chat.css +15 -2
- package/src/theme/views.css +22 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,52 @@
|
|
|
3
3
|
> 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
|
|
4
4
|
> 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
|
|
5
5
|
|
|
6
|
+
## [0.1.24] - 2026-10-27
|
|
7
|
+
|
|
8
|
+
### 中文
|
|
9
|
+
|
|
10
|
+
#### 改进
|
|
11
|
+
|
|
12
|
+
- 工作区文件管理器现在可以直接进入 public 目录,并预览其中的网页、图片等文件
|
|
13
|
+
- 文件管理器的路径导航移到文件列表上方,窄屏下标题栏不再换行,右侧按钮也不会被挤掉
|
|
14
|
+
- 手机上点击顶部导航的「聊天 / 角色 / 成就 / 设置」或返回首页后,侧边栏会自动收起
|
|
15
|
+
- 手机端的工具调用卡片改用等宽字体并加粗,长工具名和参数更容易看清
|
|
16
|
+
- 输入框提示精简为「输入消息...」,不再把指令说明塞进占位文字
|
|
17
|
+
- 技能列表与原文编辑器的字体、名称截断和「内置」标签对齐统一
|
|
18
|
+
- 工具返回给模型的结果统一使用英文,切换界面语言不再影响模型读取;工具卡片中的结果行也随之显示英文
|
|
19
|
+
|
|
20
|
+
#### 修复
|
|
21
|
+
|
|
22
|
+
- 删除会话与会话设置保存改为事务写入,中途出错不会再留下只删了一半的数据
|
|
23
|
+
- 连续点击「删除」「保存」不会再重复提交,也不会卡在加载状态
|
|
24
|
+
- 创建、编辑、删除会话失败时会提示具体原因,而不是静默失败
|
|
25
|
+
|
|
26
|
+
#### 内部
|
|
27
|
+
|
|
28
|
+
- 清理工具执行路径中不再需要的界面文案依赖
|
|
29
|
+
|
|
30
|
+
### English
|
|
31
|
+
|
|
32
|
+
#### Changed
|
|
33
|
+
|
|
34
|
+
- The workspace file manager can now browse into `public` and preview pages, images, and other files stored there
|
|
35
|
+
- The file manager's path breadcrumb now sits above the file list, and the header no longer wraps on narrow screens or pushes its buttons aside
|
|
36
|
+
- On mobile, tapping a top navigation tab (Chat, Characters, Achievements, Settings) or going home now closes the sidebar
|
|
37
|
+
- Tool call cards on mobile use a monospace, bolder typeface, so long tool names and arguments are easier to read
|
|
38
|
+
- The composer placeholder is now simply "Type a message..." instead of packing command hints into it
|
|
39
|
+
- The skill list and raw log editor now share consistent typography, name truncation, and Built-in tag alignment
|
|
40
|
+
- Tool results returned to the model are always English now, so switching the UI language no longer affects what the model reads; the result rows in tool cards show English as well
|
|
41
|
+
|
|
42
|
+
#### Fixed
|
|
43
|
+
|
|
44
|
+
- Deleting a session and saving session settings are now transactional, so a failure can no longer leave half-deleted data behind
|
|
45
|
+
- Repeatedly clicking delete or save no longer sends duplicate requests or leaves the dialog stuck loading
|
|
46
|
+
- Creating, editing, or deleting a session now reports the actual error instead of failing silently
|
|
47
|
+
|
|
48
|
+
#### Internal
|
|
49
|
+
|
|
50
|
+
- Removed the no-longer-needed UI string dependency from the tool execution path
|
|
51
|
+
|
|
6
52
|
## [0.1.23] - 2026-10-26
|
|
7
53
|
|
|
8
54
|
### 中文
|
|
@@ -656,6 +702,7 @@
|
|
|
656
702
|
- Vite + React + Zustand + Dexie stack with browser-side local persistence
|
|
657
703
|
|
|
658
704
|
<!-- 链接定义 -->
|
|
705
|
+
[0.1.24]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.23...v0.1.24
|
|
659
706
|
[0.1.23]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.22...v0.1.23
|
|
660
707
|
[0.1.22]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.21...v0.1.22
|
|
661
708
|
[0.1.21]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.20...v0.1.21
|
package/package.json
CHANGED
package/sandbox-server.mjs
CHANGED
|
@@ -596,10 +596,11 @@ const server = createServer(async (req, res) => {
|
|
|
596
596
|
const payload = JSON.parse(body);
|
|
597
597
|
const base = resolveSessionBase(payload?.session);
|
|
598
598
|
const rel = sanitizeWorkspaceRelativePath(payload?.path);
|
|
599
|
-
|
|
599
|
+
const readingPublicLink = base !== PUBLIC_WORKSPACE && (rel === 'public' || rel.startsWith('public/'));
|
|
600
|
+
if (!readingPublicLink && resolvesOutsideSession(rel, base)) throw new Error('Path is outside the workspace');
|
|
600
601
|
const file = resolve(base, rel);
|
|
601
602
|
if (!existsSync(file) || !lstatSync(file).isFile()) throw new Error('File not found');
|
|
602
|
-
assertInside(base, realpathSync(file));
|
|
603
|
+
assertInside(readingPublicLink ? PUBLIC_WORKSPACE : base, realpathSync(file));
|
|
603
604
|
const content = await readFilePromise(file);
|
|
604
605
|
if (content.length > 8 * 1024 * 1024) throw new Error('Preview file exceeds the 8MB limit');
|
|
605
606
|
res.end(JSON.stringify({ ok: true, content: content.toString('base64') }));
|
package/src/App.tsx
CHANGED
|
@@ -325,6 +325,7 @@ function SessionHeader({
|
|
|
325
325
|
const [showEdit, setShowEdit] = useState(false);
|
|
326
326
|
const [confirmReset, setConfirmReset] = useState(false);
|
|
327
327
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
328
|
+
const [deletingSession, setDeletingSession] = useState(false);
|
|
328
329
|
const [exportOpen, setExportOpen] = useState(false);
|
|
329
330
|
const [showWorkspace, setShowWorkspace] = useState(false);
|
|
330
331
|
const [showMobileActions, setShowMobileActions] = useState(false);
|
|
@@ -482,10 +483,19 @@ function SessionHeader({
|
|
|
482
483
|
<button className="btn" onClick={() => setConfirmDelete(false)}>{t('common.cancel')}</button>
|
|
483
484
|
<button
|
|
484
485
|
className="btn btn-danger"
|
|
486
|
+
disabled={deletingSession}
|
|
485
487
|
onClick={async () => {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
488
|
+
if (deletingSession) return;
|
|
489
|
+
setDeletingSession(true);
|
|
490
|
+
try {
|
|
491
|
+
await deleteSession(session.id!);
|
|
492
|
+
setConfirmDelete(false);
|
|
493
|
+
addToast(t('toast.sessionDeleted'));
|
|
494
|
+
} catch (error) {
|
|
495
|
+
addToast(t('toast.sessionDeleteFailed', { msg: (error as Error).message }), 'error');
|
|
496
|
+
} finally {
|
|
497
|
+
setDeletingSession(false);
|
|
498
|
+
}
|
|
489
499
|
}}
|
|
490
500
|
>
|
|
491
501
|
<Icon name="trash" size={13} /> {t('common.delete')}
|
|
@@ -556,7 +556,7 @@ function WorldBookList({ books, onChanged }: {
|
|
|
556
556
|
|
|
557
557
|
return (
|
|
558
558
|
<div>
|
|
559
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
559
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
560
560
|
<span className="group-label" style={{ margin: 0 }}>{t('workshop.wbCount', { n: books.length })}</span>
|
|
561
561
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
562
562
|
<button className="btn btn-sm" onClick={() => jsonFileRef.current?.click()} title={t('workshop.jsonImportTip')}>
|
|
@@ -963,8 +963,8 @@ function SkillSortItem({
|
|
|
963
963
|
<div style={{ fontSize: 18 }}>{tool.isBuiltIn ? '🧰' : '⚙️'}</div>
|
|
964
964
|
<div className="l-main">
|
|
965
965
|
<div className="l-title">
|
|
966
|
-
{tool.name}
|
|
967
|
-
{tool.isBuiltIn && <span className="tag"
|
|
966
|
+
<span className="skill-card-name">{tool.name}</span>
|
|
967
|
+
{tool.isBuiltIn && <span className="tag">{t('common.builtin')}</span>}
|
|
968
968
|
</div>
|
|
969
969
|
<div className="l-sub">{desc}</div>
|
|
970
970
|
{tool.isBuiltIn && impl !== 'native' && <div style={{ fontSize: 10.5, marginTop: 2, color: 'var(--warn)' }}>{t('workshop.implType', { t: impl })}</div>}
|
|
@@ -52,6 +52,7 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
|
|
|
52
52
|
const [turnOrderMode, setTurnOrderMode] = useState<'PRESET' | 'RANDOM'>(editingSession?.turnOrderMode ?? 'PRESET');
|
|
53
53
|
const [mentionOnlyMode, setMentionOnlyMode] = useState(editingSession?.mentionOnlyMode === true);
|
|
54
54
|
const [enableGreeting, setEnableGreeting] = useState(editingSession?.enableGreeting !== false);
|
|
55
|
+
const [saving, setSaving] = useState(false);
|
|
55
56
|
const selectedNpcIds = participantOrder.filter((id) => id !== -1);
|
|
56
57
|
const modeLabel = selectedNpcIds.length === 0 ? t('newSession.notSelected') : selectedNpcIds.length === 1 ? t('newSession.npcChat') : t('newSession.groupChat', { n: selectedNpcIds.length });
|
|
57
58
|
const sensors = useSortableSensors();
|
|
@@ -77,55 +78,59 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
|
|
|
77
78
|
}, [editingSession?.id]);
|
|
78
79
|
|
|
79
80
|
const create = async () => {
|
|
81
|
+
if (saving) return;
|
|
80
82
|
if (selectedNpcIds.length === 0) {
|
|
81
83
|
addToast(t('toast.pickOneNpc'), 'error');
|
|
82
84
|
return;
|
|
83
85
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
86
|
+
setSaving(true);
|
|
87
|
+
try {
|
|
88
|
+
const finalTitle = title.trim() || selectedNpcIds.map((id) => npcs.find((n) => n.id === id)?.name).filter(Boolean).join('、');
|
|
89
|
+
if (isEditing && editingSession?.id) {
|
|
90
|
+
await updateSessionSettings(editingSession.id, {
|
|
91
|
+
title: finalTitle,
|
|
92
|
+
npcIds: selectedNpcIds,
|
|
93
|
+
worldBookId,
|
|
94
|
+
userPersonaNpcId,
|
|
95
|
+
turnOrderMode,
|
|
96
|
+
participantOrder,
|
|
97
|
+
mentionOnlyMode,
|
|
98
|
+
enableGreeting,
|
|
99
|
+
});
|
|
100
|
+
addToast(t('toast.sessionUpdated'));
|
|
101
|
+
onClose();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const sid = selectedNpcIds.length === 1
|
|
105
|
+
? await createSession('NPC', {
|
|
106
|
+
associatedId: selectedNpcIds[0],
|
|
107
|
+
title: finalTitle,
|
|
108
|
+
worldBookId,
|
|
109
|
+
userPersonaNpcId,
|
|
110
|
+
turnOrderMode,
|
|
111
|
+
participantOrder,
|
|
112
|
+
mentionOnlyMode,
|
|
113
|
+
enableGreeting,
|
|
114
|
+
})
|
|
115
|
+
: await createSession('GROUP', {
|
|
116
|
+
npcIds: selectedNpcIds,
|
|
117
|
+
title: finalTitle,
|
|
118
|
+
worldBookId,
|
|
119
|
+
userPersonaNpcId,
|
|
120
|
+
turnOrderMode,
|
|
121
|
+
participantOrder,
|
|
122
|
+
mentionOnlyMode,
|
|
123
|
+
enableGreeting,
|
|
124
|
+
});
|
|
96
125
|
await useStore.getState().refreshSessions();
|
|
97
|
-
useStore.getState().setActiveSession(
|
|
98
|
-
addToast(t('toast.sessionUpdated'));
|
|
126
|
+
useStore.getState().setActiveSession(sid);
|
|
99
127
|
onClose();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
associatedId: selectedNpcIds[0],
|
|
106
|
-
title: finalTitle,
|
|
107
|
-
worldBookId,
|
|
108
|
-
userPersonaNpcId,
|
|
109
|
-
turnOrderMode,
|
|
110
|
-
participantOrder,
|
|
111
|
-
mentionOnlyMode,
|
|
112
|
-
enableGreeting,
|
|
113
|
-
});
|
|
114
|
-
} else {
|
|
115
|
-
sid = await createSession('GROUP', {
|
|
116
|
-
npcIds: selectedNpcIds,
|
|
117
|
-
title: finalTitle,
|
|
118
|
-
worldBookId,
|
|
119
|
-
userPersonaNpcId,
|
|
120
|
-
turnOrderMode,
|
|
121
|
-
participantOrder,
|
|
122
|
-
mentionOnlyMode,
|
|
123
|
-
enableGreeting,
|
|
124
|
-
});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (isEditing) addToast(t('toast.sessionSaveFailed', { msg: (error as Error).message }), 'error');
|
|
130
|
+
else addToast(t('toast.genFailed', { msg: (error as Error).message }), 'error');
|
|
131
|
+
} finally {
|
|
132
|
+
setSaving(false);
|
|
125
133
|
}
|
|
126
|
-
await useStore.getState().refreshSessions();
|
|
127
|
-
useStore.getState().setActiveSession(sid);
|
|
128
|
-
onClose();
|
|
129
134
|
};
|
|
130
135
|
|
|
131
136
|
const addParticipant = (npcId: number) => {
|
|
@@ -273,7 +278,7 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
|
|
|
273
278
|
</div>
|
|
274
279
|
<div className="modal-foot">
|
|
275
280
|
<button className="btn" onClick={onClose}>{t('common.cancel')}</button>
|
|
276
|
-
<button className="btn btn-primary" disabled={!canCreate} onClick={create}>{isEditing ? t('common.save') : t('common.ok')}</button>
|
|
281
|
+
<button className="btn btn-primary" disabled={!canCreate || saving} onClick={create}>{isEditing ? t('common.save') : t('common.ok')}</button>
|
|
277
282
|
</div>
|
|
278
283
|
</Modal>
|
|
279
284
|
);
|
|
@@ -62,6 +62,7 @@ export function Sidebar() {
|
|
|
62
62
|
|
|
63
63
|
const [showNew, setShowNew] = useState(false);
|
|
64
64
|
const [confirmDelete, setConfirmDelete] = useState<ChatSession | null>(null);
|
|
65
|
+
const [deletingSession, setDeletingSession] = useState(false);
|
|
65
66
|
const [editTarget, setEditTarget] = useState<ChatSession | null>(null);
|
|
66
67
|
const [sessionMenu, setSessionMenu] = useState<{ session: ChatSession; x: number; y: number } | null>(null);
|
|
67
68
|
const [query, setQuery] = useState('');
|
|
@@ -178,11 +179,17 @@ export function Sidebar() {
|
|
|
178
179
|
const npcById = (id: number | null) => (id != null ? npcs.find((n) => n.id === id) : null);
|
|
179
180
|
|
|
180
181
|
const handleDelete = async () => {
|
|
181
|
-
if (!confirmDelete) return;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
182
|
+
if (!confirmDelete || deletingSession) return;
|
|
183
|
+
setDeletingSession(true);
|
|
184
|
+
try {
|
|
185
|
+
await deleteSession(confirmDelete.id!);
|
|
186
|
+
addToast(t('toast.sessionDeleted'));
|
|
187
|
+
setConfirmDelete(null);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
addToast(t('toast.sessionDeleteFailed', { msg: (error as Error).message }), 'error');
|
|
190
|
+
} finally {
|
|
191
|
+
setDeletingSession(false);
|
|
192
|
+
}
|
|
186
193
|
};
|
|
187
194
|
|
|
188
195
|
const openEdit = (session: ChatSession) => {
|
|
@@ -281,6 +288,7 @@ export function Sidebar() {
|
|
|
281
288
|
}));
|
|
282
289
|
|
|
283
290
|
const returnHome = () => {
|
|
291
|
+
setMobileOpen(false);
|
|
284
292
|
const now = Date.now();
|
|
285
293
|
homeClickTimes.current = [...homeClickTimes.current, now]
|
|
286
294
|
.filter((time) => now - time <= SHAREHOLDER_CLICK_WINDOW_MS)
|
|
@@ -315,16 +323,16 @@ export function Sidebar() {
|
|
|
315
323
|
>
|
|
316
324
|
<img src="/tavern-harness-logo.png" alt="" />
|
|
317
325
|
</button>
|
|
318
|
-
<button className={`nav-chip ${activeView === 'chat' ? 'active' : ''}`} onClick={() => setActiveView('chat')}>
|
|
326
|
+
<button className={`nav-chip ${activeView === 'chat' ? 'active' : ''}`} onClick={() => { setActiveView('chat'); setMobileOpen(false); }}>
|
|
319
327
|
<Icon name="send" size={15} /> <span>{t('nav.chat')}</span>
|
|
320
328
|
</button>
|
|
321
|
-
<button className={`nav-chip ${activeView === 'characters' ? 'active' : ''}`} onClick={() => setActiveView('characters')}>
|
|
329
|
+
<button className={`nav-chip ${activeView === 'characters' ? 'active' : ''}`} onClick={() => { setActiveView('characters'); setMobileOpen(false); }}>
|
|
322
330
|
<Icon name="users" size={15} /> <span>{t('nav.characters')}</span>
|
|
323
331
|
</button>
|
|
324
|
-
<button className={`nav-chip ${activeView === 'stats' ? 'active' : ''}`} onClick={() => setActiveView('stats')}>
|
|
332
|
+
<button className={`nav-chip ${activeView === 'stats' ? 'active' : ''}`} onClick={() => { setActiveView('stats'); setMobileOpen(false); }}>
|
|
325
333
|
<Icon name="trophy" size={15} /> <span>{t('nav.achievements')}</span>
|
|
326
334
|
</button>
|
|
327
|
-
<button className={`nav-chip ${activeView === 'settings' ? 'active' : ''}`} onClick={() => setActiveView('settings')}>
|
|
335
|
+
<button className={`nav-chip ${activeView === 'settings' ? 'active' : ''}`} onClick={() => { setActiveView('settings'); setMobileOpen(false); }}>
|
|
328
336
|
<Icon name="settings" size={15} /> <span>{t('nav.settings')}</span>
|
|
329
337
|
{hasUnseenVersion && <span className="unseen-dot" title={t('changelog.unseen')} />}
|
|
330
338
|
</button>
|
|
@@ -531,7 +539,7 @@ export function Sidebar() {
|
|
|
531
539
|
</div>
|
|
532
540
|
<div className="modal-foot">
|
|
533
541
|
<button className="btn" onClick={() => setConfirmDelete(null)}>{t('common.cancel')}</button>
|
|
534
|
-
<button className="btn btn-danger" onClick={handleDelete}>
|
|
542
|
+
<button className="btn btn-danger" disabled={deletingSession} onClick={handleDelete}>
|
|
535
543
|
<Icon name="trash" size={13} /> {t('common.delete')}
|
|
536
544
|
</button>
|
|
537
545
|
</div>
|
|
@@ -92,8 +92,9 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
|
|
|
92
92
|
// 当前目录的直接子项(目录与文件同级)
|
|
93
93
|
const visible = useMemo(() => {
|
|
94
94
|
const directChild = (p: string) => {
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
const normalized = p.replace(/\/$/, '');
|
|
96
|
+
if (dir === '') return !normalized.includes('/');
|
|
97
|
+
return normalized.startsWith(dir) && normalized !== dir.slice(0, -1) && !normalized.slice(dir.length).includes('/');
|
|
97
98
|
};
|
|
98
99
|
return nodes.filter((n) => directChild(n.path));
|
|
99
100
|
}, [nodes, dir]);
|
|
@@ -154,23 +155,6 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
|
|
|
154
155
|
<span className="ws-fm-title">
|
|
155
156
|
<Icon name="folder" size={15} />
|
|
156
157
|
{t('header.fileManagerTitle')}
|
|
157
|
-
<span className="mono ws-fm-crumb">
|
|
158
|
-
{crumbs.length === 0 ? rootLabel : (
|
|
159
|
-
<>
|
|
160
|
-
<span className="ws-fm-crumb-link" onClick={() => setDir('')}>{rootLabel}</span>
|
|
161
|
-
{crumbs.map((c, i) => (
|
|
162
|
-
<span key={i}>
|
|
163
|
-
<span className="ws-fm-crumb-sep">/</span>
|
|
164
|
-
{i === crumbs.length - 1 ? (
|
|
165
|
-
<span className="ws-fm-crumb-cur">{c}</span>
|
|
166
|
-
) : (
|
|
167
|
-
<span className="ws-fm-crumb-link" onClick={() => setDir(crumbs.slice(0, i + 1).join('/') + '/')}>{c}</span>
|
|
168
|
-
)}
|
|
169
|
-
</span>
|
|
170
|
-
))}
|
|
171
|
-
</>
|
|
172
|
-
)}
|
|
173
|
-
</span>
|
|
174
158
|
</span>
|
|
175
159
|
<span className="ws-fm-head-actions">
|
|
176
160
|
<button
|
|
@@ -186,8 +170,22 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
|
|
|
186
170
|
</span>
|
|
187
171
|
</div>
|
|
188
172
|
<div className="modal-body ws-fm-body">
|
|
189
|
-
<div className="ws-fm-
|
|
190
|
-
|
|
173
|
+
<div className="mono ws-fm-crumb">
|
|
174
|
+
{crumbs.length === 0 ? rootLabel : (
|
|
175
|
+
<>
|
|
176
|
+
<span className="ws-fm-crumb-link" onClick={() => setDir('')}>{rootLabel}</span>
|
|
177
|
+
{crumbs.map((c, i) => (
|
|
178
|
+
<span key={i}>
|
|
179
|
+
<span className="ws-fm-crumb-sep">/</span>
|
|
180
|
+
{i === crumbs.length - 1 ? (
|
|
181
|
+
<span className="ws-fm-crumb-cur">{c}</span>
|
|
182
|
+
) : (
|
|
183
|
+
<span className="ws-fm-crumb-link" onClick={() => setDir(crumbs.slice(0, i + 1).join('/') + '/')}>{c}</span>
|
|
184
|
+
)}
|
|
185
|
+
</span>
|
|
186
|
+
))}
|
|
187
|
+
</>
|
|
188
|
+
)}
|
|
191
189
|
</div>
|
|
192
190
|
{loadState === 'loading' && (
|
|
193
191
|
<div className="ws-fm-empty">
|
package/src/core/i18n.ts
CHANGED
|
@@ -137,8 +137,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
137
137
|
'chat.chooseModel': '选模型',
|
|
138
138
|
'chat.selectModel': '选择模型',
|
|
139
139
|
'chat.attachTip': '添加图片/视频(粘贴或点击)',
|
|
140
|
-
'chat.phGroup': '
|
|
141
|
-
'chat.phSingle': '
|
|
140
|
+
'chat.phGroup': '输入消息...',
|
|
141
|
+
'chat.phSingle': '输入消息...',
|
|
142
142
|
'chat.mentionHead': '@ 点名角色',
|
|
143
143
|
'chat.stopTip': '停止生成',
|
|
144
144
|
'chat.sendTip': '发送',
|
|
@@ -442,6 +442,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
442
442
|
// ---- 提示 ----
|
|
443
443
|
'toast.sessionDeleted': '会话已删除',
|
|
444
444
|
'toast.sessionUpdated': '会话设置已更新',
|
|
445
|
+
'toast.sessionSaveFailed': '保存会话设置失败:{msg}',
|
|
446
|
+
'toast.sessionDeleteFailed': '删除会话失败:{msg}',
|
|
445
447
|
'toast.workspaceOpenLocalOnly': '仅可在运行 Tavern Harness 的设备上打开系统文件管理器',
|
|
446
448
|
'toast.sessionReset': '会话已重置',
|
|
447
449
|
'toast.sessionPinned': '已置顶对话',
|
|
@@ -662,8 +664,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
662
664
|
'chat.chooseModel': '選模型',
|
|
663
665
|
'chat.selectModel': '選擇模型',
|
|
664
666
|
'chat.attachTip': '新增圖片/影片(貼上或點擊)',
|
|
665
|
-
'chat.phGroup': '
|
|
666
|
-
'chat.phSingle': '
|
|
667
|
+
'chat.phGroup': '輸入訊息...',
|
|
668
|
+
'chat.phSingle': '輸入訊息...',
|
|
667
669
|
'chat.mentionHead': '@ 點名角色',
|
|
668
670
|
'chat.stopTip': '停止產生',
|
|
669
671
|
'chat.sendTip': '傳送',
|
|
@@ -960,6 +962,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
960
962
|
'toast.gameplayImported': '已匯入遊戲「{title}」:新角色 {npc} 個、世界書 {wb}、技能 {tools} 個、對話 {msgs} 條',
|
|
961
963
|
'toast.sessionDeleted': '會話已刪除',
|
|
962
964
|
'toast.sessionUpdated': '會話設定已更新',
|
|
965
|
+
'toast.sessionSaveFailed': '儲存會話設定失敗:{msg}',
|
|
966
|
+
'toast.sessionDeleteFailed': '刪除會話失敗:{msg}',
|
|
963
967
|
'toast.workspaceOpenLocalOnly': '僅可在執行 Tavern Harness 的裝置上開啟系統檔案管理員',
|
|
964
968
|
'toast.sessionReset': '會話已重置',
|
|
965
969
|
'toast.sessionPinned': '已置頂對話',
|
|
@@ -1176,8 +1180,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1176
1180
|
'chat.chooseModel': 'Model',
|
|
1177
1181
|
'chat.selectModel': 'Select model',
|
|
1178
1182
|
'chat.attachTip': 'Add image/video (paste or click)',
|
|
1179
|
-
'chat.phGroup': 'Type a message
|
|
1180
|
-
'chat.phSingle': 'Type a message
|
|
1183
|
+
'chat.phGroup': 'Type a message...',
|
|
1184
|
+
'chat.phSingle': 'Type a message...',
|
|
1181
1185
|
'chat.mentionHead': '@ Mention a character',
|
|
1182
1186
|
'chat.stopTip': 'Stop generating',
|
|
1183
1187
|
'chat.sendTip': 'Send',
|
|
@@ -1472,6 +1476,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1472
1476
|
|
|
1473
1477
|
'toast.sessionDeleted': 'Session deleted',
|
|
1474
1478
|
'toast.sessionUpdated': 'Conversation settings updated',
|
|
1479
|
+
'toast.sessionSaveFailed': 'Failed to save conversation settings: {msg}',
|
|
1480
|
+
'toast.sessionDeleteFailed': 'Failed to delete session: {msg}',
|
|
1475
1481
|
'toast.workspaceOpenLocalOnly': 'The system file manager can only be opened on the device running Tavern Harness',
|
|
1476
1482
|
'toast.sessionReset': 'Conversation reset',
|
|
1477
1483
|
'toast.sessionPinned': 'Conversation pinned',
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { translate } from '../i18n';
|
|
2
|
-
|
|
3
1
|
// ============================================================
|
|
4
2
|
// roll_dice — 与 App 的 DiceRoller 一致
|
|
5
3
|
// ============================================================
|
|
@@ -28,11 +26,11 @@ export function rollDice(expression: string): string {
|
|
|
28
26
|
|
|
29
27
|
let critical = '';
|
|
30
28
|
if (count === 1 && sides === 20) {
|
|
31
|
-
if (rolls[0] === 20) critical =
|
|
32
|
-
else if (rolls[0] === 1) critical =
|
|
29
|
+
if (rolls[0] === 20) critical = ' Critical hit!';
|
|
30
|
+
else if (rolls[0] === 1) critical = ' Critical fail!';
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
const parts = [rolls.join(', ')];
|
|
36
34
|
if (modifier !== 0) parts.push(`${modifier > 0 ? '+' : ''}${modifier}`);
|
|
37
|
-
return
|
|
35
|
+
return `${expr}: [${parts.join('] ')}] = ${total}${critical}`;
|
|
38
36
|
}
|
|
@@ -120,7 +120,7 @@ export async function executeGeneratedSkill(
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
export function truncateToolOutput(s: string): string {
|
|
123
|
-
return s.length > MAX_OUTPUT_CHARS ? s.slice(0, MAX_OUTPUT_CHARS)
|
|
123
|
+
return s.length > MAX_OUTPUT_CHARS ? `${s.slice(0, MAX_OUTPUT_CHARS)}\n…(truncated)` : s;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
function normalizePath(path: string): string {
|
|
@@ -398,7 +398,7 @@ async function diskFileWrite(
|
|
|
398
398
|
});
|
|
399
399
|
const data = (await resp.json()) as { ok?: boolean; message?: string };
|
|
400
400
|
if (!resp.ok || !data.ok) throw new Error(data?.message || `HTTP ${resp.status}`);
|
|
401
|
-
return
|
|
401
|
+
return `OK: Wrote ${content.length} characters to ${path}`;
|
|
402
402
|
} catch (e) {
|
|
403
403
|
throw new Error(`Failed to write to disk: ${(e as Error).message}`);
|
|
404
404
|
}
|
|
@@ -629,7 +629,7 @@ async function execShell(
|
|
|
629
629
|
approved = false;
|
|
630
630
|
}
|
|
631
631
|
}
|
|
632
|
-
if (!approved) return
|
|
632
|
+
if (!approved) return 'CANCELLED: user rejected shell';
|
|
633
633
|
const approvalError = await approveSandboxScript(confirmationRequestId!);
|
|
634
634
|
if (approvalError) return approvalError;
|
|
635
635
|
return await execSandboxScript(script, confirmationRequestId!, safeWorkspaceDir!);
|
|
@@ -8,7 +8,6 @@ import type {
|
|
|
8
8
|
ToolConfirmationRequest,
|
|
9
9
|
} from '../../types/models';
|
|
10
10
|
import { BUILTIN_TOOL_NAMES } from '../toolDefinitions';
|
|
11
|
-
import { translate } from '../i18n';
|
|
12
11
|
import { unlockAchievement } from '../achievements';
|
|
13
12
|
import { rollDice } from './builtinTools';
|
|
14
13
|
import {
|
|
@@ -299,7 +298,7 @@ async function handleFileDisplay(args: Record<string, unknown>, ctx: ToolExecuti
|
|
|
299
298
|
const payload: DisplayPayload = { path: rawPath, kind, title };
|
|
300
299
|
// 第一行携带展示引用(store 解析并写入 displayRef / 自动弹窗)。
|
|
301
300
|
// 不做内容摘要:完整内容在展示弹窗里,模型如需阅读应改用 file_read。
|
|
302
|
-
return `${DISPLAY_REF_PREFIX}${JSON.stringify(payload)}\
|
|
301
|
+
return `${DISPLAY_REF_PREFIX}${JSON.stringify(payload)}\nOK: Displayed ${rawPath} in a popup (kind=${kind})`;
|
|
303
302
|
} catch (e) {
|
|
304
303
|
return `ERROR: ${(e as Error).message}`;
|
|
305
304
|
}
|
|
@@ -358,7 +357,7 @@ async function handleFileEdit(args: Record<string, unknown>, ctx: ToolExecutionC
|
|
|
358
357
|
content.split(oldText).join(newText),
|
|
359
358
|
workspaceDir,
|
|
360
359
|
);
|
|
361
|
-
return
|
|
360
|
+
return `OK: Edited ${path}; replaced ${matches} occurrence(s)`;
|
|
362
361
|
} catch (e) {
|
|
363
362
|
return `ERROR: ${(e as Error).message}`;
|
|
364
363
|
}
|
|
@@ -411,7 +410,7 @@ async function handleCreateSkill(args: Record<string, unknown>): Promise<string>
|
|
|
411
410
|
const tool = await createSkillTool(name, String(args.description ?? ''), (args.parameters as Record<string, unknown>) ?? {}, args.execution);
|
|
412
411
|
await db.tools.add(tool);
|
|
413
412
|
await unlockAchievement('explorer');
|
|
414
|
-
return
|
|
413
|
+
return `OK: Created skill ${name}. Note: it is not enabled for any character by default; use update_character with enable_skills to enable it.`;
|
|
415
414
|
}
|
|
416
415
|
|
|
417
416
|
async function handleUpdateSkill(args: Record<string, unknown>): Promise<string> {
|
|
@@ -449,7 +448,7 @@ async function handleUpdateSkill(args: Record<string, unknown>): Promise<string>
|
|
|
449
448
|
}
|
|
450
449
|
}
|
|
451
450
|
}
|
|
452
|
-
return
|
|
451
|
+
return `OK: Updated skill ${name}`;
|
|
453
452
|
}
|
|
454
453
|
|
|
455
454
|
async function handleDeleteSkill(args: Record<string, unknown>): Promise<string> {
|
|
@@ -465,7 +464,7 @@ async function handleDeleteSkill(args: Record<string, unknown>): Promise<string>
|
|
|
465
464
|
await db.npcs.update(n.id!, { enabledToolNames: n.enabledToolNames.filter((t) => t !== name) });
|
|
466
465
|
}
|
|
467
466
|
}
|
|
468
|
-
return
|
|
467
|
+
return `OK: Deleted skill ${name}`;
|
|
469
468
|
}
|
|
470
469
|
|
|
471
470
|
// ---------- 角色 CRUD ----------
|
|
@@ -496,7 +495,7 @@ async function handleCreateCharacter(args: Record<string, unknown>): Promise<str
|
|
|
496
495
|
createdAt: Date.now(),
|
|
497
496
|
});
|
|
498
497
|
await unlockAchievement('creator');
|
|
499
|
-
return
|
|
498
|
+
return `OK: Created character ${name}`;
|
|
500
499
|
}
|
|
501
500
|
|
|
502
501
|
async function handleUpdateCharacter(args: Record<string, unknown>): Promise<string> {
|
|
@@ -536,7 +535,7 @@ async function handleUpdateCharacter(args: Record<string, unknown>): Promise<str
|
|
|
536
535
|
updates.enabledToolNames = [...enabled];
|
|
537
536
|
}
|
|
538
537
|
if (Object.keys(updates).length > 0) await db.npcs.update(npc.id!, updates);
|
|
539
|
-
return
|
|
538
|
+
return `OK: Updated character ${name}`;
|
|
540
539
|
}
|
|
541
540
|
|
|
542
541
|
async function handleDeleteCharacter(args: Record<string, unknown>): Promise<string> {
|
|
@@ -551,7 +550,7 @@ async function handleDeleteCharacter(args: Record<string, unknown>): Promise<str
|
|
|
551
550
|
await db.sessions.update(s.id!, { associatedId: null });
|
|
552
551
|
await db.participants.where('[sessionId+participantId]').equals([s.id!, npc.id!]).delete();
|
|
553
552
|
}
|
|
554
|
-
return
|
|
553
|
+
return `OK: Deleted character ${name}`;
|
|
555
554
|
}
|
|
556
555
|
|
|
557
556
|
async function handleCreateConversation(args: Record<string, unknown>): Promise<string> {
|
|
@@ -642,7 +641,7 @@ async function handleCreateLorebook(args: Record<string, unknown>): Promise<stri
|
|
|
642
641
|
if (await db.worldBooks.where('name').equals(name).first()) return `ERROR: Lorebook ${name} already exists`;
|
|
643
642
|
await db.worldBooks.add({ name, content: content.slice(0, 10_000), imageUri: null, createdAt: Date.now() });
|
|
644
643
|
await unlockAchievement('creator');
|
|
645
|
-
return
|
|
644
|
+
return `OK: Created Lorebook ${name}`;
|
|
646
645
|
}
|
|
647
646
|
|
|
648
647
|
async function handleUpdateLorebook(args: Record<string, unknown>): Promise<string> {
|
|
@@ -651,25 +650,25 @@ async function handleUpdateLorebook(args: Record<string, unknown>): Promise<stri
|
|
|
651
650
|
if (!book) return `ERROR: Lorebook ${name} does not exist`;
|
|
652
651
|
const updates: Partial<import('../../types/models').WorldBook> = {};
|
|
653
652
|
if (args.new_name) {
|
|
654
|
-
if (isBuiltinTavernHandbook(book)) return `ERROR:
|
|
653
|
+
if (isBuiltinTavernHandbook(book)) return `ERROR: Built-in Lorebook ${builtinTavernHandbookName()} cannot be renamed`;
|
|
655
654
|
updates.name = String(args.new_name).slice(0, 60);
|
|
656
655
|
}
|
|
657
656
|
if (args.content != null) updates.content = String(args.content).slice(0, 10_000);
|
|
658
657
|
await db.worldBooks.update(book.id!, updates);
|
|
659
|
-
return
|
|
658
|
+
return `OK: Updated Lorebook ${name}`;
|
|
660
659
|
}
|
|
661
660
|
|
|
662
661
|
async function handleDeleteLorebook(args: Record<string, unknown>): Promise<string> {
|
|
663
662
|
const name = String(args.name ?? '');
|
|
664
663
|
const book = await db.worldBooks.where('name').equals(name).first();
|
|
665
664
|
if (!book) return `ERROR: Lorebook ${name} does not exist`;
|
|
666
|
-
if (isBuiltinTavernHandbook(book)) return `ERROR:
|
|
665
|
+
if (isBuiltinTavernHandbook(book)) return `ERROR: Built-in Lorebook ${builtinTavernHandbookName()} cannot be deleted`;
|
|
667
666
|
await db.worldBooks.delete(book.id!);
|
|
668
667
|
const sessions = await db.sessions.toArray();
|
|
669
668
|
for (const s of sessions) {
|
|
670
669
|
if (s.worldBookId === book.id) await db.sessions.update(s.id!, { worldBookId: null });
|
|
671
670
|
}
|
|
672
|
-
return
|
|
671
|
+
return `OK: Deleted Lorebook ${name}`;
|
|
673
672
|
}
|
|
674
673
|
|
|
675
674
|
// ---------- 工具列举 ----------
|
|
@@ -1,7 +1,71 @@
|
|
|
1
1
|
{
|
|
2
|
-
"latest": "0.1.
|
|
2
|
+
"latest": "0.1.24",
|
|
3
3
|
"repo": "https://github.com/Rannichan/Tavern-Harness",
|
|
4
4
|
"entries": [
|
|
5
|
+
{
|
|
6
|
+
"version": "0.1.24",
|
|
7
|
+
"date": "2026-10-27",
|
|
8
|
+
"breaking": false,
|
|
9
|
+
"compare": "v0.1.23...v0.1.24",
|
|
10
|
+
"groups": {
|
|
11
|
+
"zh-CN": [
|
|
12
|
+
{
|
|
13
|
+
"kind": "changed",
|
|
14
|
+
"items": [
|
|
15
|
+
"工作区文件管理器现在可以直接进入 public 目录,并预览其中的网页、图片等文件",
|
|
16
|
+
"文件管理器的路径导航移到文件列表上方,窄屏下标题栏不再换行,右侧按钮也不会被挤掉",
|
|
17
|
+
"手机上点击顶部导航的「聊天 / 角色 / 成就 / 设置」或返回首页后,侧边栏会自动收起",
|
|
18
|
+
"手机端的工具调用卡片改用等宽字体并加粗,长工具名和参数更容易看清",
|
|
19
|
+
"输入框提示精简为「输入消息...」,不再把指令说明塞进占位文字",
|
|
20
|
+
"技能列表与原文编辑器的字体、名称截断和「内置」标签对齐统一",
|
|
21
|
+
"工具返回给模型的结果统一使用英文,切换界面语言不再影响模型读取;工具卡片中的结果行也随之显示英文"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"kind": "fixed",
|
|
26
|
+
"items": [
|
|
27
|
+
"删除会话与会话设置保存改为事务写入,中途出错不会再留下只删了一半的数据",
|
|
28
|
+
"连续点击「删除」「保存」不会再重复提交,也不会卡在加载状态",
|
|
29
|
+
"创建、编辑、删除会话失败时会提示具体原因,而不是静默失败"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"kind": "internal",
|
|
34
|
+
"items": [
|
|
35
|
+
"清理工具执行路径中不再需要的界面文案依赖"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"en": [
|
|
40
|
+
{
|
|
41
|
+
"kind": "changed",
|
|
42
|
+
"items": [
|
|
43
|
+
"The workspace file manager can now browse into `public` and preview pages, images, and other files stored there",
|
|
44
|
+
"The file manager's path breadcrumb now sits above the file list, and the header no longer wraps on narrow screens or pushes its buttons aside",
|
|
45
|
+
"On mobile, tapping a top navigation tab (Chat, Characters, Achievements, Settings) or going home now closes the sidebar",
|
|
46
|
+
"Tool call cards on mobile use a monospace, bolder typeface, so long tool names and arguments are easier to read",
|
|
47
|
+
"The composer placeholder is now simply \"Type a message...\" instead of packing command hints into it",
|
|
48
|
+
"The skill list and raw log editor now share consistent typography, name truncation, and Built-in tag alignment",
|
|
49
|
+
"Tool results returned to the model are always English now, so switching the UI language no longer affects what the model reads; the result rows in tool cards show English as well"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"kind": "fixed",
|
|
54
|
+
"items": [
|
|
55
|
+
"Deleting a session and saving session settings are now transactional, so a failure can no longer leave half-deleted data behind",
|
|
56
|
+
"Repeatedly clicking delete or save no longer sends duplicate requests or leaves the dialog stuck loading",
|
|
57
|
+
"Creating, editing, or deleting a session now reports the actual error instead of failing silently"
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"kind": "internal",
|
|
62
|
+
"items": [
|
|
63
|
+
"Removed the no-longer-needed UI string dependency from the tool execution path"
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
},
|
|
5
69
|
{
|
|
6
70
|
"version": "0.1.23",
|
|
7
71
|
"date": "2026-10-26",
|
package/src/store/store.ts
CHANGED
|
@@ -907,13 +907,20 @@ export const useStore = create<AppState>((set, get) => ({
|
|
|
907
907
|
},
|
|
908
908
|
|
|
909
909
|
deleteSession: async (id) => {
|
|
910
|
-
// 先取会话记录(工作目录在删除后不可查),再清理目录与数据
|
|
911
|
-
const session = await db.sessions.get(id);
|
|
912
910
|
invalidateContextCompressionJob(id);
|
|
913
|
-
await db.
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
911
|
+
const session = await db.transaction(
|
|
912
|
+
'rw',
|
|
913
|
+
[db.sessions, db.messages, db.contextCompressions, db.participants],
|
|
914
|
+
async () => {
|
|
915
|
+
// 先取会话记录(工作目录在删除后不可查),再原子删除关联数据。
|
|
916
|
+
const current = await db.sessions.get(id);
|
|
917
|
+
await db.messages.where('sessionId').equals(id).delete();
|
|
918
|
+
await db.contextCompressions.where('sessionId').equals(id).delete();
|
|
919
|
+
await db.participants.where('sessionId').equals(id).delete();
|
|
920
|
+
await db.sessions.delete(id);
|
|
921
|
+
return current;
|
|
922
|
+
},
|
|
923
|
+
);
|
|
917
924
|
// 删除会话的专属磁盘工作目录;沙箱不可用时静默跳过
|
|
918
925
|
if (session?.workspaceDir) {
|
|
919
926
|
await deleteSessionWorkspace(session.workspaceDir);
|
|
@@ -1047,7 +1054,10 @@ export const useStore = create<AppState>((set, get) => ({
|
|
|
1047
1054
|
const npcIds = [...new Set((opts.npcIds ?? []).filter((id) => Number.isFinite(id)).map(Number))].slice(0, 5);
|
|
1048
1055
|
if (npcIds.length === 0) return;
|
|
1049
1056
|
const participantOrder = opts.participantOrder ?? [-1, ...npcIds];
|
|
1050
|
-
const participants = await
|
|
1057
|
+
const [participants, existingParticipants] = await Promise.all([
|
|
1058
|
+
buildSessionParticipants(sessionId, npcIds, participantOrder),
|
|
1059
|
+
db.participants.where('sessionId').equals(sessionId).toArray(),
|
|
1060
|
+
]);
|
|
1051
1061
|
const finalNpcIds = participants.filter((p) => p.kind === 'NPC' && p.npcId != null).map((p) => p.npcId!);
|
|
1052
1062
|
if (finalNpcIds.length === 0) return;
|
|
1053
1063
|
const finalTitle = opts.title?.trim() || participants
|
|
@@ -1055,8 +1065,7 @@ export const useStore = create<AppState>((set, get) => ({
|
|
|
1055
1065
|
.map((p) => p.displayName)
|
|
1056
1066
|
.join('、');
|
|
1057
1067
|
const turnOrderMode = opts.turnOrderMode === 'RANDOM' ? 'RANDOM' : 'PRESET';
|
|
1058
|
-
|
|
1059
|
-
.sort((left, right) => left.seatOrder - right.seatOrder);
|
|
1068
|
+
existingParticipants.sort((left, right) => left.seatOrder - right.seatOrder);
|
|
1060
1069
|
const queueSettingsChanged = turnOrderMode !== session.turnOrderMode
|
|
1061
1070
|
|| existingParticipants.length !== participants.length
|
|
1062
1071
|
|| existingParticipants.some((existing, index) => {
|
|
@@ -1068,29 +1077,30 @@ export const useStore = create<AppState>((set, get) => ({
|
|
|
1068
1077
|
|| existing.seatOrder !== next.seatOrder;
|
|
1069
1078
|
});
|
|
1070
1079
|
const queue = queueSettingsChanged ? initializeTurnQueue(participants, turnOrderMode) : safeParseQueue(session.turnQueueJson);
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1080
|
+
await db.transaction('rw', db.sessions, db.participants, async () => {
|
|
1081
|
+
if (queueSettingsChanged) {
|
|
1082
|
+
await db.participants.where('sessionId').equals(sessionId).delete();
|
|
1083
|
+
await db.participants.bulkAdd(participants);
|
|
1084
|
+
}
|
|
1085
|
+
await db.sessions.update(sessionId, {
|
|
1086
|
+
title: finalTitle,
|
|
1087
|
+
mode: finalNpcIds.length === 1 ? 'NPC' : 'GROUP',
|
|
1088
|
+
associatedId: finalNpcIds.length === 1 ? finalNpcIds[0] : null,
|
|
1089
|
+
worldBookId: opts.worldBookId ?? null,
|
|
1090
|
+
userPersonaNpcId: opts.userPersonaNpcId ?? null,
|
|
1091
|
+
enableGreeting: opts.enableGreeting !== false,
|
|
1092
|
+
mentionOnlyMode: opts.mentionOnlyMode === true && finalNpcIds.length > 1,
|
|
1093
|
+
turnOrderMode,
|
|
1094
|
+
...(queueSettingsChanged ? {
|
|
1095
|
+
turnQueueJson: queueJson(queue),
|
|
1096
|
+
turnQueueHistoryJson: queueHistoryJson([queue]),
|
|
1097
|
+
loopIndex: 0,
|
|
1098
|
+
} : {}),
|
|
1099
|
+
updatedAt: Date.now(),
|
|
1100
|
+
});
|
|
1090
1101
|
});
|
|
1091
|
-
await get().loadMessages(sessionId);
|
|
1092
1102
|
await get().refreshSessions();
|
|
1093
|
-
await get().refreshLiveQueue(sessionId);
|
|
1103
|
+
if (queueSettingsChanged) await get().refreshLiveQueue(sessionId);
|
|
1094
1104
|
},
|
|
1095
1105
|
|
|
1096
1106
|
resetSessionConversation: async (sessionId) => {
|
|
@@ -2268,7 +2278,7 @@ async function performAssistantTurn(
|
|
|
2268
2278
|
approved = await requestToolConfirmation(sessionId, tc);
|
|
2269
2279
|
}
|
|
2270
2280
|
if (!approved) {
|
|
2271
|
-
result =
|
|
2281
|
+
result = `CANCELLED: user rejected ${tc.name}`;
|
|
2272
2282
|
} else {
|
|
2273
2283
|
result = await executeToolCall(tc.name, tc.argumentsJson, {
|
|
2274
2284
|
sessionId,
|
|
@@ -2283,7 +2293,7 @@ async function performAssistantTurn(
|
|
|
2283
2293
|
}
|
|
2284
2294
|
// 工具没有返回任何内容(空模板 / 空文件 / 空输出等)→ 补占位结果,
|
|
2285
2295
|
// 保证调用卡片立即显示「无结果」而不是空白
|
|
2286
|
-
if (!result || !result.trim()) result =
|
|
2296
|
+
if (!result || !result.trim()) result = '(no result)';
|
|
2287
2297
|
// 数据变更类工具执行后即时刷新 store,保证界面(角色工坊等)无需刷新即可看到最新数据
|
|
2288
2298
|
if (result.startsWith('OK:')) {
|
|
2289
2299
|
if (tc.name.includes('character')) await useStore.getState().refreshNpcs();
|
package/src/theme/chat.css
CHANGED
|
@@ -271,9 +271,9 @@
|
|
|
271
271
|
.tool-card-name {
|
|
272
272
|
flex: 1;
|
|
273
273
|
min-width: 0;
|
|
274
|
-
/* 工具名走等宽字体:和对话正文(衬线/无衬线)形成“终端 vs 消息”的区别 */
|
|
275
274
|
font-family: var(--font-mono);
|
|
276
275
|
font-size: 12px;
|
|
276
|
+
font-weight: 600;
|
|
277
277
|
letter-spacing: -0.1px;
|
|
278
278
|
white-space: nowrap;
|
|
279
279
|
overflow: hidden;
|
|
@@ -282,8 +282,11 @@
|
|
|
282
282
|
.tool-args {
|
|
283
283
|
margin: 0;
|
|
284
284
|
padding: 8px 12px;
|
|
285
|
-
font-
|
|
285
|
+
font-family: var(--font-mono);
|
|
286
|
+
font-size: 12px;
|
|
287
|
+
font-weight: 600;
|
|
286
288
|
line-height: 1.45;
|
|
289
|
+
color: var(--text-dim);
|
|
287
290
|
/* 与标题同一个底色:直接用卡片底,展开后不再出现一块深米色 */
|
|
288
291
|
background: transparent;
|
|
289
292
|
/* 上方的分隔线由 .tool-card-body 提供,避免两条边线叠成 2px */
|
|
@@ -310,6 +313,7 @@
|
|
|
310
313
|
gap: 6px;
|
|
311
314
|
padding: 6px 12px 7px;
|
|
312
315
|
border-top: 1px solid var(--border-soft);
|
|
316
|
+
font-family: var(--font-mono);
|
|
313
317
|
font-size: 12px;
|
|
314
318
|
font-weight: 600;
|
|
315
319
|
color: var(--success);
|
|
@@ -332,6 +336,7 @@
|
|
|
332
336
|
display: flex;
|
|
333
337
|
align-items: flex-start;
|
|
334
338
|
gap: 7px;
|
|
339
|
+
font-family: var(--font-mono);
|
|
335
340
|
font-size: 12px;
|
|
336
341
|
font-weight: 600;
|
|
337
342
|
color: var(--success);
|
|
@@ -1270,6 +1275,14 @@
|
|
|
1270
1275
|
padding: 10px 12px;
|
|
1271
1276
|
}
|
|
1272
1277
|
|
|
1278
|
+
.tool-card-head,
|
|
1279
|
+
.tool-card-name,
|
|
1280
|
+
.tool-args,
|
|
1281
|
+
.tool-card-result-line,
|
|
1282
|
+
.tool-result {
|
|
1283
|
+
font-weight: 700;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1273
1286
|
.composer {
|
|
1274
1287
|
--attachment-preview-gap: 10px;
|
|
1275
1288
|
padding: var(--attachment-preview-gap) 12px max(10px, env(safe-area-inset-bottom));
|
package/src/theme/views.css
CHANGED
|
@@ -498,11 +498,29 @@
|
|
|
498
498
|
font-weight: 700;
|
|
499
499
|
font-size: 13.5px;
|
|
500
500
|
}
|
|
501
|
+
.list-item.sortable-skill .l-title {
|
|
502
|
+
display: flex;
|
|
503
|
+
align-items: center;
|
|
504
|
+
gap: 8px;
|
|
505
|
+
min-width: 0;
|
|
506
|
+
}
|
|
507
|
+
.skill-card-name {
|
|
508
|
+
min-width: 0;
|
|
509
|
+
overflow: hidden;
|
|
510
|
+
text-overflow: ellipsis;
|
|
511
|
+
white-space: nowrap;
|
|
512
|
+
}
|
|
513
|
+
.list-item.sortable-skill .l-title .tag {
|
|
514
|
+
flex-shrink: 0;
|
|
515
|
+
}
|
|
501
516
|
.list-item .l-sub {
|
|
502
517
|
font-size: 11.5px;
|
|
503
518
|
color: var(--text-faint);
|
|
504
519
|
font-family: var(--font-mono);
|
|
505
520
|
}
|
|
521
|
+
.list-item.sortable-skill .l-sub {
|
|
522
|
+
color: var(--text-dim);
|
|
523
|
+
}
|
|
506
524
|
|
|
507
525
|
/* ---------- 技能表:拖拽排序 ---------- */
|
|
508
526
|
.skill-sort-hint {
|
|
@@ -1376,6 +1394,7 @@
|
|
|
1376
1394
|
}
|
|
1377
1395
|
.raw-log-editor .cm-editor {
|
|
1378
1396
|
background: var(--code-bg);
|
|
1397
|
+
font-family: var(--font-mono);
|
|
1379
1398
|
}
|
|
1380
1399
|
.raw-wrap-toggle {
|
|
1381
1400
|
display: inline-flex;
|
|
@@ -1711,9 +1730,9 @@
|
|
|
1711
1730
|
}
|
|
1712
1731
|
.ws-fm-head {
|
|
1713
1732
|
gap: 10px;
|
|
1714
|
-
flex-wrap: wrap;
|
|
1715
1733
|
}
|
|
1716
1734
|
.ws-fm-head-actions {
|
|
1735
|
+
flex-shrink: 0;
|
|
1717
1736
|
display: inline-flex;
|
|
1718
1737
|
align-items: center;
|
|
1719
1738
|
gap: 8px;
|
|
@@ -1727,7 +1746,7 @@
|
|
|
1727
1746
|
font-weight: 800;
|
|
1728
1747
|
font-size: 13.5px;
|
|
1729
1748
|
}
|
|
1730
|
-
.ws-fm-
|
|
1749
|
+
.ws-fm-crumb {
|
|
1731
1750
|
min-width: 0;
|
|
1732
1751
|
overflow: hidden;
|
|
1733
1752
|
text-overflow: ellipsis;
|
|
@@ -1755,7 +1774,7 @@
|
|
|
1755
1774
|
display: flex;
|
|
1756
1775
|
flex-direction: column;
|
|
1757
1776
|
gap: 10px;
|
|
1758
|
-
padding: 14px
|
|
1777
|
+
padding: 14px;
|
|
1759
1778
|
}
|
|
1760
1779
|
.ws-fm-note {
|
|
1761
1780
|
display: flex;
|