@yeaft/webchat-agent 1.0.186 → 1.0.189

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,165 @@
1
+ import { stmts, generateUserId, generateAgentSecret, transaction } from './connection.js';
2
+
3
+ export const userDb = {
4
+ getOrCreate(username, displayName = null) {
5
+ let user = stmts.getUserByUsername.get(username);
6
+ if (!user) {
7
+ const id = generateUserId();
8
+ const now = Date.now();
9
+ stmts.insertUser.run(id, username, displayName || username, now);
10
+ user = { id, username, display_name: displayName || username, created_at: now };
11
+ }
12
+ return user;
13
+ },
14
+
15
+ createFull(username, passwordHash, email = null, role = 'user') {
16
+ const id = generateUserId();
17
+ const now = Date.now();
18
+ const agentSecret = generateAgentSecret();
19
+ stmts.insertUserFull.run(id, username, username, passwordHash, email, agentSecret, role, now);
20
+ return { id, username, display_name: username, password_hash: passwordHash, email, agent_secret: agentSecret, role, created_at: now };
21
+ },
22
+
23
+ migrateUser(username, passwordHash, email, role = 'admin') {
24
+ const existing = stmts.getUserByUsername.get(username);
25
+ if (existing) {
26
+ if (existing.password_hash) {
27
+ return existing;
28
+ }
29
+ const newSecret = generateAgentSecret();
30
+ stmts.updateUserMigrate.run(passwordHash, email, role, newSecret, existing.id);
31
+ return { ...existing, password_hash: passwordHash, email, role, agent_secret: existing.agent_secret || newSecret };
32
+ }
33
+ return this.createFull(username, passwordHash, email, role);
34
+ },
35
+
36
+ get(id) {
37
+ return stmts.getUserById.get(id);
38
+ },
39
+
40
+ getByUsername(username) {
41
+ return stmts.getUserByUsername.get(username);
42
+ },
43
+
44
+ getUserByAgentSecret(secret) {
45
+ if (!secret) return null;
46
+ return stmts.getUserByAgentSecret.get(secret) || null;
47
+ },
48
+
49
+ getAll() {
50
+ return stmts.getAllUsers.all();
51
+ },
52
+
53
+ updateLogin(id) {
54
+ stmts.updateUserLogin.run(Date.now(), id);
55
+ },
56
+
57
+ updatePassword(userId, passwordHash) {
58
+ stmts.updateUserPassword.run(passwordHash, userId);
59
+ },
60
+
61
+ updateEmail(userId, email) {
62
+ stmts.updateUserEmail.run(email, userId);
63
+ },
64
+
65
+ updateDisplayName(userId, displayName) {
66
+ if (!displayName) return;
67
+ stmts.updateUserDisplayName.run(displayName, userId);
68
+ },
69
+
70
+ getAgentSecret(userId) {
71
+ const user = stmts.getUserById.get(userId);
72
+ return user?.agent_secret || null;
73
+ },
74
+
75
+ resetAgentSecret(userId) {
76
+ const newSecret = generateAgentSecret();
77
+ stmts.updateUserAgentSecret.run(newSecret, userId);
78
+ return newSecret;
79
+ },
80
+
81
+ updateRole(userId, role) {
82
+ stmts.updateUserRole.run(role, userId);
83
+ },
84
+
85
+ getTotp(username) {
86
+ const result = stmts.getUserTotp.get(username);
87
+ if (result) {
88
+ return {
89
+ totpSecret: result.totp_secret,
90
+ totpEnabled: !!result.totp_enabled
91
+ };
92
+ }
93
+ return null;
94
+ },
95
+
96
+ updateTotp(username, totpSecret, totpEnabled) {
97
+ let user = stmts.getUserByUsername.get(username);
98
+ if (!user) {
99
+ const id = generateUserId();
100
+ const now = Date.now();
101
+ stmts.insertUser.run(id, username, username, now);
102
+ }
103
+ stmts.updateUserTotp.run(totpSecret, totpEnabled ? 1 : 0, username);
104
+ return true;
105
+ },
106
+
107
+ getByAadOid(aadOid) {
108
+ if (!aadOid) return null;
109
+ return stmts.getUserByAadOid.get(aadOid) || null;
110
+ },
111
+
112
+ updateAadOid(userId, aadOid) {
113
+ stmts.updateUserAadOid.run(aadOid, userId);
114
+ },
115
+
116
+ /**
117
+ * Create a user from AAD profile (no password, linked by aad_oid).
118
+ * `displayName` is used as a friendlier label (e.g. the Alipay nickname);
119
+ * falls back to the username when not provided.
120
+ */
121
+ createFromAad(username, email, aadOid, role = 'pro', displayName = null) {
122
+ const id = generateUserId();
123
+ const now = Date.now();
124
+ const agentSecret = generateAgentSecret();
125
+ const display = displayName || username;
126
+ stmts.insertUserFull.run(id, username, display, null, email, agentSecret, role, now);
127
+ stmts.updateUserAadOid.run(aadOid, id);
128
+ return { id, username, display_name: display, email, aad_oid: aadOid, agent_secret: agentSecret, role, created_at: now };
129
+ },
130
+
131
+ /**
132
+ * Permanently delete a user and ALL data scoped to that user.
133
+ *
134
+ * Tables touched (all inside one transaction):
135
+ * - user_identities hard delete (also covered by FK CASCADE, belt-and-braces)
136
+ * - sessions hard delete (messages cascade via FK)
137
+ * - user_stats hard delete
138
+ * - daily_stats hard delete
139
+ * - agent_metric_watermarks cascade delete
140
+ * - custom_expert_roles hard delete (custom_expert_actions cascade via FK)
141
+ * - invitations.created_by rows deleted (codes the user issued)
142
+ * - invitations.used_by set NULL (preserve history of *who consumed what* — but we lose the link)
143
+ * - users row deleted last
144
+ *
145
+ * Caller is responsible for revoking JWT sessions (we don't import the
146
+ * session store from here to keep this layer pure).
147
+ */
148
+ deleteUser(userId) {
149
+ // No inner try/catch — any failure must bubble out so the transaction
150
+ // rolls back. A partial delete (e.g. users row gone but daily_stats
151
+ // orphaned) is worse than the operation failing outright.
152
+ const run = transaction((id) => {
153
+ stmts.deleteIdentitiesForUser.run(id);
154
+ stmts.deleteUserSessionsByUser.run(id);
155
+ stmts.deleteUserStats.run(id);
156
+ stmts.deleteDailyStatsForUser.run(id);
157
+ stmts.deleteCustomExpertRolesForUser.run(id);
158
+ stmts.deleteInvitationsCreatedBy.run(id);
159
+ stmts.clearInvitationUsedBy.run(id);
160
+ const result = stmts.deleteUserById.run(id);
161
+ return result.changes > 0;
162
+ });
163
+ return run(userId);
164
+ }
165
+ };
@@ -0,0 +1,185 @@
1
+ import db from './connection.js';
2
+ import { stmts, transaction } from './connection.js';
3
+
4
+ /**
5
+ * Get today's date string in YYYY-MM-DD format (local time).
6
+ */
7
+ function todayStr() {
8
+ const d = new Date();
9
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
10
+ }
11
+
12
+ /**
13
+ * Get the start date string for a given period.
14
+ */
15
+ function periodStartDate(period) {
16
+ const now = new Date();
17
+ switch (period) {
18
+ case 'today':
19
+ return todayStr();
20
+ case 'week': {
21
+ const d = new Date(now);
22
+ d.setDate(d.getDate() - 6); // last 7 days
23
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
24
+ }
25
+ case 'month': {
26
+ const d = new Date(now);
27
+ d.setDate(d.getDate() - 29); // last 30 days
28
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
29
+ }
30
+ default: // 'all'
31
+ return '1970-01-01';
32
+ }
33
+ }
34
+
35
+ export const userStatsDb = {
36
+ /**
37
+ * Batch flush in-memory deltas to DB.
38
+ * Writes to both user_stats (cumulative) and daily_stats (per-day).
39
+ * @param {Map<string, {requests: number, bytesSent: number, bytesReceived: number, messages: number, sessions: number}>} deltaMap
40
+ */
41
+ flushDeltas(deltaMap) {
42
+ if (deltaMap.size === 0) return;
43
+
44
+ const now = Date.now();
45
+ const today = todayStr();
46
+ const flush = transaction(() => {
47
+ for (const [userId, delta] of deltaMap) {
48
+ // Cumulative stats
49
+ stmts.upsertUserStats.run(
50
+ userId,
51
+ delta.messages || 0,
52
+ delta.sessions || 0,
53
+ delta.requests || 0,
54
+ delta.bytesSent || 0,
55
+ delta.bytesReceived || 0,
56
+ delta.inputTokens || 0,
57
+ delta.outputTokens || 0,
58
+ delta.cacheReadTokens || 0,
59
+ delta.cacheWriteTokens || 0,
60
+ delta.totalTokens || 0,
61
+ now
62
+ );
63
+ // Daily stats
64
+ stmts.upsertDailyStats.run(
65
+ userId,
66
+ today,
67
+ delta.messages || 0,
68
+ delta.sessions || 0,
69
+ delta.requests || 0,
70
+ delta.bytesSent || 0,
71
+ delta.bytesReceived || 0,
72
+ delta.inputTokens || 0,
73
+ delta.outputTokens || 0,
74
+ delta.cacheReadTokens || 0,
75
+ delta.cacheWriteTokens || 0,
76
+ delta.totalTokens || 0
77
+ );
78
+ }
79
+ });
80
+ flush();
81
+ },
82
+
83
+ getAll() {
84
+ return stmts.getUserStats.all();
85
+ },
86
+
87
+ /**
88
+ * Get user stats aggregated by period.
89
+ * @param {'today'|'week'|'month'|'all'} period
90
+ */
91
+ getByPeriod(period) {
92
+ if (period === 'all') {
93
+ return this.getAll();
94
+ }
95
+ const startDate = periodStartDate(period);
96
+ return stmts.getDailyStatsAll.all(startDate);
97
+ },
98
+
99
+ getByUserId(userId) {
100
+ return stmts.getUserStatsById.get(userId) || null;
101
+ },
102
+
103
+ /**
104
+ * Convert one cumulative agent snapshot into an idempotent user delta.
105
+ * A new metric epoch means the agent process restarted and counters began at
106
+ * zero. Repeated or lower snapshots in the same epoch never add usage.
107
+ */
108
+ recordAgentTokenSnapshot(userId, agentInstanceId, metrics = {}) {
109
+ const metricEpoch = typeof metrics.metricEpoch === 'string' ? metrics.metricEpoch : '';
110
+ if (!userId || !agentInstanceId || !metricEpoch) return null;
111
+
112
+ const fields = [
113
+ ['inputTokens', 'input_tokens'],
114
+ ['outputTokens', 'output_tokens'],
115
+ ['cacheReadTokens', 'cache_read_tokens'],
116
+ ['cacheWriteTokens', 'cache_write_tokens'],
117
+ ['totalTokens', 'total_tokens'],
118
+ ];
119
+ const current = {};
120
+ for (const [metricKey] of fields) {
121
+ current[metricKey] = Math.max(0, Number(metrics[metricKey]) || 0);
122
+ }
123
+
124
+ const now = Date.now();
125
+ return transaction(() => {
126
+ const previous = stmts.getAgentMetricWatermark.get(userId, agentInstanceId, metricEpoch);
127
+ const delta = {};
128
+ const watermark = {};
129
+ for (const [metricKey, column] of fields) {
130
+ const previousValue = Number(previous?.[column]) || 0;
131
+ delta[metricKey] = Math.max(0, current[metricKey] - previousValue);
132
+ watermark[metricKey] = Math.max(previousValue, current[metricKey]);
133
+ }
134
+ stmts.upsertAgentMetricWatermark.run(
135
+ userId,
136
+ agentInstanceId,
137
+ metricEpoch,
138
+ watermark.inputTokens,
139
+ watermark.outputTokens,
140
+ watermark.cacheReadTokens,
141
+ watermark.cacheWriteTokens,
142
+ watermark.totalTokens,
143
+ now
144
+ );
145
+ if (Object.values(delta).some(value => value > 0)) {
146
+ stmts.upsertUserStats.run(
147
+ userId, 0, 0, 0, 0, 0,
148
+ delta.inputTokens,
149
+ delta.outputTokens,
150
+ delta.cacheReadTokens,
151
+ delta.cacheWriteTokens,
152
+ delta.totalTokens,
153
+ now
154
+ );
155
+ stmts.upsertDailyStats.run(
156
+ userId, todayStr(), 0, 0, 0, 0, 0,
157
+ delta.inputTokens,
158
+ delta.outputTokens,
159
+ delta.cacheReadTokens,
160
+ delta.cacheWriteTokens,
161
+ delta.totalTokens
162
+ );
163
+ }
164
+ return delta;
165
+ })();
166
+ },
167
+
168
+ getDashboardTotals() {
169
+ return stmts.getDashboardTotals.get();
170
+ },
171
+
172
+ getDashboardTokenTotals() {
173
+ return stmts.getDashboardTokenTotals.get();
174
+ },
175
+
176
+ getTodayActiveUsers() {
177
+ const row = stmts.getTodayActiveUsers.get(todayStr());
178
+ return row?.count || 0;
179
+ },
180
+
181
+ getTodayMessages() {
182
+ const row = stmts.getTodayMessages.get(todayStr());
183
+ return row?.count || 0;
184
+ }
185
+ };
@@ -0,0 +1,258 @@
1
+ /**
2
+ * yeaft-session-db.js — server-side persistence for yeaft sessions.
3
+ *
4
+ * Mirrors session-db.js but for the yeaft engine's per-user multi-VP
5
+ * sessions. The agent owns the canonical session state on disk (under
6
+ * `~/.yeaft/sessions/<id>/`); this table is a shadow registry the
7
+ * server uses to:
8
+ *
9
+ * 1. Show the user's yeaft sessions in the sidebar BEFORE any agent
10
+ * comes online (so a reload doesn't blank the list).
11
+ * 2. List sessions across ALL of the user's agents in one place
12
+ * (cross-agent unified sidebar) — same way chat conversations
13
+ * pull from the `sessions` table regardless of which agent is
14
+ * currently selected.
15
+ *
16
+ * Rows are upserted whenever the agent emits a `group_list_updated`
17
+ * snapshot (full state) or a `group_roster_changed` delta. They are
18
+ * deleted when the agent emits a `session_crud_result` with op=delete
19
+ * or op=archive.
20
+ */
21
+
22
+ import { stmts } from './connection.js';
23
+
24
+ function safeJsonParse(s, fallback) {
25
+ if (s == null || s === '') return fallback;
26
+ try { return JSON.parse(s); }
27
+ catch (_) { return fallback; }
28
+ }
29
+
30
+ function safeJsonStringify(v) {
31
+ if (v == null) return null;
32
+ try { return JSON.stringify(v); }
33
+ catch (_) { return null; }
34
+ }
35
+
36
+ function sessionOrderKey(agentId, sessionId) {
37
+ return `${agentId || ''}\u001f${sessionId || ''}`;
38
+ }
39
+
40
+ function mapRow(row) {
41
+ if (!row) return row;
42
+ return {
43
+ id: row.id,
44
+ userId: row.user_id || null,
45
+ agentId: row.agent_id,
46
+ name: row.name || row.id,
47
+ roster: safeJsonParse(row.roster_json, []),
48
+ defaultVpId: row.default_vp_id || null,
49
+ workDir: row.work_dir || '',
50
+ config: safeJsonParse(row.config_json, {}),
51
+ announcement: row.announcement || '',
52
+ createdAt: row.created_at || null,
53
+ updatedAt: row.updated_at,
54
+ isArchived: row.is_archived === 1,
55
+ // fix-yeaft-session-list-and-menu: persisted pin state. Decorated
56
+ // onto outgoing snapshots in server/handlers/agent-output.js so the
57
+ // web sees `pinned: true/false` on each session row.
58
+ // Keep `isPinned` for existing server-side callers, and expose `pinned`
59
+ // because the web session store consumes neutral session metadata using
60
+ // that field name during DB hydration.
61
+ isPinned: row.is_pinned === 1,
62
+ pinned: row.is_pinned === 1,
63
+ sortOrder: Number.isFinite(row.sort_order) ? row.sort_order : null,
64
+ };
65
+ }
66
+
67
+ export const yeaftSessionDb = {
68
+ /**
69
+ * Upsert one session from a snapshot row. Returns nothing.
70
+ *
71
+ * @param {string} userId
72
+ * @param {string} agentId
73
+ * @param {object} session Shape from `web-bridge.snapshotSessions()`:
74
+ * { id, name, roster[], defaultVpId, workDir, config, announcement, createdAt }
75
+ */
76
+ upsertFromSnapshot(userId, agentId, session) {
77
+ if (!session || !session.id || !agentId) return;
78
+ const now = Date.now();
79
+ stmts.upsertYeaftSession.run(
80
+ session.id,
81
+ userId || null,
82
+ agentId,
83
+ session.name || session.id,
84
+ safeJsonStringify(Array.isArray(session.roster) ? session.roster : []),
85
+ session.defaultVpId || null,
86
+ session.workDir || '',
87
+ safeJsonStringify(session.config && typeof session.config === 'object' ? session.config : {}),
88
+ typeof session.announcement === 'string' ? session.announcement : '',
89
+ session.createdAt || now,
90
+ now,
91
+ 0,
92
+ );
93
+ },
94
+
95
+ /**
96
+ * Bulk reconciliation from a full snapshot. Sessions not present in
97
+ * the incoming array (but currently in the DB for this user+agent)
98
+ * are deleted — the agent has authoritatively said "these are my
99
+ * sessions right now". Behaviour matches the web `applySnapshot`
100
+ * per-agent replacement so server + client stay in sync.
101
+ *
102
+ * @param {string} userId
103
+ * @param {string} agentId
104
+ * @param {object[]} sessions
105
+ */
106
+ reconcileFromSnapshot(userId, agentId, sessions) {
107
+ const arr = Array.isArray(sessions) ? sessions : [];
108
+ const incomingIds = new Set();
109
+ for (const s of arr) {
110
+ if (s && s.id) {
111
+ incomingIds.add(s.id);
112
+ this.upsertFromSnapshot(userId, agentId, s);
113
+ }
114
+ }
115
+ // Drop rows for this (user, agent) not in the snapshot.
116
+ const existing = stmts.getYeaftSessionsByAgent.all(agentId);
117
+ for (const row of existing) {
118
+ // Only touch rows belonging to this user — guards against
119
+ // accidentally wiping another user's rows if agent ownership is
120
+ // ever shared (currently it isn't, but be defensive).
121
+ // Treat NULL user_id as foreign too — never cross-delete.
122
+ if (userId && row.user_id !== userId) continue;
123
+ if (!incomingIds.has(row.id)) {
124
+ stmts.deleteYeaftSessionForAgent.run(row.id, userId, agentId);
125
+ }
126
+ }
127
+ },
128
+
129
+ /** All non-archived rows for this user (across all agents). */
130
+ getByUser(userId) {
131
+ if (!userId) return [];
132
+ return stmts.getYeaftSessionsByUser.all(userId).map(mapRow);
133
+ },
134
+
135
+ getByAgent(agentId) {
136
+ return stmts.getYeaftSessionsByAgent.all(agentId).map(mapRow);
137
+ },
138
+
139
+ get(id) {
140
+ return mapRow(stmts.getYeaftSession.get(id));
141
+ },
142
+
143
+ getForAgent(userId, agentId, id) {
144
+ if (!userId || !agentId || !id) return null;
145
+ return mapRow(stmts.getYeaftSessionForAgent.get(id, userId, agentId));
146
+ },
147
+
148
+ delete(id) {
149
+ stmts.deleteYeaftSession.run(id);
150
+ },
151
+
152
+ deleteForAgent(userId, agentId, id) {
153
+ if (!userId || !agentId || !id) return;
154
+ stmts.deleteYeaftSessionForAgent.run(id, userId, agentId);
155
+ },
156
+
157
+ setArchived(id, archived) {
158
+ stmts.setYeaftSessionArchived.run(archived ? 1 : 0, Date.now(), id);
159
+ },
160
+
161
+ setArchivedForAgent(userId, agentId, id, archived) {
162
+ if (!userId || !agentId || !id) return;
163
+ stmts.setYeaftSessionArchivedForAgent.run(archived ? 1 : 0, Date.now(), id, userId, agentId);
164
+ },
165
+
166
+ /**
167
+ * Persist the per-session pin state. fix-yeaft-session-list-and-menu:
168
+ * yeaft sidebar's "置顶" menu item flips this — the existing
169
+ * `pin_session` / `unpin_session` WebSocket handler in
170
+ * server/handlers/client-conversation.js detects yeaft-owned ids and
171
+ * routes here (chat-owned ids fall back to sessionDb.setPinned).
172
+ *
173
+ * upsertFromSnapshot does NOT touch is_pinned (the snapshot is
174
+ * agent-authored and the agent has no notion of pin); the column is
175
+ * preserved across snapshot upserts naturally because is_pinned is
176
+ * absent from the ON CONFLICT update set in connection.js.
177
+ */
178
+ setPinned(id, pinned) {
179
+ stmts.setYeaftSessionPinned.run(pinned ? 1 : 0, Date.now(), id);
180
+ },
181
+
182
+ setPinnedForAgentRow(userId, agentId, id, pinned) {
183
+ if (!userId || !agentId || !id) return;
184
+ stmts.setYeaftSessionPinnedForAgent.run(pinned ? 1 : 0, Date.now(), id, userId, agentId);
185
+ },
186
+
187
+ setOrderForAgent(userId, agentId, sessionIds) {
188
+ if (!userId || !agentId || !Array.isArray(sessionIds)) return false;
189
+ const owned = new Set(
190
+ stmts.getYeaftSessionsByAgent.all(agentId)
191
+ .filter(row => row && row.user_id === userId)
192
+ .map(row => row.id),
193
+ );
194
+ const seen = new Set();
195
+ const ordered = [];
196
+ for (const rawId of sessionIds) {
197
+ const id = typeof rawId === 'string' ? rawId : String(rawId || '');
198
+ if (!id || seen.has(id) || !owned.has(id)) continue;
199
+ seen.add(id);
200
+ ordered.push(id);
201
+ }
202
+ if (ordered.length === 0) return false;
203
+ const now = Date.now();
204
+ ordered.forEach((id, index) => {
205
+ stmts.setYeaftSessionSortOrder.run(index, now, id, userId, agentId);
206
+ });
207
+ return true;
208
+ },
209
+
210
+ setOrderForUser(userId, sessions) {
211
+ if (!userId || !Array.isArray(sessions)) return false;
212
+ const owned = new Set(
213
+ stmts.getYeaftSessionsByUser.all(userId)
214
+ .filter(row => row && row.user_id === userId && row.agent_id && row.id)
215
+ .map(row => sessionOrderKey(row.agent_id, row.id)),
216
+ );
217
+ const seen = new Set();
218
+ const ordered = [];
219
+ for (const item of sessions) {
220
+ const agentId = typeof item?.agentId === 'string' ? item.agentId : '';
221
+ const sessionId = typeof item?.sessionId === 'string'
222
+ ? item.sessionId
223
+ : (typeof item?.id === 'string' ? item.id : '');
224
+ const key = sessionOrderKey(agentId, sessionId);
225
+ if (!agentId || !sessionId || seen.has(key) || !owned.has(key)) continue;
226
+ seen.add(key);
227
+ ordered.push({ agentId, sessionId });
228
+ }
229
+ if (ordered.length === 0) return false;
230
+ const now = Date.now();
231
+ ordered.forEach(({ agentId, sessionId }, index) => {
232
+ stmts.setYeaftSessionSortOrder.run(index, now, sessionId, userId, agentId);
233
+ });
234
+ return true;
235
+ },
236
+
237
+ /**
238
+ * Persist pin state for a Yeaft Session even if the agent snapshot has not
239
+ * reached the server-side shadow table yet. This keeps the UI pin action
240
+ * durable across reloads in the bootstrap race where the user pins a row
241
+ * that exists in the web/agent session list but not in `yeaft_sessions`.
242
+ */
243
+ setPinnedForAgent(userId, agentId, session, pinned) {
244
+ const id = typeof session === 'string' ? session : session?.id;
245
+ if (!id || !agentId) return false;
246
+ const existing = this.getForAgent(userId, agentId, id);
247
+ if (existing && existing.userId && userId && existing.userId !== userId) return false;
248
+ if (!existing) {
249
+ this.upsertFromSnapshot(userId, agentId, {
250
+ ...(session && typeof session === 'object' ? session : {}),
251
+ id,
252
+ name: (session && typeof session === 'object' && session.name) ? session.name : id,
253
+ });
254
+ }
255
+ this.setPinnedForAgentRow(userId, agentId, id, pinned);
256
+ return true;
257
+ },
258
+ };
@@ -0,0 +1,96 @@
1
+ import nodemailer from 'nodemailer';
2
+ import { CONFIG } from './config.js';
3
+
4
+ let transporter = null;
5
+
6
+ /**
7
+ * Get or create email transporter
8
+ * @returns {nodemailer.Transporter}
9
+ */
10
+ function getTransporter() {
11
+ if (!transporter) {
12
+ transporter = nodemailer.createTransport({
13
+ host: CONFIG.smtp.host,
14
+ port: CONFIG.smtp.port,
15
+ secure: CONFIG.smtp.secure,
16
+ auth: {
17
+ user: CONFIG.smtp.user,
18
+ pass: CONFIG.smtp.pass
19
+ }
20
+ });
21
+ }
22
+ return transporter;
23
+ }
24
+
25
+ /**
26
+ * Send verification code email
27
+ * @param {string} to - Recipient email address
28
+ * @param {string} code - Verification code
29
+ * @param {string} username - Username for personalization
30
+ * @returns {Promise<void>}
31
+ */
32
+ export async function sendVerificationCode(to, code, username) {
33
+ const transport = getTransporter();
34
+
35
+ const htmlContent = `
36
+ <!DOCTYPE html>
37
+ <html>
38
+ <head>
39
+ <meta charset="utf-8">
40
+ <style>
41
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
42
+ .container { max-width: 600px; margin: 0 auto; padding: 20px; }
43
+ .code { font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #2563eb; padding: 20px; background: #f0f9ff; border-radius: 8px; text-align: center; margin: 20px 0; }
44
+ .footer { color: #6b7280; font-size: 12px; margin-top: 20px; }
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <div class="container">
49
+ <h2>WebChat Login Verification</h2>
50
+ <p>Hello ${username},</p>
51
+ <p>Your verification code is:</p>
52
+ <div class="code">${code}</div>
53
+ <p>This code will expire in 5 minutes.</p>
54
+ <p>If you did not request this code, please ignore this email.</p>
55
+ <div class="footer">
56
+ <p>This email was sent by WebChat. Do not reply to this email.</p>
57
+ </div>
58
+ </div>
59
+ </body>
60
+ </html>
61
+ `;
62
+
63
+ const textContent = `
64
+ WebChat Login Verification
65
+
66
+ Hello ${username},
67
+
68
+ Your verification code is: ${code}
69
+
70
+ This code will expire in 5 minutes.
71
+
72
+ If you did not request this code, please ignore this email.
73
+ `.trim();
74
+
75
+ await transport.sendMail({
76
+ from: CONFIG.smtp.from,
77
+ to,
78
+ subject: `WebChat Verification Code: ${code}`,
79
+ text: textContent,
80
+ html: htmlContent
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Test email configuration
86
+ * @returns {Promise<{success: boolean, error?: string}>}
87
+ */
88
+ export async function testEmailConfig() {
89
+ try {
90
+ const transport = getTransporter();
91
+ await transport.verify();
92
+ return { success: true };
93
+ } catch (err) {
94
+ return { success: false, error: err.message };
95
+ }
96
+ }