tavern-harness 0.1.17 → 0.1.19
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 +64 -0
- package/package.json +1 -1
- package/src/App.tsx +34 -10
- package/src/components/CharactersView.tsx +3 -0
- package/src/components/ChatView.tsx +25 -2
- package/src/components/GameplayDialogs.tsx +3 -0
- package/src/components/Sidebar.tsx +39 -2
- package/src/components/shared.tsx +2 -0
- package/src/core/achievements.ts +4 -0
- package/src/core/i18n.ts +33 -0
- package/src/core/toolDefinitions.ts +2 -2
- package/src/core/tools/toolExecutor.ts +4 -0
- package/src/generated/changelog.json +95 -1
- package/src/store/store.ts +34 -4
- package/src/theme/chat.css +34 -21
- package/src/theme/global.css +32 -0
- package/src/theme/views.css +1 -0
- package/src/types/models.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,68 @@
|
|
|
3
3
|
> 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
|
|
4
4
|
> 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
|
|
5
5
|
|
|
6
|
+
## [0.1.19] - 2026-10-25
|
|
7
|
+
|
|
8
|
+
### 中文
|
|
9
|
+
|
|
10
|
+
#### 新增
|
|
11
|
+
|
|
12
|
+
- 新增四项「首次行动」成就:首次导出游戏(独乐乐不如众乐乐)、首次导入游戏(发现者)、首次创建角色卡或世界书(创造者)、首次创建自定义技能(探索者)
|
|
13
|
+
|
|
14
|
+
#### 改进
|
|
15
|
+
|
|
16
|
+
- 聊天标题栏更简洁:副标题只保留上下文用量,不再重复显示会话模式
|
|
17
|
+
- 手机端标题栏按钮区支持横向滑动,按钮较多时不再被挤出屏幕或误触页面
|
|
18
|
+
|
|
19
|
+
### English
|
|
20
|
+
|
|
21
|
+
#### Added
|
|
22
|
+
|
|
23
|
+
- 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)
|
|
24
|
+
|
|
25
|
+
#### Changed
|
|
26
|
+
|
|
27
|
+
- The chat header is cleaner: the subtitle now shows only context usage instead of repeating the session mode
|
|
28
|
+
- On mobile, the header action row scrolls horizontally, so buttons are no longer pushed off screen or trigger page gestures
|
|
29
|
+
|
|
30
|
+
## [0.1.18] - 2026-10-25
|
|
31
|
+
|
|
32
|
+
### 中文
|
|
33
|
+
|
|
34
|
+
#### 新增
|
|
35
|
+
|
|
36
|
+
- 聊天标题栏新增主题切换按钮,可在浅色与深色之间一键切换
|
|
37
|
+
- 会话标题下方显示当前上下文用量(如 `12.3K`),方便判断何时需要重置对话
|
|
38
|
+
|
|
39
|
+
#### 改进
|
|
40
|
+
|
|
41
|
+
- 手机端支持左右滑动:从屏幕左边缘右滑打开侧边栏,在侧边栏内左滑关闭
|
|
42
|
+
- 模型选择按钮更紧凑:名称过长时自动省略,新增展开箭头,选择面板在点击面板外任意位置时关闭
|
|
43
|
+
- 提高单轮工具调用上限:每轮最多 100 次调用、最多 30 层嵌套,复杂任务不再轻易被中断
|
|
44
|
+
- 群聊标题栏不再重复显示成员名单
|
|
45
|
+
|
|
46
|
+
#### 修复
|
|
47
|
+
|
|
48
|
+
- 修复 Android 上点击工作区、顶栏等区域时出现灰色高亮块的问题
|
|
49
|
+
|
|
50
|
+
### English
|
|
51
|
+
|
|
52
|
+
#### Added
|
|
53
|
+
|
|
54
|
+
- Added a theme toggle to the chat header for switching between light and dark in one tap
|
|
55
|
+
- The session header now shows current context usage (for example `12.3K`), making it easier to judge when to reset a conversation
|
|
56
|
+
|
|
57
|
+
#### Changed
|
|
58
|
+
|
|
59
|
+
- Mobile now supports swipe gestures: swipe in from the left edge to open the sidebar, and swipe left inside it to close
|
|
60
|
+
- 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
|
|
61
|
+
- Raised the per-turn tool limits to 100 calls and 30 levels of nesting, so complex tasks are interrupted far less often
|
|
62
|
+
- Group chat headers no longer repeat the member list
|
|
63
|
+
|
|
64
|
+
#### Fixed
|
|
65
|
+
|
|
66
|
+
- Fixed gray tap-highlight boxes appearing when tapping the workspace, top bar, and other areas on Android
|
|
67
|
+
|
|
6
68
|
## [0.1.17] - 2026-10-25
|
|
7
69
|
|
|
8
70
|
### 中文
|
|
@@ -490,6 +552,8 @@
|
|
|
490
552
|
- Vite + React + Zustand + Dexie stack with browser-side local persistence
|
|
491
553
|
|
|
492
554
|
<!-- 链接定义 -->
|
|
555
|
+
[0.1.19]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.18...v0.1.19
|
|
556
|
+
[0.1.18]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.17...v0.1.18
|
|
493
557
|
[0.1.17]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.16...v0.1.17
|
|
494
558
|
[0.1.16]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.15...v0.1.16
|
|
495
559
|
[0.1.15]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.14...v0.1.15
|
package/package.json
CHANGED
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);
|
|
@@ -287,7 +293,16 @@ function SessionHeader({
|
|
|
287
293
|
const [exportOpen, setExportOpen] = useState(false);
|
|
288
294
|
const [showWorkspace, setShowWorkspace] = useState(false);
|
|
289
295
|
|
|
290
|
-
const
|
|
296
|
+
const messages = useStore((s) => s.messages[session.id!] ?? []);
|
|
297
|
+
const contextTokens = useMemo(() => {
|
|
298
|
+
const assistantMessages = messages.filter((message) => message.role === 'assistant' && message.totalTokens > 0);
|
|
299
|
+
if (assistantMessages.length === 0) return null;
|
|
300
|
+
if (session.mode !== 'GROUP') return assistantMessages.at(-1)!.totalTokens;
|
|
301
|
+
const latestLoopIndex = assistantMessages.at(-1)!.loopIndex;
|
|
302
|
+
return Math.max(...assistantMessages
|
|
303
|
+
.filter((message) => message.loopIndex === latestLoopIndex)
|
|
304
|
+
.map((message) => message.totalTokens));
|
|
305
|
+
}, [messages, session.mode]);
|
|
291
306
|
const npcRef = session.associatedId ? npcs.find((n) => n.id === session.associatedId) : null;
|
|
292
307
|
const usesPublicWorkspace = session.mode === 'NPC' && npcRef?.isBuiltIn === true;
|
|
293
308
|
const groupNpcs = participants.filter((p) => p.kind === 'NPC').map((p) => npcs.find((n) => n.id === p.npcId)).filter(Boolean);
|
|
@@ -296,20 +311,20 @@ function SessionHeader({
|
|
|
296
311
|
colorOrdinal: n.avatarColorOrdinal,
|
|
297
312
|
imageUrl: n.avatarDataUrl,
|
|
298
313
|
}));
|
|
314
|
+
const isDarkTheme = themeMode === 'dark'
|
|
315
|
+
|| (themeMode === 'system' && document.documentElement.dataset.theme === 'dark');
|
|
316
|
+
const toggleTheme = () => void setSettings({ themeMode: isDarkTheme ? 'light' : 'dark' });
|
|
299
317
|
|
|
300
318
|
return (
|
|
301
319
|
<div className="chat-header">
|
|
302
320
|
<SessionVisual mode={session.mode} npcName={npcRef?.name ?? undefined} hue={npcRef?.avatarColorOrdinal ?? 0} imageUrl={npcRef?.avatarDataUrl} members={session.mode === 'GROUP' ? groupMemberAvatars : undefined} size="lg" />
|
|
303
321
|
<div className="tinfo">
|
|
304
322
|
<div className="ttitle">{session.title}</div>
|
|
305
|
-
|
|
306
|
-
<
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
</span>
|
|
311
|
-
)}
|
|
312
|
-
</div>
|
|
323
|
+
{contextTokens != null && (
|
|
324
|
+
<div className="tsub">
|
|
325
|
+
<span className="tsub-mode">{t('nav.contextUsage', { tokens: formatContextTokens(contextTokens) })}</span>
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
313
328
|
</div>
|
|
314
329
|
<div className="chat-actions">
|
|
315
330
|
<button className="btn-ghost icon-tooltip" title={t(usesPublicWorkspace ? 'header.workspaceRootHint' : 'header.workspaceHint')} onClick={() => setShowWorkspace(true)}>
|
|
@@ -327,6 +342,15 @@ function SessionHeader({
|
|
|
327
342
|
<button className="btn-ghost icon-tooltip danger" title={t('header.deleteTip')} onClick={() => setConfirmDelete(true)}>
|
|
328
343
|
<Icon name="trash" size={17} />
|
|
329
344
|
</button>
|
|
345
|
+
<button
|
|
346
|
+
className="btn-ghost icon-tooltip"
|
|
347
|
+
type="button"
|
|
348
|
+
title={t(isDarkTheme ? 'header.switchToLightTip' : 'header.switchToDarkTip')}
|
|
349
|
+
aria-label={t(isDarkTheme ? 'header.switchToLightTip' : 'header.switchToDarkTip')}
|
|
350
|
+
onClick={toggleTheme}
|
|
351
|
+
>
|
|
352
|
+
<Icon name={isDarkTheme ? 'sun' : 'moon'} size={17} />
|
|
353
|
+
</button>
|
|
330
354
|
</div>
|
|
331
355
|
|
|
332
356
|
{showEdit && <NewSessionMenu editingSession={session} onClose={() => setShowEdit(false)} />}
|
|
@@ -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);
|
|
@@ -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
|
|
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 (
|
|
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();
|
|
@@ -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(
|
|
@@ -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"
|
|
@@ -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/achievements.ts
CHANGED
|
@@ -22,6 +22,10 @@ export const ACHIEVEMENTS: AchievementDef[] = [
|
|
|
22
22
|
{ id: 'night-owl', name: '午夜不归人', icon: '🌙', threshold: 1_000_000, description: '累计使用 1,000,000 Token' },
|
|
23
23
|
{ id: 'legend', name: '酒馆传奇', icon: '🏆', threshold: 100_000_000, description: '累计使用 100,000,000 Token' },
|
|
24
24
|
{ id: 'th-shareholder', name: 'TH股东', icon: '📈', iconUrl: '/tavern-harness-logo.png', description: '连续点击首页图标 5 次' },
|
|
25
|
+
{ id: 'game-exporter', name: '独乐乐不如众乐乐', icon: '📤', description: '首次导出游戏' },
|
|
26
|
+
{ id: 'game-importer', name: '发现者', icon: '📥', description: '首次导入游戏' },
|
|
27
|
+
{ id: 'creator', name: '创造者', icon: '🎨', description: '首次创建角色卡或世界书' },
|
|
28
|
+
{ id: 'explorer', name: '探索者', icon: '🧩', description: '首次创建自定义技能' },
|
|
25
29
|
];
|
|
26
30
|
|
|
27
31
|
/** 生涯总 token(输入 + 输出) */
|
package/src/core/i18n.ts
CHANGED
|
@@ -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': '工作区文件',
|
|
@@ -381,6 +384,14 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
381
384
|
'ach.legend.desc': '累计使用 100,000,000 Token',
|
|
382
385
|
'ach.thShareholder.name': 'TH股东',
|
|
383
386
|
'ach.thShareholder.desc': '5 秒内连续点击首页图标 5 次',
|
|
387
|
+
'ach.gameExporter.name': '独乐乐不如众乐乐',
|
|
388
|
+
'ach.gameExporter.desc': '首次导出游戏',
|
|
389
|
+
'ach.gameImporter.name': '发现者',
|
|
390
|
+
'ach.gameImporter.desc': '首次导入游戏',
|
|
391
|
+
'ach.creator.name': '创造者',
|
|
392
|
+
'ach.creator.desc': '首次创建角色卡或世界书',
|
|
393
|
+
'ach.explorer.name': '探索者',
|
|
394
|
+
'ach.explorer.desc': '首次创建自定义技能',
|
|
384
395
|
|
|
385
396
|
// ---- 成就解锁弹窗 ----
|
|
386
397
|
'achModal.title': '🎉 成就解锁!',
|
|
@@ -573,6 +584,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
573
584
|
'nav.empty': '還沒有會話,先去找酒館老闆聊聊?',
|
|
574
585
|
'nav.noMatch': '沒有符合的會話',
|
|
575
586
|
'nav.newConversation': '開始新對話…',
|
|
587
|
+
'nav.contextUsage': '上下文佔用 {tokens}',
|
|
576
588
|
'nav.deleteSession': '刪除會話',
|
|
577
589
|
'nav.editSession': '編輯會話',
|
|
578
590
|
'nav.pinSession': '置頂對話',
|
|
@@ -599,6 +611,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
599
611
|
'header.deleteTip': '刪除對話',
|
|
600
612
|
'header.deleteTitle': '刪除對話',
|
|
601
613
|
'header.deleteConfirm': '確定刪除對話「{title}」?此操作無法復原。',
|
|
614
|
+
'header.switchToLightTip': '切換到淺色模式',
|
|
615
|
+
'header.switchToDarkTip': '切換到深色模式',
|
|
602
616
|
'header.workspaceHint': '本對話的專屬工作目錄。檔案工具限制在該目錄內;本地指令以此為起始目錄,但不具備 OS 隔離。點擊可瀏覽目錄檔案(唯讀)。',
|
|
603
617
|
'header.workspaceRootHint': '酒館老闆的單人對話固定使用公共工作目錄。檔案工具限制在該目錄內;本地指令不具備 OS 隔離。點擊可瀏覽檔案(唯讀)。',
|
|
604
618
|
'header.fileManagerTitle': '工作區檔案',
|
|
@@ -885,6 +899,14 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
885
899
|
'ach.legend.desc': '累計使用 100,000,000 Token',
|
|
886
900
|
'ach.thShareholder.name': 'TH股東',
|
|
887
901
|
'ach.thShareholder.desc': '5 秒內連續點擊首頁圖示 5 次',
|
|
902
|
+
'ach.gameExporter.name': '獨樂樂不如眾樂樂',
|
|
903
|
+
'ach.gameExporter.desc': '首次匯出遊戲',
|
|
904
|
+
'ach.gameImporter.name': '發現者',
|
|
905
|
+
'ach.gameImporter.desc': '首次匯入遊戲',
|
|
906
|
+
'ach.creator.name': '創造者',
|
|
907
|
+
'ach.creator.desc': '首次建立角色卡或世界書',
|
|
908
|
+
'ach.explorer.name': '探索者',
|
|
909
|
+
'ach.explorer.desc': '首次建立自訂技能',
|
|
888
910
|
|
|
889
911
|
'achModal.title': '🎉 成就解鎖!',
|
|
890
912
|
'achModal.total': '生涯已累計 {n} Tokens',
|
|
@@ -1071,6 +1093,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1071
1093
|
'nav.empty': 'No sessions yet — go chat with the Tavern Keeper?',
|
|
1072
1094
|
'nav.noMatch': 'No matching sessions',
|
|
1073
1095
|
'nav.newConversation': 'Start a new conversation…',
|
|
1096
|
+
'nav.contextUsage': 'Context usage {tokens}',
|
|
1074
1097
|
'nav.deleteSession': 'Delete session',
|
|
1075
1098
|
'nav.editSession': 'Edit conversation',
|
|
1076
1099
|
'nav.pinSession': 'Pin conversation',
|
|
@@ -1097,6 +1120,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1097
1120
|
'header.deleteTip': 'Delete conversation',
|
|
1098
1121
|
'header.deleteTitle': 'Delete conversation',
|
|
1099
1122
|
'header.deleteConfirm': 'Delete conversation "{title}"? This cannot be undone.',
|
|
1123
|
+
'header.switchToLightTip': 'Switch to light mode',
|
|
1124
|
+
'header.switchToDarkTip': 'Switch to dark mode',
|
|
1100
1125
|
'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
1126
|
'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
1127
|
'header.fileManagerTitle': 'Workspace files',
|
|
@@ -1383,6 +1408,14 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
|
|
|
1383
1408
|
'ach.legend.desc': 'Cumulatively used 100,000,000 tokens',
|
|
1384
1409
|
'ach.thShareholder.name': 'TH Shareholder',
|
|
1385
1410
|
'ach.thShareholder.desc': 'Clicked the home icon 5 times within 5 seconds',
|
|
1411
|
+
'ach.gameExporter.name': 'Sharing Is Caring',
|
|
1412
|
+
'ach.gameExporter.desc': 'Exported a game for the first time',
|
|
1413
|
+
'ach.gameImporter.name': 'Discoverer',
|
|
1414
|
+
'ach.gameImporter.desc': 'Imported a game for the first time',
|
|
1415
|
+
'ach.creator.name': 'Creator',
|
|
1416
|
+
'ach.creator.desc': 'Created a character card or Lorebook for the first time',
|
|
1417
|
+
'ach.explorer.name': 'Explorer',
|
|
1418
|
+
'ach.explorer.desc': 'Created a custom skill for the first time',
|
|
1386
1419
|
|
|
1387
1420
|
'achModal.title': '🎉 Achievement unlocked!',
|
|
1388
1421
|
'achModal.total': 'Lifetime total {n} tokens',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ChatCompletionTool } from '../types/models';
|
|
2
2
|
|
|
3
|
-
export const MAX_TOOL_CALL_DEPTH =
|
|
4
|
-
export const MAX_TOOL_CALLS_PER_TURN =
|
|
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 = '开始新话题';
|
|
@@ -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
|
|
|
@@ -1,7 +1,101 @@
|
|
|
1
1
|
{
|
|
2
|
-
"latest": "0.1.
|
|
2
|
+
"latest": "0.1.19",
|
|
3
3
|
"repo": "https://github.com/Rannichan/Tavern-Harness",
|
|
4
4
|
"entries": [
|
|
5
|
+
{
|
|
6
|
+
"version": "0.1.19",
|
|
7
|
+
"date": "2026-10-25",
|
|
8
|
+
"breaking": false,
|
|
9
|
+
"compare": "v0.1.18...v0.1.19",
|
|
10
|
+
"groups": {
|
|
11
|
+
"zh-CN": [
|
|
12
|
+
{
|
|
13
|
+
"kind": "added",
|
|
14
|
+
"items": [
|
|
15
|
+
"新增四项「首次行动」成就:首次导出游戏(独乐乐不如众乐乐)、首次导入游戏(发现者)、首次创建角色卡或世界书(创造者)、首次创建自定义技能(探索者)"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"kind": "changed",
|
|
20
|
+
"items": [
|
|
21
|
+
"聊天标题栏更简洁:副标题只保留上下文用量,不再重复显示会话模式",
|
|
22
|
+
"手机端标题栏按钮区支持横向滑动,按钮较多时不再被挤出屏幕或误触页面"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"en": [
|
|
27
|
+
{
|
|
28
|
+
"kind": "added",
|
|
29
|
+
"items": [
|
|
30
|
+
"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)"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"kind": "changed",
|
|
35
|
+
"items": [
|
|
36
|
+
"The chat header is cleaner: the subtitle now shows only context usage instead of repeating the session mode",
|
|
37
|
+
"On mobile, the header action row scrolls horizontally, so buttons are no longer pushed off screen or trigger page gestures"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"version": "0.1.18",
|
|
45
|
+
"date": "2026-10-25",
|
|
46
|
+
"breaking": false,
|
|
47
|
+
"compare": "v0.1.17...v0.1.18",
|
|
48
|
+
"groups": {
|
|
49
|
+
"zh-CN": [
|
|
50
|
+
{
|
|
51
|
+
"kind": "added",
|
|
52
|
+
"items": [
|
|
53
|
+
"聊天标题栏新增主题切换按钮,可在浅色与深色之间一键切换",
|
|
54
|
+
"会话标题下方显示当前上下文用量(如 `12.3K`),方便判断何时需要重置对话"
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"kind": "changed",
|
|
59
|
+
"items": [
|
|
60
|
+
"手机端支持左右滑动:从屏幕左边缘右滑打开侧边栏,在侧边栏内左滑关闭",
|
|
61
|
+
"模型选择按钮更紧凑:名称过长时自动省略,新增展开箭头,选择面板在点击面板外任意位置时关闭",
|
|
62
|
+
"提高单轮工具调用上限:每轮最多 100 次调用、最多 30 层嵌套,复杂任务不再轻易被中断",
|
|
63
|
+
"群聊标题栏不再重复显示成员名单"
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"kind": "fixed",
|
|
68
|
+
"items": [
|
|
69
|
+
"修复 Android 上点击工作区、顶栏等区域时出现灰色高亮块的问题"
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"en": [
|
|
74
|
+
{
|
|
75
|
+
"kind": "added",
|
|
76
|
+
"items": [
|
|
77
|
+
"Added a theme toggle to the chat header for switching between light and dark in one tap",
|
|
78
|
+
"The session header now shows current context usage (for example `12.3K`), making it easier to judge when to reset a conversation"
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"kind": "changed",
|
|
83
|
+
"items": [
|
|
84
|
+
"Mobile now supports swipe gestures: swipe in from the left edge to open the sidebar, and swipe left inside it to close",
|
|
85
|
+
"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",
|
|
86
|
+
"Raised the per-turn tool limits to 100 calls and 30 levels of nesting, so complex tasks are interrupted far less often",
|
|
87
|
+
"Group chat headers no longer repeat the member list"
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"kind": "fixed",
|
|
92
|
+
"items": [
|
|
93
|
+
"Fixed gray tap-highlight boxes appearing when tapping the workspace, top bar, and other areas on Android"
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
},
|
|
5
99
|
{
|
|
6
100
|
"version": "0.1.17",
|
|
7
101
|
"date": "2026-10-25",
|
package/src/store/store.ts
CHANGED
|
@@ -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
|
}
|
package/src/theme/chat.css
CHANGED
|
@@ -465,7 +465,7 @@
|
|
|
465
465
|
z-index: 1;
|
|
466
466
|
display: flex;
|
|
467
467
|
align-items: flex-end;
|
|
468
|
-
gap:
|
|
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:
|
|
512
|
+
gap: 4px;
|
|
513
513
|
height: 40px;
|
|
514
514
|
max-width: 180px;
|
|
515
|
-
padding: 0
|
|
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
|
-
|
|
520
|
-
color: var(--primary);
|
|
520
|
+
color: var(--text-dim);
|
|
521
521
|
font-size: 12px;
|
|
522
522
|
font-weight: 600;
|
|
523
|
-
transition: background 0.
|
|
523
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
524
524
|
}
|
|
525
|
-
.composer-box .composer-model-btn:hover
|
|
526
|
-
|
|
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(--
|
|
532
|
-
border-color:
|
|
533
|
-
box-shadow:
|
|
534
|
-
|
|
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
|
-
|
|
542
|
-
|
|
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
|
|
1285
|
+
padding: 0 4px;
|
|
1273
1286
|
}
|
|
1274
1287
|
|
|
1275
1288
|
.composer-model-label {
|
|
1276
1289
|
display: block;
|
|
1277
|
-
max-width:
|
|
1290
|
+
max-width: 84px;
|
|
1278
1291
|
}
|
|
1279
1292
|
|
|
1280
1293
|
.composer-box textarea {
|
package/src/theme/global.css
CHANGED
|
@@ -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;
|
|
@@ -1775,11 +1804,14 @@ hr.sep {
|
|
|
1775
1804
|
.chat-actions {
|
|
1776
1805
|
display: flex;
|
|
1777
1806
|
flex: 0 1 auto;
|
|
1807
|
+
min-width: 0;
|
|
1778
1808
|
justify-content: flex-end;
|
|
1779
1809
|
gap: 0;
|
|
1780
1810
|
max-width: none;
|
|
1781
1811
|
overflow-x: auto;
|
|
1812
|
+
overscroll-behavior-x: contain;
|
|
1782
1813
|
scrollbar-width: none;
|
|
1814
|
+
touch-action: pan-x;
|
|
1783
1815
|
}
|
|
1784
1816
|
|
|
1785
1817
|
.chat-actions::-webkit-scrollbar {
|
package/src/theme/views.css
CHANGED
package/src/types/models.ts
CHANGED
|
@@ -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;
|