tavern-harness 0.1.0
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/README.md +161 -0
- package/README.zh.md +123 -0
- package/bin/cli.js +48 -0
- package/index.html +35 -0
- package/package.json +50 -0
- package/public/favicon.png +0 -0
- package/public/tavern-harness-logo.png +0 -0
- package/sandbox-server.mjs +997 -0
- package/src/App.tsx +268 -0
- package/src/assets/tavern-handbook.md +39 -0
- package/src/components/AchievementModal.tsx +158 -0
- package/src/components/CharactersView.tsx +904 -0
- package/src/components/ChatView.tsx +1639 -0
- package/src/components/ConfirmationDialog.tsx +56 -0
- package/src/components/Dashboard.tsx +180 -0
- package/src/components/DeleteConfirmDialog.tsx +48 -0
- package/src/components/FileDisplayModal.tsx +556 -0
- package/src/components/GameplayDialogs.tsx +191 -0
- package/src/components/NewSessionMenu.tsx +362 -0
- package/src/components/SettingsView.tsx +517 -0
- package/src/components/Sidebar.tsx +392 -0
- package/src/components/StatsView.tsx +189 -0
- package/src/components/Toasts.tsx +20 -0
- package/src/components/WorkspaceFileManagerModal.tsx +216 -0
- package/src/components/shared.tsx +369 -0
- package/src/core/achievements.ts +85 -0
- package/src/core/fileDownload.ts +67 -0
- package/src/core/gameplay.ts +521 -0
- package/src/core/horizontalWheel.ts +93 -0
- package/src/core/i18n.ts +1403 -0
- package/src/core/linkedScroll.ts +236 -0
- package/src/core/markdown.ts +170 -0
- package/src/core/openai.ts +451 -0
- package/src/core/openingGreeting.ts +41 -0
- package/src/core/prompts.ts +254 -0
- package/src/core/proxy.ts +58 -0
- package/src/core/sillyTavernImporter.ts +317 -0
- package/src/core/stats.ts +91 -0
- package/src/core/strings.ts +19 -0
- package/src/core/toolDefinitions.ts +400 -0
- package/src/core/tools/builtinTools.ts +38 -0
- package/src/core/tools/generatedSkillExecutor.ts +706 -0
- package/src/core/tools/generatedWorkspace.ts +13 -0
- package/src/core/tools/toolExecutor.ts +718 -0
- package/src/core/turnLoop.ts +211 -0
- package/src/db/database.ts +647 -0
- package/src/main.tsx +23 -0
- package/src/store/store.ts +2419 -0
- package/src/theme/chat.css +1227 -0
- package/src/theme/global.css +1377 -0
- package/src/theme/theme.ts +143 -0
- package/src/theme/views.css +1430 -0
- package/src/types/models.ts +364 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +273 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Modal, Icon } from './shared';
|
|
3
|
+
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
|
|
4
|
+
import { useT } from '../core/i18n';
|
|
5
|
+
import { applySessionWorkspace } from '../core/tools/toolExecutor';
|
|
6
|
+
import { deleteWorkspaceFile, listSessionWorkspaceFiles } from '../core/tools/generatedSkillExecutor';
|
|
7
|
+
import { useStore } from '../store/store';
|
|
8
|
+
|
|
9
|
+
// ============================================================
|
|
10
|
+
// 会话工作区文件管理器
|
|
11
|
+
// - 会话头部的工作目录标签点击打开
|
|
12
|
+
// - 只展示当前会话专属磁盘工作区(sandbox_workspace/)
|
|
13
|
+
// - 目录点击进入、支持返回上级与面包屑;文件点击复用 file_display 弹窗预览
|
|
14
|
+
// - 可在确认后删除文件(不删除目录)
|
|
15
|
+
// ============================================================
|
|
16
|
+
|
|
17
|
+
function extOf(path: string): string {
|
|
18
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(path);
|
|
19
|
+
return m ? m[1].toLowerCase() : '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 与 file_display 相同的「按扩展名推断展示方式」逻辑 */
|
|
23
|
+
function kindFromPath(path: string): 'text' | 'image' | 'html' {
|
|
24
|
+
const ext = extOf(path);
|
|
25
|
+
if (/^(html?)$/i.test(ext)) return 'html';
|
|
26
|
+
if (/^(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(ext)) return 'image';
|
|
27
|
+
return 'text';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Node {
|
|
31
|
+
name: string;
|
|
32
|
+
path: string; // 会话工作区内的相对路径(目录以 / 结尾)
|
|
33
|
+
isDir: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 由扁平文件列表构建目录节点(目录与文件均以相对路径为唯一 key) */
|
|
37
|
+
function buildNodes(files: string[]): Node[] {
|
|
38
|
+
const map = new Map<string, Node>();
|
|
39
|
+
for (const rel of files) {
|
|
40
|
+
const parts = rel.split('/').filter(Boolean);
|
|
41
|
+
if (parts.length === 0) continue;
|
|
42
|
+
for (let i = 1; i < parts.length; i++) {
|
|
43
|
+
const dirPath = parts.slice(0, i).join('/') + '/';
|
|
44
|
+
if (!map.has(dirPath)) map.set(dirPath, { name: parts[i - 1], path: dirPath, isDir: true });
|
|
45
|
+
}
|
|
46
|
+
map.set(rel, { name: parts[parts.length - 1] || rel, path: rel, isDir: false });
|
|
47
|
+
}
|
|
48
|
+
return [...map.values()].sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: number; onClose: () => void }) {
|
|
52
|
+
const t = useT();
|
|
53
|
+
const setActiveDisplay = useStore((s) => s.setActiveDisplay);
|
|
54
|
+
const session = useStore((s) => s.sessions.find((x) => x.id === sessionId));
|
|
55
|
+
const associatedNpc = useStore((s) => s.npcs.find((npc) => npc.id === session?.associatedId));
|
|
56
|
+
const usesPublicWorkspace = session?.mode === 'NPC' && associatedNpc?.isBuiltIn === true;
|
|
57
|
+
const rootLabel = usesPublicWorkspace ? t('header.workspaceRoot') : session?.workspaceDir?.trim() || `session-${sessionId}`;
|
|
58
|
+
const [files, setFiles] = useState<string[] | null>(null);
|
|
59
|
+
const [loadState, setLoadState] = useState<'loading' | 'ok' | 'empty' | 'error'>('loading');
|
|
60
|
+
const [pendingDelete, setPendingDelete] = useState<Node | null>(null);
|
|
61
|
+
// 当前浏览目录('' = 根;否则为以 / 结尾的会话内相对路径)
|
|
62
|
+
const [dir, setDir] = useState('');
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
let cancelled = false;
|
|
66
|
+
setLoadState('loading');
|
|
67
|
+
setDir('');
|
|
68
|
+
const load = async () => {
|
|
69
|
+
// 多人及普通单人对话列出会话目录;酒馆老板单人对话列出 public 目录
|
|
70
|
+
try {
|
|
71
|
+
const workspaceDir = await applySessionWorkspace(sessionId);
|
|
72
|
+
const list = await listSessionWorkspaceFiles(workspaceDir);
|
|
73
|
+
if (cancelled) return;
|
|
74
|
+
setFiles(list);
|
|
75
|
+
setLoadState(list.length === 0 ? 'empty' : 'ok');
|
|
76
|
+
} catch {
|
|
77
|
+
if (cancelled) return;
|
|
78
|
+
setFiles([]);
|
|
79
|
+
setLoadState('error');
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
void load();
|
|
83
|
+
return () => {
|
|
84
|
+
cancelled = true;
|
|
85
|
+
};
|
|
86
|
+
}, [sessionId]);
|
|
87
|
+
|
|
88
|
+
const nodes = useMemo(() => buildNodes(files ?? []), [files]);
|
|
89
|
+
|
|
90
|
+
// 当前目录的直接子项(目录与文件同级)
|
|
91
|
+
const visible = useMemo(() => {
|
|
92
|
+
const directChild = (p: string) => {
|
|
93
|
+
if (dir === '') return !p.replace(/\/$/, '').includes('/');
|
|
94
|
+
return p.startsWith(dir) && p !== dir && !p.slice(dir.length).includes('/');
|
|
95
|
+
};
|
|
96
|
+
return nodes.filter((n) => directChild(n.path));
|
|
97
|
+
}, [nodes, dir]);
|
|
98
|
+
|
|
99
|
+
const crumbs = useMemo(() => (dir === '' ? [] : dir.slice(0, -1).split('/')), [dir]);
|
|
100
|
+
|
|
101
|
+
const goUp = () => {
|
|
102
|
+
if (crumbs.length <= 1) setDir('');
|
|
103
|
+
else setDir(crumbs.slice(0, -1).join('/') + '/');
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const openFile = (node: Node) => {
|
|
107
|
+
setActiveDisplay({
|
|
108
|
+
path: node.path,
|
|
109
|
+
kind: kindFromPath(node.path),
|
|
110
|
+
title: node.name,
|
|
111
|
+
sessionId,
|
|
112
|
+
presentation: 'modal',
|
|
113
|
+
});
|
|
114
|
+
onClose();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const confirmDelete = async () => {
|
|
118
|
+
if (!pendingDelete) return;
|
|
119
|
+
const workspaceDir = await applySessionWorkspace(sessionId);
|
|
120
|
+
await deleteWorkspaceFile(pendingDelete.path, workspaceDir);
|
|
121
|
+
setFiles((current) => {
|
|
122
|
+
const next = (current ?? []).filter((path) => path !== pendingDelete.path);
|
|
123
|
+
setLoadState(next.length === 0 ? 'empty' : 'ok');
|
|
124
|
+
return next;
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Modal onClose={onClose} width="min(640px, calc(100vw - 32px))" className="ws-fm-modal">
|
|
130
|
+
<div className="modal-head ws-fm-head">
|
|
131
|
+
<span className="ws-fm-title">
|
|
132
|
+
<Icon name="folder" size={15} />
|
|
133
|
+
{t('header.fileManagerTitle')}
|
|
134
|
+
<span className="mono ws-fm-crumb">
|
|
135
|
+
{crumbs.length === 0 ? rootLabel : (
|
|
136
|
+
<>
|
|
137
|
+
<span className="ws-fm-crumb-link" onClick={() => setDir('')}>{rootLabel}</span>
|
|
138
|
+
{crumbs.map((c, i) => (
|
|
139
|
+
<span key={i}>
|
|
140
|
+
<span className="ws-fm-crumb-sep">/</span>
|
|
141
|
+
{i === crumbs.length - 1 ? (
|
|
142
|
+
<span className="ws-fm-crumb-cur">{c}</span>
|
|
143
|
+
) : (
|
|
144
|
+
<span className="ws-fm-crumb-link" onClick={() => setDir(crumbs.slice(0, i + 1).join('/') + '/')}>{c}</span>
|
|
145
|
+
)}
|
|
146
|
+
</span>
|
|
147
|
+
))}
|
|
148
|
+
</>
|
|
149
|
+
)}
|
|
150
|
+
</span>
|
|
151
|
+
</span>
|
|
152
|
+
<button className="icon-btn" onClick={onClose} title={t('common.close')}><Icon name="x" /></button>
|
|
153
|
+
</div>
|
|
154
|
+
<div className="modal-body ws-fm-body">
|
|
155
|
+
<div className="ws-fm-note">
|
|
156
|
+
<Icon name="eye" size={12} /> {t('header.fileManagerHint')}
|
|
157
|
+
</div>
|
|
158
|
+
{loadState === 'loading' && (
|
|
159
|
+
<div className="ws-fm-empty">
|
|
160
|
+
<span className="spinner" style={{ width: 20, height: 20 }} />
|
|
161
|
+
<span>{t('display.loading')}</span>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
{loadState === 'empty' && <div className="ws-fm-empty">{t('header.fileManagerEmpty')}</div>}
|
|
165
|
+
{loadState === 'error' && <div className="ws-fm-empty">{t('header.fileManagerError')}</div>}
|
|
166
|
+
{loadState === 'ok' && (
|
|
167
|
+
<div className="ws-fm-list">
|
|
168
|
+
{dir !== '' && (
|
|
169
|
+
<div className="ws-fm-row" role="button" onClick={goUp} title="..">
|
|
170
|
+
<span className="ws-fm-arrow" />
|
|
171
|
+
<Icon name="folder" size={14} />
|
|
172
|
+
<span className="ws-fm-name">..</span>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
{visible.map((n) =>
|
|
176
|
+
n.isDir ? (
|
|
177
|
+
<div key={n.path} className="ws-fm-row" role="button" onClick={() => setDir(n.path)} title={n.path}>
|
|
178
|
+
<span className="ws-fm-arrow" />
|
|
179
|
+
<Icon name="folder" size={14} />
|
|
180
|
+
<span className="ws-fm-name">{n.name}/</span>
|
|
181
|
+
</div>
|
|
182
|
+
) : (
|
|
183
|
+
<div key={n.path} className="ws-fm-row" role="button" onClick={() => openFile(n)} title={n.path}>
|
|
184
|
+
<span className="ws-fm-arrow" />
|
|
185
|
+
<Icon name="file" size={14} />
|
|
186
|
+
<span className="ws-fm-name mono">{n.name}</span>
|
|
187
|
+
<span className="ws-fm-actions">
|
|
188
|
+
<span className="ws-fm-open"><Icon name="eye" size={12} /></span>
|
|
189
|
+
<button
|
|
190
|
+
className="icon-btn ws-fm-delete"
|
|
191
|
+
title={t('common.delete')}
|
|
192
|
+
aria-label={t('common.delete')}
|
|
193
|
+
onClick={(event) => {
|
|
194
|
+
event.stopPropagation();
|
|
195
|
+
setPendingDelete(n);
|
|
196
|
+
}}
|
|
197
|
+
><Icon name="trash" size={13} /></button>
|
|
198
|
+
</span>
|
|
199
|
+
</div>
|
|
200
|
+
)
|
|
201
|
+
)}
|
|
202
|
+
{visible.length === 0 && <div className="ws-fm-empty">{t('header.fileManagerEmpty')}</div>}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
{pendingDelete && (
|
|
207
|
+
<DeleteConfirmDialog
|
|
208
|
+
title={t('header.fileManagerDeleteTitle')}
|
|
209
|
+
itemName={pendingDelete.path}
|
|
210
|
+
onCancel={() => setPendingDelete(null)}
|
|
211
|
+
onConfirm={confirmDelete}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
</Modal>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { Children, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import DOMPurify from 'dompurify';
|
|
4
|
+
import { renderMarkdown, highlightMentions } from '../core/markdown';
|
|
5
|
+
import { useT } from '../core/i18n';
|
|
6
|
+
|
|
7
|
+
// ============================================================
|
|
8
|
+
// 通用弹窗容器(Portal 到 body,避开祖先 backdrop-filter 产生的
|
|
9
|
+
// 包含块,保证全页面居中、遮罩覆盖全屏、点击可用)
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
export function Modal({
|
|
13
|
+
onClose,
|
|
14
|
+
width,
|
|
15
|
+
className,
|
|
16
|
+
children,
|
|
17
|
+
}: {
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
width?: string | number;
|
|
20
|
+
className?: string;
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
}) {
|
|
23
|
+
return createPortal(
|
|
24
|
+
<>
|
|
25
|
+
<div className="overlay" onClick={onClose} />
|
|
26
|
+
<div className="modal-root" onClick={(e) => e.stopPropagation()}>
|
|
27
|
+
<div className={`modal card fade-up ${className ?? ''}`.trim()} style={width != null ? { width } : undefined}>
|
|
28
|
+
{children}
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</>,
|
|
32
|
+
document.body
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================================
|
|
37
|
+
// Markdown 渲染组件(支持数学公式 + 代码高亮)
|
|
38
|
+
// ============================================================
|
|
39
|
+
|
|
40
|
+
interface MdProps {
|
|
41
|
+
text: string;
|
|
42
|
+
mathEnabled?: boolean;
|
|
43
|
+
mentionNames?: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Markdown({ text, mathEnabled = true, mentionNames = [] }: MdProps) {
|
|
47
|
+
const html = useMemo(() => {
|
|
48
|
+
let h = renderMarkdown(text, { mathEnabled });
|
|
49
|
+
if (mentionNames.length > 0) h = highlightMentions(h, mentionNames);
|
|
50
|
+
return DOMPurify.sanitize(h, {
|
|
51
|
+
USE_PROFILES: { html: true },
|
|
52
|
+
ALLOW_UNKNOWN_PROTOCOLS: false,
|
|
53
|
+
});
|
|
54
|
+
}, [text, mathEnabled, mentionNames]);
|
|
55
|
+
|
|
56
|
+
return <div className="md" dangerouslySetInnerHTML={{ __html: html }} />;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============================================================
|
|
60
|
+
// 统一瀑布流(按当前最短列放置,避免浏览器多栏布局分配差异)
|
|
61
|
+
// ============================================================
|
|
62
|
+
|
|
63
|
+
function sameLayout(left: number[][], right: number[][]): boolean {
|
|
64
|
+
return left.length === right.length
|
|
65
|
+
&& left.every((column, index) => column.length === right[index].length
|
|
66
|
+
&& column.every((item, itemIndex) => item === right[index][itemIndex]));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function MasonryGrid({ children }: { children: React.ReactNode }) {
|
|
70
|
+
const gridRef = useRef<HTMLDivElement>(null);
|
|
71
|
+
const items = Children.toArray(children);
|
|
72
|
+
const [columnCount, setColumnCount] = useState(1);
|
|
73
|
+
const [revision, setRevision] = useState(0);
|
|
74
|
+
const [layout, setLayout] = useState<number[][]>([]);
|
|
75
|
+
|
|
76
|
+
const updateColumnCount = useCallback(() => {
|
|
77
|
+
const grid = gridRef.current;
|
|
78
|
+
if (!grid) return;
|
|
79
|
+
const styles = getComputedStyle(grid);
|
|
80
|
+
const minColumnWidth = Number.parseFloat(styles.getPropertyValue('--masonry-column-min-width'));
|
|
81
|
+
const gap = Number.parseFloat(styles.getPropertyValue('--masonry-gap'));
|
|
82
|
+
if (!Number.isFinite(minColumnWidth) || !Number.isFinite(gap)) return;
|
|
83
|
+
setColumnCount(Math.max(1, Math.floor((grid.clientWidth + gap) / (minColumnWidth + gap))));
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
useLayoutEffect(() => {
|
|
87
|
+
updateColumnCount();
|
|
88
|
+
const grid = gridRef.current;
|
|
89
|
+
if (!grid) return;
|
|
90
|
+
const observer = new ResizeObserver(updateColumnCount);
|
|
91
|
+
observer.observe(grid);
|
|
92
|
+
return () => observer.disconnect();
|
|
93
|
+
}, [updateColumnCount]);
|
|
94
|
+
|
|
95
|
+
useLayoutEffect(() => {
|
|
96
|
+
const grid = gridRef.current;
|
|
97
|
+
if (!grid) return;
|
|
98
|
+
const gap = Number.parseFloat(getComputedStyle(grid).getPropertyValue('--masonry-gap'));
|
|
99
|
+
const heights = Array.from({ length: columnCount }, () => 0);
|
|
100
|
+
const nextLayout = Array.from({ length: columnCount }, () => [] as number[]);
|
|
101
|
+
const elementsByIndex = new Map(
|
|
102
|
+
Array.from(grid.querySelectorAll<HTMLElement>('.masonry-item')).map((element) => [Number(element.dataset.index), element])
|
|
103
|
+
);
|
|
104
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
105
|
+
const element = elementsByIndex.get(index);
|
|
106
|
+
if (!element) return;
|
|
107
|
+
const targetColumn = heights.reduce((shortest, height, column) => height < heights[shortest] ? column : shortest, 0);
|
|
108
|
+
nextLayout[targetColumn].push(index);
|
|
109
|
+
heights[targetColumn] += element.offsetHeight + gap;
|
|
110
|
+
}
|
|
111
|
+
setLayout((current) => sameLayout(current, nextLayout) ? current : nextLayout);
|
|
112
|
+
}, [children, columnCount, revision]);
|
|
113
|
+
|
|
114
|
+
useLayoutEffect(() => {
|
|
115
|
+
const grid = gridRef.current;
|
|
116
|
+
if (!grid) return;
|
|
117
|
+
const observer = new ResizeObserver(() => setRevision((current) => current + 1));
|
|
118
|
+
for (const item of grid.querySelectorAll('.masonry-item')) observer.observe(item);
|
|
119
|
+
return () => observer.disconnect();
|
|
120
|
+
}, [items.length, layout]);
|
|
121
|
+
|
|
122
|
+
const visibleLayout = layout.flat().length === items.length
|
|
123
|
+
? layout
|
|
124
|
+
: [items.map((_, index) => index)];
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div ref={gridRef} className="char-grid">
|
|
128
|
+
{visibleLayout.map((column, index) => (
|
|
129
|
+
<div key={index} className="char-grid-column">
|
|
130
|
+
{column.map((itemIndex) => (
|
|
131
|
+
<div key={itemIndex} className="masonry-item" data-index={itemIndex}>
|
|
132
|
+
{items[itemIndex]}
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ============================================================
|
|
142
|
+
// 头像(彩色首字母 / 图片)
|
|
143
|
+
// ============================================================
|
|
144
|
+
|
|
145
|
+
export function Avatar({
|
|
146
|
+
name,
|
|
147
|
+
colorOrdinal,
|
|
148
|
+
imageUrl,
|
|
149
|
+
size = 'sm',
|
|
150
|
+
}: {
|
|
151
|
+
name: string;
|
|
152
|
+
colorOrdinal?: number;
|
|
153
|
+
imageUrl?: string | null;
|
|
154
|
+
size?: 'sm' | 'xs' | 'lg';
|
|
155
|
+
}) {
|
|
156
|
+
const cls = size === 'lg' ? 'avatar' : size === 'xs' ? 'avatar xs' : 'avatar sm';
|
|
157
|
+
const hue = (colorOrdinal ?? 0) % 6;
|
|
158
|
+
if (imageUrl) {
|
|
159
|
+
return (
|
|
160
|
+
<div className={cls}>
|
|
161
|
+
<img src={imageUrl} alt={name} />
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return <div className={`${cls} avatar-hue-${hue}`}>{name.slice(0, 1)}</div>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ============================================================
|
|
169
|
+
// 附件卡片:仅展示文件名(去掉图片/视频缩略图)
|
|
170
|
+
// ============================================================
|
|
171
|
+
|
|
172
|
+
export function AttachCard({ name, onRemove }: { name: string; onRemove?: () => void }) {
|
|
173
|
+
const t = useT();
|
|
174
|
+
return (
|
|
175
|
+
<div className="attach-card">
|
|
176
|
+
<span className="attach-card-icon">📎</span>
|
|
177
|
+
<span className="attach-card-name" title={name}>{name}</span>
|
|
178
|
+
{onRemove && (
|
|
179
|
+
<button className="attach-rm" onClick={onRemove} title={t('common.removeAttachment')}>
|
|
180
|
+
<Icon name="x" size={11} />
|
|
181
|
+
</button>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ============================================================
|
|
188
|
+
// 群聊头像拼贴(方框内嵌套至多 4 位参与角色头像)
|
|
189
|
+
// ============================================================
|
|
190
|
+
|
|
191
|
+
export interface SessionMember {
|
|
192
|
+
name: string;
|
|
193
|
+
colorOrdinal: number;
|
|
194
|
+
imageUrl?: string | null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function GroupCollage({ members }: { members: SessionMember[] }) {
|
|
198
|
+
// 始终渲染 2×2 四宫格;成员不足 4 位时空余格子留空
|
|
199
|
+
return (
|
|
200
|
+
<div className="gcollage gcollage-m4">
|
|
201
|
+
{[0, 1, 2, 3].map((i) => {
|
|
202
|
+
const m = members[i];
|
|
203
|
+
return (
|
|
204
|
+
<div key={i} className="gslot">
|
|
205
|
+
{m ? <Avatar name={m.name} colorOrdinal={m.colorOrdinal} imageUrl={m.imageUrl} size="xs" /> : null}
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
})}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ============================================================
|
|
214
|
+
// 会话头像(模式图标 / 群聊拼贴)
|
|
215
|
+
// ============================================================
|
|
216
|
+
|
|
217
|
+
export function SessionVisual({
|
|
218
|
+
mode,
|
|
219
|
+
npcName,
|
|
220
|
+
hue,
|
|
221
|
+
imageUrl,
|
|
222
|
+
members,
|
|
223
|
+
size = 'sm',
|
|
224
|
+
}: {
|
|
225
|
+
mode: string;
|
|
226
|
+
npcName?: string;
|
|
227
|
+
hue?: number;
|
|
228
|
+
imageUrl?: string | null;
|
|
229
|
+
members?: SessionMember[];
|
|
230
|
+
size?: 'sm' | 'lg';
|
|
231
|
+
}) {
|
|
232
|
+
if (mode === 'STANDARD') {
|
|
233
|
+
return (
|
|
234
|
+
<div className="svc">
|
|
235
|
+
<span>💬</span>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (mode === 'NPC') {
|
|
240
|
+
return (
|
|
241
|
+
<div className={`svc svc-single ${size === 'lg' ? 'svc-group-lg' : ''}`}>
|
|
242
|
+
<Avatar name={npcName ?? 'NPC'} colorOrdinal={hue ?? 0} imageUrl={imageUrl} />
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
// GROUP
|
|
247
|
+
return (
|
|
248
|
+
<div className={`svc svc-group ${size === 'lg' ? 'svc-group-lg' : ''}`}>
|
|
249
|
+
<GroupCollage members={members ?? []} />
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ============================================================
|
|
255
|
+
// 折叠面板(Thinking / 工具调用卡片共用)
|
|
256
|
+
// ============================================================
|
|
257
|
+
|
|
258
|
+
export function Collapse({
|
|
259
|
+
title,
|
|
260
|
+
icon,
|
|
261
|
+
preview,
|
|
262
|
+
children,
|
|
263
|
+
accent = 'default',
|
|
264
|
+
defaultOpen = false,
|
|
265
|
+
live = false,
|
|
266
|
+
}: {
|
|
267
|
+
title: string;
|
|
268
|
+
icon?: string;
|
|
269
|
+
preview?: string;
|
|
270
|
+
children: React.ReactNode;
|
|
271
|
+
accent?: 'think' | 'default';
|
|
272
|
+
defaultOpen?: boolean;
|
|
273
|
+
live?: boolean;
|
|
274
|
+
}) {
|
|
275
|
+
const t = useT();
|
|
276
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
277
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
278
|
+
const [overflow, setOverflow] = useState(false);
|
|
279
|
+
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
const el = contentRef.current;
|
|
282
|
+
if (el) {
|
|
283
|
+
setOverflow(el.scrollHeight > el.clientHeight + 4);
|
|
284
|
+
// 流式进行中:实时跟随最新内容(钉在底部,展示最新的一行/一段)
|
|
285
|
+
if (live && open) {
|
|
286
|
+
el.scrollTop = el.scrollHeight;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}, [preview, children, live, open]);
|
|
290
|
+
|
|
291
|
+
const cls = `collp ${accent !== 'default' ? `collp-${accent}` : ''}`;
|
|
292
|
+
return (
|
|
293
|
+
<div className={cls}>
|
|
294
|
+
<button className="collp-head" onClick={() => setOpen(!open)}>
|
|
295
|
+
{icon && <span className="collp-icon">{icon}</span>}
|
|
296
|
+
<span className="collp-title">{title}</span>
|
|
297
|
+
{live && <span className="collp-live"><span className="spinner" style={{ width: 12, height: 12 }} /></span>}
|
|
298
|
+
<span className="collp-arrow" style={{ transform: open ? 'rotate(180deg)' : undefined }}>▾</span>
|
|
299
|
+
</button>
|
|
300
|
+
{open && (
|
|
301
|
+
<div className="collp-body">
|
|
302
|
+
<div ref={contentRef} className="collp-content">{children}</div>
|
|
303
|
+
{overflow && !preview && <div className="collp-more">{t('chat.collpMore')}</div>}
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
{!open && preview && (
|
|
307
|
+
<div className={`collp-preview ${live ? 'live' : ''}`}>
|
|
308
|
+
{/* 流式中只显示最新的一行内容,否则显示开头预览 */}
|
|
309
|
+
{live ? (preview.trim().split('\n').filter(Boolean).slice(-1)[0] ?? '') : preview.slice(0, 120)}
|
|
310
|
+
{!live && preview.length > 120 ? '…' : ''}
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ============================================================
|
|
318
|
+
// 图标(轻量内联 SVG,避免依赖图标库)
|
|
319
|
+
// ============================================================
|
|
320
|
+
|
|
321
|
+
const ICONS: Record<string, React.ReactNode> = {
|
|
322
|
+
send: <path d="M3 11.5 21 3l-8.5 18-2.5-7.5L3 11.5z" />,
|
|
323
|
+
plus: <path d="M12 5v14M5 12h14" />,
|
|
324
|
+
stop: <rect x="6" y="6" width="12" height="12" rx="2" />,
|
|
325
|
+
trash: <path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3" />,
|
|
326
|
+
refresh: <path d="M21 12a9 9 0 1 1-3-6.7M21 3v6h-6" />,
|
|
327
|
+
settings: <path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />,
|
|
328
|
+
users: <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />,
|
|
329
|
+
book: <><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" /></>,
|
|
330
|
+
file: <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM14 2v6h6" />,
|
|
331
|
+
trophy: <><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18" /><path d="M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" /><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" /></>,
|
|
332
|
+
more: <><circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" /></>,
|
|
333
|
+
image: <><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="m21 15-5-5L5 21" /></>,
|
|
334
|
+
check: <path d="M20 6 9 17l-5-5" />,
|
|
335
|
+
cancel: <path d="M18 6 6 18M6 6l12 12" />,
|
|
336
|
+
x: <path d="M18 6 6 18M6 6l12 12" />,
|
|
337
|
+
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" /></>,
|
|
338
|
+
sort: <><path d="M11 5h10M11 9h7M11 13h4" /><path d="m3 17 3 3 3-3M6 20V4" /></>,
|
|
339
|
+
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" /></>,
|
|
340
|
+
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" /></>,
|
|
341
|
+
import: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 14l-5 5-5-5M12 19V7" /></>,
|
|
342
|
+
pin: <><path d="M9 4h6M12 4v3l4 3v3h-8v-3l4-3" /><path d="M12 13v7" /></>,
|
|
343
|
+
'git-branch': <><path d="M6 3v12" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></>,
|
|
344
|
+
'zoom-in': <><circle cx="11" cy="11" r="7" /><path d="m21 21-4.35-4.35M11 8v6M8 11h6" /></>,
|
|
345
|
+
'zoom-out': <><circle cx="11" cy="11" r="7" /><path d="m21 21-4.35-4.35M8 11h6" /></>,
|
|
346
|
+
pip: <><rect x="3" y="5" width="18" height="14" rx="2" /><rect x="11" y="11" width="8" height="6" rx="1" /></>,
|
|
347
|
+
'pip-exit': <><rect x="3" y="5" width="18" height="14" rx="2" /><path d="m14 14 5-5M15 9h4v4" /></>,
|
|
348
|
+
'text-wrap': <><path d="M4 6h13a4 4 0 0 1 0 8H9" /><path d="m12 11-3 3 3 3M4 18h3" /></>,
|
|
349
|
+
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" /></>,
|
|
350
|
+
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" /></>,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
export function Icon({ name, size = 15 }: { name: string; size?: number }) {
|
|
354
|
+
return (
|
|
355
|
+
<svg
|
|
356
|
+
width={size}
|
|
357
|
+
height={size}
|
|
358
|
+
viewBox="0 0 24 24"
|
|
359
|
+
fill="none"
|
|
360
|
+
stroke="currentColor"
|
|
361
|
+
strokeWidth="2"
|
|
362
|
+
strokeLinecap="round"
|
|
363
|
+
strokeLinejoin="round"
|
|
364
|
+
style={{ flexShrink: 0 }}
|
|
365
|
+
>
|
|
366
|
+
{ICONS[name] ?? ICONS.more}
|
|
367
|
+
</svg>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { db } from '../db/database';
|
|
2
|
+
|
|
3
|
+
// ============================================================
|
|
4
|
+
// 成就系统(基于生涯总 Token 数的等级制成就)
|
|
5
|
+
// ============================================================
|
|
6
|
+
|
|
7
|
+
export interface AchievementDef {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
icon: string;
|
|
11
|
+
/** 达成所需累计 Token 数(输入 + 输出) */
|
|
12
|
+
threshold: number;
|
|
13
|
+
description: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ACHIEVEMENTS: AchievementDef[] = [
|
|
17
|
+
{ id: 'traveler', name: '旅人', icon: '🧭', threshold: 1_000, description: '累计使用 1,000 Token' },
|
|
18
|
+
{ id: 'old-friend', name: '老友', icon: '🤝', threshold: 10_000, description: '累计使用 10,000 Token' },
|
|
19
|
+
{ id: 'bard', name: '吟游诗人', icon: '🎻', threshold: 100_000, description: '累计使用 100,000 Token' },
|
|
20
|
+
{ id: 'night-owl', name: '午夜不归人', icon: '🌙', threshold: 1_000_000, description: '累计使用 1,000,000 Token' },
|
|
21
|
+
{ id: 'legend', name: '酒馆传奇', icon: '🏆', threshold: 100_000_000, description: '累计使用 100,000,000 Token' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** 生涯总 token(输入 + 输出) */
|
|
25
|
+
async function getTotalTokens(): Promise<number> {
|
|
26
|
+
const stats = (await db.careerStats.get(1)) ?? { id: 1, inputTokens: 0, outputTokens: 0, totalRounds: 0 };
|
|
27
|
+
return stats.inputTokens + stats.outputTokens;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 已解锁的成就 id 集合 */
|
|
31
|
+
async function getUnlockedAchievementIds(): Promise<Set<string>> {
|
|
32
|
+
const rows = await db.achievementUnlocks.toArray();
|
|
33
|
+
return new Set(rows.map((r) => r.achievementId));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const unlockDispatcher: { push: (ach: AchievementDef, total: number) => void | Promise<void> } = {
|
|
37
|
+
push: () => {},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** 注册「解锁成就」回调(由 UI 层在启动时注入,用于弹窗撒花展示) */
|
|
41
|
+
export function registerUnlockDispatcher(
|
|
42
|
+
dispatcher: (ach: AchievementDef, total: number) => void | Promise<void>,
|
|
43
|
+
): void {
|
|
44
|
+
unlockDispatcher.push = dispatcher;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let checkLock: Promise<void> | null = null;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 检测是否有新解锁的成就(幂等:已解锁的不会重复触发)。
|
|
51
|
+
* 并发安全:内部串行执行,避免多次流式回合同时触发重复弹窗。
|
|
52
|
+
*/
|
|
53
|
+
export async function checkAchievementUnlocks(): Promise<void> {
|
|
54
|
+
if (checkLock) {
|
|
55
|
+
await checkLock;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const run = async (): Promise<void> => {
|
|
59
|
+
const total = await getTotalTokens();
|
|
60
|
+
const unlocked = await getUnlockedAchievementIds();
|
|
61
|
+
const newly: Array<{ def: AchievementDef; total: number }> = [];
|
|
62
|
+
|
|
63
|
+
for (const def of ACHIEVEMENTS) {
|
|
64
|
+
if (unlocked.has(def.id)) continue;
|
|
65
|
+
if (total >= def.threshold) {
|
|
66
|
+
await db.achievementUnlocks.add({ achievementId: def.id, unlockedAt: Date.now() });
|
|
67
|
+
newly.push({ def, total });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (newly.length > 0) {
|
|
71
|
+
// 由高到低依次展示(若多级同时达成)
|
|
72
|
+
for (const { def, total } of newly.sort((a, b) => b.def.threshold - a.def.threshold)) {
|
|
73
|
+
try {
|
|
74
|
+
await unlockDispatcher.push(def, total);
|
|
75
|
+
} catch {
|
|
76
|
+
// 忽略 UI 展示层的错误,不影响统计与持久化
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
checkLock = run().finally(() => {
|
|
82
|
+
checkLock = null;
|
|
83
|
+
});
|
|
84
|
+
await checkLock;
|
|
85
|
+
}
|