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.
Files changed (56) hide show
  1. package/README.md +161 -0
  2. package/README.zh.md +123 -0
  3. package/bin/cli.js +48 -0
  4. package/index.html +35 -0
  5. package/package.json +50 -0
  6. package/public/favicon.png +0 -0
  7. package/public/tavern-harness-logo.png +0 -0
  8. package/sandbox-server.mjs +997 -0
  9. package/src/App.tsx +268 -0
  10. package/src/assets/tavern-handbook.md +39 -0
  11. package/src/components/AchievementModal.tsx +158 -0
  12. package/src/components/CharactersView.tsx +904 -0
  13. package/src/components/ChatView.tsx +1639 -0
  14. package/src/components/ConfirmationDialog.tsx +56 -0
  15. package/src/components/Dashboard.tsx +180 -0
  16. package/src/components/DeleteConfirmDialog.tsx +48 -0
  17. package/src/components/FileDisplayModal.tsx +556 -0
  18. package/src/components/GameplayDialogs.tsx +191 -0
  19. package/src/components/NewSessionMenu.tsx +362 -0
  20. package/src/components/SettingsView.tsx +517 -0
  21. package/src/components/Sidebar.tsx +392 -0
  22. package/src/components/StatsView.tsx +189 -0
  23. package/src/components/Toasts.tsx +20 -0
  24. package/src/components/WorkspaceFileManagerModal.tsx +216 -0
  25. package/src/components/shared.tsx +369 -0
  26. package/src/core/achievements.ts +85 -0
  27. package/src/core/fileDownload.ts +67 -0
  28. package/src/core/gameplay.ts +521 -0
  29. package/src/core/horizontalWheel.ts +93 -0
  30. package/src/core/i18n.ts +1403 -0
  31. package/src/core/linkedScroll.ts +236 -0
  32. package/src/core/markdown.ts +170 -0
  33. package/src/core/openai.ts +451 -0
  34. package/src/core/openingGreeting.ts +41 -0
  35. package/src/core/prompts.ts +254 -0
  36. package/src/core/proxy.ts +58 -0
  37. package/src/core/sillyTavernImporter.ts +317 -0
  38. package/src/core/stats.ts +91 -0
  39. package/src/core/strings.ts +19 -0
  40. package/src/core/toolDefinitions.ts +400 -0
  41. package/src/core/tools/builtinTools.ts +38 -0
  42. package/src/core/tools/generatedSkillExecutor.ts +706 -0
  43. package/src/core/tools/generatedWorkspace.ts +13 -0
  44. package/src/core/tools/toolExecutor.ts +718 -0
  45. package/src/core/turnLoop.ts +211 -0
  46. package/src/db/database.ts +647 -0
  47. package/src/main.tsx +23 -0
  48. package/src/store/store.ts +2419 -0
  49. package/src/theme/chat.css +1227 -0
  50. package/src/theme/global.css +1377 -0
  51. package/src/theme/theme.ts +143 -0
  52. package/src/theme/views.css +1430 -0
  53. package/src/types/models.ts +364 -0
  54. package/src/vite-env.d.ts +1 -0
  55. package/tsconfig.json +21 -0
  56. package/vite.config.ts +273 -0
