tavern-harness 0.1.16 → 0.1.18

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,68 @@
3
3
  > 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
4
4
  > 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
5
5
 
6
+ ## [0.1.18] - 2026-10-25
7
+
8
+ ### 中文
9
+
10
+ #### 新增
11
+
12
+ - 聊天标题栏新增主题切换按钮,可在浅色与深色之间一键切换
13
+ - 会话标题下方显示当前上下文用量(如 `12.3K`),方便判断何时需要重置对话
14
+
15
+ #### 改进
16
+
17
+ - 手机端支持左右滑动:从屏幕左边缘右滑打开侧边栏,在侧边栏内左滑关闭
18
+ - 模型选择按钮更紧凑:名称过长时自动省略,新增展开箭头,选择面板在点击面板外任意位置时关闭
19
+ - 提高单轮工具调用上限:每轮最多 100 次调用、最多 30 层嵌套,复杂任务不再轻易被中断
20
+ - 群聊标题栏不再重复显示成员名单
21
+
22
+ #### 修复
23
+
24
+ - 修复 Android 上点击工作区、顶栏等区域时出现灰色高亮块的问题
25
+
26
+ ### English
27
+
28
+ #### Added
29
+
30
+ - Added a theme toggle to the chat header for switching between light and dark in one tap
31
+ - The session header now shows current context usage (for example `12.3K`), making it easier to judge when to reset a conversation
32
+
33
+ #### Changed
34
+
35
+ - Mobile now supports swipe gestures: swipe in from the left edge to open the sidebar, and swipe left inside it to close
36
+ - The model selector is more compact: long model names are truncated, a caret indicates it opens a panel, and the panel closes when you tap anywhere outside it
37
+ - Raised the per-turn tool limits to 100 calls and 30 levels of nesting, so complex tasks are interrupted far less often
38
+ - Group chat headers no longer repeat the member list
39
+
40
+ #### Fixed
41
+
42
+ - Fixed gray tap-highlight boxes appearing when tapping the workspace, top bar, and other areas on Android
43
+
44
+ ## [0.1.17] - 2026-10-25
45
+
46
+ ### 中文
47
+
48
+ #### 改进
49
+
50
+ - 在工作区文件管理器中预览文件时不再关闭管理器:预览复用管理器的遮罩层,关闭预览即可回到原来的浏览位置
51
+ - 文件删除确认改为管理器内的弹出对话框,删除过程中按钮显示加载状态,失败时会提示原因,不再静默关闭
52
+
53
+ #### 修复
54
+
55
+ - 修复在 Termux / Android 环境下 shell 命令无法解析的问题:现在会从 Termux 的 `$PREFIX/bin` 中查找受信任的命令,并保留 `head` 等 applet 的原始名称
56
+
57
+ ### English
58
+
59
+ #### Changed
60
+
61
+ - Previewing a file from the workspace file manager no longer closes the manager: the preview reuses the manager's overlay, and closing it returns you to the same browsing position
62
+ - Delete confirmation is now a dialog inside the manager; the delete button shows a loading state while the request runs, and failures surface the reason instead of closing silently
63
+
64
+ #### Fixed
65
+
66
+ - Fixed shell commands failing to resolve on Termux / Android: trusted commands are now looked up in Termux's `$PREFIX/bin`, and applets such as `head` keep their original name
67
+
6
68
  ## [0.1.16] - 2026-10-25
7
69
 
8
70
  ### 中文
@@ -466,6 +528,8 @@
466
528
  - Vite + React + Zustand + Dexie stack with browser-side local persistence
467
529
 
468
530
  <!-- 链接定义 -->
531
+ [0.1.18]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.17...v0.1.18
532
+ [0.1.17]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.16...v0.1.17
469
533
  [0.1.16]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.15...v0.1.16
470
534
  [0.1.15]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.14...v0.1.15
471
535
  [0.1.14]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.13...v0.1.14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tavern-harness",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Tavern Harness — 酒馆模式下的本地 AI Agent 助手(MyAgent-Android 的 Web 复刻)",
5
5
  "private": false,
6
6
  "type": "module",
@@ -336,9 +336,12 @@ const SHELL_ALLOWLIST_GROUPS = {
336
336
  };
337
337
  const SHELL_ALLOWED_REAL = new Set(Object.values(SHELL_ALLOWLIST_GROUPS).flat());
338
338
 
339
+ const TERMUX_BIN = process.platform === 'android' && process.env.PREFIX
340
+ ? join(process.env.PREFIX, 'bin')
341
+ : null;
339
342
  const TRUSTED_COMMAND_DIRS = (IS_WIN
340
343
  ? [GIT_USR_BIN]
341
- : ['/bin', '/usr/bin', '/usr/sbin', '/sbin'])
344
+ : ['/bin', '/usr/bin', '/usr/sbin', '/sbin', TERMUX_BIN])
342
345
  .filter(Boolean)
