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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tavern-harness",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Tavern Harness — 酒馆模式下的本地 AI Agent 助手(MyAgent-Android 的 Web 复刻)",
5
5
  "private": false,
6
6
  "type": "module",
@@ -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
- res.writeHead(status, {
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
- res.end(JSON.stringify(body));
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
- key: row.record_key,
289
- value: JSON.parse(row.value_json),
290
- updatedAt: row.updated_at,
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
- <ChatView
154
- session={activeSession}
155
- messages={sessionMessages}
156
- participants={sessionParticipants}
157
- compressions={contextCompressions[activeSession.id!] ?? []}
158
- streaming={isStreamingSession}
159
- active={activeView === 'chat'}
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
- <div className="tsub">
325
- <span className="tsub-mode">{modeLabel}</span>
326
- {contextTokens != null && (
327
- <span className="tsub-context-group">
328
- <span>·</span>
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={toolResultsByCallId}
358
- suppressedResultIds={displayedToolCallIds}
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
- setRawLog({
1420
- data: msg,
1421
- title: t('chat.rawLogTitle', { id: msg.id ?? '' }),
1422
- filename: `raw-log-${msg.id}.txt`,
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(active.presentation === 'pip' || active.presentation == null);
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 htmlZoomIn = () => setHtmlZoom((z) => Math.min(2, +(z + 0.1).toFixed(1)));
316
- const htmlZoomOut = () => setHtmlZoom((z) => Math.max(0.5, +(z - 0.1).toFixed(1)));
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">{t('display.htmlSandboxNote')}</div>
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" /></>,
@@ -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
 
@@ -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
- const controller = new AbortController();
288
- const timeout = globalThis.setTimeout(() => controller.abort(), PERSISTENCE_REQUEST_TIMEOUT_MS);
289
- let response: Response;
290
- try {
291
- response = await fetch(`/api-v3${path}`, {
292
- method: 'POST',
293
- headers: { 'Content-Type': 'application/json' },
294
- body: JSON.stringify({ ...body, clientId }),
295
- signal: controller.signal,
296
- });
297
- } catch (error) {
298
- if (error instanceof DOMException && error.name === 'AbortError') {
299
- throw new Error(`Persistence request ${path} timed out`);
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
- throw new Error('Unable to reach the persistence service');
302
- } finally {
303
- globalThis.clearTimeout(timeout);
304
- }
305
- let payload: T & { ok?: boolean; message?: string };
306
- try {
307
- payload = await response.json() as T & { ok?: boolean; message?: string };
308
- } catch {
309
- throw new Error(`Persistence service returned an invalid response (${response.status})`);
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
- throw new Error(`Persistence request ${path} failed: ${payload.message || `HTTP ${response.status}`}`);
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
- return payload;
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.18",
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",
@@ -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().loadMessages(id);
482
- get().refreshLiveQueue(id);
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
 
@@ -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
- overflow: hidden;
1053
- text-overflow: ellipsis;
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
- display: flex;
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 {
@@ -1614,11 +1614,38 @@
1614
1614
  opacity: 1;
1615
1615
  }
1616
1616
  .display-html-note {
1617
- padding: 7px 14px;
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,
@@ -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
- if (childPort != null && (await portOpen(childPort)) && (await serviceReady(childPort))) return childPort;
183
- if (persistenceChild) return null;
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
  });