@@ -0,0 +1,236 @@
1
+ // ============================================================
2
+ // 群聊「对话 ⇄ 发言队列」双向联动滚动 + 点击定位(平滑版)
3
+ // 两个滚动容器(chat-scroll / turn-queue-body)通过本模块互相驱动:
4
+ // - 滚动对话 → 队列平滑滚动到对应循环
5
+ // - 滚动队列 → 对话平滑滚动到对应循环
6
+ // - 点击队列中的某次发言 → 对话平滑滚动到该循环中对应发言者的消息
7
+ // 依赖消息行上的 data-loop(0 起始循环号)与 data-speaker;队列循环组 data-loop 同语义。
8
+ //
9
+ // 平滑实现要点:
10
+ // - 程序化滚动统一走 rAF 线性插值(lerp),不依赖 CSS scroll-behavior,
11
+ // 避免「浏览器动画反复重启」造成的跳变/闪动
12
+ // - 以「时间窗」抑制自身滚动产生的事件,防止双向驱动死循环
13
+ // ============================================================
14
+
15
+ let chatEl: HTMLElement | null = null;
16
+ let queueEl: HTMLElement | null = null;
17
+
18
+ // 程序化写入产生的滚动事件在窗口内被忽略(防死循环)
19
+ let chatSuppressUntil = 0;
20
+ let queueSuppressUntil = 0;
21
+ let queueUserControlUntil = 0;
22
+
23
+ export function registerChatEl(el: HTMLElement | null): void {
24
+ chatEl = el;
25
+ chatSuppressUntil = 0;
26
+ }
27
+ export function registerQueueEl(el: HTMLElement | null): void {
28
+ queueEl = el;
29
+ queueSuppressUntil = 0;
30
+ queueUserControlUntil = 0;
31
+ }
32
+
33
+ function clamp01(v: number): number {
34
+ return Math.max(0, Math.min(1, v));
35
+ }
36
+
37
+ function clampScroll(el: HTMLElement, top: number): number {
38
+ return Math.max(0, Math.min(top, Math.max(0, el.scrollHeight - el.clientHeight)));
39
+ }
40
+
41
+ /** 行内容在容器内的绝对位置(不受 scrollTop / 内边距影响) */
42
+ function contentOffsetOf(container: HTMLElement, row: HTMLElement): number {
43
+ const cr = container.getBoundingClientRect();
44
+ return row.getBoundingClientRect().top - cr.top + container.scrollTop;
45
+ }
46
+
47
+ interface ScanRow {
48
+ el: HTMLElement;
49
+ off: number;
50
+ h: number;
51
+ loop: number;
52
+ }
53
+
54
+ /** 扫描容器内全部带 data-loop 的行(DOM 顺序) */
55
+ function scanRows(container: HTMLElement): ScanRow[] {
56
+ const rows: ScanRow[] = [];
57
+ const cr = container.getBoundingClientRect();
58
+ container.querySelectorAll<HTMLElement>('[data-loop]').forEach((r) => {
59
+ const rr = r.getBoundingClientRect();
60
+ rows.push({ el: r, off: rr.top - cr.top + container.scrollTop, h: rr.height, loop: Number(r.dataset.loop) });
61
+ });
62
+ return rows;
63
+ }
64
+
65
+ function rowsOf(container: HTMLElement, loop: number): ScanRow[] {
66
+ return scanRows(container).filter((r) => r.loop === loop);
67
+ }
68
+
69
+ /** 某循环的内容跨度(首行顶 → 末行底),用于把滚动位置映射为 0..1 进度 */
70
+ function spanOf(container: HTMLElement, loop: number): { firstTop: number; height: number } | null {
71
+ const rows = rowsOf(container, loop);
72
+ if (rows.length === 0) return null;
73
+ const firstTop = rows[0].off;
74
+ const lastBottom = rows[rows.length - 1].off + rows[rows.length - 1].h;
75
+ return { firstTop, height: Math.max(1, lastBottom - firstTop) };
76
+ }
77
+
78
+ /** 光标(容器内绝对位置)所在循环 + 该循环内进度(0=顶,1=底) */
79
+ function cursorLoopOf(container: HTMLElement, cursor: number): { loop: number; progress: number } {
80
+ let loop = 0;
81
+ for (const r of scanRows(container)) {
82
+ if (cursor < r.off) break;
83
+ loop = r.loop;
84
+ if (cursor <= r.off + r.h) break;
85
+ }
86
+ const span = spanOf(container, loop);
87
+ const progress = span ? clamp01((cursor - span.firstTop) / span.height) : 0;
88
+ return { loop, progress };
89
+ }
90
+
91
+ /** 把某循环滚动到窗口内指定进度:循环内 progress 处的内容对齐到视口中心。
92
+ * 双向(对话→队列 / 队列→对话)用同一映射,保证来回一致;循环短于视口时
93
+ * 目标可能为负 → 钳制到 0..max,不会反向跳顶。 */
94
+ function targetForLoop(container: HTMLElement, loop: number, progress: number): number {
95
+ const span = spanOf(container, loop);
96
+ if (!span) return container.scrollTop;
97
+ const cursor = span.firstTop + progress * span.height;
98
+ return clampScroll(container, cursor - container.clientHeight / 2);
99
+ }
100
+
101
+ // ---------- 平滑滚动核心 ----------
102
+ // 两种动画机制:
103
+ // 1) chaseScroll:连续跟随(拖动/原生平滑滚动产生的连续事件)。
104
+ // 用指数平滑每帧逼近目标,事件持续到来时自然衔接,不会因为“每次重启动画”而脉冲抖动。
105
+ // 2) animateTo:单次导航(点击队列发言定位、新循环加入)。时长缓动 + 干净收尾。
106
+
107
+ const clampSet = new Set<HTMLElement>(); // 正在持续跟随的容器
108
+ const chaseWanted = new Map<HTMLElement, number>();
109
+ let chaseRaf = 0;
110
+
111
+ function writeScrollTop(el: HTMLElement, v: number): void {
112
+ const clamped = clampScroll(el, v);
113
+ if (Math.abs(clamped - el.scrollTop) < 0.5) return;
114
+ // 标记「程序化写入」时间窗:本次写入触发的滚动事件要忽略,防反馈死循环
115
+ if (el === chatEl) chatSuppressUntil = performance.now() + 90;
116
+ else if (el === queueEl) queueSuppressUntil = performance.now() + 90;
117
+ el.scrollTop = clamped;
118
+ }
119
+
120
+ function chaseTick(now: number): void {
121
+ chaseRaf = 0;
122
+ for (const el of clampSet) {
123
+ const wanted = chaseWanted.get(el) ?? el.scrollTop;
124
+ const diff = wanted - el.scrollTop;
125
+ if (Math.abs(diff) < 0.5) {
126
+ clampSet.delete(el);
127
+ chaseWanted.delete(el);
128
+ continue;
129
+ }
130
+ // 指数趋近:距目标越近越慢,避免过冲;步长上限防止太快导致“跳”
131
+ const step = Math.min(Math.abs(diff), Math.max(8, Math.abs(diff) * 0.16));
132
+ writeScrollTop(el, el.scrollTop + Math.sign(diff) * step);
133
+ }
134
+ if (clampSet.size > 0) chaseRaf = requestAnimationFrame(chaseTick);
135
+ void now;
136
+ }
137
+
138
+ /** 连续跟随:目标可被后续调用持续更新,内部每帧指数趋近 */
139
+ function chaseScroll(el: HTMLElement, target: number): void {
140
+ chaseWanted.set(el, clampScroll(el, target));
141
+ if (!clampSet.has(el)) {
142
+ clampSet.add(el);
143
+ if (!chaseRaf) chaseRaf = requestAnimationFrame(chaseTick);
144
+ }
145
+ }
146
+
147
+ interface Anim {
148
+ el: HTMLElement;
149
+ from: number;
150
+ to: number;
151
+ start: number;
152
+ dur: number;
153
+ }
154
+ let anim: Anim | null = null;
155
+ let rafId = 0;
156
+
157
+ function tick(now: number): void {
158
+ rafId = 0;
159
+ if (!anim) return;
160
+ const t = Math.min(1, (now - anim.start) / anim.dur);
161
+ const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic
162
+ writeScrollTop(anim.el, anim.from + (anim.to - anim.from) * eased);
163
+ if (t < 1) rafId = requestAnimationFrame(tick);
164
+ else anim = null;
165
+ }
166
+
167
+ function animateTo(el: HTMLElement, target: number, dur = 240): void {
168
+ const to = clampScroll(el, target);
169
+ const from = el.scrollTop;
170
+ if (Math.abs(to - from) < 1) return;
171
+ // 与持续跟随互斥:单次导航时停掉 chase
172
+ clampSet.delete(el);
173
+ chaseWanted.delete(el);
174
+ anim = { el, from, to, start: performance.now(), dur };
175
+ if (!rafId) rafId = requestAnimationFrame(tick);
176
+ }
177
+
178
+ function cancelProgrammaticScroll(el: HTMLElement): void {
179
+ clampSet.delete(el);
180
+ chaseWanted.delete(el);
181
+ if (anim?.el === el) anim = null;
182
+ }
183
+
184
+ function isChatSuppressed(): boolean {
185
+ return performance.now() < chatSuppressUntil;
186
+ }
187
+ function isQueueSuppressed(): boolean {
188
+ return performance.now() < queueSuppressUntil;
189
+ }
190
+
191
+ // ---------- 对外接口 ----------
192
+
193
+ /** 对话区滚动 → 联动队列(连续跟随,平滑) */
194
+ export function onChatScroll(): void {
195
+ if (!chatEl || !queueEl) return;
196
+ if (isChatSuppressed() || performance.now() < queueUserControlUntil) return;
197
+ const cursor = chatEl.scrollTop + chatEl.clientHeight / 2;
198
+ const { loop, progress } = cursorLoopOf(chatEl, cursor);
199
+ chaseScroll(queueEl, targetForLoop(queueEl, loop, progress));
200
+ }
201
+
202
+ /** 用户开始操作队列时,立即停止可能把队列拉回原位的程序化滚动。 */
203
+ export function onQueueScrollIntent(): void {
204
+ if (!queueEl) return;
205
+ cancelProgrammaticScroll(queueEl);
206
+ queueSuppressUntil = 0;
207
+ queueUserControlUntil = performance.now() + 600;
208
+ }
209
+
210
+ /** 队列滚动 → 联动对话(连续跟随,平滑) */
211
+ export function onQueueScroll(): void {
212
+ if (!chatEl || !queueEl) return;
213
+ if (isQueueSuppressed()) return;
214
+ queueUserControlUntil = performance.now() + 600;
215
+ const cursor = queueEl.scrollTop + queueEl.clientHeight / 2;
216
+ const { loop, progress } = cursorLoopOf(queueEl, cursor);
217
+ chaseScroll(chatEl, targetForLoop(chatEl, loop, progress));
218
+ }
219
+
220
+ /**
221
+ * 点击队列中的某次发言 → 对话平滑滚动到该循环中对应发言者的消息。
222
+ * speakerKey:玩家消息为 'player',NPC 为 participantId 字符串;null 时滚到该循环第一条。
223
+ */
224
+ export function scrollChatTo(loop: number, speakerKey: string | null): void {
225
+ if (!chatEl) return;
226
+ const rows = rowsOf(chatEl, loop);
227
+ const target = rows.find((r) => r.el.dataset.speaker === speakerKey) ?? rows[0] ?? null;
228
+ if (!target) return;
229
+ animateTo(chatEl, contentOffsetOf(chatEl, target.el) - chatEl.clientHeight * 0.2, 320);
230
+ }
231
+
232
+ /** 队列面板:新循环加入时平滑滚动到当前循环(底部对齐,整组可见) */
233
+ export function scrollQueueToLoop(loop: number): void {
234
+ if (!queueEl) return;
235
+ animateTo(queueEl, targetForLoop(queueEl, loop, 1), 280);
236
+ }
@@ -0,0 +1,170 @@
1
+ import katex from 'katex';
2
+ import 'katex/dist/katex.min.css';
3
+ import { marked } from 'marked';
4
+
5
+ // ============================================================
6
+ // Markdown 渲染(marked + katex + 代码高亮)
7
+ // ============================================================
8
+
9
+ marked.setOptions({
10
+ gfm: true,
11
+ breaks: true,
12
+ });
13
+
14
+ const CODE_KEYWORDS = new Set([
15
+ 'function','const','let','var','return','if','else','for','while','do','switch','case','break',
16
+ 'continue','new','class','extends','super','import','export','from','default','async','await',
17
+ 'try','catch','finally','throw','typeof','instanceof','in','of','this','null','undefined','true',
18
+ 'false','void','delete','yield','static','get','set','public','private','protected','readonly',
19
+ 'interface','type','enum','namespace','declare','abstract','implements','package','def','lambda',
20
+ 'then','when','where','data','typeclass','module','pub','fn','use','let','mut','impl','struct',
21
+ 'enum','trait','fn','match','println','int','float','double','char','boolean','String','int64',
22
+ 'print','range','if','elif','else','for','while','in','not','and','or','None','True','False',
23
+ ]);
24
+
25
+ /** 单块代码语法高亮(轻量级,与 App highlightCode 一致) */
26
+ export function highlightCode(code: string, lang?: string): string {
27
+ const esc = (s: string) =>
28
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
29
+
30
+ const tokenRe = /(\/\/[^\n]*)|("[^"\n]*"|'[^'\n]*')|(\b\d+(?:\.\d+)?\b)|([A-Z]\w*)|(\b[A-Za-z_]\w*\b)|(\s+)/g;
31
+
32
+ let out = '';
33
+ let last = 0;
34
+ const text = code;
35
+ let m: RegExpExecArray | null;
36
+ while ((m = tokenRe.exec(text)) !== null) {
37
+ const [full, comment, str, num, type, kw] = m;
38
+ if (m.index > last) out += esc(text.slice(last, m.index));
39
+ if (comment) out += `<span class="tok tok-comment">${esc(comment)}</span>`;
40
+ else if (str) out += `<span class="tok tok-str">${esc(str)}</span>`;
41
+ else if (num) out += `<span class="tok tok-num">${esc(num)}</span>`;
42
+ else if (type) out += `<span class="tok tok-type">${esc(type)}</span>`;
43
+ else if (kw && CODE_KEYWORDS.has(kw)) out += `<span class="tok tok-kw">${esc(kw)}</span>`;
44
+ else if (kw) out += esc(kw);
45
+ else out += esc(full);
46
+ last = m.index + full.length;
47
+ }
48
+ if (last < text.length) out += esc(text.slice(last));
49
+ void lang;
50
+ return out;
51
+ }
52
+
53
+ /** 数学公式:内联 $...$ / \(...\) 与块级 $$...$$ / \[...\] */
54
+ function renderMath(content: string, displayMode: boolean): string {
55
+ try {
56
+ return katex.renderToString(content, {
57
+ displayMode,
58
+ throwOnError: false,
59
+ output: 'html',
60
+ });
61
+ } catch {
62
+ return content;
63
+ }
64
+ }
65
+
66
+ interface BlockMathMatch {
67
+ raw: string;
68
+ content: string;
69
+ index: number;
70
+ }
71
+
72
+ /** 解析块级数学公式($$ 或 \[ \]) */
73
+ function extractBlockMath(source: string): { text: string; matches: BlockMathMatch[] } {
74
+ const matches: BlockMathMatch[] = [];
75
+ // $$...$$ (支持多行)
76
+ const re1 = /\$\$([\s\S]+?)\$\$/g;
77
+ const cleaned = source
78
+ .replace(re1, (raw, content, index) => {
79
+ matches.push({ raw, content: content.trim(), index });
80
+ return `BLOCKMATH${matches.length - 1}`;
81
+ })
82
+ .replace(/\\\[([\s\S]+?)\\\]/g, (raw, content, index) => {
83
+ matches.push({ raw, content: content.trim(), index });
84
+ return `BLOCKMATH${matches.length - 1}`;
85
+ });
86
+ return { text: cleaned, matches };
87
+ }
88
+
89
+ /** 内联数学 $...$ */
90
+ function renderInlineMath(html: string): string {
91
+ // 先保护代码块里的 $(marked 已转义为 &dollar;,避免冲突)
92
+ const inlineRe = /(?<!\\)\$(?!\$)(.+?)(?<!\\)\$/g;
93
+ let attempts = 0;
94
+ let result = html;
95
+ while (inlineRe.test(result) && attempts < 20) {
96
+ attempts++;
97
+ result = result.replace(
98
+ inlineRe,
99
+ (raw, content: string) => {
100
+ try {
101
+ const rendered = katex.renderToString(content.trim(), { throwOnError: false, displayMode: false });
102
+ return rendered;
103
+ } catch {
104
+ return raw;
105
+ }
106
+ }
107
+ );
108
+ }
109
+ return result;
110
+ }
111
+
112
+ /** 主流程:源文本 → 分段(含数学与代码高亮) */
113
+ export function renderMarkdown(source: string, opts?: { mathEnabled?: boolean }): string {
114
+ const mathEnabled = opts?.mathEnabled ?? true;
115
+ // 1. 提取块级数学
116
+ const { text, matches } = extractBlockMath(source);
117
+
118
+ // 2. 保护代码块(防止 marked 解析数学占位符)
119
+ const codeBlocks: string[] = [];
120
+ const withoutCode = text.replace(/```[\s\S]*?```/g, (raw) => {
121
+ codeBlocks.push(raw);
122
+ return `CODE${codeBlocks.length - 1}`;
123
+ });
124
+
125
+ // 3. marked 渲染
126
+ let html = marked.parse(withoutCode, { async: false }) as string;
127
+
128
+ // 4. 还原代码块并高亮
129
+ html = html.replace(/CODE(\d+)/g, (_, i) => {
130
+ const raw = codeBlocks[Number(i)];
131
+ const langMatch = raw.match(/^```(\w*)\s*\n([\s\S]*?)\n*```$/);
132
+ const lang = langMatch?.[1] ?? '';
133
+ const code = langMatch?.[2] ?? raw.replace(/^```\w*\s*\n?/, '').replace(/```$/, '');
134
+ return `<pre class="code-block"><code class="lang-${lang}">${highlightCode(code, lang)}</code></pre>`;
135
+ });
136
+
137
+ // 5. 还原块级数学
138
+ html = html.replace(/BLOCKMATH(\d+)/g, (_, i) => {
139
+ const m = matches[Number(i)];
140
+ if (mathEnabled && m) return `<div class="math-block">${renderMath(m.content, true)}</div>`;
141
+ return `<div class="math-block mono math-fallback">$$${m?.content ?? ''}$$</div>`;
142
+ });
143
+
144
+ // 6. 行内数学(保留 <code> 内部)
145
+ if (mathEnabled) {
146
+ const htmlNoCode = html.replace(/<code[^>]*>[\s\S]*?<\/code>/g, (raw) => {
147
+ const token = `CODEHTML${codeBlocks.length}`;
148
+ codeBlocks.push(raw);
149
+ return token;
150
+ });
151
+ let processed = renderInlineMath(htmlNoCode);
152
+ processed = processed.replace(/CODEHTML(\d+)/g, (_, i) => codeBlocks[i]);
153
+ html = processed;
154
+ }
155
+
156
+ return html;
157
+ }
158
+
159
+ /** @mention 高亮(发言者名字最长优先;斜体加粗 + 特殊字体颜色,作为特殊文本内指令) */
160
+ export function highlightMentions(html: string, names: string[]): string {
161
+ const sorted = [...names].sort((a, b) => b.length - a.length);
162
+ let result = html;
163
+ for (const name of sorted) {
164
+ // 仅在非 code 区域高亮(简化处理:直接替换 @名字)
165
+ // Markdown conversion normally leaves a closing HTML tag after a terminal mention (for example @用户</p>).
166
+ const re = new RegExp(`(@${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(?=\\s|[,。!?,.!?]|<|$)`, 'g');
167
+ result = result.replace(re, '<span class="mention" title="点名该角色">$1</span>');
168
+ }
169
+ return result;
170
+ }