tavern-harness 0.1.18 → 0.1.20
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 +62 -0
- package/package.json +1 -1
- package/persistence-server.mjs +50 -8
- package/src/App.tsx +49 -19
- package/src/components/CharactersView.tsx +3 -0
- package/src/components/ChatView.tsx +54 -38
- package/src/components/FileDisplayModal.tsx +41 -12
- package/src/components/GameplayDialogs.tsx +3 -0
- package/src/components/SettingsView.tsx +16 -0
- package/src/components/shared.tsx +1 -0
- package/src/core/achievements.ts +4 -0
- package/src/core/i18n.ts +33 -0
- package/src/core/tools/toolExecutor.ts +4 -0
- package/src/db/database.ts +53 -29
- package/src/generated/changelog.json +93 -1
- package/src/store/store.ts +10 -2
- package/src/theme/global.css +9 -12
- 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,66 @@
|
|
|
3
3
|
> 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
|
|
4
4
|
> 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
|
|
5
5
|
|
|
6
|
+
## [0.1.20] - 2026-10-26
|
|
7
|
+
|
|
8
|
+
### 中文
|
|
9
|
+
|
|
10
|
+
#### 新增
|
|
11
|
+
|
|
12
|
+
- 新增「纯净模式」:只显示消息正文,隐藏思考过程和工具调用;没有正文的消息也会一并隐藏,实际的思考和工具执行不受影响(在「设置 → 工具执行」中开启)
|
|
13
|
+
- HTML 预览可以在新页面打开,方便全屏查看和调试交互式页面
|
|
14
|
+
|
|
15
|
+
#### 改进
|
|
16
|
+
|
|
17
|
+
- 长会话加载明显更快:消息列表不再携带每次请求的原始日志,改为打开「查看原始日志」时按需读取;切回已加载过的会话时直接复用内存中的内容,不再重复拉取
|
|
18
|
+
- 长会话下持久化服务更稳定:响应启用 brotli / gzip 压缩,服务繁忙时刷新页面不再误报会话加载失败
|
|
19
|
+
- HTML 预览的缩放按钮移到底部说明栏,不再占用顶部工具栏,预览区域更宽敞
|
|
20
|
+
|
|
21
|
+
#### 修复
|
|
22
|
+
|
|
23
|
+
- 会话标题过长时不再挤压右侧按钮:标题限制最大宽度并在末尾渐隐
|
|
24
|
+
|
|
25
|
+
### English
|
|
26
|
+
|
|
27
|
+
#### Added
|
|
28
|
+
|
|
29
|
+
- 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)
|
|
30
|
+
- HTML previews can be opened in a new page for full-screen viewing and debugging interactive pages
|
|
31
|
+
|
|
32
|
+
#### Changed
|
|
33
|
+
|
|
34
|
+
- 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
|
|
35
|
+
- 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
|
|
36
|
+
- HTML preview zoom buttons moved to the bottom note bar, freeing the top toolbar and giving the preview more room
|
|
37
|
+
|
|
38
|
+
#### Fixed
|
|
39
|
+
|
|
40
|
+
- Long session titles no longer squeeze the header buttons: the title is capped in width and fades out at the end
|
|
41
|
+
|
|
42
|
+
## [0.1.19] - 2026-10-25
|
|
43
|
+
|
|
44
|
+
### 中文
|
|
45
|
+
|
|
46
|
+
#### 新增
|
|
47
|
+
|
|
48
|
+
- 新增四项「首次行动」成就:首次导出游戏(独乐乐不如众乐乐)、首次导入游戏(发现者)、首次创建角色卡或世界书(创造者)、首次创建自定义技能(探索者)
|
|
49
|
+
|
|
50
|
+
#### 改进
|
|
51
|
+
|
|
52
|
+
- 聊天标题栏更简洁:副标题只保留上下文用量,不再重复显示会话模式
|
|
53
|
+
- 手机端标题栏按钮区支持横向滑动,按钮较多时不再被挤出屏幕或误触页面
|
|
54
|
+
|
|
55
|
+
### English
|
|
56
|
+
|
|
57
|
+
#### Added
|
|
58
|
+
|
|
59
|
+
- Four new first-action achievements: export a game (Sharing Is Caring), import a game (Discoverer), create a character card or Lorebook (Creator), and create a custom skill (Explorer)
|
|
60
|
+
|
|
61
|
+
#### Changed
|
|
62
|
+
|
|
63
|
+
- The chat header is cleaner: the subtitle now shows only context usage instead of repeating the session mode
|
|
64
|
+
- On mobile, the header action row scrolls horizontally, so buttons are no longer pushed off screen or trigger page gestures
|
|
65
|
+
|
|
6
66
|
## [0.1.18] - 2026-10-25
|
|
7
67
|
|
|
8
68
|
### 中文
|
|
@@ -528,6 +588,8 @@
|
|
|
528
588
|
- Vite + React + Zustand + Dexie stack with browser-side local persistence
|
|
529
589
|
|
|
530
590
|
<!-- 链接定义 -->
|
|
591
|
+
[0.1.20]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.19...v0.1.20
|
|
592
|
+
[0.1.19]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.18...v0.1.19
|
|
531
593
|
[0.1.18]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.17...v0.1.18
|
|
532
594
|
[0.1.17]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.16...v0.1.17
|
|
533
595
|
[0.1.16]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.15...v0.1.16
|
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} />
|
|
@@ -293,7 +328,6 @@ function SessionHeader({
|
|
|
293
328
|
const [exportOpen, setExportOpen] = useState(false);
|
|
294
329
|
const [showWorkspace, setShowWorkspace] = useState(false);
|
|
295
330
|
|
|
296
|
-
const modeLabel = session.mode === 'STANDARD' ? t('header.modeStandard') : session.mode === 'NPC' ? t('header.modeNpc') : t('header.modeGroup');
|
|
297
331
|
const messages = useStore((s) => s.messages[session.id!] ?? []);
|
|
298
332
|
const contextTokens = useMemo(() => {
|
|
299
333
|
const assistantMessages = messages.filter((message) => message.role === 'assistant' && message.totalTokens > 0);
|
|
@@ -321,15 +355,11 @@ function SessionHeader({
|
|
|
321
355
|
<SessionVisual mode={session.mode} npcName={npcRef?.name ?? undefined} hue={npcRef?.avatarColorOrdinal ?? 0} imageUrl={npcRef?.avatarDataUrl} members={session.mode === 'GROUP' ? groupMemberAvatars : undefined} size="lg" />
|
|
322
356
|
<div className="tinfo">
|
|
323
357
|
<div className="ttitle">{session.title}</div>
|
|
324
|
-
|
|
325
|
-
<
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
<span className="tsub-mode">{t('nav.contextUsage', { tokens: formatContextTokens(contextTokens) })}</span>
|
|
330
|
-
</span>
|
|
331
|
-
)}
|
|
332
|
-
</div>
|
|
358
|
+
{contextTokens != null && (
|
|
359
|
+
<div className="tsub">
|
|
360
|
+
<span className="tsub-mode">{t('nav.contextUsage', { tokens: formatContextTokens(contextTokens) })}</span>
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
333
363
|
</div>
|
|
334
364
|
<div className="chat-actions">
|
|
335
365
|
<button className="btn-ghost icon-tooltip" title={t(usesPublicWorkspace ? 'header.workspaceRootHint' : 'header.workspaceHint')} onClick={() => setShowWorkspace(true)}>
|
|
@@ -29,6 +29,7 @@ import { textPreview } from '../core/textPreview';
|
|
|
29
29
|
import { AVATAR_STORAGE_SIZE, shrinkAvatarDataUrl } from '../core/avatarThumbnail';
|
|
30
30
|
import { ALL_BUILTIN_TOOL_NAMES } from '../core/toolDefinitions';
|
|
31
31
|
import { useT } from '../core/i18n';
|
|
32
|
+
import { unlockAchievement } from '../core/achievements';
|
|
32
33
|
|
|
33
34
|
const CharacterBookEditor = lazy(() => import('./CharacterBookEditor'));
|
|
34
35
|
|
|
@@ -297,6 +298,7 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
|
|
|
297
298
|
...npc,
|
|
298
299
|
...npcData,
|
|
299
300
|
});
|
|
301
|
+
await unlockAchievement('creator');
|
|
300
302
|
addToast(t('toast.charCreated', { name }));
|
|
301
303
|
} else {
|
|
302
304
|
await db.npcs.update(npc.id!, npcData);
|
|
@@ -517,6 +519,7 @@ function WorldBookList({ books, onChanged }: {
|
|
|
517
519
|
addToast(t('toast.wbUpdated'));
|
|
518
520
|
} else {
|
|
519
521
|
await db.worldBooks.add({ ...data, imageUri: null, createdAt: Date.now() });
|
|
522
|
+
await unlockAchievement('creator');
|
|
520
523
|
addToast(t('toast.wbCreated'));
|
|
521
524
|
}
|
|
522
525
|
setEditing(null);
|
|
@@ -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')}
|
|
@@ -3,6 +3,7 @@ import { useStore } from '../store/store';
|
|
|
3
3
|
import { Icon, Modal } from './shared';
|
|
4
4
|
import { useT } from '../core/i18n';
|
|
5
5
|
import { exportGameplay, importGameplay } from '../core/gameplay';
|
|
6
|
+
import { unlockAchievement } from '../core/achievements';
|
|
6
7
|
|
|
7
8
|
// ============================================================
|
|
8
9
|
// 游戏导出 / 导入
|
|
@@ -35,6 +36,7 @@ export function GameplayExportModal({
|
|
|
35
36
|
if (result === 'canceled') {
|
|
36
37
|
addToast(t('toast.exportCanceled'));
|
|
37
38
|
} else {
|
|
39
|
+
await unlockAchievement('game-exporter');
|
|
38
40
|
addToast(t('toast.gameplayExported'));
|
|
39
41
|
}
|
|
40
42
|
onClose();
|
|
@@ -125,6 +127,7 @@ export function GameplayImportModal({
|
|
|
125
127
|
setBusy(true);
|
|
126
128
|
try {
|
|
127
129
|
const result = await importGameplay(pendingData.current);
|
|
130
|
+
await unlockAchievement('game-importer');
|
|
128
131
|
pendingData.current = null;
|
|
129
132
|
setSelectedName(null);
|
|
130
133
|
addToast(
|
|
@@ -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/achievements.ts
CHANGED
|
@@ -22,6 +22,10 @@ export const ACHIEVEMENTS: AchievementDef[] = [
|
|
|
22
22
|
{ id: 'night-owl', name: '午夜不归人', icon: '🌙', threshold: 1_000_000, description: '累计使用 1,000,000 Token' },
|
|
23
23
|
{ id: 'legend', name: '酒馆传奇', icon: '🏆', threshold: 100_000_000, description: '累计使用 100,000,000 Token' },
|
|
24
24
|
{ id: 'th-shareholder', name: 'TH股东', icon: '📈', iconUrl: '/tavern-harness-logo.png', description: '连续点击首页图标 5 次' },
|
|
25
|
+
{ id: 'game-exporter', name: '独乐乐不如众乐乐', icon: '📤', description: '首次导出游戏' },
|
|
26
|
+
{ id: 'game-importer', name: '发现者', icon: '📥', description: '首次导入游戏' },
|
|
27
|
+
{ id: 'creator', name: '创造者', icon: '🎨', description: '首次创建角色卡或世界书' },
|
|
28
|
+
{ id: 'explorer', name: '探索者', icon: '🧩', description: '首次创建自定义技能' },
|
|
25
29
|
];
|
|
26
30
|
|
|
27
31
|
/** 生涯总 token(输入 + 输出) */
|
package/src/core/i18n.ts
CHANGED
|
@@ -195,6 +195,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
195
195
|
'settings.title': '设置',
|
|
196
196
|
'settings.sub': '模型 API 配置、超参数与主题',
|
|
197
197
|
'settings.toolExecution': '🛠️ 工具执行',
|
|
198
|
+
'settings.pureMode': '纯净模式',
|
|
199
|
+
'settings.pureModeDesc': '仅影响前端展示:隐藏对话中的思考和工具调用;没有正文的消息会一并隐藏,不影响实际思考和工具调用。',
|
|
198
200
|
'settings.yoloMode': 'YOLO 模式',
|
|
199
201
|
'settings.yoloModeDesc': '自动批准工具修改和 Shell 执行,无需逐次确认。仅在信任角色和技能时开启。',
|
|
200
202
|
'settings.genParams': '🎛 生成参数',
|
|
@@ -384,6 +386,14 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
384
386
|
'ach.legend.desc': '累计使用 100,000,000 Token',
|
|
385
387
|
'ach.thShareholder.name': 'TH股东',
|
|
386
388
|
'ach.thShareholder.desc': '5 秒内连续点击首页图标 5 次',
|
|
389
|
+
'ach.gameExporter.name': '独乐乐不如众乐乐',
|
|
390
|
+
'ach.gameExporter.desc': '首次导出游戏',
|
|
391
|
+
'ach.gameImporter.name': '发现者',
|
|
392
|
+
'ach.gameImporter.desc': '首次导入游戏',
|
|
393
|
+
'ach.creator.name': '创造者',
|
|
394
|
+
'ach.creator.desc': '首次创建角色卡或世界书',
|
|
395
|
+
'ach.explorer.name': '探索者',
|
|
396
|
+
'ach.explorer.desc': '首次创建自定义技能',
|
|
387
397
|
|
|
388
398
|
// ---- 成就解锁弹窗 ----
|
|
389
399
|
'achModal.title': '🎉 成就解锁!',
|
|
@@ -525,6 +535,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
525
535
|
'display.exitPictureInPicture': '退出画中画',
|
|
526
536
|
'display.resizePictureInPicture': '调整画中画大小',
|
|
527
537
|
'display.htmlSandboxNote': 'HTML 应用在受限沙箱中运行,并可加载同目录资源',
|
|
538
|
+
'display.openHtmlInNewPage': '在新页面打开 HTML',
|
|
528
539
|
|
|
529
540
|
// ---- 系统提示词 ----
|
|
530
541
|
'prompt.worldBook': '=== 世界书 ===',
|
|
@@ -705,6 +716,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
705
716
|
'settings.title': '設定',
|
|
706
717
|
'settings.sub': '模型 API 設定、超參數與主題',
|
|
707
718
|
'settings.toolExecution': '🛠️ 工具執行',
|
|
719
|
+
'settings.pureMode': '純淨模式',
|
|
720
|
+
'settings.pureModeDesc': '僅影響前端顯示:隱藏對話中的思考和工具呼叫;沒有正文的訊息會一併隱藏,不影響實際思考和工具呼叫。',
|
|
708
721
|
'settings.yoloMode': 'YOLO 模式',
|
|
709
722
|
'settings.yoloModeDesc': '自動批准工具修改和 Shell 執行,無需逐次確認。僅在信任角色和技能時開啟。',
|
|
710
723
|
'settings.genParams': '🎛 產生參數',
|
|
@@ -891,6 +904,14 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
891
904
|
'ach.legend.desc': '累計使用 100,000,000 Token',
|
|
892
905
|
'ach.thShareholder.name': 'TH股東',
|
|
893
906
|
'ach.thShareholder.desc': '5 秒內連續點擊首頁圖示 5 次',
|
|
907
|
+
'ach.gameExporter.name': '獨樂樂不如眾樂樂',
|
|
908
|
+
'ach.gameExporter.desc': '首次匯出遊戲',
|
|
909
|
+
'ach.gameImporter.name': '發現者',
|
|
910
|
+
'ach.gameImporter.desc': '首次匯入遊戲',
|
|
911
|
+
'ach.creator.name': '創造者',
|
|
912
|
+
'ach.creator.desc': '首次建立角色卡或世界書',
|
|
913
|
+
'ach.explorer.name': '探索者',
|
|
914
|
+
'ach.explorer.desc': '首次建立自訂技能',
|
|
894
915
|
|
|
895
916
|
'achModal.title': '🎉 成就解鎖!',
|
|
896
917
|
'achModal.total': '生涯已累計 {n} Tokens',
|
|
@@ -1027,6 +1048,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1027
1048
|
'display.exitPictureInPicture': '退出畫中畫',
|
|
1028
1049
|
'display.resizePictureInPicture': '調整畫中畫大小',
|
|
1029
1050
|
'display.htmlSandboxNote': 'HTML 應用程式在受限沙箱中執行,並可載入同目錄資源',
|
|
1051
|
+
'display.openHtmlInNewPage': '在新頁面開啟 HTML',
|
|
1030
1052
|
|
|
1031
1053
|
'prompt.worldBook': '=== 世界書 ===',
|
|
1032
1054
|
'prompt.userInfo': '=== USER INFO (用戶扮演的角色) ===',
|
|
@@ -1206,6 +1228,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1206
1228
|
'settings.title': 'Settings',
|
|
1207
1229
|
'settings.sub': 'Model API config, hyperparameters and theme',
|
|
1208
1230
|
'settings.toolExecution': '🛠️ Tool execution',
|
|
1231
|
+
'settings.pureMode': 'Pure mode',
|
|
1232
|
+
'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.',
|
|
1209
1233
|
'settings.yoloMode': 'YOLO mode',
|
|
1210
1234
|
'settings.yoloModeDesc': 'Automatically approve tool changes and Shell commands without asking each time. Only enable for trusted characters and skills.',
|
|
1211
1235
|
'settings.genParams': '🎛 Generation parameters',
|
|
@@ -1392,6 +1416,14 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1392
1416
|
'ach.legend.desc': 'Cumulatively used 100,000,000 tokens',
|
|
1393
1417
|
'ach.thShareholder.name': 'TH Shareholder',
|
|
1394
1418
|
'ach.thShareholder.desc': 'Clicked the home icon 5 times within 5 seconds',
|
|
1419
|
+
'ach.gameExporter.name': 'Sharing Is Caring',
|
|
1420
|
+
'ach.gameExporter.desc': 'Exported a game for the first time',
|
|
1421
|
+
'ach.gameImporter.name': 'Discoverer',
|
|
1422
|
+
'ach.gameImporter.desc': 'Imported a game for the first time',
|
|
1423
|
+
'ach.creator.name': 'Creator',
|
|
1424
|
+
'ach.creator.desc': 'Created a character card or Lorebook for the first time',
|
|
1425
|
+
'ach.explorer.name': 'Explorer',
|
|
1426
|
+
'ach.explorer.desc': 'Created a custom skill for the first time',
|
|
1395
1427
|
|
|
1396
1428
|
'achModal.title': '🎉 Achievement unlocked!',
|
|
1397
1429
|
'achModal.total': 'Lifetime total {n} tokens',
|
|
@@ -1528,6 +1560,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1528
1560
|
'display.exitPictureInPicture': 'Exit picture in picture',
|
|
1529
1561
|
'display.resizePictureInPicture': 'Resize picture in picture',
|
|
1530
1562
|
'display.htmlSandboxNote': 'HTML application runs in a restricted sandbox with same-directory resources available',
|
|
1563
|
+
'display.openHtmlInNewPage': 'Open HTML in a new page',
|
|
1531
1564
|
|
|
1532
1565
|
'prompt.worldBook': '=== LOREBOOK ===',
|
|
1533
1566
|
'prompt.userInfo': '=== USER INFO (role played by the user) ===',
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
} from '../../types/models';
|
|
10
10
|
import { BUILTIN_TOOL_NAMES } from '../toolDefinitions';
|
|
11
11
|
import { translate } from '../i18n';
|
|
12
|
+
import { unlockAchievement } from '../achievements';
|
|
12
13
|
import { rollDice } from './builtinTools';
|
|
13
14
|
import {
|
|
14
15
|
createSessionWorkspaceDir,
|
|
@@ -409,6 +410,7 @@ async function handleCreateSkill(args: Record<string, unknown>): Promise<string>
|
|
|
409
410
|
if (exists) return `ERROR: Skill ${name} already exists`;
|
|
410
411
|
const tool = await createSkillTool(name, String(args.description ?? ''), (args.parameters as Record<string, unknown>) ?? {}, args.execution);
|
|
411
412
|
await db.tools.add(tool);
|
|
413
|
+
await unlockAchievement('explorer');
|
|
412
414
|
return translate('tool.skillCreated', { name });
|
|
413
415
|
}
|
|
414
416
|
|
|
@@ -493,6 +495,7 @@ async function handleCreateCharacter(args: Record<string, unknown>): Promise<str
|
|
|
493
495
|
isBuiltIn: false,
|
|
494
496
|
createdAt: Date.now(),
|
|
495
497
|
});
|
|
498
|
+
await unlockAchievement('creator');
|
|
496
499
|
return translate('tool.characterCreated', { name });
|
|
497
500
|
}
|
|
498
501
|
|
|
@@ -638,6 +641,7 @@ async function handleCreateLorebook(args: Record<string, unknown>): Promise<stri
|
|
|
638
641
|
if (!name || !content) return 'ERROR: name and content are required';
|
|
639
642
|
if (await db.worldBooks.where('name').equals(name).first()) return `ERROR: Lorebook ${name} already exists`;
|
|
640
643
|
await db.worldBooks.add({ name, content: content.slice(0, 10_000), imageUri: null, createdAt: Date.now() });
|
|
644
|
+
await unlockAchievement('creator');
|
|
641
645
|
return translate('tool.lorebookCreated', { name });
|
|
642
646
|
}
|
|
643
647
|
|
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,99 @@
|
|
|
1
1
|
{
|
|
2
|
-
"latest": "0.1.
|
|
2
|
+
"latest": "0.1.20",
|
|
3
3
|
"repo": "https://github.com/Rannichan/Tavern-Harness",
|
|
4
4
|
"entries": [
|
|
5
|
+
{
|
|
6
|
+
"version": "0.1.20",
|
|
7
|
+
"date": "2026-10-26",
|
|
8
|
+
"breaking": false,
|
|
9
|
+
"compare": "v0.1.19...v0.1.20",
|
|
10
|
+
"groups": {
|
|
11
|
+
"zh-CN": [
|
|
12
|
+
{
|
|
13
|
+
"kind": "added",
|
|
14
|
+
"items": [
|
|
15
|
+
"新增「纯净模式」:只显示消息正文,隐藏思考过程和工具调用;没有正文的消息也会一并隐藏,实际的思考和工具执行不受影响(在「设置 → 工具执行」中开启)",
|
|
16
|
+
"HTML 预览可以在新页面打开,方便全屏查看和调试交互式页面"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"kind": "changed",
|
|
21
|
+
"items": [
|
|
22
|
+
"长会话加载明显更快:消息列表不再携带每次请求的原始日志,改为打开「查看原始日志」时按需读取;切回已加载过的会话时直接复用内存中的内容,不再重复拉取",
|
|
23
|
+
"长会话下持久化服务更稳定:响应启用 brotli / gzip 压缩,服务繁忙时刷新页面不再误报会话加载失败",
|
|
24
|
+
"HTML 预览的缩放按钮移到底部说明栏,不再占用顶部工具栏,预览区域更宽敞"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"kind": "fixed",
|
|
29
|
+
"items": [
|
|
30
|
+
"会话标题过长时不再挤压右侧按钮:标题限制最大宽度并在末尾渐隐"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"en": [
|
|
35
|
+
{
|
|
36
|
+
"kind": "added",
|
|
37
|
+
"items": [
|
|
38
|
+
"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)",
|
|
39
|
+
"HTML previews can be opened in a new page for full-screen viewing and debugging interactive pages"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"kind": "changed",
|
|
44
|
+
"items": [
|
|
45
|
+
"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",
|
|
46
|
+
"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",
|
|
47
|
+
"HTML preview zoom buttons moved to the bottom note bar, freeing the top toolbar and giving the preview more room"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"kind": "fixed",
|
|
52
|
+
"items": [
|
|
53
|
+
"Long session titles no longer squeeze the header buttons: the title is capped in width and fades out at the end"
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"version": "0.1.19",
|
|
61
|
+
"date": "2026-10-25",
|
|
62
|
+
"breaking": false,
|
|
63
|
+
"compare": "v0.1.18...v0.1.19",
|
|
64
|
+
"groups": {
|
|
65
|
+
"zh-CN": [
|
|
66
|
+
{
|
|
67
|
+
"kind": "added",
|
|
68
|
+
"items": [
|
|
69
|
+
"新增四项「首次行动」成就:首次导出游戏(独乐乐不如众乐乐)、首次导入游戏(发现者)、首次创建角色卡或世界书(创造者)、首次创建自定义技能(探索者)"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"kind": "changed",
|
|
74
|
+
"items": [
|
|
75
|
+
"聊天标题栏更简洁:副标题只保留上下文用量,不再重复显示会话模式",
|
|
76
|
+
"手机端标题栏按钮区支持横向滑动,按钮较多时不再被挤出屏幕或误触页面"
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
"en": [
|
|
81
|
+
{
|
|
82
|
+
"kind": "added",
|
|
83
|
+
"items": [
|
|
84
|
+
"Four new first-action achievements: export a game (Sharing Is Caring), import a game (Discoverer), create a character card or Lorebook (Creator), and create a custom skill (Explorer)"
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"kind": "changed",
|
|
89
|
+
"items": [
|
|
90
|
+
"The chat header is cleaner: the subtitle now shows only context usage instead of repeating the session mode",
|
|
91
|
+
"On mobile, the header action row scrolls horizontally, so buttons are no longer pushed off screen or trigger page gestures"
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
},
|
|
5
97
|
{
|
|
6
98
|
"version": "0.1.18",
|
|
7
99
|
"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
|
@@ -1045,12 +1045,15 @@ body.resizing .sidebar-resizer {
|
|
|
1045
1045
|
min-width: 0;
|
|
1046
1046
|
}
|
|
1047
1047
|
.chat-header .ttitle {
|
|
1048
|
+
position: relative;
|
|
1049
|
+
max-width: min(36vw, 420px);
|
|
1050
|
+
overflow: hidden;
|
|
1048
1051
|
font-family: var(--font-serif);
|
|
1049
1052
|
font-weight: 700;
|
|
1050
1053
|
font-size: 17px;
|
|
1051
1054
|
white-space: nowrap;
|
|
1052
|
-
|
|
1053
|
-
|
|
1055
|
+
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%);
|
|
1056
|
+
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%);
|
|
1054
1057
|
}
|
|
1055
1058
|
.chat-header .tsub {
|
|
1056
1059
|
font-size: 12px;
|
|
@@ -1082,6 +1085,7 @@ body.resizing .sidebar-resizer {
|
|
|
1082
1085
|
/* ---------- 聊天头部右侧动作按钮 ---------- */
|
|
1083
1086
|
.chat-actions {
|
|
1084
1087
|
display: flex;
|
|
1088
|
+
flex: 0 0 auto;
|
|
1085
1089
|
align-items: center;
|
|
1086
1090
|
gap: 4px;
|
|
1087
1091
|
}
|
|
@@ -1096,6 +1100,7 @@ body.resizing .sidebar-resizer {
|
|
|
1096
1100
|
background: transparent;
|
|
1097
1101
|
color: var(--text-dim);
|
|
1098
1102
|
transition: background 0.15s ease, color 0.15s ease;
|
|
1103
|
+
flex-shrink: 0;
|
|
1099
1104
|
cursor: pointer;
|
|
1100
1105
|
}
|
|
1101
1106
|
.chat-actions .icon-tooltip:hover {
|
|
@@ -1794,6 +1799,7 @@ hr.sep {
|
|
|
1794
1799
|
}
|
|
1795
1800
|
|
|
1796
1801
|
.chat-header .ttitle {
|
|
1802
|
+
max-width: min(28vw, 180px);
|
|
1797
1803
|
font-size: 15px;
|
|
1798
1804
|
}
|
|
1799
1805
|
|
|
@@ -1802,17 +1808,8 @@ hr.sep {
|
|
|
1802
1808
|
}
|
|
1803
1809
|
|
|
1804
1810
|
.chat-actions {
|
|
1805
|
-
|
|
1806
|
-
flex: 0 1 auto;
|
|
1807
|
-
justify-content: flex-end;
|
|
1811
|
+
flex: 0 0 auto;
|
|
1808
1812
|
gap: 0;
|
|
1809
|
-
max-width: none;
|
|
1810
|
-
overflow-x: auto;
|
|
1811
|
-
scrollbar-width: none;
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
.chat-actions::-webkit-scrollbar {
|
|
1815
|
-
display: none;
|
|
1816
1813
|
}
|
|
1817
1814
|
|
|
1818
1815
|
.chat-actions .icon-tooltip {
|
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
|
});
|