tavern-harness 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,62 @@
3
3
  > 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
4
4
  > 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
5
5
 
6
+ ## [0.1.21] - 2026-10-26
7
+
8
+ ### 中文
9
+
10
+ #### 改进
11
+
12
+ - 移动端会话头部的操作按钮收进「更多操作」菜单(工作区文件、编辑对话、重置对话、导出游玩记录、删除对话),头部不再拥挤;主题切换按钮仍然直接显示
13
+ - 移动端会话标题可用的横向空间明显变大,长标题能显示更多内容
14
+ - 会话列表中的标题和副标题过长时改为末尾渐隐,不再用省略号生硬截断
15
+ - 会话头部的上下文用量标签字号略微收小,和标题搭配更协调
16
+
17
+ ### English
18
+
19
+ #### Changed
20
+
21
+ - On mobile, session header actions now collapse into a "More actions" menu (workspace files, edit, reset, export gameplay, delete), so the header is no longer cramped; the theme toggle stays one tap away
22
+ - Mobile session titles get noticeably more horizontal room, so long titles reveal more of their text
23
+ - Long session list titles and subtitles now fade out at the end instead of being cut off with an ellipsis
24
+ - The context usage badge in the session header uses a slightly smaller font so it sits better next to the title
25
+
26
+ ## [0.1.20] - 2026-10-26
27
+
28
+ ### 中文
29
+
30
+ #### 新增
31
+
32
+ - 新增「纯净模式」:只显示消息正文,隐藏思考过程和工具调用;没有正文的消息也会一并隐藏,实际的思考和工具执行不受影响(在「设置 → 工具执行」中开启)
33
+ - HTML 预览可以在新页面打开,方便全屏查看和调试交互式页面
34
+
35
+ #### 改进
36
+
37
+ - 长会话加载明显更快:消息列表不再携带每次请求的原始日志,改为打开「查看原始日志」时按需读取;切回已加载过的会话时直接复用内存中的内容,不再重复拉取
38
+ - 长会话下持久化服务更稳定:响应启用 brotli / gzip 压缩,服务繁忙时刷新页面不再误报会话加载失败
39
+ - HTML 预览的缩放按钮移到底部说明栏,不再占用顶部工具栏,预览区域更宽敞
40
+
41
+ #### 修复
42
+
43
+ - 会话标题过长时不再挤压右侧按钮:标题限制最大宽度并在末尾渐隐
44
+
45
+ ### English
46
+
47
+ #### Added
48
+
49
+ - New "Pure mode": shows only message bodies, hiding reasoning and tool calls; messages without body text are hidden too, while actual reasoning and tool execution keep running (enable it in Settings → Tool execution)
50
+ - HTML previews can be opened in a new page for full-screen viewing and debugging interactive pages
51
+
52
+ #### Changed
53
+
54
+ - Long conversations load noticeably faster: message lists no longer carry per-request raw logs — they're fetched only when you open "View raw log" — and switching back to a session you've already loaded reuses the in-memory copy instead of fetching it again
55
+ - The persistence service holds up better with long conversations: responses are brotli / gzip compressed, and a busy service no longer triggers false "failed to load session" errors after a page refresh
56
+ - HTML preview zoom buttons moved to the bottom note bar, freeing the top toolbar and giving the preview more room
57
+
58
+ #### Fixed
59
+
60
+ - Long session titles no longer squeeze the header buttons: the title is capped in width and fades out at the end
61
+
6
62
  ## [0.1.19] - 2026-10-25
7
63
 
8
64
  ### 中文
@@ -552,6 +608,8 @@
552
608
  - Vite + React + Zustand + Dexie stack with browser-side local persistence
553
609
 
554
610
  <!-- 链接定义 -->
611
+ [0.1.21]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.20...v0.1.21
612
+ [0.1.20]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.19...v0.1.20
555
613
  [0.1.19]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.18...v0.1.19
556
614
  [0.1.18]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.17...v0.1.18
