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,254 @@
1
+ import type {
2
+ ChatParticipant,
3
+ ChatSession,
4
+ NetworkMessage,
5
+ } from '../types/models';
6
+ import { sanitizeHistoryContentForModel } from './openai';
7
+ import { translate } from './i18n';
8
+ import { NEW_TOPIC_MARKER } from './toolDefinitions';
9
+
10
+ // ============================================================
11
+ // 提示词组装 — 与 MainViewModel.buildNpcSystemPrompt 等一致
12
+ // ============================================================
13
+
14
+ export function buildNpcSystemPrompt(
15
+ npcPrompt: string,
16
+ worldBookContent?: string | null,
17
+ userPersonaPrompt?: string | null
18
+ ): string {
19
+ return (
20
+ npcPrompt +
21
+ (worldBookContent ? `\n\n=== ${translate('prompt.worldBook')} ===\n${worldBookContent}` : '') +
22
+ (userPersonaPrompt ? `\n\n=== ${translate('prompt.userInfo')} ===\n${userPersonaPrompt}` : '')
23
+ );
24
+ }
25
+
26
+ export function buildGroupSystemPrompt(
27
+ activeSpeakerName: string,
28
+ activeNpcPrompt: string,
29
+ worldBookContent?: string | null,
30
+ userPersonaPrompt?: string | null,
31
+ requestPayload?: {
32
+ allSpeakerNames: string[];
33
+ mentionOnlyMode?: boolean;
34
+ }
35
+ ): string {
36
+ const others = (requestPayload?.allSpeakerNames ?? []).filter((n) => n !== activeSpeakerName);
37
+ const playerName = 'User';
38
+
39
+ return (
40
+ `You are participating in a multi-character conversation. Reply only as ${activeSpeakerName}.\n` +
41
+ `Never write dialogue for another participant.\n` +
42
+ `Write only ${activeSpeakerName}'s message body. Do not begin your reply with a speaker label such as [${activeSpeakerName}], ${activeSpeakerName}:, or any other [name] prefix; the application already identifies the speaker.\n` +
43
+ (others.length > 0
44
+ ? `The other participants are: ${others.join(', ')}.\nThe user (a participant named "${playerName}") may speak at any moment — when they do, respond naturally as ${activeSpeakerName}.\n`
45
+ : '') +
46
+ `Messages prefixed with [${translate('prompt.roleTag')}] are utterances by that participant; messages prefixed with [${playerName}] are the user's.\n` +
47
+ (requestPayload?.mentionOnlyMode
48
+ ? `End your reply by mentioning exactly one next participant with @name. If your reply already mentions a participant, do not add another mention.\n`
49
+ : `When you need to direct the next speaker, you may use @${translate('prompt.roleTag')} in your reply to call on another participant.\n`) +
50
+ `\n=== ${activeSpeakerName} ===\n` +
51
+ activeNpcPrompt +
52
+ (worldBookContent ? `\n\n=== ${translate('prompt.worldBook')} ===\n${worldBookContent}` : '') +
53
+ (userPersonaPrompt ? `\n\n=== ${translate('prompt.userInfo')} ===\n${userPersonaPrompt}` : '')
54
+ );
55
+ }
56
+
57
+ export const STANDARD_SYSTEM_PROMPT = 'You are a helpful assistant.';
58
+
59
+ // ============================================================
60
+ // 历史消息 → 网络消息(GROUP 角色折叠 / 附件 / 思考清理)
61
+ // ============================================================
62
+
63
+ export function buildNetworkMessagesForSession(p: {
64
+ messages: Array<{
65
+ role: string;
66
+ content: string;
67
+ speakerParticipantId: number | null;
68
+ speakerName: string | null;
69
+ thinkingContent: string | null;
70
+ toolCallsJson: string;
71
+ toolCallId: string | null;
72
+ attachments: string[];
73
+ }>;
74
+ session: ChatSession | null;
75
+ participants: ChatParticipant[];
76
+ activeSpeakerParticipantId: number | null;
77
+ }): NetworkMessage[] {
78
+ const { session, participants, activeSpeakerParticipantId } = p;
79
+ const messages = trimMessagesForNewTopic(p.messages, session);
80
+ const result: NetworkMessage[] = [];
81
+ const participantMap = new Map(participants.map((pp) => [pp.participantId, pp]));
82
+ const isGroup = session?.mode === 'GROUP';
83
+ const omittedToolCallIds = new Set<string>();
84
+ if (isGroup) {
85
+ for (const m of messages) {
86
+ if (m.role !== 'assistant' || m.speakerParticipantId === activeSpeakerParticipantId) continue;
87
+ for (const toolCall of parseToolCalls(m.toolCallsJson)) omittedToolCallIds.add(toolCall.id);
88
+ }
89
+ }
90
+
91
+ for (const m of messages) {
92
+ if (m.role === 'tool') {
93
+ if (m.toolCallId != null && omittedToolCallIds.has(m.toolCallId)) continue;
94
+ result.push({
95
+ role: 'tool',
96
+ content: m.content,
97
+ tool_call_id: m.toolCallId ?? undefined,
98
+ });
99
+ continue;
100
+ }
101
+ if (m.role === 'system') {
102
+ result.push({ role: 'system', content: m.content });
103
+ continue;
104
+ }
105
+
106
+ const speakerIsActive =
107
+ m.speakerParticipantId != null && m.speakerParticipantId === activeSpeakerParticipantId;
108
+ const toolCalls = parseToolCalls(m.toolCallsJson);
109
+ if (m.role === 'assistant' && toolCalls.length > 0) {
110
+ if (!isGroup || speakerIsActive) {
111
+ result.push({
112
+ role: 'assistant',
113
+ content: sanitizeHistoryContentForModel(m.content),
114
+ tool_calls: toolCalls,
115
+ });
116
+ } else {
117
+ const content = sanitizeHistoryContentForModel(m.content);
118
+ if (content.length > 0) {
119
+ const hasLeadingLabel = /^\[[^\]\r\n]+\]/.test(content);
120
+ const speaker = m.speakerParticipantId != null ? participantMap.get(m.speakerParticipantId) : undefined;
121
+ const label = speaker?.displayName ?? m.speakerName ?? '用户';
122
+ result.push({ role: 'user', content: hasLeadingLabel ? content : `[${label}] ${content}` });
123
+ }
124
+ }
125
+ continue;
126
+ }
127
+
128
+ if (isGroup) {
129
+ if (m.role === 'assistant' && speakerIsActive) {
130
+ result.push({
131
+ role: 'assistant',
132
+ content: sanitizeHistoryContentForModel(m.content),
133
+ });
134
+ continue;
135
+ }
136
+ const hasLeadingLabel = /^\[[^\]\r\n]+\]/.test(m.content);
137
+ if (hasLeadingLabel) {
138
+ result.push({ role: 'user', content: buildContentWithAttachments(m.content, m.attachments) });
139
+ continue;
140
+ }
141
+ const isPlayer = m.role === 'user' && m.speakerParticipantId == null;
142
+ // 折叠成 user 消息,前缀 [标签]
143
+ const speaker = m.speakerParticipantId != null ? participantMap.get(m.speakerParticipantId) : undefined;
144
+ const label = isPlayer || m.speakerParticipantId == null ? '用户' : (speaker?.displayName ?? m.speakerName ?? '用户');
145
+ const text = buildContentWithAttachments(m.content, m.attachments);
146
+ result.push({ role: 'user', content: `[${label}] ${text}` });
147
+ continue;
148
+ }
149
+
150
+ // 非群聊
151
+ result.push({
152
+ role: m.role as NetworkMessage['role'],
153
+ content: buildContentWithAttachments(sanitizeHistoryContentForModel(m.content), m.attachments),
154
+ });
155
+ }
156
+
157
+ return result;
158
+ }
159
+
160
+ function trimMessagesForNewTopic<T extends {
161
+ role: string;
162
+ content: string;
163
+ speakerParticipantId: number | null;
164
+ toolCallsJson: string;
165
+ }>(
166
+ messages: T[],
167
+ session: ChatSession | null
168
+ ): T[] {
169
+ let lastNewTopicIdx = -1;
170
+ for (let i = messages.length - 1; i >= 0; i--) {
171
+ if (messages[i].role === 'system' && messages[i].content === NEW_TOPIC_MARKER) {
172
+ lastNewTopicIdx = i;
173
+ break;
174
+ }
175
+ }
176
+ if (lastNewTopicIdx < 0) return messages;
177
+
178
+ const before = messages.slice(0, lastNewTopicIdx);
179
+ const after = messages.slice(lastNewTopicIdx + 1);
180
+ const keepSystems = before.filter((m) => m.role === 'system' && m.content !== NEW_TOPIC_MARKER);
181
+
182
+ let keepGreeting: T | null = null;
183
+ if (session?.mode === 'NPC' && session.associatedId != null) {
184
+ const firstUserIdx = before.findIndex((m) => m.role === 'user');
185
+ const greetingRange = firstUserIdx >= 0 ? before.slice(0, firstUserIdx) : before;
186
+ keepGreeting =
187
+ [...greetingRange]
188
+ .reverse()
189
+ .find(
190
+ (m) =>
191
+ m.role === 'assistant' &&
192
+ m.speakerParticipantId === session.associatedId &&
193
+ (!m.toolCallsJson || m.toolCallsJson === '[]')
194
+ ) ?? null;
195
+ }
196
+
197
+ return [...keepSystems, ...(keepGreeting ? [keepGreeting] : []), ...after];
198
+ }
199
+
200
+ /** 附件转 data URL content parts(base64 图片/视频) */
201
+ function buildContentWithAttachments(content: string, attachments: string[]): string | NetworkMessage['content'] {
202
+ if (!attachments || attachments.length === 0) {
203
+ return content || null;
204
+ }
205
+ const parts: NonNullable<NetworkMessage['content']> = [];
206
+ if (content) parts.push({ type: 'text', text: content });
207
+ for (const url of attachments) {
208
+ const isImage = /^data:image\//.test(url);
209
+ parts.push({
210
+ type: isImage ? 'image_url' : 'video_url',
211
+ [isImage ? 'image_url' : 'video_url']: { url },
212
+ });
213
+ }
214
+ return parts as NetworkMessage['content'];
215
+ }
216
+
217
+ function parseToolCalls(json: string): NonNullable<NetworkMessage['tool_calls']> {
218
+ if (!json) return [];
219
+ try {
220
+ const arr = JSON.parse(json);
221
+ if (!Array.isArray(arr)) return [];
222
+ const result: NonNullable<NetworkMessage['tool_calls']> = [];
223
+ for (const tc of arr) {
224
+ if (!tc || typeof tc !== 'object') continue;
225
+ // 已是标准网络格式(id/type/function)
226
+ if (tc.id && tc.type === 'function' && tc.function && typeof tc.function === 'object') {
227
+ const fn = tc.function as { name?: unknown; arguments?: unknown };
228
+ result.push({
229
+ id: String(tc.id),
230
+ type: 'function',
231
+ function: {
232
+ name: String(fn.name ?? ''),
233
+ arguments: typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments ?? {}),
234
+ },
235
+ });
236
+ continue;
237
+ }
238
+ // 内部 ToolCallRecord 格式(id/name/argumentsJson/contentOffset)
239
+ if (tc.id && tc.name) {
240
+ result.push({
241
+ id: String(tc.id),
242
+ type: 'function',
243
+ function: {
244
+ name: String(tc.name),
245
+ arguments: typeof tc.argumentsJson === 'string' ? tc.argumentsJson : JSON.stringify(tc.argumentsJson ?? {}),
246
+ },
247
+ });
248
+ }
249
+ }
250
+ return result;
251
+ } catch {
252
+ return [];
253
+ }
254
+ }
@@ -0,0 +1,58 @@
1
+ // ============================================================
2
+ // CORS 代理辅助:当直连请求被浏览器跨域拦截(CORS)时,
3
+ // 自动回退到 Vite 开发服务器的同源代理。
4
+ // 代理规则:http://<当前站点>/api/<host:port>/<剩余路径>
5
+ // 例:http://192.168.1.5:8788/v1/ → http://localhost:5173/api/192.168.1.5:8788/v1/
6
+ // ============================================================
7
+
8
+ /** 判断当前环境是否可能提供代理(Vite dev 服务器) */
9
+ function canUseProxy(): boolean {
10
+ if (typeof window === 'undefined') return false;
11
+ // 仅当页面运行在开发服务器(非 file:// 等)时可用
12
+ return /^https?:\/\//.test(window.location.origin);
13
+ }
14
+
15
+ /** 该 URL 是否已经是同源 /api/ 代理形式 */
16
+ export function isProxyUrl(url: string): boolean {
17
+ if (typeof window === 'undefined') return false;
18
+ try {
19
+ const u = new URL(url);
20
+ return u.origin === window.location.origin && u.pathname.startsWith('/api/');
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * 把直连 Base URL 转换为同源代理 URL。
28
+ * 返回 null 表示不需要/无法转换(如已是代理、或非 http(s) URL)。
29
+ * 例:
30
+ * http://192.168.1.5:8788 → http://localhost:5173/api/192.168.1.5:8788
31
+ * http://192.168.1.5:8788/v1/ → http://localhost:5173/api/192.168.1.5:8788/v1/
32
+ * https://api.deepseek.com/v1/ → null(公有 HTTPS 服务一般带 CORS;不代理外网)
33
+ */
34
+ export function toProxyUrl(baseUrl: string): string | null {
35
+ if (!canUseProxy() || isProxyUrl(baseUrl)) return null;
36
+ let u: URL;
37
+ try {
38
+ u = new URL(baseUrl);
39
+ } catch {
40
+ return null;
41
+ }
42
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
43
+ // 仅代理内网/局域网地址,外网公开 API 保持直连(避免滥用代理)
44
+ const ipv4 = /^\d{1,3}(\.\d{1,3}){3}$/.test(u.hostname);
45
+ const isPrivateHost = ipv4 || u.hostname === 'localhost' || u.hostname === '127.0.0.1';
46
+ if (!isPrivateHost) return null;
47
+
48
+ const host = u.host; // hostname:port
49
+ const rest = u.pathname.replace(/^\/+/, '');
50
+ const path = `/api/${host}/${rest}`.replace(/\/+$/, '');
51
+ return `${window.location.origin}${path}`;
52
+ }
53
+
54
+ /** 判断 fetch 失败是否“像” CORS / 网络层问题(可用代理回退) */
55
+ export function isNetworkLikeError(e: unknown): boolean {
56
+ const msg = (e as Error)?.message || String(e);
57
+ return /Failed to fetch|NetworkError|Load failed|TypeError|Network request failed/i.test(msg);
58
+ }
@@ -0,0 +1,317 @@
1
+ import type { NpcCharacter } from '../types/models';
2
+
3
+ // ============================================================
4
+ // SillyTavern PNG 角色卡导入(chara v2 / ccv3 tEXt 块)
5
+ // ============================================================
6
+ // 依据官方规范实现:
7
+ // - v2: https://github.com/malfoyslastname/character-card-spec-v2/blob/main/spec_v2.md
8
+ // JSON 结构为 { spec, spec_version, data: TavernCardV2 },
9
+ // PNG 嵌入的 chara 块内容是「把 JSON 字符串按 UTF-8 编码后,再整体做 base64」的结果
10
+ // - v3: https://github.com/kwaroran/character-card-spec-v3/blob/main/SPEC_V3.md
11
+ // PNG 嵌入的 ccv3 块内容同样是「JSON 字符串 utf-8 -> base64」
12
+
13
+ /** TavernCardV2 的 data 字段(v2 卡字段全部嵌套在 data 中) */
14
+ interface TavernV2Data {
15
+ name?: string;
16
+ description?: string;
17
+ personality?: string;
18
+ scenario?: string;
19
+ first_mes?: string;
20
+ mes_example?: string;
21
+ system_prompt?: string;
22
+ post_history_instructions?: string;
23
+ alternate_greetings?: string[];
24
+ character_book?: StLorebook;
25
+ extensions?: Record<string, unknown>;
26
+ }
27
+
28
+ /** 顶层共有的信封结构(v2 用 spec='chara_card_v2',v3 用 spec='chara_card_v3') */
29
+ interface StEnvelope {
30
+ /** v2 时存在:'chara_card_v2' */
31
+ spec?: string;
32
+ /** v3 时存在:'chara_card_v3' */
33
+ spec_version?: string;
34
+ /** v2 卡的 data 字段 */
35
+ data?: TavernV2Data;
36
+ // -------- v3 字段(CharacterCardV3 是 TavernCardV2 的超集,且字段就在顶层) --------
37
+ name?: string;
38
+ description?: string;
39
+ personality?: string;
40
+ scenario?: string;
41
+ first_mes?: string;
42
+ mes_example?: string;
43
+ system_prompt?: string;
44
+ post_history_instructions?: string;
45
+ /** v3 的额外开场白(备用),创建对话时随机选用 */
46
+ alternate_greetings?: string[];
47
+ character_book?: StLorebook;
48
+ /** v3 的 assets 图标(可选 base64 data URL) */
49
+ assets?: Array<{ type?: string; uri?: string; name?: string; ext?: string }>;
50
+ }
51
+
52
+ interface StLorebook {
53
+ name?: string;
54
+ description?: string;
55
+ scan_depth?: number;
56
+ token_budget?: number;
57
+ recursive_scanning?: boolean;
58
+ extensions?: Record<string, unknown>;
59
+ entries?: Array<{
60
+ enabled?: boolean;
61
+ name?: string;
62
+ comment?: string;
63
+ keys?: string[];
64
+ secondary_keys?: string[];
65
+ content?: string;
66
+ selective?: boolean;
67
+ constant?: boolean;
68
+ position?: 'before_char' | 'after_char';
69
+ }>;
70
+ }
71
+
72
+ const MAX_PNG_SIZE = 8 * 1024 * 1024;
73
+
74
+ /** 尝试把字节解码为 UTF-8 文本 */
75
+ function utf8Decode(bytes: Uint8Array): string {
76
+ try {
77
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
78
+ } catch {
79
+ // 退化为 latin1,保证不会抛错
80
+ let s = '';
81
+ for (const b of bytes) s += String.fromCharCode(b);
82
+ return s;
83
+ }
84
+ }
85
+
86
+ /** 标准 base64 解码(返回 UTF-8 文本) */
87
+ function safeBase64Decode(text: string): string | null {
88
+ const trimmed = text.replace(/\s/g, '');
89
+ // 去掉可能残留的 data:...;base64, 前缀
90
+ const commaIdx = trimmed.indexOf(',');
91
+ const body = commaIdx >= 0 && /^data:[\w.+-]+\/[^;]+;base64$/i.test(trimmed.slice(0, commaIdx))
92
+ ? trimmed.slice(commaIdx + 1)
93
+ : trimmed;
94
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(body) || body.length % 4 === 1) return null;
95
+ try {
96
+ const bin = atob(body);
97
+ const bytes = new Uint8Array(bin.length);
98
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
99
+ return utf8Decode(bytes);
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * 解析 PNG tEXt 块(chara / ccv3)。
107
+ * 返回归一化后的卡片数据:v2 卡会把 data 字段展开到顶层,与 v3 字段结构对齐。
108
+ */
109
+ function parsePngChara(buffer: ArrayBuffer): { key: 'ccv3' | 'chara'; data: StEnvelope } | null {
110
+ if (buffer.byteLength > MAX_PNG_SIZE) throw new Error('PNG 文件超过 8MB');
111
+ const bytes = new Uint8Array(buffer);
112
+ const dv = new DataView(buffer);
113
+
114
+ // PNG 签名校验
115
+ if (
116
+ bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47 ||
117
+ bytes[4] !== 0x0d || bytes[5] !== 0x0a || bytes[6] !== 0x1a || bytes[7] !== 0x0a
118
+ ) {
119
+ throw new Error('不是合法的 PNG 文件');
120
+ }
121
+
122
+ let offset = 8;
123
+ let ccv3: string | null = null;
124
+ let chara: string | null = null;
125
+
126
+ while (offset + 8 <= buffer.byteLength) {
127
+ const length = dv.getUint32(offset);
128
+ const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
129
+ const dataStart = offset + 8;
130
+ const dataEnd = dataStart + length;
131
+ if (dataEnd + 4 > buffer.byteLength) break;
132
+
133
+ if (type === 'tEXt') {
134
+ // keyword\0text
135
+ let sep = dataStart;
136
+ while (sep < dataEnd && bytes[sep] !== 0) sep++;
137
+ const keyword = utf8Decode(bytes.subarray(dataStart, sep));
138
+ const rawText = bytes.subarray(sep + 1, dataEnd);
139
+ if (keyword === 'ccv3') ccv3 = utf8Decode(rawText);
140
+ else if (keyword === 'chara') chara = utf8Decode(rawText);
141
+ }
142
+ offset = dataEnd + 4; // 跳过 CRC
143
+ if (type === 'IEND') break;
144
+ }
145
+
146
+ // 官方规范:tEXt 内容的 JSON 是 utf-8 -> base64 编码,先尝试 base64 解码再 JSON.parse
147
+ const candidates: Array<{ key: 'ccv3' | 'chara'; raw: string | null }> = [
148
+ { key: 'ccv3', raw: ccv3 },
149
+ { key: 'chara', raw: chara },
150
+ ];
151
+
152
+ for (const { key, raw } of candidates) {
153
+ if (!raw) continue;
154
+ for (const text of [safeBase64Decode(raw), raw]) {
155
+ if (!text) continue;
156
+ try {
157
+ const json = JSON.parse(text) as unknown;
158
+ const data = normalizeCard(json);
159
+ if (data) return { key, data };
160
+ } catch {
161
+ /* 尝试下一种解码 */
162
+ }
163
+ }
164
+ }
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * 把规范里的两种信封结构归一化为统一的字段结构:
170
+ * - v2: { spec, spec_version, data: {...} } -> 展开 data
171
+ * - v3: { spec, spec_version, ...顶层字段 } -> 原样
172
+ * 也兼容某些工具导出的「字段直接裸露在顶层」的 v2 变体。
173
+ */
174
+ function normalizeCard(json: unknown): StEnvelope | null {
175
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) return null;
176
+ const obj = json as Record<string, unknown>;
177
+
178
+ // 情况一:有 data 字段(v2 规范信封)
179
+ if (obj.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) {
180
+ const data = obj.data as Record<string, unknown>;
181
+ return {
182
+ spec: typeof obj.spec === 'string' ? obj.spec : undefined,
183
+ spec_version: typeof obj.spec_version === 'string' ? obj.spec_version : undefined,
184
+ ...(data as unknown as TavernV2Data),
185
+ };
186
+ }
187
+
188
+ // 情况二:顶层直接是卡数据(v3 规范 / v2 变体)
189
+ // 必须至少有一个角色字段,避免把乱码 JSON 误判成卡
190
+ const hasCardField = ['name', 'description', 'personality', 'first_mes', 'scenario'].some(
191
+ (k) => typeof obj[k] === 'string'
192
+ );
193
+ if (hasCardField) {
194
+ return {
195
+ spec: typeof obj.spec === 'string' ? obj.spec : undefined,
196
+ spec_version: typeof obj.spec_version === 'string' ? obj.spec_version : undefined,
197
+ ...(obj as unknown as TavernV2Data),
198
+ };
199
+ }
200
+
201
+ return null;
202
+ }
203
+
204
+ /** 从 SillyTavern 结构化字段组装人设 prompt(与 App 一致) */
205
+ function buildPromptFromStructured(data: StEnvelope, fallbackName: string): string {
206
+ const sections: Array<[string, string | undefined]> = [
207
+ ['角色描述', data.description],
208
+ ['性格', data.personality],
209
+ ['场景', data.scenario],
210
+ ['示例对话', data.mes_example],
211
+ ['系统提示词', data.system_prompt],
212
+ ['历史后指令', data.post_history_instructions],
213
+ ];
214
+ const parts = sections
215
+ .filter(([, v]) => v && v.trim())
216
+ .map(([k, v]) => `### ${k}\n${v}`);
217
+ if (parts.length === 0) return `你是${fallbackName}。`;
218
+ return parts.join('\n\n');
219
+ }
220
+
221
+ /** 从卡片内嵌资源中提取头像(v3 assets 里的 base64 data URL) */
222
+ function extractAvatarFromCard(data: StEnvelope): string | null {
223
+ const icon = (data.assets ?? []).find((a) => a?.type === 'icon' && a?.uri?.startsWith('data:image/'));
224
+ if (icon?.uri) return icon.uri.slice(0, 2_000_000); // 控制存储体积
225
+ return null;
226
+ }
227
+
228
+ /** 把 PNG 文件字节转成 data URL(导入时作为角色头像的兜底来源) */
229
+ function pngToDataUrl(buffer: ArrayBuffer): string {
230
+ const bytes = new Uint8Array(buffer);
231
+ let bin = '';
232
+ const CHUNK = 0x8000;
233
+ for (let i = 0; i < bytes.length; i += CHUNK) {
234
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
235
+ }
236
+ return `data:image/png;base64,${btoa(bin)}`;
237
+ }
238
+
239
+ /**
240
+ * 解析头像:优先卡内 assets 图标(v3),否则直接用 PNG 文件本身作为头像。
241
+ * 卡片 PNG 通常就是角色立绘/头像,保证几乎所有角色卡导入后都有头像。
242
+ */
243
+ function resolveAvatar(data: StEnvelope, pngDataUrl: string | null): string | null {
244
+ return extractAvatarFromCard(data) ?? pngDataUrl;
245
+ }
246
+
247
+ /** 渲染内嵌 Lorebook(character_book)为可读文本 */
248
+ function renderLorebook(book: StLorebook | undefined, fallbackName: string) {
249
+ if (!book || !Array.isArray(book.entries)) return null;
250
+ const entries = book.entries.filter((e) => e.enabled !== false && e.content && e.content.trim());
251
+ if (entries.length === 0) return null;
252
+ const rendered = entries
253
+ .map(
254
+ (e) =>
255
+ `### ${e.name || e.comment || '条目'}\n` +
256
+ (e.keys && e.keys.length > 0 ? `触发词:${e.keys.join('、')}\n` : '') +
257
+ e.content
258
+ )
259
+ .join('\n\n');
260
+ return {
261
+ name: (book.name || `${fallbackName} 的世界书`).slice(0, 60),
262
+ content: rendered.slice(0, 10_000),
263
+ };
264
+ }
265
+
266
+ /** 归一化多开场白:主开场白 + 去重后的 alternate_greetings(去空白/截断) */
267
+ function buildGreetings(data: StEnvelope, maxLen = 1000): { greeting: string; alternateGreetings: string[] } {
268
+ const greeting = (data.first_mes ?? '').slice(0, maxLen);
269
+ const alternates = (data.alternate_greetings ?? [])
270
+ .map((g) => (typeof g === 'string' ? g.trim() : ''))
271
+ .filter((g, i, arr) => g && g !== greeting && arr.indexOf(g) === i)
272
+ .slice(0, 20)
273
+ .map((g) => g.slice(0, maxLen));
274
+ return { greeting, alternateGreetings: alternates };
275
+ }
276
+
277
+ // ============================================================
278
+ // 预填草稿(不落库):UI 把解析结果填入「新建角色 / 新建 Lorebook」表单,
279
+ // 由用户确认后手动保存
280
+ // ============================================================
281
+
282
+ export interface ParsedSillyTavernCard {
283
+ /** v2 / v3 */
284
+ version: string;
285
+ /** 预填到新建角色表单的数据(含头像、人设 prompt、开场白) */
286
+ character: NpcCharacter;
287
+ /** 卡内嵌的 Lorebook(若有),预填到新建 Lorebook 表单 */
288
+ worldBook: { name: string; content: string } | null;
289
+ }
290
+
291
+ /** 解析 PNG 角色卡为表单草稿,不写入数据库(PNG 图片本身作为头像) */
292
+ export async function parseSillyTavernCardFile(file: File): Promise<ParsedSillyTavernCard> {
293
+ const buffer = await file.arrayBuffer();
294
+ const parsed = parsePngChara(buffer);
295
+ if (!parsed) throw new Error('未找到角色卡数据(ccv3 / chara)');
296
+
297
+ const { key, data } = parsed;
298
+ const name = data.name?.trim() || '未命名角色';
299
+ const { greeting, alternateGreetings } = buildGreetings(data);
300
+
301
+ const character: NpcCharacter = {
302
+ name,
303
+ prompt: buildPromptFromStructured(data, name),
304
+ greeting,
305
+ alternateGreetings,
306
+ avatarColorOrdinal: Math.floor(Math.random() * 6),
307
+ avatarDataUrl: resolveAvatar(data, pngToDataUrl(buffer)),
308
+ enabledToolNames: [],
309
+ isBuiltIn: false,
310
+ createdAt: Date.now(),
311
+ };
312
+
313
+ const lorebook = renderLorebook(data.character_book, name);
314
+ const worldBook = lorebook ? { name: lorebook.name, content: lorebook.content } : null;
315
+
316
+ return { version: key === 'ccv3' ? 'v3' : 'v2', character, worldBook };
317
+ }