tavern-harness 0.1.19 → 0.1.21
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 +58 -0
- package/package.json +1 -1
- package/persistence-server.mjs +50 -8
- package/src/App.tsx +88 -11
- package/src/components/ChatView.tsx +54 -38
- package/src/components/FileDisplayModal.tsx +41 -12
- package/src/components/SettingsView.tsx +16 -0
- package/src/components/shared.tsx +1 -0
- package/src/core/i18n.ts +15 -0
- package/src/db/database.ts +53 -29
- package/src/generated/changelog.json +85 -1
- package/src/store/store.ts +10 -2
- package/src/theme/global.css +66 -14
- package/src/theme/views.css +28 -1
- package/src/types/models.ts +4 -0
- package/vite.config.ts +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,62 @@
|
|
|
3
3
|
> 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
|
|
4
4
|
> 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
|
|
5
5
|
|
|
6
|
+
## [0.1.21] - 2026-10-26
|
|
7
|
+
|
|
8
|
+
### 中文
|
|
9
|
+
|
|
10
|
+
#### 改进
|
|
11
|
+
|
|
12
|
+
- 移动端会话头部的操作按钮收进「更多操作」菜单(工作区文件、编辑对话、重置对话、导出游玩记录、删除对话),头部不再拥挤;主题切换按钮仍然直接显示
|
|
13
|
+
- 移动端会话标题可用的横向空间明显变大,长标题能显示更多内容
|
|
14
|
+
- 会话列表中的标题和副标题过长时改为末尾渐隐,不再用省略号生硬截断
|
|
15
|
+
- 会话头部的上下文用量标签字号略微收小,和标题搭配更协调
|
|
16
|
+
|
|
17
|
+
### English
|
|
18
|
+
|
|
19
|
+
#### Changed
|
|
20
|
+
|
|
21
|
+
- On mobile, session header actions now collapse into a "More actions" menu (workspace files, edit, reset, export gameplay, delete), so the header is no longer cramped; the theme toggle stays one tap away
|
|
22
|
+
- Mobile session titles get noticeably more horizontal room, so long titles reveal more of their text
|
|
23
|
+
- Long session list titles and subtitles now fade out at the end instead of being cut off with an ellipsis
|
|
24
|
+
- The context usage badge in the session header uses a slightly smaller font so it sits better next to the title
|
|
25
|
+
|
|
26
|
+
## [0.1.20] - 2026-10-26
|
|
27
|
+
|
|
28
|
+
### 中文
|
|
29
|
+
|
|
30
|
+
#### 新增
|
|
31
|
+
|
|
32
|
+
- 新增「纯净模式」:只显示消息正文,隐藏思考过程和工具调用;没有正文的消息也会一并隐藏,实际的思考和工具执行不受影响(在「设置 → 工具执行」中开启)
|
|
33
|
+
- HTML 预览可以在新页面打开,方便全屏查看和调试交互式页面
|
|
34
|
+
|
|
35
|
+
#### 改进
|
|
36
|
+
|
|
37
|
+
- 长会话加载明显更快:消息列表不再携带每次请求的原始日志,改为打开「查看原始日志」时按需读取;切回已加载过的会话时直接复用内存中的内容,不再重复拉取
|
|
38
|
+
- 长会话下持久化服务更稳定:响应启用 brotli / gzip 压缩,服务繁忙时刷新页面不再误报会话加载失败
|
|
39
|
+
- HTML 预览的缩放按钮移到底部说明栏,不再占用顶部工具栏,预览区域更宽敞
|
|
40
|
+
|
|
41
|
+
#### 修复
|
|
42
|
+
|
|
43
|
+
- 会话标题过长时不再挤压右侧按钮:标题限制最大宽度并在末尾渐隐
|
|
44
|
+
|
|
45
|
+
### English
|
|
46
|
+
|
|
47
|
+
#### Added
|
|
48
|
+
|
|
49
|
+
- New "Pure mode": shows only message bodies, hiding reasoning and tool calls; messages without body text are hidden too, while actual reasoning and tool execution keep running (enable it in Settings → Tool execution)
|
|
50
|
+
- HTML previews can be opened in a new page for full-screen viewing and debugging interactive pages
|
|
51
|
+
|
|
52
|
+
#### Changed
|
|
53
|
+
|
|
54
|
+
- Long conversations load noticeably faster: message lists no longer carry per-request raw logs — they're fetched only when you open "View raw log" — and switching back to a session you've already loaded reuses the in-memory copy instead of fetching it again
|
|
55
|
+
- The persistence service holds up better with long conversations: responses are brotli / gzip compressed, and a busy service no longer triggers false "failed to load session" errors after a page refresh
|
|
56
|
+
- HTML preview zoom buttons moved to the bottom note bar, freeing the top toolbar and giving the preview more room
|
|
57
|
+
|
|
58
|
+
#### Fixed
|
|
59
|
+
|
|
60
|
+
- Long session titles no longer squeeze the header buttons: the title is capped in width and fades out at the end
|
|
61
|
+
|
|
6
62
|
## [0.1.19] - 2026-10-25
|
|
7
63
|
|
|
8
64
|
### 中文
|
|
@@ -552,6 +608,8 @@
|
|
|
552
608
|
- Vite + React + Zustand + Dexie stack with browser-side local persistence
|
|
553
609
|
|
|
554
610
|
<!-- 链接定义 -->
|
|
611
|
+
[0.1.21]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.20...v0.1.21
|
|
612
|
+
[0.1.20]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.19...v0.1.20
|
|
555
613
|
[0.1.19]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.18...v0.1.19
|
|
556
614
|
[0.1.18]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.17...v0.1.18
|
|
557
615
|
[0.1.17]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.16...v0.1.17
|
package/package.json
CHANGED
package/persistence-server.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { createServer } from 'node:http';
|
|
6
6
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:zlib';
|
|
7
8
|
import { mkdirSync } from 'node:fs';
|
|
8
9
|
import { homedir } from 'node:os';
|
|
9
10
|
import { dirname, join, resolve } from 'node:path';
|
|
@@ -72,11 +73,26 @@ function setWriteLock(lock) {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
function json(res, status, body) {
|
|
75
|
-
|
|
76
|
+
const payload = Buffer.from(JSON.stringify(body));
|
|
77
|
+
const acceptedEncodings = String(res.req.headers['accept-encoding'] || '').toLowerCase();
|
|
78
|
+
const headers = {
|
|
76
79
|
'Content-Type': 'application/json; charset=utf-8',
|
|
77
80
|
'Cache-Control': 'no-store',
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
'Vary': 'Accept-Encoding',
|
|
82
|
+
};
|
|
83
|
+
let encoded = payload;
|
|
84
|
+
if (payload.length >= 1024 && /\bbr\b/.test(acceptedEncodings)) {
|
|
85
|
+
encoded = brotliCompressSync(payload, {
|
|
86
|
+
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
|
|
87
|
+
});
|
|
88
|
+
headers['Content-Encoding'] = 'br';
|
|
89
|
+
} else if (payload.length >= 1024 && /\bgzip\b/.test(acceptedEncodings)) {
|
|
90
|
+
encoded = gzipSync(payload, { level: 6 });
|
|
91
|
+
headers['Content-Encoding'] = 'gzip';
|
|
92
|
+
}
|
|
93
|
+
headers['Content-Length'] = String(encoded.length);
|
|
94
|
+
res.writeHead(status, headers);
|
|
95
|
+
res.end(encoded);
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
function readJson(req, maxBytes) {
|
|
@@ -152,6 +168,12 @@ function saveRecord(collection, key, value) {
|
|
|
152
168
|
|
|
153
169
|
const getRecord = database.prepare('SELECT value_json, updated_at FROM app_records WHERE collection = ? AND record_key = ?');
|
|
154
170
|
const listRecords = database.prepare('SELECT record_key, value_json, updated_at FROM app_records WHERE collection = ? ORDER BY record_key');
|
|
171
|
+
const getMessageRawLog = database.prepare(`
|
|
172
|
+
SELECT json_extract(value_json, '$.rawRequestBody') AS raw_request_body,
|
|
173
|
+
json_extract(value_json, '$.rawResponseBody') AS raw_response_body
|
|
174
|
+
FROM app_records
|
|
175
|
+
WHERE collection = 'messages' AND record_key = ?
|
|
176
|
+
`);
|
|
155
177
|
const listRecordsByJsonField = database.prepare(`
|
|
156
178
|
SELECT record_key, value_json, updated_at
|
|
157
179
|
FROM app_records
|
|
@@ -268,6 +290,21 @@ const server = createServer(async (req, res) => {
|
|
|
268
290
|
return;
|
|
269
291
|
}
|
|
270
292
|
|
|
293
|
+
if (pathname === '/records/message-raw-log') {
|
|
294
|
+
if (collection !== 'messages' || !validKey(body.key)) {
|
|
295
|
+
json(res, 400, { ok: false, message: 'A valid message key is required' });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const record = getMessageRawLog.get(body.key);
|
|
299
|
+
json(res, 200, {
|
|
300
|
+
ok: true,
|
|
301
|
+
rawLog: record ? {
|
|
302
|
+
rawRequestBody: record.raw_request_body ?? null,
|
|
303
|
+
rawResponseBody: record.raw_response_body ?? null,
|
|
304
|
+
} : null,
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
271
308
|
if (pathname === '/records/list' || pathname === '/records/list-by-field') {
|
|
272
309
|
let rows;
|
|
273
310
|
if (pathname === '/records/list-by-field') {
|
|
@@ -284,11 +321,16 @@ const server = createServer(async (req, res) => {
|
|
|
284
321
|
} else {
|
|
285
322
|
rows = listRecords.all(collection);
|
|
286
323
|
}
|
|
287
|
-
const records = rows.map((row) =>
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
324
|
+
const records = rows.map((row) => {
|
|
325
|
+
const value = JSON.parse(row.value_json);
|
|
326
|
+
// 原始请求与响应只在用户显式打开日志时按消息 id 获取,避免长会话重复传输大字段。
|
|
327
|
+
if (collection === 'messages') {
|
|
328
|
+
value.rawLogAvailable = Boolean(value.rawRequestBody || value.rawResponseBody);
|
|
329
|
+
value.rawRequestBody = null;
|
|
330
|
+
value.rawResponseBody = null;
|
|
331
|
+
}
|
|
332
|
+
return { key: row.record_key, value, updatedAt: row.updated_at };
|
|
333
|
+
});
|
|
292
334
|
json(res, 200, { ok: true, records });
|
|
293
335
|
return;
|
|
294
336
|
}
|
package/src/App.tsx
CHANGED
|
@@ -24,6 +24,27 @@ function formatContextTokens(tokens: number): string {
|
|
|
24
24
|
return `${(tokens / 1_000).toFixed(1)}K`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function ChatLoadingPlaceholder({ session }: { session: import('./types/models').ChatSession }) {
|
|
28
|
+
const npcs = useStore((s) => s.npcs);
|
|
29
|
+
const t = useT();
|
|
30
|
+
const npc = session.associatedId != null ? npcs.find((item) => item.id === session.associatedId) : null;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="chat-scroll" aria-busy="true">
|
|
34
|
+
<div className="chat-col">
|
|
35
|
+
<div className="empty-state fade-up">
|
|
36
|
+
<div className="big">🫖</div>
|
|
37
|
+
<div>
|
|
38
|
+
{session.mode === 'STANDARD' && <><b>{t('chat.emptyStandardTitle')}</b><br />{t('chat.emptyStandardSub')}</>}
|
|
39
|
+
{session.mode === 'NPC' && <><b>{t('chat.emptyNpcTitle', { name: npc?.name ?? t('chat.speakerChar') })}</b><br />{npc?.greeting}</>}
|
|
40
|
+
{session.mode === 'GROUP' && <><b>{t('chat.emptyGroupTitle')}</b><br />{t('chat.emptyGroupSub')}</>}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
27
48
|
export default function App() {
|
|
28
49
|
const [takingControl, setTakingControl] = useState(false);
|
|
29
50
|
const initStatus = useStore((s) => s.initStatus);
|
|
@@ -39,6 +60,7 @@ export default function App() {
|
|
|
39
60
|
setMountedViews([...mountedViews, activeView]);
|
|
40
61
|
}
|
|
41
62
|
const activeSessionId = useStore((s) => s.activeSessionId);
|
|
63
|
+
const [renderedSessionId, setRenderedSessionId] = useState<number | null>(activeSessionId);
|
|
42
64
|
const sessions = useStore((s) => s.sessions);
|
|
43
65
|
const messages = useStore((s) => s.messages);
|
|
44
66
|
const contextCompressions = useStore((s) => s.contextCompressions);
|
|
@@ -75,10 +97,19 @@ export default function App() {
|
|
|
75
97
|
}, [isStreaming]);
|
|
76
98
|
|
|
77
99
|
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null;
|
|
78
|
-
const sessionMessages = activeSessionId != null ? (messages[activeSessionId] ?? []) : [];
|
|
79
100
|
const sessionParticipants = activeSessionId != null ? (participants[activeSessionId] ?? []) : [];
|
|
80
101
|
const isStreamingSession = useStore((s) => s.streaming.sessionId === activeSessionId);
|
|
81
102
|
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (activeSessionId == null || messages[activeSessionId] !== undefined) {
|
|
105
|
+
setRenderedSessionId(activeSessionId);
|
|
106
|
+
}
|
|
107
|
+
}, [activeSessionId, messages]);
|
|
108
|
+
|
|
109
|
+
const renderedSession = sessions.find((s) => s.id === renderedSessionId) ?? null;
|
|
110
|
+
const renderedSessionMessages = renderedSessionId != null ? (messages[renderedSessionId] ?? []) : [];
|
|
111
|
+
const renderedSessionParticipants = renderedSessionId != null ? (participants[renderedSessionId] ?? []) : [];
|
|
112
|
+
|
|
82
113
|
const handleTakeOverControl = async () => {
|
|
83
114
|
setTakingControl(true);
|
|
84
115
|
try {
|
|
@@ -150,14 +181,18 @@ export default function App() {
|
|
|
150
181
|
<SessionHeader session={activeSession} participants={sessionParticipants} />
|
|
151
182
|
<div className="chat-main-row">
|
|
152
183
|
<div className="chat-main-col">
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
184
|
+
{renderedSession != null && renderedSession.id === activeSession.id ? (
|
|
185
|
+
<ChatView
|
|
186
|
+
session={renderedSession}
|
|
187
|
+
messages={renderedSessionMessages}
|
|
188
|
+
participants={renderedSessionParticipants}
|
|
189
|
+
compressions={contextCompressions[renderedSession.id!] ?? []}
|
|
190
|
+
streaming={isStreamingSession}
|
|
191
|
+
active={activeView === 'chat'}
|
|
192
|
+
/>
|
|
193
|
+
) : (
|
|
194
|
+
<ChatLoadingPlaceholder session={activeSession} />
|
|
195
|
+
)}
|
|
161
196
|
<ChatInput sessionId={activeSession.id!} />
|
|
162
197
|
</div>
|
|
163
198
|
<TurnQueuePanel session={activeSession} participants={sessionParticipants} />
|
|
@@ -292,6 +327,8 @@ function SessionHeader({
|
|
|
292
327
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
293
328
|
const [exportOpen, setExportOpen] = useState(false);
|
|
294
329
|
const [showWorkspace, setShowWorkspace] = useState(false);
|
|
330
|
+
const [showMobileActions, setShowMobileActions] = useState(false);
|
|
331
|
+
const mobileActionsRef = useRef<HTMLDivElement>(null);
|
|
295
332
|
|
|
296
333
|
const messages = useStore((s) => s.messages[session.id!] ?? []);
|
|
297
334
|
const contextTokens = useMemo(() => {
|
|
@@ -315,6 +352,17 @@ function SessionHeader({
|
|
|
315
352
|
|| (themeMode === 'system' && document.documentElement.dataset.theme === 'dark');
|
|
316
353
|
const toggleTheme = () => void setSettings({ themeMode: isDarkTheme ? 'light' : 'dark' });
|
|
317
354
|
|
|
355
|
+
useEffect(() => {
|
|
356
|
+
if (!showMobileActions) return;
|
|
357
|
+
const closeOnOutsidePointerDown = (event: PointerEvent) => {
|
|
358
|
+
if (!mobileActionsRef.current?.contains(event.target as Node)) {
|
|
359
|
+
setShowMobileActions(false);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
document.addEventListener('pointerdown', closeOnOutsidePointerDown);
|
|
363
|
+
return () => document.removeEventListener('pointerdown', closeOnOutsidePointerDown);
|
|
364
|
+
}, [showMobileActions]);
|
|
365
|
+
|
|
318
366
|
return (
|
|
319
367
|
<div className="chat-header">
|
|
320
368
|
<SessionVisual mode={session.mode} npcName={npcRef?.name ?? undefined} hue={npcRef?.avatarColorOrdinal ?? 0} imageUrl={npcRef?.avatarDataUrl} members={session.mode === 'GROUP' ? groupMemberAvatars : undefined} size="lg" />
|
|
@@ -326,7 +374,7 @@ function SessionHeader({
|
|
|
326
374
|
</div>
|
|
327
375
|
)}
|
|
328
376
|
</div>
|
|
329
|
-
<div className="chat-actions">
|
|
377
|
+
<div className="chat-actions" ref={mobileActionsRef}>
|
|
330
378
|
<button className="btn-ghost icon-tooltip" title={t(usesPublicWorkspace ? 'header.workspaceRootHint' : 'header.workspaceHint')} onClick={() => setShowWorkspace(true)}>
|
|
331
379
|
<Icon name="folder" size={17} />
|
|
332
380
|
</button>
|
|
@@ -343,7 +391,17 @@ function SessionHeader({
|
|
|
343
391
|
<Icon name="trash" size={17} />
|
|
344
392
|
</button>
|
|
345
393
|
<button
|
|
346
|
-
className="btn-ghost icon-tooltip"
|
|
394
|
+
className="btn-ghost icon-tooltip chat-actions-more"
|
|
395
|
+
type="button"
|
|
396
|
+
title={t('header.moreActions')}
|
|
397
|
+
aria-label={t('header.moreActions')}
|
|
398
|
+
aria-expanded={showMobileActions}
|
|
399
|
+
onClick={() => setShowMobileActions(!showMobileActions)}
|
|
400
|
+
>
|
|
401
|
+
<Icon name="more" size={18} />
|
|
402
|
+
</button>
|
|
403
|
+
<button
|
|
404
|
+
className="btn-ghost icon-tooltip chat-theme-toggle"
|
|
347
405
|
type="button"
|
|
348
406
|
title={t(isDarkTheme ? 'header.switchToLightTip' : 'header.switchToDarkTip')}
|
|
349
407
|
aria-label={t(isDarkTheme ? 'header.switchToLightTip' : 'header.switchToDarkTip')}
|
|
@@ -351,6 +409,25 @@ function SessionHeader({
|
|
|
351
409
|
>
|
|
352
410
|
<Icon name={isDarkTheme ? 'sun' : 'moon'} size={17} />
|
|
353
411
|
</button>
|
|
412
|
+
{showMobileActions && (
|
|
413
|
+
<div className="chat-actions-menu card">
|
|
414
|
+
<button onClick={() => { setShowWorkspace(true); setShowMobileActions(false); }}>
|
|
415
|
+
<Icon name="folder" size={15} /> {t('header.workspaceAction')}
|
|
416
|
+
</button>
|
|
417
|
+
<button onClick={() => { setShowEdit(true); setShowMobileActions(false); }}>
|
|
418
|
+
<Icon name="pencil" size={15} /> {t('nav.editSession')}
|
|
419
|
+
</button>
|
|
420
|
+
<button onClick={() => { setConfirmReset(true); setShowMobileActions(false); }}>
|
|
421
|
+
<Icon name="refresh" size={15} /> {t('header.resetSessionTip')}
|
|
422
|
+
</button>
|
|
423
|
+
<button onClick={() => { setExportOpen(true); setShowMobileActions(false); }}>
|
|
424
|
+
<Icon name="share" size={15} /> {t('nav.exportGameplay')}
|
|
425
|
+
</button>
|
|
426
|
+
<button className="danger" onClick={() => { setConfirmDelete(true); setShowMobileActions(false); }}>
|
|
427
|
+
<Icon name="trash" size={15} /> {t('header.deleteTip')}
|
|
428
|
+
</button>
|
|
429
|
+
</div>
|
|
430
|
+
)}
|
|
354
431
|
</div>
|
|
355
432
|
|
|
356
433
|
{showEdit && <NewSessionMenu editingSession={session} onClose={() => setShowEdit(false)} />}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Fragment, lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type UIEvent } from 'react';
|
|
2
2
|
import { useStore } from '../store/store';
|
|
3
|
+
import { db } from '../db/database';
|
|
3
4
|
import { type ChatMessage, type ChatParticipant, type ChatSession, type ContextCompression, type ToolCallRecord, displayModelName } from '../types/models';
|
|
4
5
|
import { Avatar, Icon, Markdown, Collapse, Modal, AttachCard } from './shared';
|
|
5
6
|
import { FileDisplayViewButton, parseStoredDisplayRef } from './FileDisplayModal';
|
|
@@ -119,6 +120,39 @@ export function ChatView({
|
|
|
119
120
|
() => new Map(compressions.map((compression) => [compression.endMessageId, compression])),
|
|
120
121
|
[compressions],
|
|
121
122
|
);
|
|
123
|
+
const parsedToolCallsCache = useRef(new Map<number, { source: string; calls: ToolCallRecord[] }>());
|
|
124
|
+
const toolMessageData = useMemo(() => {
|
|
125
|
+
const resultsByCallId = new Map<string, ChatMessage[]>();
|
|
126
|
+
const displayedCallIds = new Set<string>();
|
|
127
|
+
|
|
128
|
+
for (const message of messages) {
|
|
129
|
+
if (message.role === 'tool' && message.toolCallId) {
|
|
130
|
+
const results = resultsByCallId.get(message.toolCallId);
|
|
131
|
+
if (results) results.push(message);
|
|
132
|
+
else resultsByCallId.set(message.toolCallId, [message]);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (message.role !== 'assistant' || !message.toolCallsJson) continue;
|
|
136
|
+
|
|
137
|
+
const cached = message.id != null ? parsedToolCallsCache.current.get(message.id) : undefined;
|
|
138
|
+
let calls: ToolCallRecord[];
|
|
139
|
+
if (cached?.source === message.toolCallsJson) {
|
|
140
|
+
calls = cached.calls;
|
|
141
|
+
} else {
|
|
142
|
+
try {
|
|
143
|
+
calls = JSON.parse(message.toolCallsJson) as ToolCallRecord[];
|
|
144
|
+
} catch {
|
|
145
|
+
calls = [];
|
|
146
|
+
}
|
|
147
|
+
if (message.id != null) {
|
|
148
|
+
parsedToolCallsCache.current.set(message.id, { source: message.toolCallsJson, calls });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (const call of calls) if (call.id) displayedCallIds.add(call.id);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { resultsByCallId, displayedCallIds };
|
|
155
|
+
}, [messages]);
|
|
122
156
|
|
|
123
157
|
useEffect(() => {
|
|
124
158
|
const el = scrollRef.current;
|
|
@@ -275,33 +309,6 @@ export function ChatView({
|
|
|
275
309
|
return personaId != null ? s.npcs.find((npc) => npc.id === personaId)?.name ?? null : null;
|
|
276
310
|
});
|
|
277
311
|
|
|
278
|
-
// 工具调用结果按 toolCallId 分组;已由 assistant 消息工具卡片展示的调用 id,其结果并入卡片内部渲染
|
|
279
|
-
const toolResultsByCallId = useMemo(() => {
|
|
280
|
-
const map = new Map<string, ChatMessage[]>();
|
|
281
|
-
for (const m of messages) {
|
|
282
|
-
if (m.role === 'tool' && m.toolCallId) {
|
|
283
|
-
const arr = map.get(m.toolCallId);
|
|
284
|
-
if (arr) arr.push(m);
|
|
285
|
-
else map.set(m.toolCallId, [m]);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
return map;
|
|
289
|
-
}, [messages]);
|
|
290
|
-
const displayedToolCallIds = useMemo(() => {
|
|
291
|
-
const ids = new Set<string>();
|
|
292
|
-
for (const m of messages) {
|
|
293
|
-
if (m.role !== 'assistant') continue;
|
|
294
|
-
try {
|
|
295
|
-
for (const tc of JSON.parse(m.toolCallsJson || '[]') as ToolCallRecord[]) {
|
|
296
|
-
if (tc.id) ids.add(tc.id);
|
|
297
|
-
}
|
|
298
|
-
} catch {
|
|
299
|
-
/* ignore */
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
return ids;
|
|
303
|
-
}, [messages]);
|
|
304
|
-
|
|
305
312
|
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
|
|
306
313
|
const el = event.currentTarget;
|
|
307
314
|
// 隐藏过程中浏览器会把滚动位置夹到 0 并派发一次 scroll 事件,
|
|
@@ -354,8 +361,8 @@ export function ChatView({
|
|
|
354
361
|
userPersonaName={userPersonaName}
|
|
355
362
|
loopIndex={isGroup ? (m.loopIndex ?? 0) : null}
|
|
356
363
|
streaming={streaming && m.id === lastMsgId(messages)}
|
|
357
|
-
toolResultsByCallId={
|
|
358
|
-
suppressedResultIds={
|
|
364
|
+
toolResultsByCallId={toolMessageData.resultsByCallId}
|
|
365
|
+
suppressedResultIds={toolMessageData.displayedCallIds}
|
|
359
366
|
/>
|
|
360
367
|
{compressionsByEndMessageId.has(m.id!) && (
|
|
361
368
|
<div className="compression-banner-wrap">
|
|
@@ -422,6 +429,7 @@ function MessageBubble({
|
|
|
422
429
|
}) {
|
|
423
430
|
const t = useT();
|
|
424
431
|
const providers = useStore((s) => s.providers);
|
|
432
|
+
const pureMode = useStore((s) => s.settings?.pureMode === true);
|
|
425
433
|
const turnStreaming = useStore((s) => s.streaming.sessionId === session.id);
|
|
426
434
|
const longPressProps = useMessageLongPress(msg, session);
|
|
427
435
|
// System 消息(/new 标记)
|
|
@@ -434,6 +442,7 @@ function MessageBubble({
|
|
|
434
442
|
|
|
435
443
|
// 工具消息
|
|
436
444
|
if (msg.role === 'tool') {
|
|
445
|
+
if (pureMode) return null;
|
|
437
446
|
// 该结果已并入发起它的工具调用卡片内部 → 不再独立渲染
|
|
438
447
|
if (msg.toolCallId && suppressedResultIds.has(msg.toolCallId)) return null;
|
|
439
448
|
const isError = msg.content.startsWith('ERROR:') || msg.content.startsWith('CANCELLED:');
|
|
@@ -486,6 +495,8 @@ function MessageBubble({
|
|
|
486
495
|
return <UserBubble msg={msg} session={session} userPersonaName={userPersonaName} loopIndex={loopIndex} />;
|
|
487
496
|
}
|
|
488
497
|
|
|
498
|
+
if (pureMode && !displayContent && msg.attachments.length === 0) return null;
|
|
499
|
+
|
|
489
500
|
// 用于「点击队列发言 → 对话定位」:NPC 用 participantId,玩家/未知用 null(定位到循环首条)
|
|
490
501
|
const speakerKey = msg.speakerParticipantId != null ? String(msg.speakerParticipantId) : null;
|
|
491
502
|
|
|
@@ -510,7 +521,7 @@ function MessageBubble({
|
|
|
510
521
|
{msg.modelUsed && !msg.modelUsed.startsWith('scheduled:') && <span className="msg-model" title={msg.modelUsed}>{displayModelName(providers, msg.modelUsed)}</span>}
|
|
511
522
|
<span className="msg-time">{fmtTime(msg.timestamp)}</span>
|
|
512
523
|
</div>
|
|
513
|
-
{msg.thinkingContent && (
|
|
524
|
+
{!pureMode && msg.thinkingContent && (
|
|
514
525
|
<Collapse
|
|
515
526
|
title={t('chat.thinking')}
|
|
516
527
|
icon="🧠"
|
|
@@ -521,7 +532,7 @@ function MessageBubble({
|
|
|
521
532
|
<Markdown text={msg.thinkingContent} mathEnabled={false} />
|
|
522
533
|
</Collapse>
|
|
523
534
|
)}
|
|
524
|
-
{toolCalls.length > 0 && (
|
|
535
|
+
{!pureMode && toolCalls.length > 0 && (
|
|
525
536
|
<div className="tool-calls">
|
|
526
537
|
{toolCalls.map((tc, i) => (
|
|
527
538
|
<ToolCallCard
|
|
@@ -1411,16 +1422,21 @@ export function MessageMenu() {
|
|
|
1411
1422
|
},
|
|
1412
1423
|
});
|
|
1413
1424
|
}
|
|
1414
|
-
if (msg.rawRequestBody || msg.rawResponseBody) {
|
|
1425
|
+
if (msg.rawRequestBody || msg.rawResponseBody || msg.rawLogAvailable) {
|
|
1415
1426
|
actions.push({
|
|
1416
1427
|
label: t('chat.viewRaw'),
|
|
1417
1428
|
icon: 'file',
|
|
1418
|
-
onClick: () => {
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1429
|
+
onClick: async () => {
|
|
1430
|
+
const rawLog = msg.rawRequestBody || msg.rawResponseBody
|
|
1431
|
+
? msg
|
|
1432
|
+
: await db.messages.getRawLog(msg.id!);
|
|
1433
|
+
if (rawLog) {
|
|
1434
|
+
setRawLog({
|
|
1435
|
+
data: { ...rawLog, id: msg.id, role: msg.role },
|
|
1436
|
+
title: t('chat.rawLogTitle', { id: msg.id ?? '' }),
|
|
1437
|
+
filename: `raw-log-${msg.id}.txt`,
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1424
1440
|
closeMsgMenu();
|
|
1425
1441
|
},
|
|
1426
1442
|
});
|
|
@@ -79,6 +79,7 @@ export function FileDisplayModal() {
|
|
|
79
79
|
const [loadState, setLoadState] = useState<'loading' | 'ok' | 'error'>('loading');
|
|
80
80
|
const [imgError, setImgError] = useState(false);
|
|
81
81
|
const [htmlZoom, setHtmlZoom] = useState(1);
|
|
82
|
+
const lastModalHtmlZoomRef = useRef(1);
|
|
82
83
|
const [textWrap, setTextWrap] = useState(true);
|
|
83
84
|
const [isPictureInPicture, setIsPictureInPicture] = useState(true);
|
|
84
85
|
const [pipRect, setPipRect] = useState<PipRect>(createPipRect);
|
|
@@ -124,11 +125,14 @@ export function FileDisplayModal() {
|
|
|
124
125
|
setImgError(false);
|
|
125
126
|
setZoom(1);
|
|
126
127
|
setPan({ x: 0, y: 0 });
|
|
127
|
-
setHtmlZoom(1);
|
|
128
128
|
setTextWrap(true);
|
|
129
129
|
if (isNewDisplay) {
|
|
130
|
+
const opensInPictureInPicture = active.presentation === 'pip' || active.presentation == null;
|
|
131
|
+
setHtmlZoom(opensInPictureInPicture ? lastModalHtmlZoomRef.current : 1);
|
|
130
132
|
setPipRect(createPipRect());
|
|
131
|
-
setIsPictureInPicture(
|
|
133
|
+
setIsPictureInPicture(opensInPictureInPicture);
|
|
134
|
+
} else {
|
|
135
|
+
setHtmlZoom(1);
|
|
132
136
|
}
|
|
133
137
|
}, [active]);
|
|
134
138
|
|
|
@@ -276,6 +280,11 @@ export function FileDisplayModal() {
|
|
|
276
280
|
setIsPictureInPicture(false);
|
|
277
281
|
};
|
|
278
282
|
|
|
283
|
+
const openHtmlInNewPage = () => {
|
|
284
|
+
if (!active || !workspaceDir) return;
|
|
285
|
+
window.open(previewUrl(active.path, workspaceDir), '_blank', 'noopener,noreferrer');
|
|
286
|
+
};
|
|
287
|
+
|
|
279
288
|
const startResize = (edge: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right') => (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
280
289
|
pipGestureRef.current = {
|
|
281
290
|
type: 'resize',
|
|
@@ -312,8 +321,16 @@ export function FileDisplayModal() {
|
|
|
312
321
|
|
|
313
322
|
const zoomIn = () => setZoom((z) => Math.min(8, +(z * 1.3).toFixed(2)));
|
|
314
323
|
const zoomOut = () => setZoom((z) => Math.max(0.1, +(z / 1.3).toFixed(2)));
|
|
315
|
-
const
|
|
316
|
-
|
|
324
|
+
const updateHtmlZoom = (update: (zoom: number) => number) => {
|
|
325
|
+
setHtmlZoom((zoom) => {
|
|
326
|
+
const nextZoom = update(zoom);
|
|
327
|
+
if (!isPictureInPicture) lastModalHtmlZoomRef.current = nextZoom;
|
|
328
|
+
return nextZoom;
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
const htmlZoomIn = () => updateHtmlZoom((z) => Math.min(2, +(z + 0.1).toFixed(1)));
|
|
332
|
+
const htmlZoomOut = () => updateHtmlZoom((z) => Math.max(0.5, +(z - 0.1).toFixed(1)));
|
|
333
|
+
const resetHtmlZoom = () => updateHtmlZoom(() => 1);
|
|
317
334
|
const resetView = () => {
|
|
318
335
|
setZoom(1);
|
|
319
336
|
setPan({ x: 0, y: 0 });
|
|
@@ -393,7 +410,16 @@ export function FileDisplayModal() {
|
|
|
393
410
|
}}
|
|
394
411
|
/>
|
|
395
412
|
</div>
|
|
396
|
-
<div className="display-html-note">
|
|
413
|
+
<div className="display-html-note">
|
|
414
|
+
<span>{t('display.htmlSandboxNote')}</span>
|
|
415
|
+
{!isPictureInPicture && (
|
|
416
|
+
<span className="display-html-zoom-group">
|
|
417
|
+
<button className="icon-btn" title={t('display.htmlZoomOut')} onClick={htmlZoomOut}><Icon name="zoom-out" size={15} /></button>
|
|
418
|
+
<button className="icon-btn" title={t('display.resetHtmlZoom')} onClick={resetHtmlZoom}>{Math.round(htmlZoom * 100)}%</button>
|
|
419
|
+
<button className="icon-btn" title={t('display.htmlZoomIn')} onClick={htmlZoomIn}><Icon name="zoom-in" size={15} /></button>
|
|
420
|
+
</span>
|
|
421
|
+
)}
|
|
422
|
+
</div>
|
|
397
423
|
</div>
|
|
398
424
|
);
|
|
399
425
|
}
|
|
@@ -451,13 +477,6 @@ export function FileDisplayModal() {
|
|
|
451
477
|
<button className="icon-btn" title={t('display.zoomIn')} onClick={zoomIn}><Icon name="zoom-in" size={15} /></button>
|
|
452
478
|
</span>
|
|
453
479
|
)}
|
|
454
|
-
{active.kind === 'html' && loadState === 'ok' && (
|
|
455
|
-
<span className="display-zoom-group">
|
|
456
|
-
<button className="icon-btn" title={t('display.htmlZoomOut')} onClick={htmlZoomOut}><Icon name="zoom-out" size={15} /></button>
|
|
457
|
-
<button className="icon-btn" title={t('display.resetHtmlZoom')} onClick={() => setHtmlZoom(1)}>{Math.round(htmlZoom * 100)}%</button>
|
|
458
|
-
<button className="icon-btn" title={t('display.htmlZoomIn')} onClick={htmlZoomIn}><Icon name="zoom-in" size={15} /></button>
|
|
459
|
-
</span>
|
|
460
|
-
)}
|
|
461
480
|
{active.kind === 'text' && !MD_EXTS.has(ext) && loadState === 'ok' && (
|
|
462
481
|
<button
|
|
463
482
|
className={`icon-btn ${textWrap ? 'active' : ''}`}
|
|
@@ -469,6 +488,16 @@ export function FileDisplayModal() {
|
|
|
469
488
|
<Icon name="text-wrap" size={17} />
|
|
470
489
|
</button>
|
|
471
490
|
)}
|
|
491
|
+
{active.kind === 'html' && loadState === 'ok' && workspaceDir && (
|
|
492
|
+
<button
|
|
493
|
+
className="icon-btn"
|
|
494
|
+
title={t('display.openHtmlInNewPage')}
|
|
495
|
+
aria-label={t('display.openHtmlInNewPage')}
|
|
496
|
+
onClick={openHtmlInNewPage}
|
|
497
|
+
>
|
|
498
|
+
<Icon name="open-in-new" size={17} />
|
|
499
|
+
</button>
|
|
500
|
+
)}
|
|
472
501
|
<button
|
|
473
502
|
className="icon-btn"
|
|
474
503
|
title={t(isPictureInPicture ? 'display.exitPictureInPicture' : 'display.pictureInPicture')}
|
|
@@ -82,6 +82,22 @@ export const SettingsView = memo(function SettingsView() {
|
|
|
82
82
|
<span className="switch-slider" />
|
|
83
83
|
</label>
|
|
84
84
|
</div>
|
|
85
|
+
<div className="set-row settings-tool-row">
|
|
86
|
+
<div>
|
|
87
|
+
<div className="s-label" id="pure-mode-label">{t('settings.pureMode')}</div>
|
|
88
|
+
<div className="s-desc" id="pure-mode-desc">{t('settings.pureModeDesc')}</div>
|
|
89
|
+
</div>
|
|
90
|
+
<label className="switch">
|
|
91
|
+
<input
|
|
92
|
+
type="checkbox"
|
|
93
|
+
aria-labelledby="pure-mode-label"
|
|
94
|
+
aria-describedby="pure-mode-desc"
|
|
95
|
+
checked={settings.pureMode === true}
|
|
96
|
+
onChange={(e) => setSettings({ pureMode: e.target.checked })}
|
|
97
|
+
/>
|
|
98
|
+
<span className="switch-slider" />
|
|
99
|
+
</label>
|
|
100
|
+
</div>
|
|
85
101
|
</div>
|
|
86
102
|
|
|
87
103
|
{/* 生成参数 */}
|
|
@@ -403,6 +403,7 @@ const ICONS: Record<string, React.ReactNode> = {
|
|
|
403
403
|
cancel: <path d="M18 6 6 18M6 6l12 12" />,
|
|
404
404
|
x: <path d="M18 6 6 18M6 6l12 12" />,
|
|
405
405
|
share: <><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /><path d="m16 6-4-4-4 4M12 2v13" /></>,
|
|
406
|
+
'open-in-new': <><path d="M14 3h7v7M21 3l-9 9" /><path d="M19 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6" /></>,
|
|
406
407
|
sort: <><path d="M11 5h10M11 9h7M11 13h4" /><path d="m3 17 3 3 3-3M6 20V4" /></>,
|
|
407
408
|
pencil: <><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5l-5 1 1-5Z" /><path d="m15 5 4 4" /></>,
|
|
408
409
|
upload: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="m17 8-5-5-5 5M12 3v12" /></>,
|
package/src/core/i18n.ts
CHANGED
|
@@ -81,6 +81,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
81
81
|
'header.modeStandard': '标准对话',
|
|
82
82
|
'header.modeNpc': '角色对话',
|
|
83
83
|
'header.modeGroup': '群聊',
|
|
84
|
+
'header.moreActions': '更多操作',
|
|
85
|
+
'header.workspaceAction': '工作区文件',
|
|
84
86
|
'header.editSessionTip': '编辑对话设置',
|
|
85
87
|
'header.resetSessionTip': '重置对话',
|
|
86
88
|
'header.resetSessionTitle': '重置对话',
|
|
@@ -195,6 +197,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
195
197
|
'settings.title': '设置',
|
|
196
198
|
'settings.sub': '模型 API 配置、超参数与主题',
|
|
197
199
|
'settings.toolExecution': '🛠️ 工具执行',
|
|
200
|
+
'settings.pureMode': '纯净模式',
|
|
201
|
+
'settings.pureModeDesc': '仅影响前端展示:隐藏对话中的思考和工具调用;没有正文的消息会一并隐藏,不影响实际思考和工具调用。',
|
|
198
202
|
'settings.yoloMode': 'YOLO 模式',
|
|
199
203
|
'settings.yoloModeDesc': '自动批准工具修改和 Shell 执行,无需逐次确认。仅在信任角色和技能时开启。',
|
|
200
204
|
'settings.genParams': '🎛 生成参数',
|
|
@@ -533,6 +537,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
533
537
|
'display.exitPictureInPicture': '退出画中画',
|
|
534
538
|
'display.resizePictureInPicture': '调整画中画大小',
|
|
535
539
|
'display.htmlSandboxNote': 'HTML 应用在受限沙箱中运行,并可加载同目录资源',
|
|
540
|
+
'display.openHtmlInNewPage': '在新页面打开 HTML',
|
|
536
541
|
|
|
537
542
|
// ---- 系统提示词 ----
|
|
538
543
|
'prompt.worldBook': '=== 世界书 ===',
|
|
@@ -602,6 +607,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
602
607
|
'header.modeStandard': '標準對話',
|
|
603
608
|
'header.modeNpc': '角色對話',
|
|
604
609
|
'header.modeGroup': '群聊',
|
|
610
|
+
'header.moreActions': '更多操作',
|
|
611
|
+
'header.workspaceAction': '工作區檔案',
|
|
605
612
|
'header.editSessionTip': '編輯對話設定',
|
|
606
613
|
'header.resetSessionTip': '重置對話',
|
|
607
614
|
'header.resetSessionTitle': '重置對話',
|
|
@@ -713,6 +720,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
713
720
|
'settings.title': '設定',
|
|
714
721
|
'settings.sub': '模型 API 設定、超參數與主題',
|
|
715
722
|
'settings.toolExecution': '🛠️ 工具執行',
|
|
723
|
+
'settings.pureMode': '純淨模式',
|
|
724
|
+
'settings.pureModeDesc': '僅影響前端顯示:隱藏對話中的思考和工具呼叫;沒有正文的訊息會一併隱藏,不影響實際思考和工具呼叫。',
|
|
716
725
|
'settings.yoloMode': 'YOLO 模式',
|
|
717
726
|
'settings.yoloModeDesc': '自動批准工具修改和 Shell 執行,無需逐次確認。僅在信任角色和技能時開啟。',
|
|
718
727
|
'settings.genParams': '🎛 產生參數',
|
|
@@ -1043,6 +1052,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1043
1052
|
'display.exitPictureInPicture': '退出畫中畫',
|
|
1044
1053
|
'display.resizePictureInPicture': '調整畫中畫大小',
|
|
1045
1054
|
'display.htmlSandboxNote': 'HTML 應用程式在受限沙箱中執行,並可載入同目錄資源',
|
|
1055
|
+
'display.openHtmlInNewPage': '在新頁面開啟 HTML',
|
|
1046
1056
|
|
|
1047
1057
|
'prompt.worldBook': '=== 世界書 ===',
|
|
1048
1058
|
'prompt.userInfo': '=== USER INFO (用戶扮演的角色) ===',
|
|
@@ -1111,6 +1121,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1111
1121
|
'header.modeStandard': 'Standard chat',
|
|
1112
1122
|
'header.modeNpc': 'Character chat',
|
|
1113
1123
|
'header.modeGroup': 'Group chat',
|
|
1124
|
+
'header.moreActions': 'More actions',
|
|
1125
|
+
'header.workspaceAction': 'Workspace files',
|
|
1114
1126
|
'header.editSessionTip': 'Edit conversation settings',
|
|
1115
1127
|
'header.resetSessionTip': 'Reset conversation',
|
|
1116
1128
|
'header.resetSessionTitle': 'Reset conversation',
|
|
@@ -1222,6 +1234,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1222
1234
|
'settings.title': 'Settings',
|
|
1223
1235
|
'settings.sub': 'Model API config, hyperparameters and theme',
|
|
1224
1236
|
'settings.toolExecution': '🛠️ Tool execution',
|
|
1237
|
+
'settings.pureMode': 'Pure mode',
|
|
1238
|
+
'settings.pureModeDesc': 'Front-end display only: hide reasoning and tool calls in chats; messages without body content are hidden too. Actual reasoning and tool calls still run.',
|
|
1225
1239
|
'settings.yoloMode': 'YOLO mode',
|
|
1226
1240
|
'settings.yoloModeDesc': 'Automatically approve tool changes and Shell commands without asking each time. Only enable for trusted characters and skills.',
|
|
1227
1241
|
'settings.genParams': '🎛 Generation parameters',
|
|
@@ -1552,6 +1566,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1552
1566
|
'display.exitPictureInPicture': 'Exit picture in picture',
|
|
1553
1567
|
'display.resizePictureInPicture': 'Resize picture in picture',
|
|
1554
1568
|
'display.htmlSandboxNote': 'HTML application runs in a restricted sandbox with same-directory resources available',
|
|
1569
|
+
'display.openHtmlInNewPage': 'Open HTML in a new page',
|
|
1555
1570
|
|
|
1556
1571
|
'prompt.worldBook': '=== LOREBOOK ===',
|
|
1557
1572
|
'prompt.userInfo': '=== USER INFO (role played by the user) ===',
|
package/src/db/database.ts
CHANGED
|
@@ -282,40 +282,52 @@ let hasWriteLock = false;
|
|
|
282
282
|
let lockHeartbeat: number | null = null;
|
|
283
283
|
|
|
284
284
|
const PERSISTENCE_REQUEST_TIMEOUT_MS = 8_000;
|
|
285
|
+
const PERSISTENCE_STARTUP_RETRY_DELAY_MS = 250;
|
|
285
286
|
|
|
286
287
|
async function persistenceRequest<T>(path: string, body: Record<string, unknown> = {}): Promise<T> {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
288
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
289
|
+
const controller = new AbortController();
|
|
290
|
+
const timeout = globalThis.setTimeout(() => controller.abort(), PERSISTENCE_REQUEST_TIMEOUT_MS);
|
|
291
|
+
let response: Response;
|
|
292
|
+
try {
|
|
293
|
+
response = await fetch(`/api-v3${path}`, {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
headers: { 'Content-Type': 'application/json' },
|
|
296
|
+
body: JSON.stringify({ ...body, clientId }),
|
|
297
|
+
signal: controller.signal,
|
|
298
|
+
});
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (attempt === 0) {
|
|
301
|
+
await new Promise((resolve) => globalThis.setTimeout(resolve, PERSISTENCE_STARTUP_RETRY_DELAY_MS));
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
305
|
+
throw new Error(`Persistence request ${path} timed out`);
|
|
306
|
+
}
|
|
307
|
+
throw new Error('Unable to reach the persistence service');
|
|
308
|
+
} finally {
|
|
309
|
+
globalThis.clearTimeout(timeout);
|
|
300
310
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
if (!response.ok || payload.ok === false) {
|
|
312
|
-
if (response.status === 423) {
|
|
313
|
-
hasWriteLock = false;
|
|
314
|
-
markControlReadOnly();
|
|
311
|
+
let payload: T & { ok?: boolean; message?: string };
|
|
312
|
+
try {
|
|
313
|
+
payload = await response.json() as T & { ok?: boolean; message?: string };
|
|
314
|
+
} catch {
|
|
315
|
+
throw new Error(`Persistence service returned an invalid response (${response.status})`);
|
|
316
|
+
}
|
|
317
|
+
if (response.status === 503 && payload.message === 'Persistence server not running' && attempt === 0) {
|
|
318
|
+
await new Promise((resolve) => globalThis.setTimeout(resolve, PERSISTENCE_STARTUP_RETRY_DELAY_MS));
|
|
319
|
+
continue;
|
|
315
320
|
}
|
|
316
|
-
|
|
321
|
+
if (!response.ok || payload.ok === false) {
|
|
322
|
+
if (response.status === 423) {
|
|
323
|
+
hasWriteLock = false;
|
|
324
|
+
markControlReadOnly();
|
|
325
|
+
}
|
|
326
|
+
throw new Error(`Persistence request ${path} failed: ${payload.message || `HTTP ${response.status}`}`);
|
|
327
|
+
}
|
|
328
|
+
return payload;
|
|
317
329
|
}
|
|
318
|
-
|
|
330
|
+
throw new Error(`Persistence request ${path} failed after retry`);
|
|
319
331
|
}
|
|
320
332
|
|
|
321
333
|
async function requireWriteLock(): Promise<void> {
|
|
@@ -497,6 +509,14 @@ class RemoteTable<T extends object> {
|
|
|
497
509
|
const result = await persistenceRequest<{ record: { value: T } | null }>('/records/get', { collection: this.collection, key: Array.isArray(key) ? key.join(':') : String(key) });
|
|
498
510
|
return result.record?.value;
|
|
499
511
|
}
|
|
512
|
+
async getRawLog(key: number): Promise<Pick<ChatMessage, 'rawRequestBody' | 'rawResponseBody'> | null> {
|
|
513
|
+
if (this.collection !== 'messages') throw new Error('Raw logs are only available for messages');
|
|
514
|
+
const result = await persistenceRequest<{ rawLog: Pick<ChatMessage, 'rawRequestBody' | 'rawResponseBody'> | null }>('/records/message-raw-log', {
|
|
515
|
+
collection: this.collection,
|
|
516
|
+
key: String(key),
|
|
517
|
+
});
|
|
518
|
+
return result.rawLog;
|
|
519
|
+
}
|
|
500
520
|
async count(): Promise<number> { return (await this.toArray()).length; }
|
|
501
521
|
async add(value: T): Promise<number> {
|
|
502
522
|
await requireWriteLock();
|
|
@@ -651,6 +671,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|
|
651
671
|
isThinkingModeEnabled: true,
|
|
652
672
|
isToolCallsEnabled: true,
|
|
653
673
|
yoloMode: false,
|
|
674
|
+
pureMode: false,
|
|
654
675
|
statsResetTime: null,
|
|
655
676
|
};
|
|
656
677
|
|
|
@@ -697,6 +718,9 @@ export async function initDatabase(): Promise<void> {
|
|
|
697
718
|
if (settings && settings.yoloMode === undefined) {
|
|
698
719
|
await db.settings.update(1, { yoloMode: false });
|
|
699
720
|
}
|
|
721
|
+
if (settings && settings.pureMode === undefined) {
|
|
722
|
+
await db.settings.update(1, { pureMode: false });
|
|
723
|
+
}
|
|
700
724
|
if (settings && (settings.language !== null || settings.themeMode !== 'system')) {
|
|
701
725
|
await db.settings.update(1, { language: null, themeMode: 'system' });
|
|
702
726
|
}
|
|
@@ -1,7 +1,91 @@
|
|
|
1
1
|
{
|
|
2
|
-
"latest": "0.1.
|
|
2
|
+
"latest": "0.1.21",
|
|
3
3
|
"repo": "https://github.com/Rannichan/Tavern-Harness",
|
|
4
4
|
"entries": [
|
|
5
|
+
{
|
|
6
|
+
"version": "0.1.21",
|
|
7
|
+
"date": "2026-10-26",
|
|
8
|
+
"breaking": false,
|
|
9
|
+
"compare": "v0.1.20...v0.1.21",
|
|
10
|
+
"groups": {
|
|
11
|
+
"zh-CN": [
|
|
12
|
+
{
|
|
13
|
+
"kind": "changed",
|
|
14
|
+
"items": [
|
|
15
|
+
"移动端会话头部的操作按钮收进「更多操作」菜单(工作区文件、编辑对话、重置对话、导出游玩记录、删除对话),头部不再拥挤;主题切换按钮仍然直接显示",
|
|
16
|
+
"移动端会话标题可用的横向空间明显变大,长标题能显示更多内容",
|
|
17
|
+
"会话列表中的标题和副标题过长时改为末尾渐隐,不再用省略号生硬截断",
|
|
18
|
+
"会话头部的上下文用量标签字号略微收小,和标题搭配更协调"
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"en": [
|
|
23
|
+
{
|
|
24
|
+
"kind": "changed",
|
|
25
|
+
"items": [
|
|
26
|
+
"On mobile, session header actions now collapse into a \"More actions\" menu (workspace files, edit, reset, export gameplay, delete), so the header is no longer cramped; the theme toggle stays one tap away",
|
|
27
|
+
"Mobile session titles get noticeably more horizontal room, so long titles reveal more of their text",
|
|
28
|
+
"Long session list titles and subtitles now fade out at the end instead of being cut off with an ellipsis",
|
|
29
|
+
"The context usage badge in the session header uses a slightly smaller font so it sits better next to the title"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"version": "0.1.20",
|
|
37
|
+
"date": "2026-10-26",
|
|
38
|
+
"breaking": false,
|
|
39
|
+
"compare": "v0.1.19...v0.1.20",
|
|
40
|
+
"groups": {
|
|
41
|
+
"zh-CN": [
|
|
42
|
+
{
|
|
43
|
+
"kind": "added",
|
|
44
|
+
"items": [
|
|
45
|
+
"新增「纯净模式」:只显示消息正文,隐藏思考过程和工具调用;没有正文的消息也会一并隐藏,实际的思考和工具执行不受影响(在「设置 → 工具执行」中开启)",
|
|
46
|
+
"HTML 预览可以在新页面打开,方便全屏查看和调试交互式页面"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"kind": "changed",
|
|
51
|
+
"items": [
|
|
52
|
+
"长会话加载明显更快:消息列表不再携带每次请求的原始日志,改为打开「查看原始日志」时按需读取;切回已加载过的会话时直接复用内存中的内容,不再重复拉取",
|
|
53
|
+
"长会话下持久化服务更稳定:响应启用 brotli / gzip 压缩,服务繁忙时刷新页面不再误报会话加载失败",
|
|
54
|
+
"HTML 预览的缩放按钮移到底部说明栏,不再占用顶部工具栏,预览区域更宽敞"
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"kind": "fixed",
|
|
59
|
+
"items": [
|
|
60
|
+
"会话标题过长时不再挤压右侧按钮:标题限制最大宽度并在末尾渐隐"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
"en": [
|
|
65
|
+
{
|
|
66
|
+
"kind": "added",
|
|
67
|
+
"items": [
|
|
68
|
+
"New \"Pure mode\": shows only message bodies, hiding reasoning and tool calls; messages without body text are hidden too, while actual reasoning and tool execution keep running (enable it in Settings → Tool execution)",
|
|
69
|
+
"HTML previews can be opened in a new page for full-screen viewing and debugging interactive pages"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"kind": "changed",
|
|
74
|
+
"items": [
|
|
75
|
+
"Long conversations load noticeably faster: message lists no longer carry per-request raw logs — they're fetched only when you open \"View raw log\" — and switching back to a session you've already loaded reuses the in-memory copy instead of fetching it again",
|
|
76
|
+
"The persistence service holds up better with long conversations: responses are brotli / gzip compressed, and a busy service no longer triggers false \"failed to load session\" errors after a page refresh",
|
|
77
|
+
"HTML preview zoom buttons moved to the bottom note bar, freeing the top toolbar and giving the preview more room"
|
|
78
|
+
]
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"kind": "fixed",
|
|
82
|
+
"items": [
|
|
83
|
+
"Long session titles no longer squeeze the header buttons: the title is capped in width and fades out at the end"
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
},
|
|
5
89
|
{
|
|
6
90
|
"version": "0.1.19",
|
|
7
91
|
"date": "2026-10-25",
|
package/src/store/store.ts
CHANGED
|
@@ -478,8 +478,16 @@ export const useStore = create<AppState>((set, get) => ({
|
|
|
478
478
|
return { activeSessionId: id, activeView: 'chat', activitySessionIds };
|
|
479
479
|
});
|
|
480
480
|
if (id != null) {
|
|
481
|
-
get()
|
|
482
|
-
|
|
481
|
+
const state = get();
|
|
482
|
+
// 已加载的会话切回时复用内存快照,避免重复传输、解析并覆盖整段历史。
|
|
483
|
+
// 生成中的会话仍强制刷新,以合并其他写入路径持久化的最新字段。
|
|
484
|
+
if (state.messages[id] === undefined || state.streaming.sessionId === id) {
|
|
485
|
+
void state.loadMessages(id).catch((error) => {
|
|
486
|
+
console.error(`Failed to load session ${id}`, error);
|
|
487
|
+
state.addToast(translate('toast.genFailed', { msg: error instanceof Error ? error.message : String(error) }), 'error');
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
void state.refreshLiveQueue(id).catch((error) => console.error(`Failed to load queue for session ${id}`, error));
|
|
483
491
|
}
|
|
484
492
|
},
|
|
485
493
|
|
package/src/theme/global.css
CHANGED
|
@@ -573,9 +573,10 @@ body.resizing .sidebar-resizer {
|
|
|
573
573
|
}
|
|
574
574
|
.session-item .stitle-text {
|
|
575
575
|
overflow: hidden;
|
|
576
|
-
text-overflow: ellipsis;
|
|
577
576
|
white-space: nowrap;
|
|
578
577
|
min-width: 0;
|
|
578
|
+
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
|
|
579
|
+
mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
|
|
579
580
|
}
|
|
580
581
|
.session-item .spinned {
|
|
581
582
|
display: inline-flex;
|
|
@@ -615,7 +616,8 @@ body.resizing .sidebar-resizer {
|
|
|
615
616
|
color: var(--text-faint);
|
|
616
617
|
white-space: nowrap;
|
|
617
618
|
overflow: hidden;
|
|
618
|
-
|
|
619
|
+
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
|
|
620
|
+
mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
|
|
619
621
|
}
|
|
620
622
|
/* ---------- 会话区域(头部 + 滚动列表) ---------- */
|
|
621
623
|
.session-section {
|
|
@@ -1045,12 +1047,15 @@ body.resizing .sidebar-resizer {
|
|
|
1045
1047
|
min-width: 0;
|
|
1046
1048
|
}
|
|
1047
1049
|
.chat-header .ttitle {
|
|
1050
|
+
position: relative;
|
|
1051
|
+
max-width: min(36vw, 420px);
|
|
1052
|
+
overflow: hidden;
|
|
1048
1053
|
font-family: var(--font-serif);
|
|
1049
1054
|
font-weight: 700;
|
|
1050
1055
|
font-size: 17px;
|
|
1051
1056
|
white-space: nowrap;
|
|
1052
|
-
|
|
1053
|
-
|
|
1057
|
+
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%);
|
|
1058
|
+
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%);
|
|
1054
1059
|
}
|
|
1055
1060
|
.chat-header .tsub {
|
|
1056
1061
|
font-size: 12px;
|
|
@@ -1069,6 +1074,7 @@ body.resizing .sidebar-resizer {
|
|
|
1069
1074
|
}
|
|
1070
1075
|
.chat-header .tsub-mode {
|
|
1071
1076
|
flex-shrink: 0;
|
|
1077
|
+
font-size: 12px;
|
|
1072
1078
|
font-weight: 700;
|
|
1073
1079
|
color: var(--primary);
|
|
1074
1080
|
}
|
|
@@ -1082,6 +1088,7 @@ body.resizing .sidebar-resizer {
|
|
|
1082
1088
|
/* ---------- 聊天头部右侧动作按钮 ---------- */
|
|
1083
1089
|
.chat-actions {
|
|
1084
1090
|
display: flex;
|
|
1091
|
+
flex: 0 0 auto;
|
|
1085
1092
|
align-items: center;
|
|
1086
1093
|
gap: 4px;
|
|
1087
1094
|
}
|
|
@@ -1096,6 +1103,7 @@ body.resizing .sidebar-resizer {
|
|
|
1096
1103
|
background: transparent;
|
|
1097
1104
|
color: var(--text-dim);
|
|
1098
1105
|
transition: background 0.15s ease, color 0.15s ease;
|
|
1106
|
+
flex-shrink: 0;
|
|
1099
1107
|
cursor: pointer;
|
|
1100
1108
|
}
|
|
1101
1109
|
.chat-actions .icon-tooltip:hover {
|
|
@@ -1106,6 +1114,10 @@ body.resizing .sidebar-resizer {
|
|
|
1106
1114
|
color: var(--danger);
|
|
1107
1115
|
background: var(--danger-soft);
|
|
1108
1116
|
}
|
|
1117
|
+
.chat-actions .chat-actions-more,
|
|
1118
|
+
.chat-actions-menu {
|
|
1119
|
+
display: none;
|
|
1120
|
+
}
|
|
1109
1121
|
|
|
1110
1122
|
/* ---------- 卡片/仪表盘 ---------- */
|
|
1111
1123
|
.dash-scroll {
|
|
@@ -1794,6 +1806,7 @@ hr.sep {
|
|
|
1794
1806
|
}
|
|
1795
1807
|
|
|
1796
1808
|
.chat-header .ttitle {
|
|
1809
|
+
max-width: min(52vw, 260px);
|
|
1797
1810
|
font-size: 15px;
|
|
1798
1811
|
}
|
|
1799
1812
|
|
|
@@ -1802,28 +1815,67 @@ hr.sep {
|
|
|
1802
1815
|
}
|
|
1803
1816
|
|
|
1804
1817
|
.chat-actions {
|
|
1805
|
-
|
|
1806
|
-
flex: 0
|
|
1807
|
-
min-width: 0;
|
|
1808
|
-
justify-content: flex-end;
|
|
1818
|
+
position: relative;
|
|
1819
|
+
flex: 0 0 auto;
|
|
1809
1820
|
gap: 0;
|
|
1810
|
-
max-width: none;
|
|
1811
|
-
overflow-x: auto;
|
|
1812
|
-
overscroll-behavior-x: contain;
|
|
1813
|
-
scrollbar-width: none;
|
|
1814
|
-
touch-action: pan-x;
|
|
1815
1821
|
}
|
|
1816
1822
|
|
|
1817
|
-
.chat-actions
|
|
1823
|
+
.chat-actions .icon-tooltip:not(.chat-actions-more, .chat-theme-toggle) {
|
|
1818
1824
|
display: none;
|
|
1819
1825
|
}
|
|
1820
1826
|
|
|
1827
|
+
.chat-actions .chat-actions-more {
|
|
1828
|
+
display: inline-flex;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1821
1831
|
.chat-actions .icon-tooltip {
|
|
1822
1832
|
width: 34px;
|
|
1823
1833
|
height: 34px;
|
|
1824
1834
|
flex-shrink: 0;
|
|
1825
1835
|
}
|
|
1826
1836
|
|
|
1837
|
+
.chat-actions-menu {
|
|
1838
|
+
position: absolute;
|
|
1839
|
+
top: calc(100% + 6px);
|
|
1840
|
+
right: 0;
|
|
1841
|
+
z-index: 60;
|
|
1842
|
+
display: flex;
|
|
1843
|
+
flex-direction: column;
|
|
1844
|
+
min-width: 164px;
|
|
1845
|
+
padding: 6px;
|
|
1846
|
+
background: var(--surface);
|
|
1847
|
+
border: 1px solid var(--border);
|
|
1848
|
+
border-radius: 12px;
|
|
1849
|
+
box-shadow: var(--shadow-lg);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
.chat-actions-menu button {
|
|
1853
|
+
display: flex;
|
|
1854
|
+
align-items: center;
|
|
1855
|
+
gap: 9px;
|
|
1856
|
+
width: 100%;
|
|
1857
|
+
padding: 8px 10px;
|
|
1858
|
+
border: none;
|
|
1859
|
+
border-radius: 8px;
|
|
1860
|
+
background: none;
|
|
1861
|
+
color: var(--text);
|
|
1862
|
+
font-size: 13px;
|
|
1863
|
+
font-weight: 600;
|
|
1864
|
+
text-align: left;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
.chat-actions-menu button:hover {
|
|
1868
|
+
background: var(--surface-2);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
.chat-actions-menu button.danger {
|
|
1872
|
+
color: var(--danger);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
.chat-actions-menu button.danger:hover {
|
|
1876
|
+
background: var(--danger-soft);
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1827
1879
|
.chat-scroll {
|
|
1828
1880
|
padding-top: 14px;
|
|
1829
1881
|
}
|
package/src/theme/views.css
CHANGED
|
@@ -1614,11 +1614,38 @@
|
|
|
1614
1614
|
opacity: 1;
|
|
1615
1615
|
}
|
|
1616
1616
|
.display-html-note {
|
|
1617
|
-
|
|
1617
|
+
min-height: 29px;
|
|
1618
|
+
box-sizing: border-box;
|
|
1619
|
+
padding: 2px 14px;
|
|
1620
|
+
display: flex;
|
|
1621
|
+
align-items: center;
|
|
1622
|
+
justify-content: space-between;
|
|
1623
|
+
gap: 10px;
|
|
1618
1624
|
font-size: 11.5px;
|
|
1619
1625
|
color: var(--text-faint);
|
|
1620
1626
|
border-top: 1px solid var(--border-soft);
|
|
1621
1627
|
}
|
|
1628
|
+
.display-html-note > span:first-child {
|
|
1629
|
+
min-width: 0;
|
|
1630
|
+
overflow: hidden;
|
|
1631
|
+
text-overflow: ellipsis;
|
|
1632
|
+
white-space: nowrap;
|
|
1633
|
+
}
|
|
1634
|
+
.display-html-zoom-group {
|
|
1635
|
+
display: flex;
|
|
1636
|
+
flex-shrink: 0;
|
|
1637
|
+
align-items: center;
|
|
1638
|
+
gap: 2px;
|
|
1639
|
+
}
|
|
1640
|
+
.display-html-zoom-group .icon-btn {
|
|
1641
|
+
width: 24px;
|
|
1642
|
+
height: 24px;
|
|
1643
|
+
border-radius: 6px;
|
|
1644
|
+
}
|
|
1645
|
+
.display-html-zoom-group .icon-btn:nth-child(2) {
|
|
1646
|
+
width: 42px;
|
|
1647
|
+
font-size: 11px;
|
|
1648
|
+
}
|
|
1622
1649
|
|
|
1623
1650
|
/* 文本 / 代码 / 纯文本 */
|
|
1624
1651
|
.display-md-scroll,
|
package/src/types/models.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface AppSettings {
|
|
|
55
55
|
isToolCallsEnabled: boolean;
|
|
56
56
|
/** 自动批准工具执行确认(YOLO);默认关闭。 */
|
|
57
57
|
yoloMode: boolean;
|
|
58
|
+
/** 隐藏对话中的思考与工具调用,仅保留消息正文;默认关闭。 */
|
|
59
|
+
pureMode: boolean;
|
|
58
60
|
statsResetTime: number | null;
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -218,6 +220,8 @@ export interface ChatMessage {
|
|
|
218
220
|
displayRef: string | null;
|
|
219
221
|
rawRequestBody: string | null;
|
|
220
222
|
rawResponseBody: string | null;
|
|
223
|
+
/** 列表加载时仅标记原始日志可用;正文在用户打开日志时按需读取。 */
|
|
224
|
+
rawLogAvailable?: boolean;
|
|
221
225
|
}
|
|
222
226
|
|
|
223
227
|
/** 保留原始消息的上下文压缩记录,仅用于构建后续 LLM 请求。 */
|
package/vite.config.ts
CHANGED
|
@@ -179,8 +179,10 @@ function persistenceProxyPlugin(): Plugin {
|
|
|
179
179
|
const ensurePersistence = async (): Promise<number | null> => {
|
|
180
180
|
if (starting) return starting;
|
|
181
181
|
starting = (async () => {
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
// 已经由本 Vite 实例拉起的子进程无需在每个请求前探活:服务忙于压缩或读取长会话时,
|
|
183
|
+
// 400 ms 健康检查会误判它已离线,导致刷新后会话请求收到伪 503。
|
|
184
|
+
if (persistenceChild && childPort != null && persistenceChild.exitCode == null) return childPort;
|
|
185
|
+
if (persistenceChild) stopPersistence();
|
|
184
186
|
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + 20; port++) {
|
|
185
187
|
if (await portOpen(port)) continue;
|
|
186
188
|
childPort = port;
|
|
@@ -240,6 +242,11 @@ function persistenceProxyPlugin(): Plugin {
|
|
|
240
242
|
proxyRes.pipe(res);
|
|
241
243
|
});
|
|
242
244
|
proxyReq.once('error', (error: Error) => {
|
|
245
|
+
// 连接错误才说明端口失效;下一个请求会清理并重新拉起服务。
|
|
246
|
+
if (childPort === port) {
|
|
247
|
+
persistenceChild = null;
|
|
248
|
+
childPort = null;
|
|
249
|
+
}
|
|
243
250
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
244
251
|
res.end(JSON.stringify({ ok: false, message: `Persistence proxy error: ${error.message}` }));
|
|
245
252
|
});
|