@yeaft/webchat-agent 1.0.188 → 1.0.190

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 (121) hide show
  1. package/cli.js +12 -0
  2. package/index.js +10 -2
  3. package/local-run.js +218 -0
  4. package/local-runtime/server/.env.example +54 -0
  5. package/local-runtime/server/api.js +111 -0
  6. package/local-runtime/server/auth/aad.js +235 -0
  7. package/local-runtime/server/auth/login.js +156 -0
  8. package/local-runtime/server/auth/oauth-flow.js +277 -0
  9. package/local-runtime/server/auth/password-reset.js +134 -0
  10. package/local-runtime/server/auth/providers/alipay.js +125 -0
  11. package/local-runtime/server/auth/providers/github.js +82 -0
  12. package/local-runtime/server/auth/providers/google.js +60 -0
  13. package/local-runtime/server/auth/providers/microsoft.js +71 -0
  14. package/local-runtime/server/auth/providers/types.js +57 -0
  15. package/local-runtime/server/auth/providers/wechat.js +68 -0
  16. package/local-runtime/server/auth/register.js +91 -0
  17. package/local-runtime/server/auth/session-store.js +57 -0
  18. package/local-runtime/server/auth/token.js +85 -0
  19. package/local-runtime/server/auth/totp-auth.js +133 -0
  20. package/local-runtime/server/auth/utils.js +42 -0
  21. package/local-runtime/server/auth.js +8 -0
  22. package/local-runtime/server/check-node-version.js +74 -0
  23. package/local-runtime/server/config.js +298 -0
  24. package/local-runtime/server/context.js +140 -0
  25. package/local-runtime/server/create-user.js +59 -0
  26. package/local-runtime/server/database.js +12 -0
  27. package/local-runtime/server/db/connection.js +963 -0
  28. package/local-runtime/server/db/expert-db.js +171 -0
  29. package/local-runtime/server/db/identity-db.js +92 -0
  30. package/local-runtime/server/db/invitation-db.js +38 -0
  31. package/local-runtime/server/db/message-db.js +257 -0
  32. package/local-runtime/server/db/session-db.js +118 -0
  33. package/local-runtime/server/db/user-db.js +165 -0
  34. package/local-runtime/server/db/user-stats-db.js +185 -0
  35. package/local-runtime/server/db/yeaft-session-db.js +258 -0
  36. package/local-runtime/server/email.js +96 -0
  37. package/local-runtime/server/encryption.js +105 -0
  38. package/local-runtime/server/handlers/agent-conversation.js +347 -0
  39. package/local-runtime/server/handlers/agent-file-terminal.js +99 -0
  40. package/local-runtime/server/handlers/agent-output.js +854 -0
  41. package/local-runtime/server/handlers/agent-sync.js +399 -0
  42. package/local-runtime/server/handlers/agent-work-center.js +27 -0
  43. package/local-runtime/server/handlers/client-conversation.js +1182 -0
  44. package/local-runtime/server/handlers/client-misc.js +254 -0
  45. package/local-runtime/server/handlers/client-work-center.js +269 -0
  46. package/local-runtime/server/handlers/client-workbench.js +146 -0
  47. package/local-runtime/server/handlers/session-pin-router.js +61 -0
  48. package/local-runtime/server/heartbeat-policy.js +46 -0
  49. package/local-runtime/server/index.js +275 -0
  50. package/local-runtime/server/package.json +55 -0
  51. package/local-runtime/server/perf-trace.js +154 -0
  52. package/local-runtime/server/proxy.js +273 -0
  53. package/local-runtime/server/routes/admin-routes.js +207 -0
  54. package/local-runtime/server/routes/auth-routes.js +322 -0
  55. package/local-runtime/server/routes/expert-routes.js +117 -0
  56. package/local-runtime/server/routes/invitation-routes.js +60 -0
  57. package/local-runtime/server/routes/session-routes.js +112 -0
  58. package/local-runtime/server/routes/upload-routes.js +109 -0
  59. package/local-runtime/server/routes/user-routes.js +241 -0
  60. package/local-runtime/server/totp.js +74 -0
  61. package/local-runtime/server/work-item-attachment-policy.js +56 -0
  62. package/local-runtime/server/ws-agent.js +319 -0
  63. package/local-runtime/server/ws-client.js +214 -0
  64. package/local-runtime/server/ws-utils.js +394 -0
  65. package/local-runtime/server/yeaft-asset-store.js +339 -0
  66. package/local-runtime/version.json +1 -0
  67. package/local-runtime/web/app.bundle.js +7673 -0
  68. package/local-runtime/web/app.bundle.js.gz +0 -0
  69. package/local-runtime/web/assets/avatars/README.md +34 -0
  70. package/local-runtime/web/assets/avatars/ada.svg +1 -0
  71. package/local-runtime/web/assets/avatars/alan.svg +1 -0
  72. package/local-runtime/web/assets/avatars/alice.svg +1 -0
  73. package/local-runtime/web/assets/avatars/anders.svg +1 -0
  74. package/local-runtime/web/assets/avatars/bezos.svg +1 -0
  75. package/local-runtime/web/assets/avatars/borges.svg +1 -0
  76. package/local-runtime/web/assets/avatars/buffett.svg +1 -0
  77. package/local-runtime/web/assets/avatars/clausewitz.svg +1 -0
  78. package/local-runtime/web/assets/avatars/dalio.svg +1 -0
  79. package/local-runtime/web/assets/avatars/dieter.svg +1 -0
  80. package/local-runtime/web/assets/avatars/drucker.svg +1 -0
  81. package/local-runtime/web/assets/avatars/einstein.svg +1 -0
  82. package/local-runtime/web/assets/avatars/grace.svg +1 -0
  83. package/local-runtime/web/assets/avatars/harari.svg +1 -0
  84. package/local-runtime/web/assets/avatars/jung.svg +1 -0
  85. package/local-runtime/web/assets/avatars/kahneman.svg +1 -0
  86. package/local-runtime/web/assets/avatars/ken.svg +1 -0
  87. package/local-runtime/web/assets/avatars/kongzi.svg +1 -0
  88. package/local-runtime/web/assets/avatars/kubrick.svg +1 -0
  89. package/local-runtime/web/assets/avatars/linus.svg +1 -0
  90. package/local-runtime/web/assets/avatars/luxun.svg +1 -0
  91. package/local-runtime/web/assets/avatars/margaret.svg +1 -0
  92. package/local-runtime/web/assets/avatars/martin.svg +1 -0
  93. package/local-runtime/web/assets/avatars/miyazaki.svg +1 -0
  94. package/local-runtime/web/assets/avatars/munger.svg +1 -0
  95. package/local-runtime/web/assets/avatars/nietzsche.svg +1 -0
  96. package/local-runtime/web/assets/avatars/norman.svg +1 -0
  97. package/local-runtime/web/assets/avatars/shannon.svg +1 -0
  98. package/local-runtime/web/assets/avatars/simaqian.svg +1 -0
  99. package/local-runtime/web/assets/avatars/socrates.svg +1 -0
  100. package/local-runtime/web/assets/avatars/steve.svg +1 -0
  101. package/local-runtime/web/assets/avatars/sudongpo.svg +1 -0
  102. package/local-runtime/web/assets/avatars/sunzi.svg +1 -0
  103. package/local-runtime/web/docx-preview.min.js +2 -0
  104. package/local-runtime/web/docx-preview.min.js.gz +0 -0
  105. package/local-runtime/web/html-to-image.min.js +2 -0
  106. package/local-runtime/web/html-to-image.min.js.gz +0 -0
  107. package/local-runtime/web/index.html +21 -0
  108. package/local-runtime/web/jszip.min.js +13 -0
  109. package/local-runtime/web/jszip.min.js.gz +0 -0
  110. package/local-runtime/web/mermaid.min.js +2843 -0
  111. package/local-runtime/web/mermaid.min.js.gz +0 -0
  112. package/local-runtime/web/msal-browser.min.js +69 -0
  113. package/local-runtime/web/msal-browser.min.js.gz +0 -0
  114. package/local-runtime/web/style.bundle.css +10 -0
  115. package/local-runtime/web/style.bundle.css.gz +0 -0
  116. package/local-runtime/web/vendor.bundle.js +1522 -0
  117. package/local-runtime/web/vendor.bundle.js.gz +0 -0
  118. package/local-runtime/web/xlsx.min.js +24 -0
  119. package/local-runtime/web/xlsx.min.js.gz +0 -0
  120. package/package.json +13 -3
  121. package/scripts/prepare-local-runtime.js +25 -0