343
346
  .map((dir) => {
344
347
  try {
@@ -358,10 +361,15 @@ function resolveTrustedCommand(command) {
358
361
  try {
359
362
  const resolved = realpathSync(candidate);
360
363
  if (!resolved.startsWith(trustedDir + sep)) return null;
361
- if (IS_WIN) return resolved; // Windows 无 POSIX 权限位(Git usr/bin 的 exe 显示 666),跳过 uid/writable 校验
364
+ if (IS_WIN) return candidate; // Windows 无 POSIX 权限位(Git usr/bin 的 exe 显示 666),跳过 uid/writable 校验
362
365
  const executable = statSync(resolved);
363
366
  const writableByNonOwner = (executable.mode & 0o022) !== 0;
364
- if (executable.uid === 0 && !writableByNonOwner) return resolved;
367
+ const trustedSystemExecutable = executable.uid === 0 && !writableByNonOwner;
368
+ const trustedTermuxExecutable = trustedDir === TERMUX_BIN
369
+ && executable.uid === process.getuid()
370
+ && !writableByNonOwner;
371
+ // Preserve the applet path: resolving /system/bin/head to toybox loses the applet name.
372
+ if (trustedSystemExecutable || trustedTermuxExecutable) return candidate;
365
373
  } catch { /* try next candidate */ }
366
374
  }
367
375
  }
package/src/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
2
2
  import { useStore, type ActiveView } from './store/store';
3
3
  import { Sidebar } from './components/Sidebar';
4
4
  import { ChatView, ChatInput, MessageMenu, TurnQueuePanel } from './components/ChatView';
@@ -20,6 +20,10 @@ import { useT } from './core/i18n';
20
20
  import './theme/chat.css';
21
21
  import './theme/views.css';
22
22
 
23
+ function formatContextTokens(tokens: number): string {
24
+ return `${(tokens / 1_000).toFixed(1)}K`;
25
+ }
26
+
23
27
  export default function App() {
24
28
  const [takingControl, setTakingControl] = useState(false);
25
29
  const initStatus = useStore((s) => s.initStatus);
@@ -280,6 +284,8 @@ function SessionHeader({
280
284
  const addToast = useStore((s) => s.addToast);
281
285
  const deleteSession = useStore((s) => s.deleteSession);
282
286
  const resetSessionConversation = useStore((s) => s.resetSessionConversation);
287
+ const themeMode = useStore((s) => s.settings?.themeMode ?? 'system');
288
+ const setSettings = useStore((s) => s.setSettings);
283
289
  const t = useT();
284
290
  const [showEdit, setShowEdit] = useState(false);
285
291
  const [confirmReset, setConfirmReset] = useState(false);
@@ -288,6 +294,16 @@ function SessionHeader({
288
294
  const [showWorkspace, setShowWorkspace] = useState(false);
289
295
 
290
296
  const modeLabel = session.mode === 'STANDARD' ? t('header.modeStandard') : session.mode === 'NPC' ? t('header.modeNpc') : t('header.modeGroup');
297
+ const messages = useStore((s) => s.messages[session.id!] ?? []);
298
+ const contextTokens = useMemo(() => {
299
+ const assistantMessages = messages.filter((message) => message.role === 'assistant' && message.totalTokens > 0);
300
+ if (assistantMessages.length === 0) return null;
301
+ if (session.mode !== 'GROUP') return assistantMessages.at(-1)!.totalTokens;
302
+ const latestLoopIndex = assistantMessages.at(-1)!.loopIndex;
303
+ return Math.max(...assistantMessages
304
+ .filter((message) => message.loopIndex === latestLoopIndex)
305
+ .map((message) => message.totalTokens));
306
+ }, [messages, session.mode]);
291
307
  const npcRef = session.associatedId ? npcs.find((n) => n.id === session.associatedId) : null;
292
308
  const usesPublicWorkspace = session.mode === 'NPC' && npcRef?.isBuiltIn === true;
293
309
  const groupNpcs = participants.filter((p) => p.kind === 'NPC').map((p) => npcs.find((n) => n.id === p.npcId)).filter(Boolean);
@@ -296,6 +312,9 @@ function SessionHeader({
296
312
  colorOrdinal: n.avatarColorOrdinal,
297
313
  imageUrl: n.avatarDataUrl,
298
314
  }));
315
+ const isDarkTheme = themeMode === 'dark'
316
+ || (themeMode === 'system' && document.documentElement.dataset.theme === 'dark');
317
+ const toggleTheme = () => void setSettings({ themeMode: isDarkTheme ? 'light' : 'dark' });
299
318
 
300
319
  return (
301
320
  <div className="chat-header">
@@ -304,9 +323,10 @@ function SessionHeader({
304
323
  <div className="ttitle">{session.title}</div>
305
324
  <div className="tsub">
306
325
  <span className="tsub-mode">{modeLabel}</span>
307
- {session.mode === 'GROUP' && (
308
- <span className="tsub-members">
309
- {groupNpcs.map((n) => n!.name).join(' · ')}
326
+ {contextTokens != null && (
327
+ <span className="tsub-context-group">
328
+ <span>·</span>
329
+ <span className="tsub-mode">{t('nav.contextUsage', { tokens: formatContextTokens(contextTokens) })}</span>
310
330
  </span>
311
331
  )}
312
332
  </div>
@@ -327,6 +347,15 @@ function SessionHeader({
327
347
  <button className="btn-ghost icon-tooltip danger" title={t('header.deleteTip')} onClick={() => setConfirmDelete(true)}>
328
348
  <Icon name="trash" size={17} />
329
349
  </button>
350
+ <button
351
+ className="btn-ghost icon-tooltip"
352
+ type="button"
353
+ title={t(isDarkTheme ? 'header.switchToLightTip' : 'header.switchToDarkTip')}
354
+ aria-label={t(isDarkTheme ? 'header.switchToLightTip' : 'header.switchToDarkTip')}
355
+ onClick={toggleTheme}
356
+ >
357
+ <Icon name={isDarkTheme ? 'sun' : 'moon'} size={17} />
358
+ </button>
330
359
  </div>
331
360
 
332
361
  {showEdit && <NewSessionMenu editingSession={session} onClose={() => setShowEdit(false)} />}
@@ -787,8 +787,22 @@ export function ChatInput({ sessionId }: { sessionId: number }) {
787
787
  const providers = useStore((s) => s.providers);
788
788
  const defaultModel = useStore((s) => s.settings?.defaultModel?.trim() || '');
789
789
  const defaultProviderId = useStore((s) => s.settings?.defaultProviderId ?? null);
790
+ const modelLabelRef = useRef<HTMLSpanElement>(null);
791
+ const [isModelLabelTruncated, setIsModelLabelTruncated] = useState(false);
792
+ const displayedModelName = defaultModel ? displayModelName(providers, defaultModel, defaultProviderId) : t('chat.chooseModel');
790
793
  const composerPlaceholder = session?.mode === 'GROUP' ? t('chat.phGroup') : t('chat.phSingle');
791
794
 
795
+ useLayoutEffect(() => {
796
+ const label = modelLabelRef.current;
797
+ if (!label) return;
798
+
799
+ const updateTruncation = () => setIsModelLabelTruncated(label.scrollWidth > label.clientWidth);
800
+ updateTruncation();
801
+ const observer = new ResizeObserver(updateTruncation);
802
+ observer.observe(label);
803
+ return () => observer.disconnect();
804
+ }, [displayedModelName]);
805
+
792
806
  const canSend = text.trim().length > 0 || attachments.length > 0;
793
807
 
794
808
  const doSend = () => {
@@ -983,10 +997,18 @@ export function ChatInput({ sessionId }: { sessionId: number }) {
983
997
  <button
984
998
  className={`composer-model-btn ${showModels ? 'open' : ''}`}
985
999
  title={t('chat.selectModel')}
1000
+ aria-expanded={showModels}
986
1001
  disabled={streaming}
987
1002
  onClick={() => setShowModels(!showModels)}
988
1003
  >
989
- <span className="composer-model-label" title={defaultModel}>{defaultModel ? displayModelName(providers, defaultModel, defaultProviderId) : t('chat.chooseModel')}</span>
1004
+ <span
1005
+ ref={modelLabelRef}
1006
+ className={`composer-model-label ${isModelLabelTruncated ? 'truncated' : ''}`}
1007
+ title={defaultModel}
1008
+ >
1009
+ {displayedModelName}
1010
+ </span>
1011
+ <span className="composer-model-caret" aria-hidden="true" />
990
1012
  </button>
991
1013
  {showModels && (
992
1014
  <ModelPickerPanel onClose={() => setShowModels(false)} />
@@ -1156,7 +1178,8 @@ function ModelPickerPanel({ onClose }: { onClose: () => void }) {
1156
1178
 
1157
1179
  useEffect(() => {
1158
1180
  const onDocClick = (e: MouseEvent) => {
1159
- if (ref.current && !ref.current.contains(e.target as Node)) onClose();
1181
+ if (e.target instanceof Element && e.target.closest('.composer-model')) return;
1182
+ onClose();
1160
1183
  };
1161
1184
  const onEsc = (e: KeyboardEvent) => {
1162
1185
  if (e.key === 'Escape') onClose();
@@ -128,7 +128,7 @@ export function FileDisplayModal() {
128
128
  setTextWrap(true);
129
129
  if (isNewDisplay) {
130
130
  setPipRect(createPipRect());
131
- setIsPictureInPicture(active.presentation !== 'modal');
131
+ setIsPictureInPicture(active.presentation === 'pip' || active.presentation == null);
132
132
  }
133
133
  }, [active]);
134
134
 
@@ -263,6 +263,7 @@ export function FileDisplayModal() {
263
263
 
264
264
  const title = active?.title?.trim() || (active ? basenameOf(active.path) : '');
265
265
  const ext = active ? extensionOf(active.path) : '';
266
+ const isWorkspacePreview = active?.presentation === 'workspace';
266
267
 
267
268
  const close = () => setActiveDisplay(null);
268
269
 
@@ -485,10 +486,10 @@ export function FileDisplayModal() {
485
486
 
486
487
  return createPortal(
487
488
  <>
488
- {!isPictureInPicture && <div key="overlay" className="overlay" />}
489
+ {!isPictureInPicture && !isWorkspacePreview && <div key="overlay" className="overlay" />}
489
490
  <div
490
491
  key="display-container"
491
- className={isPictureInPicture ? 'display-pip card' : 'modal-root'}
492
+ className={isPictureInPicture ? 'display-pip card' : 'modal-root display-modal-root'}
492
493
  style={isPictureInPicture
493
494
  ? { left: pipRect.x, top: pipRect.y, width: pipRect.width, height: pipRect.height }
494
495
  : undefined}
@@ -28,6 +28,7 @@ function openGlobalGameplayExport(sessionId: number, includeHistory: boolean) {
28
28
 
29
29
  export function Sidebar() {
30
30
  const [mobileOpen, setMobileOpen] = useState(false);
31
+ const sidebarRef = useRef<HTMLElement>(null);
31
32
  const sessions = useStore((s) => s.sessions);
32
33
  const npcs = useStore((s) => s.npcs);
33
34
  const activeSessionId = useStore((s) => s.activeSessionId);
@@ -121,6 +122,42 @@ export function Sidebar() {
121
122
  };
122
123
  }, []);
123
124
 
125
+ useEffect(() => {
126
+ const isMobile = () => window.matchMedia('(max-width: 680px)').matches;
127
+ let startX = 0;
128
+ let startY = 0;
129
+ let tracking = false;
130
+
131
+ const onTouchStart = (event: TouchEvent) => {
132
+ if (!isMobile() || event.touches.length !== 1) return;
133
+ const touch = event.touches[0];
134
+ const startedInSidebar = sidebarRef.current?.contains(event.target as Node) ?? false;
135
+ if (!mobileOpen && touch.clientX > 24) return;
136
+ if (mobileOpen && !startedInSidebar) return;
137
+ startX = touch.clientX;
138
+ startY = touch.clientY;
139
+ tracking = true;
140
+ };
141
+
142
+ const onTouchEnd = (event: TouchEvent) => {
143
+ if (!tracking || !isMobile() || event.changedTouches.length !== 1) return;
144
+ tracking = false;
145
+ const touch = event.changedTouches[0];
146
+ const deltaX = touch.clientX - startX;
147
+ const deltaY = touch.clientY - startY;
148
+ if (Math.abs(deltaX) < 56 || Math.abs(deltaX) <= Math.abs(deltaY)) return;
149
+ if (!mobileOpen && deltaX > 0) setMobileOpen(true);
150
+ if (mobileOpen && deltaX < 0) setMobileOpen(false);
151
+ };
152
+
153
+ window.addEventListener('touchstart', onTouchStart, { passive: true });
154
+ window.addEventListener('touchend', onTouchEnd, { passive: true });
155
+ return () => {
156
+ window.removeEventListener('touchstart', onTouchStart);
157
+ window.removeEventListener('touchend', onTouchEnd);
158
+ };
159
+ }, [mobileOpen]);
160
+
124
161
  const npcById = (id: number | null) => (id != null ? npcs.find((n) => n.id === id) : null);
125
162
 
126
163
  const handleDelete = async () => {
@@ -251,7 +288,7 @@ export function Sidebar() {
251
288
  >
252
289
  <Icon name={mobileOpen ? 'x' : 'menu'} size={22} />
253
290
  </button>
254
- <nav className="mobile-topbar-nav" aria-label={t('nav.chat')}>
291
+ <nav className="mobile-topbar-nav" data-hscroll aria-label={t('nav.chat')}>
255
292
  <button
256
293
  className="mobile-home-button"
257
294
  type="button"
@@ -277,7 +314,7 @@ export function Sidebar() {
277
314
  </nav>
278
315
  </div>
279
316
  {mobileOpen && <button className="mobile-sidebar-backdrop" type="button" aria-label={t('common.close')} onClick={() => setMobileOpen(false)} />}
280
- <aside className={`sidebar ${mobileOpen ? 'mobile-open' : ''}`}>
317
+ <aside ref={sidebarRef} className={`sidebar ${mobileOpen ? 'mobile-open' : ''}`}>
281
318
  <div className="sidebar-head">
282
319
  <div
283
320
  className="brand"
@@ -1,6 +1,5 @@
1
1
  import { useEffect, useMemo, useState } from 'react';
2
2
  import { Modal, Icon } from './shared';
3
- import { DeleteConfirmDialog } from './DeleteConfirmDialog';
4
3
  import { useT } from '../core/i18n';
5
4
  import { applySessionWorkspace } from '../core/tools/toolExecutor';
6
5
  import { deleteWorkspaceFile, listSessionWorkspaceFiles, openSessionWorkspace } from '../core/tools/generatedSkillExecutor';
@@ -59,6 +58,7 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
59
58
  const [files, setFiles] = useState<string[] | null>(null);
60
59
  const [loadState, setLoadState] = useState<'loading' | 'ok' | 'empty' | 'error'>('loading');
61
60
  const [pendingDelete, setPendingDelete] = useState<Node | null>(null);
61
+ const [deleting, setDeleting] = useState(false);
62
62
  const [openingLocation, setOpeningLocation] = useState(false);
63
63
  // 当前浏览目录('' = 根;否则为以 / 结尾的会话内相对路径)
64
64
  const [dir, setDir] = useState('');
@@ -111,22 +111,29 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
111
111
  kind: kindFromPath(node.path),
112
112
  title: node.name,
113
113
  sessionId,
114
- presentation: 'modal',
114
+ presentation: 'workspace',
115
115
  });
116
- onClose();
117
116
  };
118
117
 
119
118
  const confirmDelete = async () => {
120
- if (!pendingDelete) return;
121
- const workspaceDir = await applySessionWorkspace(sessionId);
122
- await deleteWorkspaceFile(pendingDelete.path, workspaceDir);
123
- setFiles((current) => {
124
- const next = (current ?? []).filter((path) => (
125
- pendingDelete.isDir ? !path.startsWith(pendingDelete.path) : path !== pendingDelete.path
126
- ));
127
- setLoadState(next.length === 0 ? 'empty' : 'ok');
128
- return next;
129
- });
119
+ if (!pendingDelete || deleting) return;
120
+ setDeleting(true);
121
+ try {
122
+ const workspaceDir = await applySessionWorkspace(sessionId);
123
+ await deleteWorkspaceFile(pendingDelete.path, workspaceDir);
124
+ setFiles((current) => {
125
+ const next = (current ?? []).filter((path) => (
126
+ pendingDelete.isDir ? !path.startsWith(pendingDelete.path) : path !== pendingDelete.path
127
+ ));
128
+ setLoadState(next.length === 0 ? 'empty' : 'ok');
129
+ return next;
130
+ });
131
+ setPendingDelete(null);
132
+ } catch (error) {
133
+ addToast((error as Error).message, 'error');
134
+ } finally {
135
+ setDeleting(false);
136
+ }
130
137
  };
131
138
 
132
139
  const openWorkspaceLocation = async () => {
@@ -233,12 +240,24 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
233
240
  )}
234
241
  </div>
235
242
  {pendingDelete && (
236
- <DeleteConfirmDialog
237
- title={t('header.fileManagerDeleteTitle')}
238
- itemName={pendingDelete.path}
239
- onCancel={() => setPendingDelete(null)}
240
- onConfirm={confirmDelete}
241
- />
243
+ <div className="ws-fm-delete-layer" role="dialog" aria-modal="true" aria-labelledby="ws-fm-delete-title">
244
+ <div className="ws-fm-delete-dialog card fade-up">
245
+ <div className="modal-head">
246
+ <span id="ws-fm-delete-title" style={{ fontWeight: 800, fontSize: 15 }}>{t('header.fileManagerDeleteTitle')}</span>
247
+ <button className="icon-btn" onClick={() => setPendingDelete(null)} disabled={deleting} title={t('common.close')}>
248
+ <Icon name="x" />
249
+ </button>
250
+ </div>
251
+ <div className="modal-body">{t('common.confirmDelete', { name: pendingDelete.path })}</div>
252
+ <div className="modal-foot">
253
+ <button className="btn" onClick={() => setPendingDelete(null)} disabled={deleting}>{t('common.cancel')}</button>
254
+ <button className="btn btn-danger" onClick={() => void confirmDelete()} disabled={deleting}>
255
+ {deleting ? <span className="spinner" style={{ width: 13, height: 13 }} /> : <Icon name="trash" size={13} />}
256
+ {t('common.delete')}
257
+ </button>
258
+ </div>
259
+ </div>
260
+ </div>
242
261
  )}
243
262
  </Modal>
244
263
  );
@@ -415,6 +415,8 @@ const ICONS: Record<string, React.ReactNode> = {
415
415
  'pip-exit': <><rect x="3" y="5" width="18" height="14" rx="2" /><path d="m14 14 5-5M15 9h4v4" /></>,
416
416
  'text-wrap': <><path d="M4 6h13a4 4 0 0 1 0 8H9" /><path d="m12 11-3 3 3 3M4 18h3" /></>,
417
417
  eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" /><circle cx="12" cy="12" r="3" /></>,
418
+ sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" /></>,
419
+ moon: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />,
418
420
  folder: <><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /></>,
419
421
  copy: <><rect x="8" y="8" width="13" height="13" rx="2" /><path d="M5 16V6a2 2 0 0 1 2-2h10" /></>,
420
422
  };
package/src/core/i18n.ts CHANGED
@@ -62,6 +62,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
62
62
  'nav.empty': '还没有会话,先去找酒馆老板聊聊?',
63
63
  'nav.noMatch': '没有匹配的会话',
64
64
  'nav.newConversation': '开始新对话…',
65
+ 'nav.contextUsage': '上下文占用 {tokens}',
65
66
  'nav.deleteSession': '删除会话',
66
67
  'nav.editSession': '编辑会话',
67
68
  'nav.pinSession': '置顶对话',
@@ -89,6 +90,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
89
90
  'header.deleteTip': '删除对话',
90
91
  'header.deleteTitle': '删除对话',
91
92
  'header.deleteConfirm': '确定删除对话「{title}」?该操作不可恢复。',
93
+ 'header.switchToLightTip': '切换到浅色模式',
94
+ 'header.switchToDarkTip': '切换到深色模式',
92
95
  'header.workspaceHint': '本对话的专属工作目录。文件工具限制在该目录内;本地命令以此为起始目录,但不具备 OS 隔离。点击可浏览目录文件(只读)。',
93
96
  'header.workspaceRootHint': '酒馆老板的单人对话固定使用公共工作目录。文件工具限制在该目录内;本地命令不具备 OS 隔离。点击可浏览文件(只读)。',
94
97
  'header.fileManagerTitle': '工作区文件',
@@ -573,6 +576,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
573
576
  'nav.empty': '還沒有會話,先去找酒館老闆聊聊?',
574
577
  'nav.noMatch': '沒有符合的會話',
575
578
  'nav.newConversation': '開始新對話…',
579
+ 'nav.contextUsage': '上下文佔用 {tokens}',
576
580
  'nav.deleteSession': '刪除會話',
577
581
  'nav.editSession': '編輯會話',
578
582
  'nav.pinSession': '置頂對話',
@@ -599,6 +603,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
599
603
  'header.deleteTip': '刪除對話',
600
604
  'header.deleteTitle': '刪除對話',
601
605
  'header.deleteConfirm': '確定刪除對話「{title}」?此操作無法復原。',
606
+ 'header.switchToLightTip': '切換到淺色模式',
607
+ 'header.switchToDarkTip': '切換到深色模式',
602
608
  'header.workspaceHint': '本對話的專屬工作目錄。檔案工具限制在該目錄內;本地指令以此為起始目錄,但不具備 OS 隔離。點擊可瀏覽目錄檔案(唯讀)。',
603
609
  'header.workspaceRootHint': '酒館老闆的單人對話固定使用公共工作目錄。檔案工具限制在該目錄內;本地指令不具備 OS 隔離。點擊可瀏覽檔案(唯讀)。',
604
610
  'header.fileManagerTitle': '工作區檔案',
@@ -1071,6 +1077,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1071
1077
  'nav.empty': 'No sessions yet — go chat with the Tavern Keeper?',
1072
1078
  'nav.noMatch': 'No matching sessions',
1073
1079
  'nav.newConversation': 'Start a new conversation…',
1080
+ 'nav.contextUsage': 'Context usage {tokens}',
1074
1081
  'nav.deleteSession': 'Delete session',
1075
1082
  'nav.editSession': 'Edit conversation',
1076
1083
  'nav.pinSession': 'Pin conversation',
@@ -1097,6 +1104,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1097
1104
  'header.deleteTip': 'Delete conversation',
1098
1105
  'header.deleteTitle': 'Delete conversation',
1099
1106
  'header.deleteConfirm': 'Delete conversation "{title}"? This cannot be undone.',
1107
+ 'header.switchToLightTip': 'Switch to light mode',
1108
+ 'header.switchToDarkTip': 'Switch to dark mode',
1100
1109
  'header.workspaceHint': 'This conversation\'s private workspace. File tools are restricted to it; local commands start here but are not OS-isolated. Click to browse its files (read-only).',
1101
1110
  'header.workspaceRootHint': 'A one-on-one conversation with the Tavern Keeper always uses the public workspace. File tools are restricted to it; local commands are not OS-isolated. Click to browse files (read-only).',
1102
1111
  'header.fileManagerTitle': 'Workspace files',
@@ -1,7 +1,7 @@
1
1
  import type { ChatCompletionTool } from '../types/models';
2
2
 
3
- export const MAX_TOOL_CALL_DEPTH = 10;
4
- export const MAX_TOOL_CALLS_PER_TURN = 20;
3
+ export const MAX_TOOL_CALL_DEPTH = 30;
4
+ export const MAX_TOOL_CALLS_PER_TURN = 100;
5
5
  export const MAX_IDENTICAL_TOOL_CALLS = 3;
6
6
  export const MAX_CONSECUTIVE_TOOL_FAILURES = 3;
7
7
  export const NEW_TOPIC_MARKER = '开始新话题';
@@ -1,7 +1,101 @@
1
1
  {
2
- "latest": "0.1.16",
2
+ "latest": "0.1.18",
3
3
  "repo": "https://github.com/Rannichan/Tavern-Harness",
4
4
  "entries": [
5
+ {
6
+ "version": "0.1.18",
7
+ "date": "2026-10-25",
8
+ "breaking": false,
9
+ "compare": "v0.1.17...v0.1.18",
10
+ "groups": {
11
+ "zh-CN": [
12
+ {
13
+ "kind": "added",
14
+ "items": [
15
+ "聊天标题栏新增主题切换按钮,可在浅色与深色之间一键切换",
16
+ "会话标题下方显示当前上下文用量(如 `12.3K`),方便判断何时需要重置对话"
17
+ ]
18
+ },
19
+ {
20
+ "kind": "changed",
21
+ "items": [
22
+ "手机端支持左右滑动:从屏幕左边缘右滑打开侧边栏,在侧边栏内左滑关闭",
23
+ "模型选择按钮更紧凑:名称过长时自动省略,新增展开箭头,选择面板在点击面板外任意位置时关闭",
24
+ "提高单轮工具调用上限:每轮最多 100 次调用、最多 30 层嵌套,复杂任务不再轻易被中断",
25
+ "群聊标题栏不再重复显示成员名单"
26
+ ]
27
+ },
28
+ {
29
+ "kind": "fixed",
30
+ "items": [
31
+ "修复 Android 上点击工作区、顶栏等区域时出现灰色高亮块的问题"
32
+ ]
33
+ }
34
+ ],
35
+ "en": [
36
+ {
37
+ "kind": "added",
38
+ "items": [
39
+ "Added a theme toggle to the chat header for switching between light and dark in one tap",
40
+ "The session header now shows current context usage (for example `12.3K`), making it easier to judge when to reset a conversation"
41
+ ]
42
+ },
43
+ {
44
+ "kind": "changed",
45
+ "items": [
46
+ "Mobile now supports swipe gestures: swipe in from the left edge to open the sidebar, and swipe left inside it to close",
47
+ "The model selector is more compact: long model names are truncated, a caret indicates it opens a panel, and the panel closes when you tap anywhere outside it",
48
+ "Raised the per-turn tool limits to 100 calls and 30 levels of nesting, so complex tasks are interrupted far less often",
49
+ "Group chat headers no longer repeat the member list"
50
+ ]
51
+ },
52
+ {
53
+ "kind": "fixed",
54
+ "items": [
55
+ "Fixed gray tap-highlight boxes appearing when tapping the workspace, top bar, and other areas on Android"
56
+ ]
57
+ }
58
+ ]
59
+ }
60
+ },
61
+ {
62
+ "version": "0.1.17",
63
+ "date": "2026-10-25",
64
+ "breaking": false,
65
+ "compare": "v0.1.16...v0.1.17",
66
+ "groups": {
67
+ "zh-CN": [
68
+ {
69
+ "kind": "changed",
70
+ "items": [
71
+ "在工作区文件管理器中预览文件时不再关闭管理器:预览复用管理器的遮罩层,关闭预览即可回到原来的浏览位置",
72
+ "文件删除确认改为管理器内的弹出对话框,删除过程中按钮显示加载状态,失败时会提示原因,不再静默关闭"
73
+ ]
74
+ },
75
+ {
76
+ "kind": "fixed",
77
+ "items": [
78
+ "修复在 Termux / Android 环境下 shell 命令无法解析的问题:现在会从 Termux 的 `$PREFIX/bin` 中查找受信任的命令,并保留 `head` 等 applet 的原始名称"
79
+ ]
80
+ }
81
+ ],
82
+ "en": [
83
+ {
84
+ "kind": "changed",
85
+ "items": [
86
+ "Previewing a file from the workspace file manager no longer closes the manager: the preview reuses the manager's overlay, and closing it returns you to the same browsing position",
87
+ "Delete confirmation is now a dialog inside the manager; the delete button shows a loading state while the request runs, and failures surface the reason instead of closing silently"
88
+ ]
89
+ },
90
+ {
91
+ "kind": "fixed",
92
+ "items": [
93
+ "Fixed shell commands failing to resolve on Termux / Android: trusted commands are now looked up in Termux's `$PREFIX/bin`, and applets such as `head` keep their original name"
94
+ ]
95
+ }
96
+ ]
97
+ }
98
+ },
5
99
  {
6
100
  "version": "0.1.16",
7
101
  "date": "2026-10-25",
@@ -90,8 +90,8 @@ export interface ActiveDisplay {
90
90
  title?: string;
91
91
  /** 产生该展示的会话 id(读取文件时按其专属工作目录解析,null = 共享工作区) */
92
92
  sessionId: number | null;
93
- /** 初始展示方式;文件管理器使用大弹窗,file_display 默认使用小窗 */
94
- presentation?: 'pip' | 'modal';
93
+ /** 初始展示方式;文件管理器预览复用其遮罩层,file_display 默认使用小窗 */
94
+ presentation?: 'pip' | 'modal' | 'workspace';
95
95
  }
96
96
 
97
97
  /** 启动阶段:加载中 / 数据就绪 / 核心数据加载失败(可重试) */
@@ -281,6 +281,23 @@ function sortNpcs(npcs: NpcCharacter[]): NpcCharacter[] {
281
281
  return [...npcs].sort((a, b) => Number(b.isBuiltIn) - Number(a.isBuiltIn) || a.createdAt - b.createdAt);
282
282
  }
283
283
 
284
+ async function updateSessionContextTokens(
285
+ sessionId: number,
286
+ totalTokens: number,
287
+ loopIndex: number | null,
288
+ ): Promise<Partial<Pick<ChatSession, 'contextTokens' | 'contextTokensLoopIndex'>>> {
289
+ const session = await db.sessions.get(sessionId);
290
+ if (!session) return {};
291
+ if (session.mode !== 'GROUP') {
292
+ return { contextTokens: totalTokens, contextTokensLoopIndex: null };
293
+ }
294
+ const isCurrentLoop = session.contextTokensLoopIndex === loopIndex;
295
+ return {
296
+ contextTokens: isCurrentLoop ? Math.max(session.contextTokens ?? 0, totalTokens) : totalTokens,
297
+ contextTokensLoopIndex: loopIndex,
298
+ };
299
+ }
300
+
284
301
  /** 单回合生成结果:ok 成功 / failed 失败(应回退) / stopped 用户主动停止(不回退) */
285
302
  type TurnResult = 'ok' | 'failed' | 'stopped';
286
303
 
@@ -686,6 +703,8 @@ export const useStore = create<AppState>((set, get) => ({
686
703
  turnQueueHistoryJson: session.turnQueueHistoryJson,
687
704
  loopIndex: session.loopIndex,
688
705
  lastMessage: session.lastMessage,
706
+ contextTokens: session.contextTokens,
707
+ contextTokensLoopIndex: session.contextTokensLoopIndex,
689
708
  updatedAt: session.updatedAt,
690
709
  };
691
710
 
@@ -703,6 +722,8 @@ export const useStore = create<AppState>((set, get) => ({
703
722
  await db.sessions.update(sessionId, {
704
723
  updatedAt: Date.now(),
705
724
  lastMessage: lastVisible ? sessionPreviewText(lastVisible.content) || '…' : '',
725
+ contextTokens: null,
726
+ contextTokensLoopIndex: null,
706
727
  });
707
728
 
708
729
  let result: TurnResult;
@@ -757,6 +778,8 @@ export const useStore = create<AppState>((set, get) => ({
757
778
  turnQueueHistoryJson: originalSession.turnQueueHistoryJson,
758
779
  loopIndex: originalSession.loopIndex,
759
780
  lastMessage: originalSession.lastMessage,
781
+ contextTokens: originalSession.contextTokens,
782
+ contextTokensLoopIndex: originalSession.contextTokensLoopIndex,
760
783
  updatedAt: originalSession.updatedAt,
761
784
  };
762
785
  // 编辑该消息,删除它之后的所有消息(不含自身)
@@ -1085,6 +1108,8 @@ export const useStore = create<AppState>((set, get) => ({
1085
1108
  turnQueueHistoryJson: queueHistoryJson([queue]),
1086
1109
  loopIndex: 0,
1087
1110
  lastMessage,
1111
+ contextTokens: null,
1112
+ contextTokensLoopIndex: null,
1088
1113
  updatedAt: Date.now(),
1089
1114
  });
1090
1115
  await get().loadMessages(sessionId);
@@ -2110,7 +2135,7 @@ async function performAssistantTurn(
2110
2135
  if (errorMsg) {
2111
2136
  const preserved = await persistPartialDraft(
2112
2137
  draftId, sessionId, content, thinking, request, rawLines, promptTokens, completionTokens,
2113
- startTime, model, !roundCounted, npc,
2138
+ startTime, model, !roundCounted, npc, turnLoopIndex,
2114
2139
  );
2115
2140
  finishSessionActivity(sessionId);
2116
2141
  useStore.getState().addToast(translate('toast.genFailed', { msg: errorMsg }), 'error');
@@ -2121,7 +2146,7 @@ async function performAssistantTurn(
2121
2146
  if (stopped || abortController.signal.aborted) {
2122
2147
  await persistPartialDraft(
2123
2148
  draftId, sessionId, content, thinking, request, rawLines, promptTokens, completionTokens,
2124
- startTime, model, !roundCounted, npc,
2149
+ startTime, model, !roundCounted, npc, turnLoopIndex,
2125
2150
  );
2126
2151
  break;
2127
2152
  }
@@ -2181,11 +2206,13 @@ async function performAssistantTurn(
2181
2206
  null
2182
2207
  );
2183
2208
 
2184
- // 更新会话预览
2209
+ // 更新会话预览与本轮上下文占用。群聊同一轮保留各角色请求中的最大值。
2185
2210
  const preview = sessionPreviewText(normalizedContent);
2211
+ const contextTokens = await updateSessionContextTokens(sessionId, promptTokens + finalTokens, turnLoopIndex);
2186
2212
  await db.sessions.update(sessionId, {
2187
2213
  updatedAt: Date.now(),
2188
2214
  lastMessage: preview || '…',
2215
+ ...contextTokens,
2189
2216
  });
2190
2217
 
2191
2218
  if (finalToolCalls.length === 0 && session.mode !== 'GROUP') {
@@ -2400,7 +2427,8 @@ async function persistPartialDraft(
2400
2427
  startTime: number,
2401
2428
  model: string,
2402
2429
  countRound: boolean,
2403
- npc: NpcCharacter | null
2430
+ npc: NpcCharacter | null,
2431
+ loopIndex: number | null,
2404
2432
  ): Promise<boolean> {
2405
2433
  const latencyMs = Date.now() - startTime;
2406
2434
  const normalizedContent = trimEdgeNewlines(content);
@@ -2436,9 +2464,11 @@ async function persistPartialDraft(
2436
2464
  null
2437
2465
  );
2438
2466
  const preview = sessionPreviewText(normalizedContent);
2467
+ const contextTokens = await updateSessionContextTokens(sessionId, promptTokens + finalTokens, loopIndex);
2439
2468
  await db.sessions.update(sessionId, {
2440
2469
  updatedAt: Date.now(),
2441
2470
  lastMessage: preview || '…',
2471
+ ...contextTokens,
2442
2472
  });
2443
2473
  return true;
2444
2474
  }
@@ -465,7 +465,7 @@
465
465
  z-index: 1;
466
466
  display: flex;
467
467
  align-items: flex-end;
468
- gap: 8px;
468
+ gap: 4px;
469
469
  max-width: 820px;
470
470
  margin: 0 auto;
471
471
  background: var(--surface);
@@ -509,37 +509,50 @@
509
509
  .composer-box .composer-model-btn {
510
510
  display: inline-flex;
511
511
  align-items: center;
512
- gap: 5px;
512
+ gap: 4px;
513
513
  height: 40px;
514
514
  max-width: 180px;
515
- padding: 0 10px;
515
+ padding: 0 4px;
516
+ border: 1px solid transparent;
516
517
  border-radius: 12px;
517
518
  flex-shrink: 0;
518
519
  background: var(--surface-2);
519
- border: 1px solid var(--border-soft);
520
- color: var(--primary);
520
+ color: var(--text-dim);
521
521
  font-size: 12px;
522
522
  font-weight: 600;
523
- transition: background 0.32s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.32s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.32s cubic-bezier(0.16, 1, 0.3, 1);
523
+ transition: background 0.15s ease, color 0.15s ease;
524
524
  }
525
- .composer-box .composer-model-btn:hover {
526
- background: color-mix(in srgb, var(--primary) 8%, transparent);
527
- border-color: color-mix(in srgb, var(--primary) 28%, transparent);
528
- }
529
- /* 弹层打开(激活态)才显示辉光 */
525
+ .composer-box .composer-model-btn:hover,
526
+ .composer-box .composer-model-btn:active,
530
527
  .composer-box .composer-model-btn.open {
531
- background: var(--primary-soft);
532
- border-color: color-mix(in srgb, var(--primary) 44%, transparent);
533
- box-shadow:
534
- 0 0 0 1px color-mix(in srgb, var(--primary) 22%, transparent),
535
- 0 0 8px color-mix(in srgb, var(--glow) 38%, transparent),
536
- 0 0 16px color-mix(in srgb, var(--glow) 16%, transparent);
528
+ background: var(--surface-2);
529
+ border-color: transparent;
530
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 24%, transparent);
531
+ color: var(--primary);
537
532
  }
538
533
  .composer-model-label {
534
+ min-width: 0;
535
+ flex: 1;
539
536
  white-space: nowrap;
540
537
  overflow: hidden;
541
- text-overflow: ellipsis;
542
- max-width: 120px;
538
+ max-width: 170px;
539
+ }
540
+ .composer-model-caret {
541
+ width: 0;
542
+ height: 0;
543
+ flex: 0 0 auto;
544
+ border-bottom: 4px solid currentColor;
545
+ border-right: 4px solid transparent;
546
+ border-left: 4px solid transparent;
547
+ opacity: 0.7;
548
+ transition: transform 0.15s ease;
549
+ }
550
+ .composer-model-btn.open .composer-model-caret {
551
+ transform: rotate(180deg);
552
+ }
553
+ .composer-model-label.truncated {
554
+ -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 16px), transparent);
555
+ mask-image: linear-gradient(to right, #000 calc(100% - 16px), transparent);
543
556
  }
544
557
  .composer-model {
545
558
  position: relative;
@@ -1269,12 +1282,12 @@
1269
1282
  .composer-box .composer-model-btn {
1270
1283
  width: auto;
1271
1284
  max-width: 104px;
1272
- padding: 0 8px;
1285
+ padding: 0 4px;
1273
1286
  }
1274
1287
 
1275
1288
  .composer-model-label {
1276
1289
  display: block;
1277
- max-width: 86px;
1290
+ max-width: 84px;
1278
1291
  }
1279
1292
 
1280
1293
  .composer-box textarea {
@@ -83,6 +83,13 @@ button {
83
83
  font-family: inherit;
84
84
  cursor: pointer;
85
85
  white-space: nowrap;
86
+ }
87
+
88
+ /* Android WebView adds a blue overlay to non-button controls such as switch labels. */
89
+ button,
90
+ a,
91
+ label,
92
+ [role='button'] {
86
93
  -webkit-tap-highlight-color: transparent;
87
94
  }
88
95
 
@@ -1065,6 +1072,12 @@ body.resizing .sidebar-resizer {
1065
1072
  font-weight: 700;
1066
1073
  color: var(--primary);
1067
1074
  }
1075
+ .chat-header .tsub-context-group {
1076
+ display: inline-flex;
1077
+ align-items: center;
1078
+ gap: 2px;
1079
+ margin-left: -6px;
1080
+ }
1068
1081
 
1069
1082
  /* ---------- 聊天头部右侧动作按钮 ---------- */
1070
1083
  .chat-actions {
@@ -1666,6 +1679,22 @@ hr.sep {
1666
1679
  display: none;
1667
1680
  }
1668
1681
 
1682
+ /* 与桌面导航一致:仅在相应方向仍有内容被遮挡时淡出边缘。 */
1683
+ .mobile-topbar-nav.has-overflow.hidden-left {
1684
+ -webkit-mask-image: linear-gradient(to right, transparent 0, #000 18px);
1685
+ mask-image: linear-gradient(to right, transparent 0, #000 18px);
1686
+ }
1687
+
1688
+ .mobile-topbar-nav.has-overflow.hidden-right {
1689
+ -webkit-mask-image: linear-gradient(to left, transparent 0, #000 18px);
1690
+ mask-image: linear-gradient(to left, transparent 0, #000 18px);
1691
+ }
1692
+
1693
+ .mobile-topbar-nav.has-overflow.hidden-left.hidden-right {
1694
+ -webkit-mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 calc(100% - 18px), transparent 100%);
1695
+ mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 calc(100% - 18px), transparent 100%);
1696
+ }
1697
+
1669
1698
  .mobile-home-button {
1670
1699
  display: inline-flex;
1671
1700
  align-items: center;
@@ -1409,9 +1409,13 @@
1409
1409
  font-size: 13px;
1410
1410
  }
1411
1411
  /* ---------- 文件展示弹窗(file_display) ---------- */
1412
+ .display-modal-root {
1413
+ z-index: 902;
1414
+ }
1412
1415
  .display-modal {
1413
1416
  width: min(880px, calc(100vw - 32px));
1414
1417
  height: min(82%, 720px);
1418
+ overflow: hidden;
1415
1419
  }
1416
1420
  .display-pip {
1417
1421
  position: fixed;
@@ -1748,6 +1752,7 @@
1748
1752
  color: var(--text-dim);
1749
1753
  font-size: 13px;
1750
1754
  user-select: none;
1755
+ -webkit-tap-highlight-color: transparent;
1751
1756
  }
1752
1757
  .ws-fm-row:hover {
1753
1758
  background: color-mix(in srgb, var(--primary) 10%, transparent);
@@ -1788,6 +1793,20 @@
1788
1793
  gap: 10px;
1789
1794
  flex: 1;
1790
1795
  }
1796
+ .ws-fm-delete-layer {
1797
+ position: fixed;
1798
+ inset: 0;
1799
+ z-index: 902;
1800
+ display: grid;
1801
+ place-items: center;
1802
+ padding: 16px;
1803
+ pointer-events: auto;
1804
+ }
1805
+ .ws-fm-delete-dialog {
1806
+ width: min(420px, 100%);
1807
+ background: var(--surface);
1808
+ box-shadow: var(--shadow-lg);
1809
+ }
1791
1810
 
1792
1811
  /* ---------- 版本记录(设置 → 关于) ---------- */
1793
1812
  .settings-about-section {
@@ -151,6 +151,10 @@ export interface ChatSession {
151
151
  turnQueueHistoryJson: string;
152
152
  loopIndex: number;
153
153
  lastMessage: string;
154
+ /** 最近完成请求的上下文总 Token;群聊中为最新轮次所有请求的最大值。 */
155
+ contextTokens?: number | null;
156
+ /** contextTokens 对应的群聊轮次;单人会话为 null。 */
157
+ contextTokensLoopIndex?: number | null;
154
158
  /** 置顶标记:1 = 置顶(侧边栏会话列表置顶区),0 = 未置顶。默认 0(false) */
155
159
  pinned: number;
156
160
  updatedAt: number;