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,211 @@
|
|
|
1
|
+
import type { ChatParticipant, ChatSession, TurnOrderMode } from '../types/models';
|
|
2
|
+
import { translate } from './i18n';
|
|
3
|
+
import { escapeRegExp } from './strings';
|
|
4
|
+
|
|
5
|
+
// ============================================================
|
|
6
|
+
// 魔数命令(与 App 一致:/new 与 /pass)
|
|
7
|
+
// ============================================================
|
|
8
|
+
|
|
9
|
+
interface MagicCommand {
|
|
10
|
+
text: string;
|
|
11
|
+
description: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MAGIC_COMMANDS: MagicCommand[] = [
|
|
15
|
+
{ text: '/new', description: '' },
|
|
16
|
+
{ text: '/pass', description: '' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** 获取命令描述(每次读取当前语言) */
|
|
20
|
+
function commandDescription(text: string): string {
|
|
21
|
+
return text === '/new' ? translate('chat.cmdNew') : translate('chat.cmdPass');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 精确匹配 /new 或 /pass(有附件时不生效) */
|
|
25
|
+
export function parseMagicCommand(text: string, hasAttachments: boolean): { text: string } | null {
|
|
26
|
+
if (hasAttachments) return null;
|
|
27
|
+
const trimmed = text.trim();
|
|
28
|
+
const cmd = MAGIC_COMMANDS.find((c) => c.text === trimmed);
|
|
29
|
+
return cmd ? { text: cmd.text } : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function suggestMagicCommands(input: string): MagicCommand[] {
|
|
33
|
+
if (!input.startsWith('/') || input.includes('\n')) return [];
|
|
34
|
+
return MAGIC_COMMANDS.filter((c) => c.text.startsWith(input)).map((c) => ({
|
|
35
|
+
text: c.text,
|
|
36
|
+
description: commandDescription(c.text),
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ============================================================
|
|
41
|
+
// 回合循环(纯队列数学,与 App 的 TurnLoop.kt 一致)
|
|
42
|
+
// ============================================================
|
|
43
|
+
|
|
44
|
+
/** 初始化发言队列:全体成员(含玩家)按座位排序;RANDOM 洗牌 */
|
|
45
|
+
export function initializeTurnQueue(participants: ChatParticipant[], mode: TurnOrderMode): string[] {
|
|
46
|
+
const sorted = [...participants].sort((a, b) => a.seatOrder - b.seatOrder);
|
|
47
|
+
const ids = sorted.map((p) => String(p.participantId));
|
|
48
|
+
if (mode === 'RANDOM') {
|
|
49
|
+
return shuffle(ids);
|
|
50
|
+
}
|
|
51
|
+
return ids;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function shuffle<T>(arr: T[]): T[] {
|
|
55
|
+
const a = [...arr];
|
|
56
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
57
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
58
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
59
|
+
}
|
|
60
|
+
return a;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 从文本中提取被 @ 提及的参与者 id(最长名称优先匹配) */
|
|
64
|
+
export function mentionedParticipantIds(
|
|
65
|
+
text: string,
|
|
66
|
+
participants: ChatParticipant[],
|
|
67
|
+
speakerParticipantId: number
|
|
68
|
+
): number[] {
|
|
69
|
+
const mentionables = participants
|
|
70
|
+
.filter((p) => p.participantId !== speakerParticipantId)
|
|
71
|
+
.flatMap((p) => p.kind === 'PLAYER'
|
|
72
|
+
? ['User', '用户', '用戶'].map((name) => ({ id: p.participantId, name }))
|
|
73
|
+
: [{ id: p.participantId, name: p.displayName }])
|
|
74
|
+
.sort((a, b) => b.name.length - a.name.length);
|
|
75
|
+
const hits: Array<{ id: number; index: number; length: number }> = [];
|
|
76
|
+
for (const p of mentionables) {
|
|
77
|
+
// @名字 后跟空白或标点(中英文)
|
|
78
|
+
const re = new RegExp(`@${escapeRegExp(p.name)}(?=\\s|[,。!?,.!?]|$)`, 'g');
|
|
79
|
+
for (const m of text.matchAll(re)) {
|
|
80
|
+
hits.push({ id: p.id, index: m.index ?? 0, length: m[0].length });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
hits.sort((a, b) => (a.index - b.index) || (b.length - a.length));
|
|
84
|
+
const found: number[] = [];
|
|
85
|
+
const occupied: Array<{ start: number; end: number }> = [];
|
|
86
|
+
for (const h of hits) {
|
|
87
|
+
const start = h.index;
|
|
88
|
+
const end = h.index + h.length;
|
|
89
|
+
const overlap = occupied.some((r) => !(end <= r.start || start >= r.end));
|
|
90
|
+
if (overlap || found.includes(h.id)) continue;
|
|
91
|
+
occupied.push({ start, end });
|
|
92
|
+
found.push(h.id);
|
|
93
|
+
}
|
|
94
|
+
return found;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 完成一轮发言:发言者(或 /pass 跳过的玩家)移出队列。
|
|
99
|
+
* 被 @ 点名者统一插入到「当前发言者之后」(即剩余队列队首):
|
|
100
|
+
* - 若原本仍在本轮队列中,先移除原位置再插入(避免重复);
|
|
101
|
+
* - 若原本已不在本轮队列中,则直接插入。
|
|
102
|
+
* 多个 @ 按文本出现顺序排列(后出现的排在前者后面)。
|
|
103
|
+
*/
|
|
104
|
+
export function completeTurn(
|
|
105
|
+
queue: string[],
|
|
106
|
+
speakerParticipantId: number,
|
|
107
|
+
mentionedIds: number[]
|
|
108
|
+
): string[] {
|
|
109
|
+
const rest = queue.filter((id) => id !== String(speakerParticipantId));
|
|
110
|
+
const mentioned = [...new Set(mentionedIds.map(String))];
|
|
111
|
+
let out = [...rest];
|
|
112
|
+
for (const id of mentioned) {
|
|
113
|
+
out = out.filter((q) => q !== id);
|
|
114
|
+
}
|
|
115
|
+
return [...mentioned, ...out];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 玩家 /pass 跳过:将玩家移出队列,不修改任何对话历史 */
|
|
119
|
+
export function passPlayer(queue: string[], playerParticipantId: number): string[] {
|
|
120
|
+
return queue.filter((id) => id !== String(playerParticipantId));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 把某位发言者重新放回队列(供「重新生成/编辑消息」后联动发言队列使用):
|
|
125
|
+
* - 若该卷仍在本轮队列中 → 移回队首(优先补发,模拟“重讲一遍”)
|
|
126
|
+
* - 若该卷已被消费(不在队列、说明本轮已轮过)→ 插入队首,相当于进入新一轮补发
|
|
127
|
+
* 无论哪种情况,都保留其余成员相对顺序,仅把目标提到队首。
|
|
128
|
+
*/
|
|
129
|
+
export function requeueSpeaker(queue: string[], speakerParticipantId: number): string[] {
|
|
130
|
+
const id = String(speakerParticipantId);
|
|
131
|
+
const rest = queue.filter((q) => q !== id);
|
|
132
|
+
return [id, ...rest];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 刷新队列:为空时 loopIndex++ 并重新初始化;返回是否刚开启新循环 */
|
|
136
|
+
export function refreshQueue(
|
|
137
|
+
session: Pick<ChatSession, 'turnOrderMode' | 'loopIndex' | 'turnQueueJson'>,
|
|
138
|
+
participants: ChatParticipant[]
|
|
139
|
+
): { queue: string[]; loopIndex: number; loopStarted: boolean } {
|
|
140
|
+
let queue = safeParseQueue(session.turnQueueJson);
|
|
141
|
+
const validIds = new Set(participants.map((p) => String(p.participantId)));
|
|
142
|
+
queue = queue.filter((id) => validIds.has(id));
|
|
143
|
+
let loopIndex = session.loopIndex;
|
|
144
|
+
let loopStarted = false;
|
|
145
|
+
if (queue.length === 0) {
|
|
146
|
+
loopIndex += 1;
|
|
147
|
+
queue = initializeTurnQueue(participants, session.turnOrderMode);
|
|
148
|
+
loopStarted = true;
|
|
149
|
+
}
|
|
150
|
+
return { queue, loopIndex, loopStarted };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function safeParseQueue(json: string): string[] {
|
|
154
|
+
try {
|
|
155
|
+
const v = JSON.parse(json || '[]');
|
|
156
|
+
return Array.isArray(v) ? v.map(String) : [];
|
|
157
|
+
} catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function queueJson(queue: string[]): string {
|
|
163
|
+
return JSON.stringify(queue);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ============================================================
|
|
167
|
+
// 循环历史(完整初始顺序)辅助
|
|
168
|
+
// ============================================================
|
|
169
|
+
|
|
170
|
+
/** 解析循环历史 JSON(二维数组),失败返回空数组 */
|
|
171
|
+
export function safeParseQueueHistory(json: string): string[][] {
|
|
172
|
+
try {
|
|
173
|
+
const v = JSON.parse(json || '[]');
|
|
174
|
+
if (Array.isArray(v) && v.every((x) => Array.isArray(x))) {
|
|
175
|
+
return v.map((arr) => arr.map(String));
|
|
176
|
+
}
|
|
177
|
+
return [];
|
|
178
|
+
} catch {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 序列化循环历史 → JSON */
|
|
184
|
+
export function queueHistoryJson(history: string[][]): string {
|
|
185
|
+
return JSON.stringify(history);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 计算用于左侧「发言队列」展示的队列(含补全逻辑):
|
|
190
|
+
* - 队列非空 → 不解构,原样返回(含当前发言者)
|
|
191
|
+
* - 队列为空(循环结束)→ 模拟下一循环的初始顺序(不加 loopIndex,仅展示)
|
|
192
|
+
*/
|
|
193
|
+
export function effectiveDisplayQueue(
|
|
194
|
+
session: Pick<ChatSession, 'mode' | 'turnOrderMode' | 'loopIndex' | 'turnQueueJson'>,
|
|
195
|
+
participants: ChatParticipant[]
|
|
196
|
+
): string[] {
|
|
197
|
+
if (session.mode !== 'GROUP') return [];
|
|
198
|
+
const { queue } = refreshQueue(session, participants);
|
|
199
|
+
if (queue.length > 0) return queue;
|
|
200
|
+
return initializeTurnQueue(participants, session.turnOrderMode);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 流式输出中的发言者标签:玩家 → 用户,NPC → 显示名 */
|
|
204
|
+
export function speakerLabel(p: ChatParticipant): string {
|
|
205
|
+
return p.kind === 'PLAYER' ? translate('common.user') : p.displayName;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 一个实用的 AI 工具调用 id 回退生成 */
|
|
209
|
+
export function fallbackToolCallId(index: number): string {
|
|
210
|
+
return `call-${Date.now()}-${index}`;
|
|
211
|
+
}
|