@@ -0,0 +1,963 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { randomBytes, randomUUID } from 'crypto';
3
+ import { dirname, join } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { existsSync, mkdirSync } from 'fs';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+
9
+ // 数据库文件位置
10
+ const DATA_DIR = process.env.SERVER_DATA_DIR || process.env.TEST_DB_DIR || join(__dirname, '../../data');
11
+ const DB_PATH = process.env.SERVER_DATA_DIR
12
+ ? join(DATA_DIR, 'webchat.db')
13
+ : (process.env.TEST_DB_PATH || join(DATA_DIR, 'webchat.db'));
14
+
15
+ // 确保数据目录存在
16
+ if (!existsSync(DATA_DIR)) {
17
+ mkdirSync(DATA_DIR, { recursive: true });
18
+ }
19
+
20
+ // 创建数据库连接
21
+ const db = new DatabaseSync(DB_PATH);
22
+
23
+ // 启用 WAL 模式提高性能
24
+ db.exec('PRAGMA journal_mode = WAL');
25
+ db.exec('PRAGMA foreign_keys = ON');
26
+
27
+ // 初始化表结构(不包含索引,索引在迁移后创建)
28
+ db.exec(`
29
+ -- 用户表
30
+ CREATE TABLE IF NOT EXISTS users (
31
+ id TEXT PRIMARY KEY,
32
+ username TEXT UNIQUE NOT NULL,
33
+ display_name TEXT,
34
+ created_at INTEGER NOT NULL,
35
+ last_login_at INTEGER
36
+ );
37
+
38
+ -- 会话表
39
+ CREATE TABLE IF NOT EXISTS sessions (
40
+ id TEXT PRIMARY KEY,
41
+ agent_id TEXT NOT NULL,
42
+ agent_name TEXT,
43
+ claude_session_id TEXT,
44
+ work_dir TEXT,
45
+ title TEXT,
46
+ created_at INTEGER NOT NULL,
47
+ updated_at INTEGER NOT NULL,
48
+ is_active INTEGER DEFAULT 1
49
+ );
50
+
51
+ -- 消息表
52
+ CREATE TABLE IF NOT EXISTS messages (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ session_id TEXT NOT NULL,
55
+ role TEXT NOT NULL,
56
+ content TEXT NOT NULL,
57
+ message_type TEXT,
58
+ tool_name TEXT,
59
+ tool_input TEXT,
60
+ created_at INTEGER NOT NULL,
61
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
62
+ );
63
+
64
+ -- 邀请码表
65
+ CREATE TABLE IF NOT EXISTS invitations (
66
+ id TEXT PRIMARY KEY,
67
+ created_by TEXT NOT NULL,
68
+ created_at INTEGER NOT NULL,
69
+ used_by TEXT,
70
+ used_at INTEGER,
71
+ expires_at INTEGER NOT NULL,
72
+ role TEXT DEFAULT 'user',
73
+ FOREIGN KEY (created_by) REFERENCES users(id),
74
+ FOREIGN KEY (used_by) REFERENCES users(id)
75
+ );
76
+
77
+ -- 用户统计表
78
+ CREATE TABLE IF NOT EXISTS user_stats (
79
+ user_id TEXT PRIMARY KEY REFERENCES users(id),
80
+ message_count INTEGER DEFAULT 0,
81
+ session_count INTEGER DEFAULT 0,
82
+ request_count INTEGER DEFAULT 0,
83
+ bytes_sent INTEGER DEFAULT 0,
84
+ bytes_received INTEGER DEFAULT 0,
85
+ input_tokens INTEGER DEFAULT 0,
86
+ output_tokens INTEGER DEFAULT 0,
87
+ cache_read_tokens INTEGER DEFAULT 0,
88
+ cache_write_tokens INTEGER DEFAULT 0,
89
+ total_tokens INTEGER DEFAULT 0,
90
+ updated_at INTEGER NOT NULL DEFAULT 0
91
+ );
92
+
93
+ -- 每日统计表(按天聚合用户用量)
94
+ CREATE TABLE IF NOT EXISTS daily_stats (
95
+ user_id TEXT NOT NULL REFERENCES users(id),
96
+ date TEXT NOT NULL,
97
+ message_count INTEGER DEFAULT 0,
98
+ session_count INTEGER DEFAULT 0,
99
+ request_count INTEGER DEFAULT 0,
100
+ bytes_sent INTEGER DEFAULT 0,
101
+ bytes_received INTEGER DEFAULT 0,
102
+ input_tokens INTEGER DEFAULT 0,
103
+ output_tokens INTEGER DEFAULT 0,
104
+ cache_read_tokens INTEGER DEFAULT 0,
105
+ cache_write_tokens INTEGER DEFAULT 0,
106
+ total_tokens INTEGER DEFAULT 0,
107
+ PRIMARY KEY (user_id, date)
108
+ );
109
+
110
+ -- Agent token metric watermarks. Agent snapshots are cumulative within one
111
+ -- process epoch; this table makes reconnects and server restarts idempotent.
112
+ CREATE TABLE IF NOT EXISTS agent_metric_watermarks (
113
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
114
+ agent_instance_id TEXT NOT NULL,
115
+ metric_epoch TEXT NOT NULL,
116
+ input_tokens INTEGER DEFAULT 0,
117
+ output_tokens INTEGER DEFAULT 0,
118
+ cache_read_tokens INTEGER DEFAULT 0,
119
+ cache_write_tokens INTEGER DEFAULT 0,
120
+ total_tokens INTEGER DEFAULT 0,
121
+ updated_at INTEGER NOT NULL,
122
+ PRIMARY KEY (user_id, agent_instance_id, metric_epoch)
123
+ );
124
+
125
+ -- 基本索引(不依赖迁移列)
126
+ CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent_id);
127
+ CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC);
128
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
129
+ CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
130
+ -- feat-chat-load-perf: composite covering index for the role='user' filter
131
+ -- used by getRecentUserMessageIds / getLastUserMessage / getUserMessageIdsBeforeId.
132
+ -- Pre-fix: SQLite seeks by (session_id) then post-filters every row by role,
133
+ -- which is hundreds of ms on sessions with thousands of messages. Post-fix:
134
+ -- direct index seek + reverse scan limited to N user rows. Built in the
135
+ -- base block (not postMigrationIndexes) because session_id / role / id all
136
+ -- live in the base CREATE TABLE — there's no migration-column dependency.
137
+ -- IF NOT EXISTS makes this a one-shot cost on first startup post-deploy; on
138
+ -- a messages table with hundreds of thousands of rows the build can take
139
+ -- several seconds, so we time it just below. WAL mode (line 22) lets
140
+ -- concurrent reads continue during the build; writers will block briefly.
141
+ CREATE INDEX IF NOT EXISTS idx_messages_session_role_id ON messages(session_id, role, id DESC);
142
+ CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON daily_stats(date);
143
+ `);
144
+
145
+ // 数据库迁移 - 添加缺失的列
146
+ const migrations = [
147
+ `ALTER TABLE sessions ADD COLUMN user_id TEXT REFERENCES users(id)`,
148
+ `ALTER TABLE users ADD COLUMN totp_secret TEXT`,
149
+ `ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0`,
150
+ `ALTER TABLE users ADD COLUMN password_hash TEXT`,
151
+ `ALTER TABLE users ADD COLUMN email TEXT`,
152
+ `ALTER TABLE users ADD COLUMN agent_secret TEXT`,
153
+ `ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'`,
154
+ `ALTER TABLE messages ADD COLUMN metadata TEXT`,
155
+ `ALTER TABLE sessions ADD COLUMN is_pinned INTEGER DEFAULT 0`,
156
+ `ALTER TABLE users ADD COLUMN aad_oid TEXT`,
157
+ // fix-chat-title-sticky: persist the "user manually renamed this session"
158
+ // bit so it survives agent reconnect / server restart / DB rehydration.
159
+ // Before this column existed, the bit lived only on the in-memory
160
+ // `convInfo.customTitle` flag, and every rebuild path
161
+ // (agent-conversation handlers, agent-sync, get_agents) wiped it —
162
+ // letting the per-message auto-title write at
163
+ // `client-conversation.js:351` clobber the user's renamed title.
164
+ `ALTER TABLE sessions ADD COLUMN is_custom_title INTEGER DEFAULT 0`,
165
+ // feat-chat-load-perf: one-shot rebuild sentinel for the `bulkAddHistory`
166
+ // timestamp-range heuristic in server/db/message-db.js. The heuristic
167
+ // (when count > 5 and (max_ts - min_ts) < 1000ms) was designed to repair
168
+ // sessions whose timestamps got bunched by an old anchor-detection bug,
169
+ // by deleting all rows for the session and re-inserting from the agent's
170
+ // historyMessages payload. Without a sentinel, every subsequent resume
171
+ // re-triggers the rebuild (because the rebuild itself produces tightly
172
+ // spaced `ts = lastTs + 1` values that re-pass the < 1000ms test), so the
173
+ // user pays a full delete+rebuild on EVERY session open. The sentinel
174
+ // (Unix-ms stamp at the time of the one and only repair) lets the
175
+ // heuristic fire exactly once per session for the lifetime of the row.
176
+ `ALTER TABLE sessions ADD COLUMN ts_rebuilt_at INTEGER DEFAULT 0`,
177
+ // fix-copilot-provider-persist: persist the conversation's PROVIDER
178
+ // (claude-code / copilot / ...) so it survives an agent process restart.
179
+ // Before this column the provider lived ONLY in the agent's in-memory
180
+ // ctx.conversations Map (state.providerName). On restart that Map is empty,
181
+ // so the agent reported no provider, the server rebuilt convs from DB
182
+ // without one → the UI lost the "copilot" marker AND sends mis-routed to
183
+ // Claude (handleUserInput resolved providerName to the default). The send
184
+ // forward now reads this column so the agent can self-heal its ACP child.
185
+ `ALTER TABLE sessions ADD COLUMN provider TEXT`,
186
+ `ALTER TABLE user_stats ADD COLUMN input_tokens INTEGER DEFAULT 0`,
187
+ `ALTER TABLE user_stats ADD COLUMN output_tokens INTEGER DEFAULT 0`,
188
+ `ALTER TABLE user_stats ADD COLUMN cache_read_tokens INTEGER DEFAULT 0`,
189
+ `ALTER TABLE user_stats ADD COLUMN cache_write_tokens INTEGER DEFAULT 0`,
190
+ `ALTER TABLE user_stats ADD COLUMN total_tokens INTEGER DEFAULT 0`,
191
+ `ALTER TABLE daily_stats ADD COLUMN input_tokens INTEGER DEFAULT 0`,
192
+ `ALTER TABLE daily_stats ADD COLUMN output_tokens INTEGER DEFAULT 0`,
193
+ `ALTER TABLE daily_stats ADD COLUMN cache_read_tokens INTEGER DEFAULT 0`,
194
+ `ALTER TABLE daily_stats ADD COLUMN cache_write_tokens INTEGER DEFAULT 0`,
195
+ `ALTER TABLE daily_stats ADD COLUMN total_tokens INTEGER DEFAULT 0`
196
+ ];
197
+
198
+ // Yeaft sessions table — server-side persistence so the unified sidebar
199
+ // can list yeaft sessions across all the user's agents (online or not)
200
+ // and survive reload, mirroring how chat conversations work via the
201
+ // `sessions` table. Schema is deliberately separate because the
202
+ // lifecycle and metadata diverge (roster, defaultVpId, per-session
203
+ // config overrides — none of which are chat concerns).
204
+ const yeaftSessionsTable = `
205
+ CREATE TABLE IF NOT EXISTS yeaft_sessions (
206
+ id TEXT NOT NULL,
207
+ user_id TEXT REFERENCES users(id),
208
+ agent_id TEXT NOT NULL,
209
+ name TEXT,
210
+ roster_json TEXT,
211
+ default_vp_id TEXT,
212
+ work_dir TEXT,
213
+ config_json TEXT,
214
+ announcement TEXT,
215
+ created_at INTEGER,
216
+ updated_at INTEGER NOT NULL,
217
+ is_archived INTEGER DEFAULT 0,
218
+ is_pinned INTEGER DEFAULT 0,
219
+ sort_order INTEGER,
220
+ PRIMARY KEY (user_id, agent_id, id)
221
+ );
222
+
223
+ CREATE INDEX IF NOT EXISTS idx_yeaft_sessions_user ON yeaft_sessions(user_id);
224
+ CREATE INDEX IF NOT EXISTS idx_yeaft_sessions_agent ON yeaft_sessions(agent_id);
225
+ CREATE INDEX IF NOT EXISTS idx_yeaft_sessions_id ON yeaft_sessions(id);
226
+ CREATE INDEX IF NOT EXISTS idx_yeaft_sessions_updated ON yeaft_sessions(updated_at DESC);
227
+ `;
228
+ db.exec(yeaftSessionsTable);
229
+
230
+ try {
231
+ const tableInfo = db.prepare(`PRAGMA table_info(yeaft_sessions)`).all();
232
+ const idColumn = tableInfo.find(col => col && col.name === 'id');
233
+ if (idColumn && Number(idColumn.pk) === 1) {
234
+ db.exec(`
235
+ CREATE TABLE IF NOT EXISTS yeaft_sessions_composite (
236
+ id TEXT NOT NULL,
237
+ user_id TEXT REFERENCES users(id),
238
+ agent_id TEXT NOT NULL,
239
+ name TEXT,
240
+ roster_json TEXT,
241
+ default_vp_id TEXT,
242
+ work_dir TEXT,
243
+ config_json TEXT,
244
+ announcement TEXT,
245
+ created_at INTEGER,
246
+ updated_at INTEGER NOT NULL,
247
+ is_archived INTEGER DEFAULT 0,
248
+ is_pinned INTEGER DEFAULT 0,
249
+ sort_order INTEGER,
250
+ PRIMARY KEY (user_id, agent_id, id)
251
+ );
252
+ INSERT OR REPLACE INTO yeaft_sessions_composite
253
+ (id, user_id, agent_id, name, roster_json, default_vp_id, work_dir,
254
+ config_json, announcement, created_at, updated_at, is_archived, is_pinned, sort_order)
255
+ SELECT id, user_id, agent_id, name, roster_json, default_vp_id, work_dir,
256
+ config_json, announcement, created_at, updated_at, is_archived, is_pinned, sort_order
257
+ FROM yeaft_sessions;
258
+ DROP TABLE yeaft_sessions;
259
+ ALTER TABLE yeaft_sessions_composite RENAME TO yeaft_sessions;
260
+ CREATE INDEX IF NOT EXISTS idx_yeaft_sessions_user ON yeaft_sessions(user_id);
261
+ CREATE INDEX IF NOT EXISTS idx_yeaft_sessions_agent ON yeaft_sessions(agent_id);
262
+ CREATE INDEX IF NOT EXISTS idx_yeaft_sessions_id ON yeaft_sessions(id);
263
+ CREATE INDEX IF NOT EXISTS idx_yeaft_sessions_updated ON yeaft_sessions(updated_at DESC);
264
+ `);
265
+ }
266
+ } catch (e) {
267
+ console.warn('[DB] yeaft_sessions composite-key migration failed:', e?.message || e);
268
+ }
269
+
270
+ // Yeaft session schema additions (separate from `migrations` above so the
271
+ // table existence in the CREATE block above is guaranteed before we try
272
+ // to ALTER it). Same try/swallow pattern: ignores "column exists" on
273
+ // fresh DBs that already got the column from CREATE TABLE.
274
+ const yeaftMigrations = [
275
+ // fix-yeaft-session-list-and-menu: per-session pin state. Lives on the
276
+ // server so it survives reload / cross-device / agent restart; mirrored
277
+ // into chatStore.pinnedSessions on the web so sort logic stays unified
278
+ // between chat and yeaft.
279
+ `ALTER TABLE yeaft_sessions ADD COLUMN is_pinned INTEGER DEFAULT 0`,
280
+ `ALTER TABLE yeaft_sessions ADD COLUMN sort_order INTEGER`,
281
+ ];
282
+ for (const migration of yeaftMigrations) {
283
+ try { db.exec(migration); } catch (_) { /* column exists */ }
284
+ }
285
+
286
+ for (const migration of migrations) {
287
+ try {
288
+ db.exec(migration);
289
+ } catch (e) {
290
+ // 列已存在,忽略错误
291
+ }
292
+ }
293
+
294
+ // User identities table (multi-provider SSO + account binding)
295
+ // One user can have multiple identities (microsoft / github / google / wechat / alipay).
296
+ // UNIQUE(provider, subject) enforces "this provider account is bound to one user only".
297
+ const identityTable = `
298
+ CREATE TABLE IF NOT EXISTS user_identities (
299
+ id TEXT PRIMARY KEY,
300
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
301
+ provider TEXT NOT NULL,
302
+ subject TEXT NOT NULL,
303
+ email TEXT,
304
+ display_name TEXT,
305
+ created_at INTEGER NOT NULL,
306
+ last_login_at INTEGER,
307
+ UNIQUE(provider, subject)
308
+ );
309
+
310
+ CREATE INDEX IF NOT EXISTS idx_identities_user ON user_identities(user_id);
311
+ CREATE INDEX IF NOT EXISTS idx_identities_provider ON user_identities(provider);
312
+ `;
313
+ try { db.exec(identityTable); } catch (e) { /* tables already exist */ }
314
+
315
+ // One-time backfill: copy existing users.aad_oid into user_identities so
316
+ // legacy AAD users automatically participate in the new identity model.
317
+ try {
318
+ const aadUsers = db.prepare("SELECT id, email FROM users WHERE aad_oid IS NOT NULL AND aad_oid != ''").all();
319
+ if (aadUsers.length > 0) {
320
+ const checkStmt = db.prepare(
321
+ "SELECT id FROM user_identities WHERE provider = 'microsoft' AND user_id = ?"
322
+ );
323
+ const insertStmt = db.prepare(
324
+ `INSERT OR IGNORE INTO user_identities (id, user_id, provider, subject, email, display_name, created_at)
325
+ VALUES (?, ?, 'microsoft', ?, ?, NULL, ?)`
326
+ );
327
+ const getOidStmt = db.prepare("SELECT aad_oid FROM users WHERE id = ?");
328
+ for (const u of aadUsers) {
329
+ if (checkStmt.get(u.id)) continue;
330
+ const oidRow = getOidStmt.get(u.id);
331
+ const oid = oidRow?.aad_oid;
332
+ if (!oid) continue;
333
+ const idVal = `idn_${randomUUID()}`;
334
+ try { insertStmt.run(idVal, u.id, oid, u.email || null, Date.now()); } catch (e) { /* unique conflict */ }
335
+ }
336
+ }
337
+ } catch (e) { /* table missing or migration error — non-fatal */ }
338
+
339
+ // Custom expert roles tables (帮帮团自定义角色)
340
+ const customExpertTables = `
341
+ CREATE TABLE IF NOT EXISTS custom_expert_roles (
342
+ id TEXT PRIMARY KEY,
343
+ user_id TEXT NOT NULL REFERENCES users(id),
344
+ role_id TEXT NOT NULL,
345
+ name TEXT NOT NULL,
346
+ full_name TEXT,
347
+ title TEXT NOT NULL,
348
+ title_en TEXT,
349
+ group_id TEXT NOT NULL DEFAULT 'custom',
350
+ icon TEXT,
351
+ message_prefix TEXT,
352
+ message_prefix_en TEXT,
353
+ created_at INTEGER NOT NULL,
354
+ updated_at INTEGER NOT NULL,
355
+ UNIQUE(user_id, role_id)
356
+ );
357
+
358
+ CREATE TABLE IF NOT EXISTS custom_expert_actions (
359
+ id TEXT PRIMARY KEY,
360
+ role_row_id TEXT NOT NULL REFERENCES custom_expert_roles(id) ON DELETE CASCADE,
361
+ action_id TEXT NOT NULL,
362
+ name TEXT NOT NULL,
363
+ name_en TEXT,
364
+ message_template TEXT,
365
+ message_template_en TEXT,
366
+ default_message TEXT,
367
+ default_message_en TEXT,
368
+ UNIQUE(role_row_id, action_id)
369
+ );
370
+
371
+ CREATE INDEX IF NOT EXISTS idx_custom_expert_roles_user ON custom_expert_roles(user_id);
372
+ CREATE INDEX IF NOT EXISTS idx_custom_expert_actions_role ON custom_expert_actions(role_row_id);
373
+ `;
374
+ try { db.exec(customExpertTables); } catch (e) { /* tables already exist */ }
375
+
376
+ // 创建依赖迁移列的索引(在迁移后)
377
+ const postMigrationIndexes = [
378
+ `CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`,
379
+ `CREATE INDEX IF NOT EXISTS idx_users_agent_secret ON users(agent_secret)`,
380
+ `CREATE INDEX IF NOT EXISTS idx_users_aad_oid ON users(aad_oid)`
381
+ ];
382
+ // Time the post-migration index pass so the operational signal lands in the
383
+ // deploy log on first startup after composite-index addition — a multi-second
384
+ // index build over the messages table should not look like "the server hung".
385
+ // (The composite idx_messages_session_role_id itself was moved into the base
386
+ // CREATE INDEX block above; its first-time cost shows up in the engine init.)
387
+ console.time('[db] postMigrationIndexes');
388
+ for (const idx of postMigrationIndexes) {
389
+ try { db.exec(idx); } catch (e) { /* 索引已存在 */ }
390
+ }
391
+ console.timeEnd('[db] postMigrationIndexes');
392
+
393
+ // 生成用户级 Agent 密钥
394
+ export function generateAgentSecret() {
395
+ return randomBytes(32).toString('hex');
396
+ }
397
+
398
+ // 生成用户 ID
399
+ export function generateUserId() {
400
+ return `user_${randomUUID()}`;
401
+ }
402
+
403
+ // 准备常用语句
404
+ export const stmts = {
405
+ // User 操作
406
+ insertUser: db.prepare(`
407
+ INSERT INTO users (id, username, display_name, created_at)
408
+ VALUES (?, ?, ?, ?)
409
+ `),
410
+
411
+ insertUserFull: db.prepare(`
412
+ INSERT INTO users (id, username, display_name, password_hash, email, agent_secret, role, created_at)
413
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
414
+ `),
415
+
416
+ updateUserLogin: db.prepare(`
417
+ UPDATE users SET last_login_at = ? WHERE id = ?
418
+ `),
419
+
420
+ updateUserPassword: db.prepare(`
421
+ UPDATE users SET password_hash = ? WHERE id = ?
422
+ `),
423
+
424
+ updateUserEmail: db.prepare(`
425
+ UPDATE users SET email = ? WHERE id = ?
426
+ `),
427
+
428
+ updateUserDisplayName: db.prepare(`
429
+ UPDATE users SET display_name = ? WHERE id = ?
430
+ `),
431
+
432
+ updateUserAgentSecret: db.prepare(`
433
+ UPDATE users SET agent_secret = ? WHERE id = ?
434
+ `),
435
+
436
+ updateUserRole: db.prepare(`
437
+ UPDATE users SET role = ? WHERE id = ?
438
+ `),
439
+
440
+ updateUserMigrate: db.prepare(`
441
+ UPDATE users SET password_hash = ?, email = ?, role = ?, agent_secret = COALESCE(agent_secret, ?) WHERE id = ?
442
+ `),
443
+
444
+ getUserById: db.prepare(`
445
+ SELECT * FROM users WHERE id = ?
446
+ `),
447
+
448
+ getUserByUsername: db.prepare(`
449
+ SELECT * FROM users WHERE username = ?
450
+ `),
451
+
452
+ getUserByAgentSecret: db.prepare(`
453
+ SELECT * FROM users WHERE agent_secret = ?
454
+ `),
455
+
456
+ getAllUsers: db.prepare(`
457
+ SELECT * FROM users ORDER BY created_at DESC
458
+ `),
459
+
460
+ updateUserTotp: db.prepare(`
461
+ UPDATE users SET totp_secret = ?, totp_enabled = ? WHERE username = ?
462
+ `),
463
+
464
+ getUserTotp: db.prepare(`
465
+ SELECT totp_secret, totp_enabled FROM users WHERE username = ?
466
+ `),
467
+
468
+ getUserByAadOid: db.prepare(`
469
+ SELECT * FROM users WHERE aad_oid = ?
470
+ `),
471
+
472
+ updateUserAadOid: db.prepare(`
473
+ UPDATE users SET aad_oid = ? WHERE id = ?
474
+ `),
475
+
476
+ // Invitation 操作
477
+ insertInvitation: db.prepare(`
478
+ INSERT INTO invitations (id, created_by, created_at, expires_at, role)
479
+ VALUES (?, ?, ?, ?, ?)
480
+ `),
481
+
482
+ getInvitation: db.prepare(`
483
+ SELECT * FROM invitations WHERE id = ?
484
+ `),
485
+
486
+ useInvitation: db.prepare(`
487
+ UPDATE invitations SET used_by = ?, used_at = ? WHERE id = ?
488
+ `),
489
+
490
+ getInvitationsByUser: db.prepare(`
491
+ SELECT i.*, u.username AS used_by_username
492
+ FROM invitations i
493
+ LEFT JOIN users u ON i.used_by = u.id
494
+ WHERE i.created_by = ?
495
+ ORDER BY i.created_at DESC
496
+ `),
497
+
498
+ deleteInvitation: db.prepare(`
499
+ DELETE FROM invitations WHERE id = ? AND created_by = ? AND used_by IS NULL
500
+ `),
501
+
502
+ cleanupExpiredInvitations: db.prepare(`
503
+ DELETE FROM invitations WHERE expires_at < ? AND used_by IS NULL
504
+ `),
505
+
506
+ // Session 操作
507
+ insertSession: db.prepare(`
508
+ INSERT INTO sessions (id, user_id, agent_id, agent_name, claude_session_id, work_dir, title, provider, created_at, updated_at)
509
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
510
+ `),
511
+
512
+ updateSession: db.prepare(`
513
+ UPDATE sessions SET
514
+ claude_session_id = COALESCE(?, claude_session_id),
515
+ title = COALESCE(?, title),
516
+ is_custom_title = COALESCE(?, is_custom_title),
517
+ updated_at = ?
518
+ WHERE id = ?
519
+ `),
520
+
521
+ updateSessionActive: db.prepare(`
522
+ UPDATE sessions SET is_active = ?, updated_at = ? WHERE id = ?
523
+ `),
524
+
525
+ // fix-session-dup: transfer a session row to a new owning agent.
526
+ // Needed when the user resumes a conversation against a different
527
+ // agent than the one that originally created it — without this,
528
+ // DB.agent_id keeps pointing at the old agent and on the next
529
+ // `get_agents` restore (client-conversation.js:`get_agents`) the
530
+ // conv gets reseated into the OLD agent's in-memory Map alongside
531
+ // the new owner, which is the server-side root of Bug 2 (one conv
532
+ // rendered as two sidebar rows with different agent badges).
533
+ updateSessionAgent: db.prepare(`
534
+ UPDATE sessions SET agent_id = ?, agent_name = ?, updated_at = ? WHERE id = ?
535
+ `),
536
+
537
+ updateSessionPinned: db.prepare(`
538
+ UPDATE sessions SET is_pinned = ?, updated_at = ? WHERE id = ?
539
+ `),
540
+
541
+ // fix-copilot-provider-persist: persist the conversation's code-agent
542
+ // provider so it survives an agent process restart. Mirrors the pinned/
543
+ // agent update shape. Only written when a non-default provider is known
544
+ // (the create/resume handlers pass msg.provider through).
545
+ updateSessionProvider: db.prepare(`
546
+ UPDATE sessions SET provider = ?, updated_at = ? WHERE id = ?
547
+ `),
548
+
549
+ // feat-chat-load-perf: one-shot sentinel for the bulkAddHistory timestamp-
550
+ // rebuild repair path. ts_rebuilt_at = 0 means "never repaired"; non-zero
551
+ // means "repair already ran at this Unix-ms". The repair is destructive
552
+ // (DELETE + re-INSERT all rows for the session) so it must run at most once
553
+ // per session over its lifetime. Statements live in the Session block
554
+ // because they SELECT/UPDATE the `sessions` table — the bulkAddHistory
555
+ // call site in server/db/message-db.js is the only consumer today.
556
+ getSessionTsRebuiltAt: db.prepare(`
557
+ SELECT ts_rebuilt_at FROM sessions WHERE id = ?
558
+ `),
559
+
560
+ markSessionTsRebuilt: db.prepare(`
561
+ UPDATE sessions SET ts_rebuilt_at = ? WHERE id = ?
562
+ `),
563
+
564
+ getSession: db.prepare(`
565
+ SELECT * FROM sessions WHERE id = ?
566
+ `),
567
+
568
+ getSessionsByAgent: db.prepare(`
569
+ SELECT * FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC LIMIT ?
570
+ `),
571
+
572
+ getSessionsByUser: db.prepare(`
573
+ SELECT * FROM sessions WHERE user_id = ? ORDER BY updated_at DESC LIMIT ?
574
+ `),
575
+
576
+ getSessionsByUserAndAgent: db.prepare(`
577
+ SELECT * FROM sessions WHERE user_id = ? AND agent_id = ? ORDER BY updated_at DESC LIMIT ?
578
+ `),
579
+
580
+ getAllSessions: db.prepare(`
581
+ SELECT * FROM sessions ORDER BY updated_at DESC LIMIT ?
582
+ `),
583
+
584
+ getActiveSessions: db.prepare(`
585
+ SELECT * FROM sessions WHERE is_active = 1 ORDER BY updated_at DESC
586
+ `),
587
+
588
+ getActiveSessionsByUser: db.prepare(`
589
+ SELECT * FROM sessions WHERE (user_id = ? OR user_id IS NULL) AND is_active = 1 ORDER BY updated_at DESC
590
+ `),
591
+
592
+ deleteSession: db.prepare(`
593
+ DELETE FROM sessions WHERE id = ?
594
+ `),
595
+
596
+ // Message 操作
597
+ insertMessage: db.prepare(`
598
+ INSERT INTO messages (session_id, role, content, message_type, tool_name, tool_input, created_at, metadata)
599
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
600
+ `),
601
+
602
+ getRecentUserMessageIds: db.prepare(`
603
+ SELECT id FROM messages WHERE session_id = ? AND role = 'user'
604
+ ORDER BY id DESC LIMIT ?
605
+ `),
606
+
607
+ getMessagesFromId: db.prepare(`
608
+ SELECT * FROM messages WHERE session_id = ? AND id >= ?
609
+ ORDER BY id ASC
610
+ `),
611
+
612
+ getUserMessageIdsBeforeId: db.prepare(`
613
+ SELECT id FROM messages WHERE session_id = ? AND role = 'user' AND id < ?
614
+ ORDER BY id DESC LIMIT ?
615
+ `),
616
+
617
+ getMessagesBetweenIds: db.prepare(`
618
+ SELECT * FROM messages WHERE session_id = ? AND id >= ? AND id < ?
619
+ ORDER BY id ASC
620
+ `),
621
+
622
+ getMessagesBySession: db.prepare(`
623
+ SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC
624
+ `),
625
+
626
+ getRecentMessages: db.prepare(`
627
+ SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?
628
+ `),
629
+
630
+ getMessagesAfterId: db.prepare(`
631
+ SELECT * FROM messages WHERE session_id = ? AND id > ? ORDER BY id ASC
632
+ `),
633
+
634
+ getMessagesBeforeId: db.prepare(`
635
+ SELECT * FROM messages WHERE session_id = ? AND id < ?
636
+ ORDER BY id DESC LIMIT ?
637
+ `),
638
+
639
+ getMessageCount: db.prepare(`
640
+ SELECT COUNT(*) as count FROM messages WHERE session_id = ?
641
+ `),
642
+
643
+ getTimestampRange: db.prepare(`
644
+ SELECT MIN(created_at) as min_ts, MAX(created_at) as max_ts, COUNT(*) as count
645
+ FROM messages WHERE session_id = ?
646
+ `),
647
+
648
+ // feat-chat-load-perf: one-shot sentinel for the bulkAddHistory timestamp-
649
+ // rebuild repair path. ts_rebuilt_at = 0 means "never repaired"; non-zero
650
+ // means "repair already ran at this Unix-ms". The repair is destructive
651
+ // (DELETE + re-INSERT all rows for the session) so it must run at most once
652
+ // per session over its lifetime.
653
+ getSessionTsRebuiltAt: db.prepare(`
654
+ SELECT ts_rebuilt_at FROM sessions WHERE id = ?
655
+ `),
656
+
657
+ markSessionTsRebuilt: db.prepare(`
658
+ UPDATE sessions SET ts_rebuilt_at = ? WHERE id = ?
659
+ `),
660
+
661
+ getLastUserMessage: db.prepare(`
662
+ SELECT * FROM messages WHERE session_id = ? AND role = 'user'
663
+ ORDER BY id DESC LIMIT 1
664
+ `),
665
+
666
+ deleteMessagesBySession: db.prepare(`
667
+ DELETE FROM messages WHERE session_id = ?
668
+ `),
669
+
670
+ deleteMessagesAfterId: db.prepare(`
671
+ DELETE FROM messages WHERE session_id = ? AND id > ?
672
+ `),
673
+
674
+ updateMessageMetadata: db.prepare(`
675
+ UPDATE messages SET metadata = ? WHERE id = ?
676
+ `),
677
+
678
+ // UserStats 操作
679
+ upsertUserStats: db.prepare(`
680
+ INSERT INTO user_stats (
681
+ user_id, message_count, session_count, request_count, bytes_sent, bytes_received,
682
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, updated_at
683
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
684
+ ON CONFLICT(user_id) DO UPDATE SET
685
+ message_count = message_count + excluded.message_count,
686
+ session_count = session_count + excluded.session_count,
687
+ request_count = request_count + excluded.request_count,
688
+ bytes_sent = bytes_sent + excluded.bytes_sent,
689
+ bytes_received = bytes_received + excluded.bytes_received,
690
+ input_tokens = input_tokens + excluded.input_tokens,
691
+ output_tokens = output_tokens + excluded.output_tokens,
692
+ cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
693
+ cache_write_tokens = cache_write_tokens + excluded.cache_write_tokens,
694
+ total_tokens = total_tokens + excluded.total_tokens,
695
+ updated_at = excluded.updated_at
696
+ `),
697
+
698
+ // DailyStats 操作
699
+ upsertDailyStats: db.prepare(`
700
+ INSERT INTO daily_stats (
701
+ user_id, date, message_count, session_count, request_count, bytes_sent, bytes_received,
702
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens
703
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
704
+ ON CONFLICT(user_id, date) DO UPDATE SET
705
+ message_count = message_count + excluded.message_count,
706
+ session_count = session_count + excluded.session_count,
707
+ request_count = request_count + excluded.request_count,
708
+ bytes_sent = bytes_sent + excluded.bytes_sent,
709
+ bytes_received = bytes_received + excluded.bytes_received,
710
+ input_tokens = input_tokens + excluded.input_tokens,
711
+ output_tokens = output_tokens + excluded.output_tokens,
712
+ cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
713
+ cache_write_tokens = cache_write_tokens + excluded.cache_write_tokens,
714
+ total_tokens = total_tokens + excluded.total_tokens
715
+ `),
716
+
717
+ getDailyStatsAll: db.prepare(`
718
+ SELECT ds.user_id, u.username, u.display_name, u.role, u.last_login_at,
719
+ SUM(ds.message_count) as message_count, SUM(ds.session_count) as session_count,
720
+ SUM(ds.request_count) as request_count, SUM(ds.bytes_sent) as bytes_sent,
721
+ SUM(ds.bytes_received) as bytes_received,
722
+ SUM(ds.input_tokens) as input_tokens, SUM(ds.output_tokens) as output_tokens,
723
+ SUM(ds.cache_read_tokens) as cache_read_tokens,
724
+ SUM(ds.cache_write_tokens) as cache_write_tokens,
725
+ SUM(ds.total_tokens) as total_tokens
726
+ FROM daily_stats ds
727
+ JOIN users u ON ds.user_id = u.id
728
+ WHERE ds.date >= ?
729
+ GROUP BY ds.user_id
730
+ ORDER BY message_count DESC
731
+ `),
732
+
733
+ getTodayActiveUsers: db.prepare(`
734
+ SELECT COUNT(DISTINCT user_id) as count FROM daily_stats WHERE date = ?
735
+ `),
736
+
737
+ getTodayMessages: db.prepare(`
738
+ SELECT COALESCE(SUM(message_count), 0) as count FROM daily_stats WHERE date = ?
739
+ `),
740
+
741
+ getUserStats: db.prepare(`
742
+ SELECT us.*, u.username, u.display_name, u.role, u.last_login_at
743
+ FROM user_stats us
744
+ JOIN users u ON us.user_id = u.id
745
+ ORDER BY us.message_count DESC
746
+ `),
747
+
748
+ getUserStatsById: db.prepare(`
749
+ SELECT * FROM user_stats WHERE user_id = ?
750
+ `),
751
+
752
+ getAgentMetricWatermark: db.prepare(`
753
+ SELECT * FROM agent_metric_watermarks
754
+ WHERE user_id = ? AND agent_instance_id = ? AND metric_epoch = ?
755
+ `),
756
+
757
+ upsertAgentMetricWatermark: db.prepare(`
758
+ INSERT INTO agent_metric_watermarks (
759
+ user_id, agent_instance_id, metric_epoch, input_tokens, output_tokens,
760
+ cache_read_tokens, cache_write_tokens, total_tokens, updated_at
761
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
762
+ ON CONFLICT(user_id, agent_instance_id, metric_epoch) DO UPDATE SET
763
+ input_tokens = excluded.input_tokens,
764
+ output_tokens = excluded.output_tokens,
765
+ cache_read_tokens = excluded.cache_read_tokens,
766
+ cache_write_tokens = excluded.cache_write_tokens,
767
+ total_tokens = excluded.total_tokens,
768
+ updated_at = excluded.updated_at
769
+ `),
770
+
771
+ // Identity 操作 (multi-provider SSO)
772
+ insertIdentity: db.prepare(`
773
+ INSERT INTO user_identities (id, user_id, provider, subject, email, display_name, created_at, last_login_at)
774
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
775
+ `),
776
+
777
+ getIdentityBySubject: db.prepare(`
778
+ SELECT * FROM user_identities WHERE provider = ? AND subject = ?
779
+ `),
780
+
781
+ getIdentitiesByUser: db.prepare(`
782
+ SELECT * FROM user_identities WHERE user_id = ? ORDER BY created_at ASC
783
+ `),
784
+
785
+ getIdentityForUser: db.prepare(`
786
+ SELECT * FROM user_identities WHERE user_id = ? AND provider = ?
787
+ `),
788
+
789
+ countIdentitiesByUser: db.prepare(`
790
+ SELECT COUNT(*) as count FROM user_identities WHERE user_id = ?
791
+ `),
792
+
793
+ updateIdentityLogin: db.prepare(`
794
+ UPDATE user_identities SET last_login_at = ? WHERE id = ?
795
+ `),
796
+
797
+ deleteIdentityForUser: db.prepare(`
798
+ DELETE FROM user_identities WHERE user_id = ? AND provider = ?
799
+ `),
800
+
801
+ // Hard-delete cascade for account deletion.
802
+ // user_identities and messages cascade automatically; the rest are explicit.
803
+ deleteUserSessionsByUser: db.prepare(`
804
+ DELETE FROM sessions WHERE user_id = ?
805
+ `),
806
+ deleteYeaftSessionsByUserCascade: db.prepare(`
807
+ DELETE FROM yeaft_sessions WHERE user_id = ?
808
+ `),
809
+ deleteIdentitiesForUser: db.prepare(`
810
+ DELETE FROM user_identities WHERE user_id = ?
811
+ `),
812
+ deleteUserStats: db.prepare(`
813
+ DELETE FROM user_stats WHERE user_id = ?
814
+ `),
815
+ deleteDailyStatsForUser: db.prepare(`
816
+ DELETE FROM daily_stats WHERE user_id = ?
817
+ `),
818
+ deleteCustomExpertRolesForUser: db.prepare(`
819
+ DELETE FROM custom_expert_roles WHERE user_id = ?
820
+ `),
821
+ // Invitations: keep history but null-out the FK so it doesn't block deletion.
822
+ // (created_by is NOT NULL, so for invitations the user created we just delete them.)
823
+ deleteInvitationsCreatedBy: db.prepare(`
824
+ DELETE FROM invitations WHERE created_by = ?
825
+ `),
826
+ clearInvitationUsedBy: db.prepare(`
827
+ UPDATE invitations SET used_by = NULL WHERE used_by = ?
828
+ `),
829
+ deleteUserById: db.prepare(`
830
+ DELETE FROM users WHERE id = ?
831
+ `),
832
+
833
+ // Yeaft session 操作
834
+ upsertYeaftSession: db.prepare(`
835
+ INSERT INTO yeaft_sessions
836
+ (id, user_id, agent_id, name, roster_json, default_vp_id, work_dir,
837
+ config_json, announcement, created_at, updated_at, is_archived)
838
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
839
+ ON CONFLICT(user_id, agent_id, id) DO UPDATE SET
840
+ user_id = COALESCE(excluded.user_id, user_id),
841
+ name = excluded.name,
842
+ roster_json = excluded.roster_json,
843
+ default_vp_id = excluded.default_vp_id,
844
+ work_dir = excluded.work_dir,
845
+ config_json = excluded.config_json,
846
+ announcement = excluded.announcement,
847
+ created_at = COALESCE(yeaft_sessions.created_at, excluded.created_at),
848
+ updated_at = excluded.updated_at,
849
+ is_archived = excluded.is_archived
850
+ `),
851
+
852
+ getYeaftSession: db.prepare(`
853
+ SELECT * FROM yeaft_sessions WHERE id = ? ORDER BY updated_at DESC LIMIT 1
854
+ `),
855
+
856
+ getYeaftSessionForAgent: db.prepare(`
857
+ SELECT * FROM yeaft_sessions WHERE id = ? AND user_id = ? AND agent_id = ?
858
+ `),
859
+
860
+ getYeaftSessionsByUser: db.prepare(`
861
+ SELECT * FROM yeaft_sessions
862
+ WHERE user_id = ? AND is_archived = 0
863
+ ORDER BY CASE WHEN sort_order IS NULL THEN 1 ELSE 0 END, sort_order ASC, updated_at DESC
864
+ `),
865
+
866
+ getYeaftSessionsByAgent: db.prepare(`
867
+ SELECT * FROM yeaft_sessions
868
+ WHERE agent_id = ? AND is_archived = 0
869
+ ORDER BY CASE WHEN sort_order IS NULL THEN 1 ELSE 0 END, sort_order ASC, updated_at DESC
870
+ `),
871
+
872
+ deleteYeaftSession: db.prepare(`
873
+ DELETE FROM yeaft_sessions WHERE id = ?
874
+ `),
875
+
876
+ deleteYeaftSessionForAgent: db.prepare(`
877
+ DELETE FROM yeaft_sessions WHERE id = ? AND user_id = ? AND agent_id = ?
878
+ `),
879
+
880
+ deleteYeaftSessionsByUser: db.prepare(`
881
+ DELETE FROM yeaft_sessions WHERE user_id = ?
882
+ `),
883
+
884
+ setYeaftSessionArchived: db.prepare(`
885
+ UPDATE yeaft_sessions SET is_archived = ?, updated_at = ? WHERE id = ?
886
+ `),
887
+
888
+ setYeaftSessionArchivedForAgent: db.prepare(`
889
+ UPDATE yeaft_sessions SET is_archived = ?, updated_at = ? WHERE id = ? AND user_id = ? AND agent_id = ?
890
+ `),
891
+
892
+ setYeaftSessionPinned: db.prepare(`
893
+ UPDATE yeaft_sessions SET is_pinned = ?, updated_at = ? WHERE id = ?
894
+ `),
895
+
896
+ setYeaftSessionPinnedForAgent: db.prepare(`
897
+ UPDATE yeaft_sessions SET is_pinned = ?, updated_at = ? WHERE id = ? AND user_id = ? AND agent_id = ?
898
+ `),
899
+
900
+ setYeaftSessionSortOrder: db.prepare(`
901
+ UPDATE yeaft_sessions
902
+ SET sort_order = ?, updated_at = ?
903
+ WHERE id = ? AND user_id = ? AND agent_id = ?
904
+ `),
905
+
906
+ // Dashboard 聚合
907
+ getDashboardTotals: db.prepare(`
908
+ SELECT
909
+ (SELECT COUNT(*) FROM users) as total_users,
910
+ (SELECT COUNT(*) FROM sessions) as total_sessions,
911
+ (SELECT COUNT(*) FROM messages) as total_messages
912
+ `),
913
+
914
+ getDashboardTokenTotals: db.prepare(`
915
+ SELECT
916
+ COALESCE(SUM(input_tokens), 0) as input_tokens,
917
+ COALESCE(SUM(output_tokens), 0) as output_tokens,
918
+ COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens,
919
+ COALESCE(SUM(cache_write_tokens), 0) as cache_write_tokens,
920
+ COALESCE(SUM(total_tokens), 0) as total_tokens
921
+ FROM user_stats
922
+ `)
923
+ };
924
+
925
+ // 关闭数据库连接(用于优雅退出)
926
+ let dbClosed = false;
927
+ export function closeDb() {
928
+ if (dbClosed) return;
929
+ db.close();
930
+ dbClosed = true;
931
+ }
932
+
933
+ /**
934
+ * Run a function inside a SQLite transaction.
935
+ * node:sqlite (DatabaseSync) does not provide better-sqlite3's `db.transaction(fn)`
936
+ * helper, so we wrap BEGIN/COMMIT/ROLLBACK manually.
937
+ *
938
+ * Returns a function with the same signature as `fn` (call it with the same args)
939
+ * to mirror the better-sqlite3 API.
940
+ *
941
+ * @template T
942
+ * @template {any[]} A
943
+ * @param {(...args: A) => T} fn
944
+ * @returns {(...args: A) => T}
945
+ */
946
+ export function transaction(fn) {
947
+ return (...args) => {
948
+ db.exec('BEGIN');
949
+ try {
950
+ const result = fn(...args);
951
+ db.exec('COMMIT');
952
+ return result;
953
+ } catch (err) {
954
+ try { db.exec('ROLLBACK'); } catch { /* ignore rollback errors */ }
955
+ throw err;
956
+ }
957
+ };
958
+ }
959
+
960
+ // 进程退出时关闭数据库(兜底)
961
+ process.on('exit', closeDb);
962
+
963
+ export default db;