@musnows/scriverse 0.9.9 → 1.0.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/dist/database.js CHANGED
@@ -8,10 +8,225 @@ import { documentShortSearchTerms, id, normalizeDocumentSearchText, splitDocumen
8
8
  import { createChapterLineIds, parseChapterAnnotationLineHashes, parseChapterLineIds } from "./chapter-annotation-anchor.js";
9
9
  export const PLATFORM_AI_WORK_ID = "__scriverse_platform_ai__";
10
10
  export const SYSTEM_USER_ID = "__scriverse_system_user__";
11
- // 版本 81 用于列表查询索引;版本 82 由 Store 写入实体版本基线标记;版本 83 创建协作状态表;版本 84 创建备份加密表;版本 85 持久化协作变更动作;版本 86 扩展直接图片上传格式;版本 87 增加作品与分卷回收站;版本 88 持久化 AI 对话分支幂等键;版本 89 持久化 AI 对话流请求锁与幂等状态;版本 90 持久化 AI 连通性测试冷却状态;版本 91 建立章节段落行号索引;版本 92 增加 AI 对话收藏状态;版本 93 优化伏笔计划回收章节查询;版本 94 增加模型思考强度;版本 95 增加供应商最大输出参数选择;版本 96 扩展模型思考强度档位;版本 97 增加人物性别字段;版本 98 增加正文稳定等待配置;版本 99 持久化关系扮演中的用户角色;版本 100 增加平台 AI 流事件空闲超时配置;版本 101 将平台 AI 流事件空闲超时上限提升至 600 秒;版本 102 创建角色头像元数据表;版本 103 回填历史 AI 对话归属并建立用户列表索引;版本 104 扩大供应商协议约束以支持 OpenAI Responses;版本 105 增加独立分卷剧情顺序;版本 106 增加供应商思考类型配置;版本 107 增加 AI Cache Write 输入 Token 统计;版本 108 增加作品 AI 每月 Token 额度;版本 109 增加供应商日、月 Token 额度;版本 110 将日、月 Token 额度下限调整为大于 0;版本 111 扩展模型思考强度为 auto;版本 112 为 CLI API Key 增加可复制的加密密文;版本 113 增加角色收藏状态与列表索引;版本 114 增加组织、设定档案与想法收藏状态及列表索引;版本 115 增加 AI 对话会话级场景钉;版本 116 增加可持久化且可撤销的 Desktop Bearer 会话;版本 117 增加作品离线授权、同步变更游标与幂等变更结果;版本 118 增加供应商分析请求超时配置;版本 119 记录分析任务是否由 API Key 创建;版本 120 将书籍资料收藏按用户隔离并增加书籍级共享置顶;版本 121 强制作品 Owner 非空并建立用户外键约束;版本 122 创建 AI 写入审批与持久化提问表,并增加按角色归属的共享角色扮演记忆及作品级正文编辑偏好;版本 123 为正文评论持久化逐行哈希锚点;版本 124 为正文行和评论锚点持久化稳定行身份;版本 125 增加显式语义检索配置、分片索引、模型类型与上下文快照;版本 126 为作品增加加密的远程 MCP 配置与工具目录。
11
+ // 版本 81 用于列表查询索引;版本 82 由 Store 写入实体版本基线标记;版本 83 创建协作状态表;版本 84 创建备份加密表;版本 85 持久化协作变更动作;版本 86 扩展直接图片上传格式;版本 87 增加作品与分卷回收站;版本 88 持久化 AI 对话分支幂等键;版本 89 持久化 AI 对话流请求锁与幂等状态;版本 90 持久化 AI 连通性测试冷却状态;版本 91 建立章节段落行号索引;版本 92 增加 AI 对话收藏状态;版本 93 优化伏笔计划回收章节查询;版本 94 增加模型思考强度;版本 95 增加供应商最大输出参数选择;版本 96 扩展模型思考强度档位;版本 97 增加人物性别字段;版本 98 增加正文稳定等待配置;版本 99 持久化关系扮演中的用户角色;版本 100 增加平台 AI 流事件空闲超时配置;版本 101 将平台 AI 流事件空闲超时上限提升至 600 秒;版本 102 创建角色头像元数据表;版本 103 回填历史 AI 对话归属并建立用户列表索引;版本 104 扩大供应商协议约束以支持 OpenAI Responses;版本 105 增加独立分卷剧情顺序;版本 106 增加供应商思考类型配置;版本 107 增加 AI Cache Write 输入 Token 统计;版本 108 增加作品 AI 每月 Token 额度;版本 109 增加供应商日、月 Token 额度;版本 110 将日、月 Token 额度下限调整为大于 0;版本 111 扩展模型思考强度为 auto;版本 112 为 CLI API Key 增加可复制的加密密文;版本 113 增加角色收藏状态与列表索引;版本 114 增加组织、设定档案与想法收藏状态及列表索引;版本 115 增加 AI 对话会话级场景钉;版本 116 增加可持久化且可撤销的 Desktop Bearer 会话;版本 117 增加作品离线授权、同步变更游标与幂等变更结果;版本 118 增加供应商分析请求超时配置;版本 119 记录分析任务是否由 API Key 创建;版本 120 将书籍资料收藏按用户隔离并增加书籍级共享置顶;版本 121 强制作品 Owner 非空并建立用户外键约束;版本 122 创建 AI 写入审批与持久化提问表,并增加按角色归属的共享角色扮演记忆及作品级正文编辑偏好;版本 123 为正文评论持久化逐行哈希锚点;版本 124 为正文行和评论锚点持久化稳定行身份;版本 125 增加显式语义检索配置、分片索引、模型类型与上下文快照;版本 126 为作品增加加密的远程 MCP 配置与工具目录;版本 127 创建跨作品 IM 会话、成员、消息、角色上下文与 AI 链路状态;版本 128 为退出成员冻结会话快照;版本 129 持久化 IM 历史头像版本;版本 130 为 IM 手动重试增加幂等来源;版本 131 限定迁移期回填已有 IM 头像版本;版本 132 优化 IM 会话成员范围查询;版本 133 优化 IM 历史头像授权查询;版本 134 为 IM 历史头像哈希建立表达式索引;版本 135 为 AI 提问增加批量问题与回答字段。
12
12
  export const ENTITY_VERSION_BASELINE_MIGRATION_VERSION = 82;
13
- export const DATABASE_SCHEMA_VERSION = 126;
13
+ export const DATABASE_SCHEMA_VERSION = 135;
14
14
  export const SQLITE_IOERR_SHMSIZE = 4874;
15
+ const IM_SCHEMA_SQL = `
16
+ CREATE TABLE IF NOT EXISTS im_user_settings (
17
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
18
+ preferred_name TEXT NOT NULL DEFAULT '' CHECK(length(preferred_name) <= 80),
19
+ pronouns TEXT NOT NULL DEFAULT '' CHECK(length(pronouns) <= 80),
20
+ identity_summary TEXT NOT NULL DEFAULT '' CHECK(length(identity_summary) <= 2000),
21
+ additional_notes TEXT NOT NULL DEFAULT '' CHECK(length(additional_notes) <= 4000),
22
+ primary_model_id TEXT REFERENCES models(id) ON DELETE SET NULL,
23
+ fallback_model_id TEXT REFERENCES models(id) ON DELETE SET NULL,
24
+ retry_count INTEGER NOT NULL DEFAULT 3 CHECK(retry_count BETWEEN 1 AND 20),
25
+ updated_at TEXT NOT NULL,
26
+ CHECK(primary_model_id IS NULL OR fallback_model_id IS NULL OR primary_model_id <> fallback_model_id)
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS im_conversations (
30
+ id TEXT PRIMARY KEY,
31
+ kind TEXT NOT NULL CHECK(kind IN ('direct', 'group')),
32
+ owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
33
+ direct_character_id TEXT,
34
+ title TEXT NOT NULL CHECK(length(title) BETWEEN 1 AND 80),
35
+ reply_mode TEXT NOT NULL DEFAULT 'mention' CHECK(reply_mode IN ('mention', 'proactive')),
36
+ response_threshold INTEGER NOT NULL DEFAULT 60 CHECK(response_threshold BETWEEN 0 AND 100),
37
+ max_ai_messages INTEGER NOT NULL DEFAULT 20 CHECK(max_ai_messages BETWEEN 1 AND 100),
38
+ context_epoch INTEGER NOT NULL DEFAULT 1 CHECK(context_epoch > 0),
39
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'disbanded')),
40
+ created_at TEXT NOT NULL,
41
+ updated_at TEXT NOT NULL,
42
+ disbanded_at TEXT,
43
+ UNIQUE(owner_user_id, direct_character_id),
44
+ CHECK((kind = 'direct' AND direct_character_id IS NOT NULL) OR (kind = 'group' AND direct_character_id IS NULL))
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS im_human_memberships (
48
+ id TEXT PRIMARY KEY,
49
+ conversation_id TEXT NOT NULL REFERENCES im_conversations(id) ON DELETE CASCADE,
50
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
51
+ role TEXT NOT NULL CHECK(role IN ('owner', 'member')),
52
+ joined_sequence INTEGER NOT NULL DEFAULT 0 CHECK(joined_sequence >= 0),
53
+ left_sequence INTEGER CHECK(left_sequence IS NULL OR left_sequence >= joined_sequence),
54
+ last_read_sequence INTEGER NOT NULL DEFAULT 0 CHECK(last_read_sequence >= 0),
55
+ conversation_snapshot_json TEXT CHECK(conversation_snapshot_json IS NULL OR (json_valid(conversation_snapshot_json) AND json_type(conversation_snapshot_json) = 'object')),
56
+ joined_at TEXT NOT NULL,
57
+ left_at TEXT
58
+ );
59
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_im_human_memberships_active
60
+ ON im_human_memberships(conversation_id, user_id) WHERE left_at IS NULL;
61
+ CREATE INDEX IF NOT EXISTS idx_im_human_memberships_user
62
+ ON im_human_memberships(user_id, left_at, conversation_id);
63
+ CREATE INDEX IF NOT EXISTS idx_im_human_memberships_conversation
64
+ ON im_human_memberships(conversation_id, joined_at, id);
65
+
66
+ CREATE TABLE IF NOT EXISTS im_character_memberships (
67
+ id TEXT PRIMARY KEY,
68
+ conversation_id TEXT NOT NULL REFERENCES im_conversations(id) ON DELETE CASCADE,
69
+ character_id TEXT REFERENCES characters(id) ON DELETE SET NULL,
70
+ source_work_id TEXT REFERENCES works(id) ON DELETE SET NULL,
71
+ snapshot_json TEXT NOT NULL CHECK(json_valid(snapshot_json) AND json_type(snapshot_json) = 'object'),
72
+ joined_sequence INTEGER NOT NULL DEFAULT 0 CHECK(joined_sequence >= 0),
73
+ left_sequence INTEGER CHECK(left_sequence IS NULL OR left_sequence >= joined_sequence),
74
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'suspended', 'removed')),
75
+ joined_at TEXT NOT NULL,
76
+ left_at TEXT
77
+ );
78
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_im_character_memberships_active
79
+ ON im_character_memberships(conversation_id, character_id) WHERE left_at IS NULL AND character_id IS NOT NULL;
80
+ CREATE INDEX IF NOT EXISTS idx_im_character_memberships_character
81
+ ON im_character_memberships(character_id, left_at, conversation_id);
82
+ CREATE INDEX IF NOT EXISTS idx_im_character_memberships_conversation
83
+ ON im_character_memberships(conversation_id, joined_at, id);
84
+
85
+ CREATE TABLE IF NOT EXISTS im_messages (
86
+ id TEXT PRIMARY KEY,
87
+ conversation_id TEXT NOT NULL REFERENCES im_conversations(id) ON DELETE CASCADE,
88
+ sequence INTEGER NOT NULL CHECK(sequence > 0),
89
+ context_epoch INTEGER NOT NULL DEFAULT 1 CHECK(context_epoch > 0),
90
+ sender_kind TEXT NOT NULL CHECK(sender_kind IN ('human', 'character', 'system')),
91
+ sender_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
92
+ sender_character_id TEXT REFERENCES characters(id) ON DELETE SET NULL,
93
+ sender_snapshot_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(sender_snapshot_json) AND json_type(sender_snapshot_json) = 'object'),
94
+ content TEXT NOT NULL CHECK(length(content) BETWEEN 1 AND 20000),
95
+ chain_id TEXT,
96
+ request_id TEXT,
97
+ metadata_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(metadata_json) AND json_type(metadata_json) = 'object'),
98
+ created_at TEXT NOT NULL,
99
+ UNIQUE(conversation_id, sequence),
100
+ UNIQUE(conversation_id, request_id),
101
+ CHECK(
102
+ (sender_kind = 'human' AND sender_user_id IS NOT NULL AND sender_character_id IS NULL)
103
+ OR (sender_kind = 'character' AND sender_user_id IS NULL)
104
+ OR (sender_kind = 'system' AND sender_user_id IS NULL AND sender_character_id IS NULL)
105
+ )
106
+ );
107
+ CREATE INDEX IF NOT EXISTS idx_im_messages_conversation
108
+ ON im_messages(conversation_id, sequence DESC);
109
+ CREATE INDEX IF NOT EXISTS idx_im_messages_human_avatar
110
+ ON im_messages(
111
+ conversation_id, sender_user_id,
112
+ COALESCE(NULLIF(json_extract(sender_snapshot_json, '$.avatarSha256'), ''),
113
+ CASE WHEN instr(json_extract(sender_snapshot_json, '$.avatarUrl'), '?v=') > 0
114
+ THEN substr(json_extract(sender_snapshot_json, '$.avatarUrl'), instr(json_extract(sender_snapshot_json, '$.avatarUrl'), '?v=') + 3) END),
115
+ sequence
116
+ ) WHERE sender_kind = 'human';
117
+ CREATE INDEX IF NOT EXISTS idx_im_messages_character_avatar
118
+ ON im_messages(
119
+ conversation_id, sender_character_id,
120
+ COALESCE(NULLIF(json_extract(sender_snapshot_json, '$.avatarSha256'), ''),
121
+ CASE WHEN instr(json_extract(sender_snapshot_json, '$.avatarUrl'), '?v=') > 0
122
+ THEN substr(json_extract(sender_snapshot_json, '$.avatarUrl'), instr(json_extract(sender_snapshot_json, '$.avatarUrl'), '?v=') + 3) END),
123
+ sequence
124
+ ) WHERE sender_kind = 'character';
125
+ CREATE INDEX IF NOT EXISTS idx_im_messages_character_snapshot_avatar
126
+ ON im_messages(
127
+ conversation_id, json_extract(sender_snapshot_json, '$.id'),
128
+ COALESCE(NULLIF(json_extract(sender_snapshot_json, '$.avatarSha256'), ''),
129
+ CASE WHEN instr(json_extract(sender_snapshot_json, '$.avatarUrl'), '?v=') > 0
130
+ THEN substr(json_extract(sender_snapshot_json, '$.avatarUrl'), instr(json_extract(sender_snapshot_json, '$.avatarUrl'), '?v=') + 3) END),
131
+ sequence
132
+ )
133
+ WHERE sender_kind = 'character' AND sender_character_id IS NULL;
134
+
135
+ CREATE TABLE IF NOT EXISTS im_mentions (
136
+ message_id TEXT NOT NULL REFERENCES im_messages(id) ON DELETE CASCADE,
137
+ position INTEGER NOT NULL CHECK(position >= 0),
138
+ target_kind TEXT NOT NULL CHECK(target_kind IN ('character', 'user')),
139
+ target_id TEXT NOT NULL,
140
+ target_snapshot_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(target_snapshot_json) AND json_type(target_snapshot_json) = 'object'),
141
+ PRIMARY KEY(message_id, position)
142
+ ) WITHOUT ROWID;
143
+ CREATE INDEX IF NOT EXISTS idx_im_mentions_target
144
+ ON im_mentions(target_kind, target_id, message_id);
145
+
146
+ CREATE TABLE IF NOT EXISTS im_message_deliveries (
147
+ message_id TEXT NOT NULL REFERENCES im_messages(id) ON DELETE CASCADE,
148
+ character_membership_id TEXT NOT NULL REFERENCES im_character_memberships(id) ON DELETE CASCADE,
149
+ delivered_at TEXT NOT NULL,
150
+ PRIMARY KEY(message_id, character_membership_id)
151
+ ) WITHOUT ROWID;
152
+ CREATE INDEX IF NOT EXISTS idx_im_message_deliveries_character
153
+ ON im_message_deliveries(character_membership_id, message_id);
154
+
155
+ CREATE TABLE IF NOT EXISTS im_character_contexts (
156
+ character_membership_id TEXT NOT NULL REFERENCES im_character_memberships(id) ON DELETE CASCADE,
157
+ context_epoch INTEGER NOT NULL CHECK(context_epoch > 0),
158
+ summary TEXT NOT NULL DEFAULT '',
159
+ summarized_through_sequence INTEGER NOT NULL DEFAULT 0 CHECK(summarized_through_sequence >= 0),
160
+ updated_at TEXT NOT NULL,
161
+ PRIMARY KEY(character_membership_id, context_epoch)
162
+ ) WITHOUT ROWID;
163
+
164
+ CREATE TABLE IF NOT EXISTS im_chains (
165
+ id TEXT PRIMARY KEY,
166
+ conversation_id TEXT NOT NULL REFERENCES im_conversations(id) ON DELETE CASCADE,
167
+ initiator_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
168
+ authorization_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
169
+ trigger_message_id TEXT NOT NULL REFERENCES im_messages(id) ON DELETE CASCADE,
170
+ retry_source_chain_id TEXT REFERENCES im_chains(id) ON DELETE SET NULL,
171
+ mode TEXT NOT NULL CHECK(mode IN ('direct', 'mention', 'proactive')),
172
+ threshold INTEGER NOT NULL CHECK(threshold BETWEEN 0 AND 100),
173
+ max_ai_messages INTEGER NOT NULL CHECK(max_ai_messages BETWEEN 1 AND 100),
174
+ retry_count INTEGER NOT NULL CHECK(retry_count BETWEEN 1 AND 20),
175
+ primary_model_id TEXT REFERENCES models(id) ON DELETE SET NULL,
176
+ fallback_model_id TEXT REFERENCES models(id) ON DELETE SET NULL,
177
+ model_stage TEXT NOT NULL DEFAULT 'primary' CHECK(model_stage IN ('primary', 'fallback')),
178
+ status TEXT NOT NULL CHECK(status IN ('queued', 'running', 'waiting_config', 'quiet', 'completed', 'cancelled', 'failed', 'interrupted', 'limit')),
179
+ generated_count INTEGER NOT NULL DEFAULT 0 CHECK(generated_count >= 0),
180
+ error_code TEXT,
181
+ error_message TEXT,
182
+ created_at TEXT NOT NULL,
183
+ updated_at TEXT NOT NULL,
184
+ completed_at TEXT
185
+ );
186
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_im_chains_active
187
+ ON im_chains(conversation_id) WHERE status IN ('queued', 'running', 'waiting_config');
188
+ CREATE INDEX IF NOT EXISTS idx_im_chains_initiator
189
+ ON im_chains(initiator_user_id, status, created_at);
190
+
191
+ CREATE TABLE IF NOT EXISTS im_chain_turns (
192
+ id TEXT PRIMARY KEY,
193
+ chain_id TEXT NOT NULL REFERENCES im_chains(id) ON DELETE CASCADE,
194
+ character_membership_id TEXT NOT NULL REFERENCES im_character_memberships(id) ON DELETE CASCADE,
195
+ kind TEXT NOT NULL CHECK(kind IN ('judge', 'reply', 'compact')),
196
+ score INTEGER CHECK(score IS NULL OR score BETWEEN 0 AND 100),
197
+ selected INTEGER NOT NULL DEFAULT 0 CHECK(selected IN (0, 1)),
198
+ status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'completed', 'skipped', 'failed', 'cancelled')),
199
+ model_id TEXT REFERENCES models(id) ON DELETE SET NULL,
200
+ model_stage TEXT CHECK(model_stage IS NULL OR model_stage IN ('primary', 'fallback')),
201
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count >= 0),
202
+ duration_ms INTEGER CHECK(duration_ms IS NULL OR duration_ms >= 0),
203
+ failure TEXT,
204
+ ai_call_ids_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(ai_call_ids_json) AND json_type(ai_call_ids_json) = 'array'),
205
+ created_at TEXT NOT NULL,
206
+ completed_at TEXT
207
+ );
208
+ CREATE INDEX IF NOT EXISTS idx_im_chain_turns_chain
209
+ ON im_chain_turns(chain_id, created_at, id);
210
+
211
+ CREATE TABLE IF NOT EXISTS im_avatar_versions (
212
+ conversation_id TEXT NOT NULL REFERENCES im_conversations(id) ON DELETE CASCADE,
213
+ participant_kind TEXT NOT NULL CHECK(participant_kind IN ('character', 'user')),
214
+ participant_id TEXT NOT NULL,
215
+ sha256 TEXT NOT NULL CHECK(length(sha256) = 64),
216
+ mime_type TEXT NOT NULL,
217
+ byte_length INTEGER NOT NULL CHECK(byte_length >= 0),
218
+ storage_key TEXT,
219
+ content BLOB,
220
+ width INTEGER NOT NULL CHECK(width > 0),
221
+ height INTEGER NOT NULL CHECK(height > 0),
222
+ created_at TEXT NOT NULL,
223
+ PRIMARY KEY(conversation_id, participant_kind, participant_id, sha256),
224
+ CHECK((participant_kind = 'character' AND storage_key IS NOT NULL AND content IS NULL)
225
+ OR (participant_kind = 'user' AND storage_key IS NULL AND content IS NOT NULL))
226
+ ) WITHOUT ROWID;
227
+ CREATE INDEX IF NOT EXISTS idx_im_avatar_versions_storage
228
+ ON im_avatar_versions(storage_key) WHERE storage_key IS NOT NULL;
229
+ `;
15
230
  export function isSqliteDiskIoError(error) {
16
231
  if (!(error instanceof Error))
17
232
  return false;
@@ -76,10 +291,12 @@ export class Database {
76
291
  constructor(filename) {
77
292
  this.filename = filename;
78
293
  logger.info("database.opening", { databasePath: filename, inMemory: filename === ":memory:" });
294
+ let raw = null;
79
295
  try {
80
296
  if (filename !== ":memory:")
81
297
  mkdirSync(dirname(filename), { recursive: true });
82
- this.raw = new DatabaseSync(filename);
298
+ raw = new DatabaseSync(filename);
299
+ this.raw = raw;
83
300
  this.raw.exec("PRAGMA foreign_keys = ON");
84
301
  this.raw.exec("PRAGMA busy_timeout = 5000");
85
302
  if (filename !== ":memory:")
@@ -96,6 +313,12 @@ export class Database {
96
313
  logger.info("database.ready", { inMemory: filename === ":memory:", schemaVersion: Number(migration?.version ?? 0) });
97
314
  }
98
315
  catch (error) {
316
+ try {
317
+ raw?.close();
318
+ }
319
+ catch (closeError) {
320
+ logger.warn("database.open_failure_close_failed", { databasePath: filename, error: sanitizeError(closeError) });
321
+ }
99
322
  logSqliteDiskIoError(filename, error);
100
323
  logger.error("database.open_failed", { databasePath: filename, error: sanitizeError(error) });
101
324
  throw error;
@@ -909,6 +1132,7 @@ export class Database {
909
1132
  CREATE INDEX IF NOT EXISTS idx_foreshadow_occurrences_chapter ON foreshadow_occurrences(chapter_id, role);
910
1133
  CREATE INDEX IF NOT EXISTS idx_continuation_guards_suggestion ON continuation_guard_runs(suggestion_id, created_at DESC);
911
1134
  `);
1135
+ this.raw.exec(IM_SCHEMA_SQL);
912
1136
  this.applyDataMigrations();
913
1137
  }
914
1138
  applyDataMigrations() {
@@ -4634,7 +4858,7 @@ export class Database {
4634
4858
  this.run("CREATE INDEX IF NOT EXISTS idx_ai_write_plans_initiator ON ai_write_plans(initiator_user_id)");
4635
4859
  this.run("CREATE INDEX IF NOT EXISTS idx_ai_write_plans_owner ON ai_write_plans(conversation_owner_user_id)");
4636
4860
  this.run("CREATE INDEX IF NOT EXISTS idx_ai_write_plan_operations_plan ON ai_write_plan_operations(plan_id, seq)");
4637
- // AskUserQuestions 提问记录:一次一个问题,持久化保存,支持刷新后继续查看。
4861
+ // AskUserQuestions 提问记录:一次工具调用对应一个问题批次,持久化保存,支持刷新后继续查看。
4638
4862
  this.run(`
4639
4863
  CREATE TABLE IF NOT EXISTS ai_user_questions (
4640
4864
  id TEXT PRIMARY KEY,
@@ -4644,10 +4868,12 @@ export class Database {
4644
4868
  recipient_user_id TEXT,
4645
4869
  question TEXT NOT NULL,
4646
4870
  options_json TEXT NOT NULL DEFAULT '[]',
4871
+ questions_json TEXT NOT NULL DEFAULT '[]',
4647
4872
  status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'answered', 'rejected', 'expired')),
4648
4873
  selected_option INTEGER,
4649
4874
  answer_text TEXT NOT NULL DEFAULT '',
4650
4875
  is_custom_answer INTEGER NOT NULL DEFAULT 0,
4876
+ answers_json TEXT NOT NULL DEFAULT '[]',
4651
4877
  tool_call_id TEXT,
4652
4878
  continuation_json TEXT NOT NULL DEFAULT '{}',
4653
4879
  resume_state TEXT NOT NULL DEFAULT 'pending' CHECK(resume_state IN ('pending', 'claimed', 'completed', 'failed')),
@@ -4977,6 +5203,320 @@ export class Database {
4977
5203
  if (foreignKeys.length > 0)
4978
5204
  throw new Error(`数据库外键检查失败:发现 ${foreignKeys.length} 条异常记录`);
4979
5205
  }
5206
+ const imTablesPresent = [
5207
+ "im_user_settings",
5208
+ "im_conversations",
5209
+ "im_human_memberships",
5210
+ "im_character_memberships",
5211
+ "im_messages",
5212
+ "im_mentions",
5213
+ "im_message_deliveries",
5214
+ "im_character_contexts",
5215
+ "im_chains",
5216
+ "im_chain_turns"
5217
+ ].every((table) => this.get("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?", table) !== undefined);
5218
+ const imMessageContextEpochPresent = this.all("PRAGMA table_info(im_messages)")
5219
+ .some((column) => column.name === "context_epoch");
5220
+ if (!applied.has(127) || !imTablesPresent || !imMessageContextEpochPresent) {
5221
+ this.transaction(() => {
5222
+ this.raw.exec(IM_SCHEMA_SQL);
5223
+ const messageColumns = new Set(this.all("PRAGMA table_info(im_messages)").map((column) => column.name));
5224
+ if (!messageColumns.has("context_epoch")) {
5225
+ this.run("ALTER TABLE im_messages ADD COLUMN context_epoch INTEGER NOT NULL DEFAULT 1 CHECK(context_epoch > 0)");
5226
+ }
5227
+ this.run("INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (127, ?)", new Date().toISOString());
5228
+ });
5229
+ const integrity = this.all("PRAGMA integrity_check");
5230
+ if (integrity.some((row) => row.integrity_check !== "ok")) {
5231
+ throw new Error(`数据库完整性检查失败:${integrity.map((row) => row.integrity_check).join(";")}`);
5232
+ }
5233
+ const foreignKeys = this.all("PRAGMA foreign_key_check");
5234
+ if (foreignKeys.length > 0)
5235
+ throw new Error(`数据库外键检查失败:发现 ${foreignKeys.length} 条异常记录`);
5236
+ }
5237
+ const imHumanConversationSnapshotPresent = this.all("PRAGMA table_info(im_human_memberships)")
5238
+ .some((column) => column.name === "conversation_snapshot_json");
5239
+ if (!applied.has(128) || !imHumanConversationSnapshotPresent) {
5240
+ this.transaction(() => {
5241
+ if (!imHumanConversationSnapshotPresent) {
5242
+ this.run(`ALTER TABLE im_human_memberships ADD COLUMN conversation_snapshot_json TEXT
5243
+ CHECK(conversation_snapshot_json IS NULL OR (json_valid(conversation_snapshot_json) AND json_type(conversation_snapshot_json) = 'object'))`);
5244
+ }
5245
+ this.run("INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (128, ?)", new Date().toISOString());
5246
+ });
5247
+ const integrity = this.all("PRAGMA integrity_check");
5248
+ if (integrity.some((row) => row.integrity_check !== "ok")) {
5249
+ throw new Error(`数据库完整性检查失败:${integrity.map((row) => row.integrity_check).join(";")}`);
5250
+ }
5251
+ const foreignKeys = this.all("PRAGMA foreign_key_check");
5252
+ if (foreignKeys.length > 0)
5253
+ throw new Error(`数据库外键检查失败:发现 ${foreignKeys.length} 条异常记录`);
5254
+ }
5255
+ const imAvatarVersionsPresent = this.get("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'im_avatar_versions'") !== undefined;
5256
+ if (!applied.has(129) || !imAvatarVersionsPresent) {
5257
+ this.transaction(() => {
5258
+ this.raw.exec(`CREATE TABLE IF NOT EXISTS im_avatar_versions (
5259
+ conversation_id TEXT NOT NULL REFERENCES im_conversations(id) ON DELETE CASCADE,
5260
+ participant_kind TEXT NOT NULL CHECK(participant_kind IN ('character', 'user')),
5261
+ participant_id TEXT NOT NULL,
5262
+ sha256 TEXT NOT NULL CHECK(length(sha256) = 64),
5263
+ mime_type TEXT NOT NULL,
5264
+ byte_length INTEGER NOT NULL CHECK(byte_length >= 0),
5265
+ storage_key TEXT,
5266
+ content BLOB,
5267
+ width INTEGER NOT NULL CHECK(width > 0),
5268
+ height INTEGER NOT NULL CHECK(height > 0),
5269
+ created_at TEXT NOT NULL,
5270
+ PRIMARY KEY(conversation_id, participant_kind, participant_id, sha256),
5271
+ CHECK((participant_kind = 'character' AND storage_key IS NOT NULL AND content IS NULL)
5272
+ OR (participant_kind = 'user' AND storage_key IS NULL AND content IS NOT NULL))
5273
+ ) WITHOUT ROWID;
5274
+ CREATE INDEX IF NOT EXISTS idx_im_avatar_versions_storage
5275
+ ON im_avatar_versions(storage_key) WHERE storage_key IS NOT NULL;`);
5276
+ this.run("INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (129, ?)", new Date().toISOString());
5277
+ });
5278
+ const integrity = this.all("PRAGMA integrity_check");
5279
+ if (integrity.some((row) => row.integrity_check !== "ok")) {
5280
+ throw new Error(`数据库完整性检查失败:${integrity.map((row) => row.integrity_check).join(";")}`);
5281
+ }
5282
+ const foreignKeys = this.all("PRAGMA foreign_key_check");
5283
+ if (foreignKeys.length > 0)
5284
+ throw new Error(`数据库外键检查失败:发现 ${foreignKeys.length} 条异常记录`);
5285
+ }
5286
+ const imChainRetrySourcePresent = this.all("PRAGMA table_info(im_chains)")
5287
+ .some((column) => column.name === "retry_source_chain_id");
5288
+ const imChainRetrySourceIndexPresent = this.get("SELECT 1 AS present FROM sqlite_master WHERE type = 'index' AND name = 'idx_im_chains_retry_source'") !== undefined;
5289
+ if (!applied.has(130) || !imChainRetrySourcePresent || !imChainRetrySourceIndexPresent) {
5290
+ this.transaction(() => {
5291
+ if (!imChainRetrySourcePresent) {
5292
+ this.run("ALTER TABLE im_chains ADD COLUMN retry_source_chain_id TEXT REFERENCES im_chains(id) ON DELETE SET NULL");
5293
+ }
5294
+ this.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_im_chains_retry_source
5295
+ ON im_chains(retry_source_chain_id) WHERE retry_source_chain_id IS NOT NULL`);
5296
+ this.run("INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (130, ?)", new Date().toISOString());
5297
+ });
5298
+ const integrity = this.all("PRAGMA integrity_check");
5299
+ if (integrity.some((row) => row.integrity_check !== "ok")) {
5300
+ throw new Error(`数据库完整性检查失败:${integrity.map((row) => row.integrity_check).join(";")}`);
5301
+ }
5302
+ const foreignKeys = this.all("PRAGMA foreign_key_check");
5303
+ if (foreignKeys.length > 0)
5304
+ throw new Error(`数据库外键检查失败:发现 ${foreignKeys.length} 条异常记录`);
5305
+ }
5306
+ const imAvatarVersionsEmpty = Number(this.get("SELECT COUNT(*) AS count FROM im_avatar_versions")?.count ?? 0) === 0;
5307
+ const imAvatarVersionRecoverySourcePresent = imAvatarVersionsEmpty && this.get(`SELECT 1 AS present
5308
+ WHERE EXISTS (
5309
+ SELECT 1 FROM im_character_memberships membership
5310
+ JOIN character_avatars avatar ON avatar.character_id = membership.character_id
5311
+ WHERE membership.left_at IS NULL
5312
+ ) OR EXISTS (
5313
+ SELECT 1 FROM im_human_memberships membership
5314
+ JOIN user_avatars avatar ON avatar.user_id = membership.user_id
5315
+ WHERE membership.left_at IS NULL
5316
+ )`) !== undefined;
5317
+ if (!applied.has(131) || !imAvatarVersionsPresent || imAvatarVersionRecoverySourcePresent) {
5318
+ this.transaction(() => {
5319
+ this.run(`INSERT OR IGNORE INTO im_avatar_versions (
5320
+ conversation_id, participant_kind, participant_id, sha256, mime_type, byte_length,
5321
+ storage_key, content, width, height, created_at
5322
+ )
5323
+ SELECT membership.conversation_id, 'character', avatar.character_id, avatar.sha256,
5324
+ avatar.mime_type, avatar.byte_length, avatar.storage_key, NULL,
5325
+ avatar.width, avatar.height, avatar.updated_at
5326
+ FROM im_character_memberships membership
5327
+ JOIN character_avatars avatar ON avatar.character_id = membership.character_id
5328
+ WHERE membership.left_at IS NULL`);
5329
+ this.run(`INSERT OR IGNORE INTO im_avatar_versions (
5330
+ conversation_id, participant_kind, participant_id, sha256, mime_type, byte_length,
5331
+ storage_key, content, width, height, created_at
5332
+ )
5333
+ SELECT membership.conversation_id, 'user', avatar.user_id, avatar.sha256,
5334
+ avatar.mime_type, avatar.byte_length, NULL, avatar.content,
5335
+ avatar.width, avatar.height, avatar.updated_at
5336
+ FROM im_human_memberships membership
5337
+ JOIN user_avatars avatar ON avatar.user_id = membership.user_id
5338
+ WHERE membership.left_at IS NULL`);
5339
+ this.run(`INSERT OR IGNORE INTO im_avatar_versions (
5340
+ conversation_id, participant_kind, participant_id, sha256, mime_type, byte_length,
5341
+ storage_key, content, width, height, created_at
5342
+ )
5343
+ SELECT message.conversation_id, 'character', avatar.character_id, avatar.sha256,
5344
+ avatar.mime_type, avatar.byte_length, avatar.storage_key, NULL,
5345
+ avatar.width, avatar.height, message.created_at
5346
+ FROM im_messages message
5347
+ JOIN character_avatars avatar
5348
+ ON avatar.character_id = COALESCE(message.sender_character_id, json_extract(message.sender_snapshot_json, '$.id'))
5349
+ AND avatar.sha256 = COALESCE(
5350
+ NULLIF(json_extract(message.sender_snapshot_json, '$.avatarSha256'), ''),
5351
+ CASE WHEN instr(json_extract(message.sender_snapshot_json, '$.avatarUrl'), '?v=') > 0
5352
+ THEN substr(json_extract(message.sender_snapshot_json, '$.avatarUrl'), instr(json_extract(message.sender_snapshot_json, '$.avatarUrl'), '?v=') + 3)
5353
+ END
5354
+ )
5355
+ WHERE message.sender_kind = 'character'`);
5356
+ this.run(`INSERT OR IGNORE INTO im_avatar_versions (
5357
+ conversation_id, participant_kind, participant_id, sha256, mime_type, byte_length,
5358
+ storage_key, content, width, height, created_at
5359
+ )
5360
+ SELECT message.conversation_id, 'user', avatar.user_id, avatar.sha256,
5361
+ avatar.mime_type, avatar.byte_length, NULL, avatar.content,
5362
+ avatar.width, avatar.height, message.created_at
5363
+ FROM im_messages message
5364
+ JOIN user_avatars avatar
5365
+ ON avatar.user_id = COALESCE(message.sender_user_id, json_extract(message.sender_snapshot_json, '$.userId'))
5366
+ AND avatar.sha256 = COALESCE(
5367
+ NULLIF(json_extract(message.sender_snapshot_json, '$.avatarSha256'), ''),
5368
+ CASE WHEN instr(json_extract(message.sender_snapshot_json, '$.avatarUrl'), '?v=') > 0
5369
+ THEN substr(json_extract(message.sender_snapshot_json, '$.avatarUrl'), instr(json_extract(message.sender_snapshot_json, '$.avatarUrl'), '?v=') + 3)
5370
+ END
5371
+ )
5372
+ WHERE message.sender_kind = 'human'`);
5373
+ this.run(`INSERT OR IGNORE INTO im_avatar_versions (
5374
+ conversation_id, participant_kind, participant_id, sha256, mime_type, byte_length,
5375
+ storage_key, content, width, height, created_at
5376
+ )
5377
+ SELECT viewer.conversation_id, 'character', avatar.character_id, avatar.sha256,
5378
+ avatar.mime_type, avatar.byte_length, avatar.storage_key, NULL,
5379
+ avatar.width, avatar.height, viewer.left_at
5380
+ FROM im_human_memberships viewer, json_each(viewer.conversation_snapshot_json, '$.participants.characters') participant
5381
+ JOIN character_avatars avatar
5382
+ ON avatar.character_id = json_extract(participant.value, '$.characterId')
5383
+ AND avatar.sha256 = substr(
5384
+ json_extract(participant.value, '$.avatarUrl'),
5385
+ instr(json_extract(participant.value, '$.avatarUrl'), '?v=') + 3
5386
+ )
5387
+ WHERE viewer.conversation_snapshot_json IS NOT NULL AND viewer.left_at IS NOT NULL`);
5388
+ this.run(`INSERT OR IGNORE INTO im_avatar_versions (
5389
+ conversation_id, participant_kind, participant_id, sha256, mime_type, byte_length,
5390
+ storage_key, content, width, height, created_at
5391
+ )
5392
+ SELECT viewer.conversation_id, 'user', avatar.user_id, avatar.sha256,
5393
+ avatar.mime_type, avatar.byte_length, NULL, avatar.content,
5394
+ avatar.width, avatar.height, viewer.left_at
5395
+ FROM im_human_memberships viewer, json_each(viewer.conversation_snapshot_json, '$.participants.humans') participant
5396
+ JOIN user_avatars avatar
5397
+ ON avatar.user_id = json_extract(participant.value, '$.userId')
5398
+ AND avatar.sha256 = substr(
5399
+ json_extract(participant.value, '$.avatarUrl'),
5400
+ instr(json_extract(participant.value, '$.avatarUrl'), '?v=') + 3
5401
+ )
5402
+ WHERE viewer.conversation_snapshot_json IS NOT NULL AND viewer.left_at IS NOT NULL`);
5403
+ this.run("INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (131, ?)", new Date().toISOString());
5404
+ });
5405
+ const integrity = this.all("PRAGMA integrity_check");
5406
+ if (integrity.some((row) => row.integrity_check !== "ok")) {
5407
+ throw new Error(`数据库完整性检查失败:${integrity.map((row) => row.integrity_check).join(";")}`);
5408
+ }
5409
+ const foreignKeys = this.all("PRAGMA foreign_key_check");
5410
+ if (foreignKeys.length > 0)
5411
+ throw new Error(`数据库外键检查失败:发现 ${foreignKeys.length} 条异常记录`);
5412
+ }
5413
+ const imMembershipConversationIndexesPresent = [
5414
+ "idx_im_human_memberships_conversation",
5415
+ "idx_im_character_memberships_conversation"
5416
+ ].every((index) => this.get("SELECT 1 AS present FROM sqlite_master WHERE type = 'index' AND name = ?", index) !== undefined);
5417
+ if (!applied.has(132) || !imMembershipConversationIndexesPresent) {
5418
+ this.transaction(() => {
5419
+ this.run(`CREATE INDEX IF NOT EXISTS idx_im_human_memberships_conversation
5420
+ ON im_human_memberships(conversation_id, joined_at, id)`);
5421
+ this.run(`CREATE INDEX IF NOT EXISTS idx_im_character_memberships_conversation
5422
+ ON im_character_memberships(conversation_id, joined_at, id)`);
5423
+ this.run("INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (132, ?)", new Date().toISOString());
5424
+ });
5425
+ const integrity = this.all("PRAGMA integrity_check");
5426
+ if (integrity.some((row) => row.integrity_check !== "ok")) {
5427
+ throw new Error(`数据库完整性检查失败:${integrity.map((row) => row.integrity_check).join(";")}`);
5428
+ }
5429
+ const foreignKeys = this.all("PRAGMA foreign_key_check");
5430
+ if (foreignKeys.length > 0)
5431
+ throw new Error(`数据库外键检查失败:发现 ${foreignKeys.length} 条异常记录`);
5432
+ }
5433
+ const imMessageAvatarIndexesPresent = [
5434
+ "idx_im_messages_human_avatar",
5435
+ "idx_im_messages_character_avatar",
5436
+ "idx_im_messages_character_snapshot_avatar"
5437
+ ].every((index) => this.get("SELECT 1 AS present FROM sqlite_master WHERE type = 'index' AND name = ?", index) !== undefined);
5438
+ if (!applied.has(133) || !imMessageAvatarIndexesPresent) {
5439
+ this.transaction(() => {
5440
+ this.run(`CREATE INDEX IF NOT EXISTS idx_im_messages_human_avatar
5441
+ ON im_messages(conversation_id, sender_user_id, sequence) WHERE sender_kind = 'human'`);
5442
+ this.run(`CREATE INDEX IF NOT EXISTS idx_im_messages_character_avatar
5443
+ ON im_messages(conversation_id, sender_character_id, sequence) WHERE sender_kind = 'character'`);
5444
+ this.run(`CREATE INDEX IF NOT EXISTS idx_im_messages_character_snapshot_avatar
5445
+ ON im_messages(conversation_id, json_extract(sender_snapshot_json, '$.id'), sequence)
5446
+ WHERE sender_kind = 'character' AND sender_character_id IS NULL`);
5447
+ this.run("INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (133, ?)", new Date().toISOString());
5448
+ });
5449
+ const integrity = this.all("PRAGMA integrity_check");
5450
+ if (integrity.some((row) => row.integrity_check !== "ok")) {
5451
+ throw new Error(`数据库完整性检查失败:${integrity.map((row) => row.integrity_check).join(";")}`);
5452
+ }
5453
+ const foreignKeys = this.all("PRAGMA foreign_key_check");
5454
+ if (foreignKeys.length > 0)
5455
+ throw new Error(`数据库外键检查失败:发现 ${foreignKeys.length} 条异常记录`);
5456
+ }
5457
+ const imMessageAvatarHashIndexesPresent = [
5458
+ "idx_im_messages_human_avatar",
5459
+ "idx_im_messages_character_avatar",
5460
+ "idx_im_messages_character_snapshot_avatar"
5461
+ ].every((index) => String(this.get("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?", index)?.sql ?? "").includes("avatarSha256"));
5462
+ if (!applied.has(134) || !imMessageAvatarHashIndexesPresent) {
5463
+ this.transaction(() => {
5464
+ this.run("DROP INDEX IF EXISTS idx_im_messages_human_avatar");
5465
+ this.run("DROP INDEX IF EXISTS idx_im_messages_character_avatar");
5466
+ this.run("DROP INDEX IF EXISTS idx_im_messages_character_snapshot_avatar");
5467
+ this.raw.exec(`CREATE INDEX idx_im_messages_human_avatar
5468
+ ON im_messages(
5469
+ conversation_id, sender_user_id,
5470
+ COALESCE(NULLIF(json_extract(sender_snapshot_json, '$.avatarSha256'), ''),
5471
+ CASE WHEN instr(json_extract(sender_snapshot_json, '$.avatarUrl'), '?v=') > 0
5472
+ THEN substr(json_extract(sender_snapshot_json, '$.avatarUrl'), instr(json_extract(sender_snapshot_json, '$.avatarUrl'), '?v=') + 3) END),
5473
+ sequence
5474
+ ) WHERE sender_kind = 'human';
5475
+ CREATE INDEX idx_im_messages_character_avatar
5476
+ ON im_messages(
5477
+ conversation_id, sender_character_id,
5478
+ COALESCE(NULLIF(json_extract(sender_snapshot_json, '$.avatarSha256'), ''),
5479
+ CASE WHEN instr(json_extract(sender_snapshot_json, '$.avatarUrl'), '?v=') > 0
5480
+ THEN substr(json_extract(sender_snapshot_json, '$.avatarUrl'), instr(json_extract(sender_snapshot_json, '$.avatarUrl'), '?v=') + 3) END),
5481
+ sequence
5482
+ ) WHERE sender_kind = 'character';
5483
+ CREATE INDEX idx_im_messages_character_snapshot_avatar
5484
+ ON im_messages(
5485
+ conversation_id, json_extract(sender_snapshot_json, '$.id'),
5486
+ COALESCE(NULLIF(json_extract(sender_snapshot_json, '$.avatarSha256'), ''),
5487
+ CASE WHEN instr(json_extract(sender_snapshot_json, '$.avatarUrl'), '?v=') > 0
5488
+ THEN substr(json_extract(sender_snapshot_json, '$.avatarUrl'), instr(json_extract(sender_snapshot_json, '$.avatarUrl'), '?v=') + 3) END),
5489
+ sequence
5490
+ ) WHERE sender_kind = 'character' AND sender_character_id IS NULL;`);
5491
+ this.run("INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (134, ?)", new Date().toISOString());
5492
+ });
5493
+ const integrity = this.all("PRAGMA integrity_check");
5494
+ if (integrity.some((row) => row.integrity_check !== "ok")) {
5495
+ throw new Error(`数据库完整性检查失败:${integrity.map((row) => row.integrity_check).join(";")}`);
5496
+ }
5497
+ const foreignKeys = this.all("PRAGMA foreign_key_check");
5498
+ if (foreignKeys.length > 0)
5499
+ throw new Error(`数据库外键检查失败:发现 ${foreignKeys.length} 条异常记录`);
5500
+ }
5501
+ const aiQuestionColumns = new Set(this.all("PRAGMA table_info(ai_user_questions)").map((column) => column.name));
5502
+ if (!applied.has(135) || !aiQuestionColumns.has("questions_json") || !aiQuestionColumns.has("answers_json")) {
5503
+ this.transaction(() => {
5504
+ if (!aiQuestionColumns.has("questions_json")) {
5505
+ this.run("ALTER TABLE ai_user_questions ADD COLUMN questions_json TEXT NOT NULL DEFAULT '[]'");
5506
+ }
5507
+ if (!aiQuestionColumns.has("answers_json")) {
5508
+ this.run("ALTER TABLE ai_user_questions ADD COLUMN answers_json TEXT NOT NULL DEFAULT '[]'");
5509
+ }
5510
+ this.run("INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (135, ?)", new Date().toISOString());
5511
+ });
5512
+ const integrity = this.all("PRAGMA integrity_check");
5513
+ if (integrity.some((row) => row.integrity_check !== "ok")) {
5514
+ throw new Error(`数据库完整性检查失败:${integrity.map((row) => row.integrity_check).join(";")}`);
5515
+ }
5516
+ const foreignKeys = this.all("PRAGMA foreign_key_check");
5517
+ if (foreignKeys.length > 0)
5518
+ throw new Error(`数据库外键检查失败:发现 ${foreignKeys.length} 条异常记录`);
5519
+ }
4980
5520
  }
4981
5521
  normalizeCharacterName(value) {
4982
5522
  return value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLocaleLowerCase("zh-CN");
@@ -4998,6 +5538,11 @@ export class Database {
4998
5538
  WHERE status = 'running'`, JSON.stringify([{ message: "服务重启导致任务中断" }]), timestamp);
4999
5539
  this.run(`UPDATE s3_backup_runs SET status = 'failed', error_message = COALESCE(error_message, '服务重启导致备份中断'), finished_at = ?
5000
5540
  WHERE status = 'running'`, timestamp);
5541
+ this.run(`UPDATE im_chains SET status = 'interrupted', error_code = 'IM_CHAIN_RUNTIME_RESTARTED',
5542
+ error_message = '服务重启导致 IM 交流链中断,可从原消息重试', updated_at = ?, completed_at = ?
5543
+ WHERE status IN ('queued', 'running')`, timestamp, timestamp);
5544
+ this.run(`UPDATE im_chain_turns SET status = 'cancelled', failure = COALESCE(failure, '服务重启导致调用中断'), completed_at = ?
5545
+ WHERE status IN ('pending', 'running')`, timestamp);
5001
5546
  }
5002
5547
  }
5003
5548
  //# sourceMappingURL=database.js.map