557
615
  [0.1.17]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.16...v0.1.17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tavern-harness",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
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} />
@@ -292,6 +327,8 @@ function SessionHeader({
292
327
  const [confirmDelete, setConfirmDelete] = useState(false);
293
328
  const [exportOpen, setExportOpen] = useState(false);
294
329
  const [showWorkspace, setShowWorkspace] = useState(false);
330
+ const [showMobileActions, setShowMobileActions] = useState(false);
331
+ const mobileActionsRef = useRef<HTMLDivElement>(null);
295
332
 
296
333
  const messages = useStore((s) => s.messages[session.id!] ?? []);
297
334
  const contextTokens = useMemo(() => {
@@ -315,6 +352,17 @@ function SessionHeader({
315
352
  || (themeMode === 'system' && document.documentElement.dataset.theme === 'dark');
316
353
  const toggleTheme = () => void setSettings({ themeMode: isDarkTheme ? 'light' : 'dark' });
317
354
 
355
+ useEffect(() => {
356
+ if (!showMobileActions) return;
357
+ const closeOnOutsidePointerDown = (event: PointerEvent) => {
358
+ if (!mobileActionsRef.current?.contains(event.target as Node)) {
359
+ setShowMobileActions(false);
360
+ }
361
+ };
362
+ document.addEventListener('pointerdown', closeOnOutsidePointerDown);
363
+ return () => document.removeEventListener('pointerdown', closeOnOutsidePointerDown);
364
+ }, [showMobileActions]);
365
+
318
366
  return (
319
367
  <div className="chat-header">
320
368
  <SessionVisual mode={session.mode} npcName={npcRef?.name ?? undefined} hue={npcRef?.avatarColorOrdinal ?? 0} imageUrl={npcRef?.avatarDataUrl} members={session.mode === 'GROUP' ? groupMemberAvatars : undefined} size="lg" />
@@ -326,7 +374,7 @@ function SessionHeader({
326
374
  </div>
327
375
  )}
328
376
  </div>
329
- <div className="chat-actions">
377
+ <div className="chat-actions" ref={mobileActionsRef}>
330
378
  <button className="btn-ghost icon-tooltip" title={t(usesPublicWorkspace ? 'header.workspaceRootHint' : 'header.workspaceHint')} onClick={() => setShowWorkspace(true)}>
331
379
  <Icon name="folder" size={17} />
332
380
  </button>
@@ -343,7 +391,17 @@ function SessionHeader({
343
391
  <Icon name="trash" size={17} />
344
392
  </button>
345
393
  <button
346
- className="btn-ghost icon-tooltip"
394
+ className="btn-ghost icon-tooltip chat-actions-more"
395
+ type="button"
396
+ title={t('header.moreActions')}
397
+ aria-label={t('header.moreActions')}
398
+ aria-expanded={showMobileActions}
399
+ onClick={() => setShowMobileActions(!showMobileActions)}
400
+ >
401
+ <Icon name="more" size={18} />
402
+ </button>
403
+ <button
404
+ className="btn-ghost icon-tooltip chat-theme-toggle"
347
405
  type="button"
348
406
  title={t(isDarkTheme ? 'header.switchToLightTip' : 'header.switchToDarkTip')}
349
407
  aria-label={t(isDarkTheme ? 'header.switchToLightTip' : 'header.switchToDarkTip')}
@@ -351,6 +409,25 @@ function SessionHeader({
351
409
  >
352
410
  <Icon name={isDarkTheme ? 'sun' : 'moon'} size={17} />
353
411
  </button>
412
+ {showMobileActions && (
413
+ <div className="chat-actions-menu card">
414
+ <button onClick={() => { setShowWorkspace(true); setShowMobileActions(false); }}>
415
+ <Icon name="folder" size={15} /> {t('header.workspaceAction')}
416
+ </button>
417
+ <button onClick={() => { setShowEdit(true); setShowMobileActions(false); }}>
418
+ <Icon name="pencil" size={15} /> {t('nav.editSession')}
419
+ </button>
420
+ <button onClick={() => { setConfirmReset(true); setShowMobileActions(false); }}>
421
+ <Icon name="refresh" size={15} /> {t('header.resetSessionTip')}
422
+ </button>
423
+ <button onClick={() => { setExportOpen(true); setShowMobileActions(false); }}>
424
+ <Icon name="share" size={15} /> {t('nav.exportGameplay')}
425
+ </button>
426
+ <button className="danger" onClick={() => { setConfirmDelete(true); setShowMobileActions(false); }}>
427
+ <Icon name="trash" size={15} /> {t('header.deleteTip')}
428
+ </button>
429
+ </div>
430
+ )}
354
431
  </div>
355
432
 
356
433
  {showEdit && <NewSessionMenu editingSession={session} onClose={() => setShowEdit(false)} />}
@@ -1,5 +1,6 @@
1
1
  import { Fragment, lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type UIEvent } from 'react';
2
2
  import { useStore } from '../store/store';
3
+ import { db } from '../db/database';
3
4
  import { type ChatMessage, type ChatParticipant, type ChatSession, type ContextCompression, type ToolCallRecord, displayModelName } from '../types/models';
4
5
  import { Avatar, Icon, Markdown, Collapse, Modal, AttachCard } from './shared';
5
6
  import { FileDisplayViewButton, parseStoredDisplayRef } from './FileDisplayModal';
@@ -119,6 +120,39 @@ export function ChatView({
119
120
  () => new Map(compressions.map((compression) => [compression.endMessageId, compression])),
120
121
  [compressions],
121
122
  );
123
+ const parsedToolCallsCache = useRef(new Map<number, { source: string; calls: ToolCallRecord[] }>());
124
+ const toolMessageData = useMemo(() => {
125
+ const resultsByCallId = new Map<string, ChatMessage[]>();
126
+ const displayedCallIds = new Set<string>();
127
+
128
+ for (const message of messages) {
129
+ if (message.role === 'tool' && message.toolCallId) {
130
+ const results = resultsByCallId.get(message.toolCallId);
131
+ if (results) results.push(message);
132
+ else resultsByCallId.set(message.toolCallId, [message]);
133
+ continue;
134
+ }
135
+ if (message.role !== 'assistant' || !message.toolCallsJson) continue;
136
+
137
+ const cached = message.id != null ? parsedToolCallsCache.current.get(message.id) : undefined;
138
+ let calls: ToolCallRecord[];
139
+ if (cached?.source === message.toolCallsJson) {
140
+ calls = cached.calls;
141
+ } else {
142
+ try {
143
+ calls = JSON.parse(message.toolCallsJson) as ToolCallRecord[];
144
+ } catch {
145
+ calls = [];
146
+ }
147
+ if (message.id != null) {
148
+ parsedToolCallsCache.current.set(message.id, { source: message.toolCallsJson, calls });
149
+ }
150
+ }
151
+ for (const call of calls) if (call.id) displayedCallIds.add(call.id);
152
+ }
153
+
154
+ return { resultsByCallId, displayedCallIds };
155
+ }, [messages]);
122
156
 
123
157
  useEffect(() => {
124
158
  const el = scrollRef.current;
@@ -275,33 +309,6 @@ export function ChatView({
275
309
  return personaId != null ? s.npcs.find((npc) => npc.id === personaId)?.name ?? null : null;
276
310
  });
277
311
 
278
- // 工具调用结果按 toolCallId 分组;已由 assistant 消息工具卡片展示的调用 id,其结果并入卡片内部渲染
279
- const toolResultsByCallId = useMemo(() => {
280
- const map = new Map<string, ChatMessage[]>();
281
- for (const m of messages) {
282
- if (m.role === 'tool' && m.toolCallId) {
283
- const arr = map.get(m.toolCallId);
284
- if (arr) arr.push(m);
285
- else map.set(m.toolCallId, [m]);
286
- }
287
- }
288
- return map;
289
- }, [messages]);
290
- const displayedToolCallIds = useMemo(() => {
291
- const ids = new Set<string>();
292
- for (const m of messages) {
293
- if (m.role !== 'assistant') continue;
294
- try {
295
- for (const tc of JSON.parse(m.toolCallsJson || '[]') as ToolCallRecord[]) {
296
- if (tc.id) ids.add(tc.id);
297
- }
298
- } catch {
299
- /* ignore */
300
- }
301
- }
302
- return ids;
303
- }, [messages]);
304
-
305
312
  const handleScroll = (event: UIEvent<HTMLDivElement>) => {
306
313
  const el = event.currentTarget;
307
314
  // 隐藏过程中浏览器会把滚动位置夹到 0 并派发一次 scroll 事件,
@@ -354,8 +361,8 @@ export function ChatView({
354
361
  userPersonaName={userPersonaName}
355
362
  loopIndex={isGroup ? (m.loopIndex ?? 0) : null}
356
363
  streaming={streaming && m.id === lastMsgId(messages)}
357
- toolResultsByCallId={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')}
@@ -82,6 +82,22 @@ export const SettingsView = memo(function SettingsView() {
82
82
  <span className="switch-slider" />
83
83
  </label>
84
84
  </div>
85
+ <div className="set-row settings-tool-row">
86
+ <div>
87
+ <div className="s-label" id="pure-mode-label">{t('settings.pureMode')}</div>
88
+ <div className="s-desc" id="pure-mode-desc">{t('settings.pureModeDesc')}</div>
89
+ </div>
90
+ <label className="switch">
91
+ <input
92
+ type="checkbox"
93
+ aria-labelledby="pure-mode-label"
94
+ aria-describedby="pure-mode-desc"
95
+ checked={settings.pureMode === true}
96
+ onChange={(e) => setSettings({ pureMode: e.target.checked })}
97
+ />
98
+ <span className="switch-slider" />
99
+ </label>
100
+ </div>
85
101
  </div>
86
102
 
87
103
  {/* 生成参数 */}
@@ -403,6 +403,7 @@ const ICONS: Record<string, React.ReactNode> = {
403
403
  cancel: <path d="M18 6 6 18M6 6l12 12" />,
404
404
  x: <path d="M18 6 6 18M6 6l12 12" />,
405
405
  share: <><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /><path d="m16 6-4-4-4 4M12 2v13" /></>,
406
+ 'open-in-new': <><path d="M14 3h7v7M21 3l-9 9" /><path d="M19 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6" /></>,
406
407
  sort: <><path d="M11 5h10M11 9h7M11 13h4" /><path d="m3 17 3 3 3-3M6 20V4" /></>,
407
408
  pencil: <><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5l-5 1 1-5Z" /><path d="m15 5 4 4" /></>,
408
409
  upload: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="m17 8-5-5-5 5M12 3v12" /></>,
package/src/core/i18n.ts CHANGED
@@ -81,6 +81,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
81
81
  'header.modeStandard': '标准对话',
82
82
  'header.modeNpc': '角色对话',
83
83
  'header.modeGroup': '群聊',
84
+ 'header.moreActions': '更多操作',
85
+ 'header.workspaceAction': '工作区文件',
84
86
  'header.editSessionTip': '编辑对话设置',
85
87
  'header.resetSessionTip': '重置对话',
86
88
  'header.resetSessionTitle': '重置对话',
@@ -195,6 +197,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
195
197
  'settings.title': '设置',
196
198
  'settings.sub': '模型 API 配置、超参数与主题',
197
199
  'settings.toolExecution': '🛠️ 工具执行',
200
+ 'settings.pureMode': '纯净模式',
201
+ 'settings.pureModeDesc': '仅影响前端展示:隐藏对话中的思考和工具调用;没有正文的消息会一并隐藏,不影响实际思考和工具调用。',
198
202
  'settings.yoloMode': 'YOLO 模式',
199
203
  'settings.yoloModeDesc': '自动批准工具修改和 Shell 执行,无需逐次确认。仅在信任角色和技能时开启。',
200
204
  'settings.genParams': '🎛 生成参数',
@@ -533,6 +537,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
533
537
  'display.exitPictureInPicture': '退出画中画',
534
538
  'display.resizePictureInPicture': '调整画中画大小',
535
539
  'display.htmlSandboxNote': 'HTML 应用在受限沙箱中运行,并可加载同目录资源',
540
+ 'display.openHtmlInNewPage': '在新页面打开 HTML',
536
541
 
537
542
  // ---- 系统提示词 ----
538
543
  'prompt.worldBook': '=== 世界书 ===',
@@ -602,6 +607,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
602
607
  'header.modeStandard': '標準對話',
603
608
  'header.modeNpc': '角色對話',
604
609
  'header.modeGroup': '群聊',
610
+ 'header.moreActions': '更多操作',
611
+ 'header.workspaceAction': '工作區檔案',
605
612
  'header.editSessionTip': '編輯對話設定',
606
613
  'header.resetSessionTip': '重置對話',
607
614
  'header.resetSessionTitle': '重置對話',
@@ -713,6 +720,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
713
720
  'settings.title': '設定',
714
721
  'settings.sub': '模型 API 設定、超參數與主題',
715
722
  'settings.toolExecution': '🛠️ 工具執行',
723
+ 'settings.pureMode': '純淨模式',
724
+ 'settings.pureModeDesc': '僅影響前端顯示:隱藏對話中的思考和工具呼叫;沒有正文的訊息會一併隱藏,不影響實際思考和工具呼叫。',
716
725
  'settings.yoloMode': 'YOLO 模式',
717
726
  'settings.yoloModeDesc': '自動批准工具修改和 Shell 執行,無需逐次確認。僅在信任角色和技能時開啟。',
718
727
  'settings.genParams': '🎛 產生參數',
@@ -1043,6 +1052,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1043
1052
  'display.exitPictureInPicture': '退出畫中畫',
1044
1053
  'display.resizePictureInPicture': '調整畫中畫大小',
1045
1054
  'display.htmlSandboxNote': 'HTML 應用程式在受限沙箱中執行,並可載入同目錄資源',
1055
+ 'display.openHtmlInNewPage': '在新頁面開啟 HTML',
1046
1056
 
1047
1057
  'prompt.worldBook': '=== 世界書 ===',
1048
1058
  'prompt.userInfo': '=== USER INFO (用戶扮演的角色) ===',
@@ -1111,6 +1121,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1111
1121
  'header.modeStandard': 'Standard chat',
1112
1122
  'header.modeNpc': 'Character chat',
1113
1123
  'header.modeGroup': 'Group chat',
1124
+ 'header.moreActions': 'More actions',
1125
+ 'header.workspaceAction': 'Workspace files',
1114
1126
  'header.editSessionTip': 'Edit conversation settings',
1115
1127
  'header.resetSessionTip': 'Reset conversation',
1116
1128
  'header.resetSessionTitle': 'Reset conversation',
@@ -1222,6 +1234,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1222
1234
  'settings.title': 'Settings',
1223
1235
  'settings.sub': 'Model API config, hyperparameters and theme',
1224
1236
  'settings.toolExecution': '🛠️ Tool execution',
1237
+ 'settings.pureMode': 'Pure mode',
1238
+ 'settings.pureModeDesc': 'Front-end display only: hide reasoning and tool calls in chats; messages without body content are hidden too. Actual reasoning and tool calls still run.',
1225
1239
  'settings.yoloMode': 'YOLO mode',
1226
1240
  'settings.yoloModeDesc': 'Automatically approve tool changes and Shell commands without asking each time. Only enable for trusted characters and skills.',
1227
1241
  'settings.genParams': '🎛 Generation parameters',
@@ -1552,6 +1566,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1552
1566
  'display.exitPictureInPicture': 'Exit picture in picture',
1553
1567
  'display.resizePictureInPicture': 'Resize picture in picture',
1554
1568
  'display.htmlSandboxNote': 'HTML application runs in a restricted sandbox with same-directory resources available',
1569
+ 'display.openHtmlInNewPage': 'Open HTML in a new page',
1555
1570
 
1556
1571
  'prompt.worldBook': '=== LOREBOOK ===',
1557
1572
  'prompt.userInfo': '=== USER INFO (role played by the user) ===',
@@ -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,91 @@
1
1
  {
2
- "latest": "0.1.19",
2
+ "latest": "0.1.21",
3
3
  "repo": "https://github.com/Rannichan/Tavern-Harness",
4
4
  "entries": [
5
+ {
6
+ "version": "0.1.21",
7
+ "date": "2026-10-26",
8
+ "breaking": false,
9
+ "compare": "v0.1.20...v0.1.21",
10
+ "groups": {
11
+ "zh-CN": [
12
+ {
13
+ "kind": "changed",
14
+ "items": [
15
+ "移动端会话头部的操作按钮收进「更多操作」菜单(工作区文件、编辑对话、重置对话、导出游玩记录、删除对话),头部不再拥挤;主题切换按钮仍然直接显示",
16
+ "移动端会话标题可用的横向空间明显变大,长标题能显示更多内容",
17
+ "会话列表中的标题和副标题过长时改为末尾渐隐,不再用省略号生硬截断",
18
+ "会话头部的上下文用量标签字号略微收小,和标题搭配更协调"
19
+ ]
20
+ }
21
+ ],
22
+ "en": [
23
+ {
24
+ "kind": "changed",
25
+ "items": [
26
+ "On mobile, session header actions now collapse into a \"More actions\" menu (workspace files, edit, reset, export gameplay, delete), so the header is no longer cramped; the theme toggle stays one tap away",
27
+ "Mobile session titles get noticeably more horizontal room, so long titles reveal more of their text",
28
+ "Long session list titles and subtitles now fade out at the end instead of being cut off with an ellipsis",
29
+ "The context usage badge in the session header uses a slightly smaller font so it sits better next to the title"
30
+ ]
31
+ }
32
+ ]
33
+ }
34
+ },
35
+ {
36
+ "version": "0.1.20",
37
+ "date": "2026-10-26",
38
+ "breaking": false,
39
+ "compare": "v0.1.19...v0.1.20",
40
+ "groups": {
41
+ "zh-CN": [
42
+ {
43
+ "kind": "added",
44
+ "items": [
45
+ "新增「纯净模式」:只显示消息正文,隐藏思考过程和工具调用;没有正文的消息也会一并隐藏,实际的思考和工具执行不受影响(在「设置 → 工具执行」中开启)",
46
+ "HTML 预览可以在新页面打开,方便全屏查看和调试交互式页面"
47
+ ]
48
+ },
49
+ {
50
+ "kind": "changed",
51
+ "items": [
52
+ "长会话加载明显更快:消息列表不再携带每次请求的原始日志,改为打开「查看原始日志」时按需读取;切回已加载过的会话时直接复用内存中的内容,不再重复拉取",
53
+ "长会话下持久化服务更稳定:响应启用 brotli / gzip 压缩,服务繁忙时刷新页面不再误报会话加载失败",
54
+ "HTML 预览的缩放按钮移到底部说明栏,不再占用顶部工具栏,预览区域更宽敞"
55
+ ]
56
+ },
57
+ {
58
+ "kind": "fixed",
59
+ "items": [
60
+ "会话标题过长时不再挤压右侧按钮:标题限制最大宽度并在末尾渐隐"
61
+ ]
62
+ }
63
+ ],
64
+ "en": [
65
+ {
66
+ "kind": "added",
67
+ "items": [
68
+ "New \"Pure mode\": shows only message bodies, hiding reasoning and tool calls; messages without body text are hidden too, while actual reasoning and tool execution keep running (enable it in Settings → Tool execution)",
69
+ "HTML previews can be opened in a new page for full-screen viewing and debugging interactive pages"
70
+ ]
71
+ },
72
+ {
73
+ "kind": "changed",
74
+ "items": [
75
+ "Long conversations load noticeably faster: message lists no longer carry per-request raw logs — they're fetched only when you open \"View raw log\" — and switching back to a session you've already loaded reuses the in-memory copy instead of fetching it again",
76
+ "The persistence service holds up better with long conversations: responses are brotli / gzip compressed, and a busy service no longer triggers false \"failed to load session\" errors after a page refresh",
77
+ "HTML preview zoom buttons moved to the bottom note bar, freeing the top toolbar and giving the preview more room"
78
+ ]
79
+ },
80
+ {
81
+ "kind": "fixed",
82
+ "items": [
83
+ "Long session titles no longer squeeze the header buttons: the title is capped in width and fades out at the end"
84
+ ]
85
+ }
86
+ ]
87
+ }
88
+ },
5
89
  {
6
90
  "version": "0.1.19",
7
91
  "date": "2026-10-25",
@@ -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
 
@@ -573,9 +573,10 @@ body.resizing .sidebar-resizer {
573
573
  }
574
574
  .session-item .stitle-text {
575
575
  overflow: hidden;
576
- text-overflow: ellipsis;
577
576
  white-space: nowrap;
578
577
  min-width: 0;
578
+ -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
579
+ mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
579
580
  }
580
581
  .session-item .spinned {
581
582
  display: inline-flex;
@@ -615,7 +616,8 @@ body.resizing .sidebar-resizer {
615
616
  color: var(--text-faint);
616
617
  white-space: nowrap;
617
618
  overflow: hidden;
618
- text-overflow: ellipsis;
619
+ -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
620
+ mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
619
621
  }
620
622
  /* ---------- 会话区域(头部 + 滚动列表) ---------- */
621
623
  .session-section {
@@ -1045,12 +1047,15 @@ body.resizing .sidebar-resizer {
1045
1047
  min-width: 0;
1046
1048
  }
1047
1049
  .chat-header .ttitle {
1050
+ position: relative;
1051
+ max-width: min(36vw, 420px);
1052
+ overflow: hidden;
1048
1053
  font-family: var(--font-serif);
1049
1054
  font-weight: 700;
1050
1055
  font-size: 17px;
1051
1056
  white-space: nowrap;
1052
- overflow: hidden;
1053
- text-overflow: ellipsis;
1057
+ -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%);
1058
+ mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent 100%);
1054
1059
  }
1055
1060
  .chat-header .tsub {
1056
1061
  font-size: 12px;
@@ -1069,6 +1074,7 @@ body.resizing .sidebar-resizer {
1069
1074
  }
1070
1075
  .chat-header .tsub-mode {
1071
1076
  flex-shrink: 0;
1077
+ font-size: 12px;
1072
1078
  font-weight: 700;
1073
1079
  color: var(--primary);
1074
1080
  }
@@ -1082,6 +1088,7 @@ body.resizing .sidebar-resizer {
1082
1088
  /* ---------- 聊天头部右侧动作按钮 ---------- */
1083
1089
  .chat-actions {
1084
1090
  display: flex;
1091
+ flex: 0 0 auto;
1085
1092
  align-items: center;
1086
1093
  gap: 4px;
1087
1094
  }
@@ -1096,6 +1103,7 @@ body.resizing .sidebar-resizer {
1096
1103
  background: transparent;
1097
1104
  color: var(--text-dim);
1098
1105
  transition: background 0.15s ease, color 0.15s ease;
1106
+ flex-shrink: 0;
1099
1107
  cursor: pointer;
1100
1108
  }
1101
1109
  .chat-actions .icon-tooltip:hover {
@@ -1106,6 +1114,10 @@ body.resizing .sidebar-resizer {
1106
1114
  color: var(--danger);
1107
1115
  background: var(--danger-soft);
1108
1116
  }
1117
+ .chat-actions .chat-actions-more,
1118
+ .chat-actions-menu {
1119
+ display: none;
1120
+ }
1109
1121
 
1110
1122
  /* ---------- 卡片/仪表盘 ---------- */
1111
1123
  .dash-scroll {
@@ -1794,6 +1806,7 @@ hr.sep {
1794
1806
  }
1795
1807
 
1796
1808
  .chat-header .ttitle {
1809
+ max-width: min(52vw, 260px);
1797
1810
  font-size: 15px;
1798
1811
  }
1799
1812
 
@@ -1802,28 +1815,67 @@ hr.sep {
1802
1815
  }
1803
1816
 
1804
1817
  .chat-actions {
1805
- display: flex;
1806
- flex: 0 1 auto;
1807
- min-width: 0;
1808
- justify-content: flex-end;
1818
+ position: relative;
1819
+ flex: 0 0 auto;
1809
1820
  gap: 0;
1810
- max-width: none;
1811
- overflow-x: auto;
1812
- overscroll-behavior-x: contain;
1813
- scrollbar-width: none;
1814
- touch-action: pan-x;
1815
1821
  }
1816
1822
 
1817
- .chat-actions::-webkit-scrollbar {
1823
+ .chat-actions .icon-tooltip:not(.chat-actions-more, .chat-theme-toggle) {
1818
1824
  display: none;
1819
1825
  }
1820
1826
 
1827
+ .chat-actions .chat-actions-more {
1828
+ display: inline-flex;
1829
+ }
1830
+
1821
1831
  .chat-actions .icon-tooltip {
1822
1832
  width: 34px;
1823
1833
  height: 34px;
1824
1834
  flex-shrink: 0;
1825
1835
  }
1826
1836
 
1837
+ .chat-actions-menu {
1838
+ position: absolute;
1839
+ top: calc(100% + 6px);
1840
+ right: 0;
1841
+ z-index: 60;
1842
+ display: flex;
1843
+ flex-direction: column;
1844
+ min-width: 164px;
1845
+ padding: 6px;
1846
+ background: var(--surface);
1847
+ border: 1px solid var(--border);
1848
+ border-radius: 12px;
1849
+ box-shadow: var(--shadow-lg);
1850
+ }
1851
+
1852
+ .chat-actions-menu button {
1853
+ display: flex;
1854
+ align-items: center;
1855
+ gap: 9px;
1856
+ width: 100%;
1857
+ padding: 8px 10px;
1858
+ border: none;
1859
+ border-radius: 8px;
1860
+ background: none;
1861
+ color: var(--text);
1862
+ font-size: 13px;
1863
+ font-weight: 600;
1864
+ text-align: left;
1865
+ }
1866
+
1867
+ .chat-actions-menu button:hover {
1868
+ background: var(--surface-2);
1869
+ }
1870
+
1871
+ .chat-actions-menu button.danger {
1872
+ color: var(--danger);
1873
+ }
1874
+
1875
+ .chat-actions-menu button.danger:hover {
1876
+ background: var(--danger-soft);
1877
+ }
1878
+
1827
1879
  .chat-scroll {
1828
1880
  padding-top: 14px;
1829
1881
  }
@@ -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
  });