@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.
- package/cli.js +12 -0
- package/index.js +10 -2
- package/local-run.js +218 -0
- package/local-runtime/server/.env.example +54 -0
- package/local-runtime/server/api.js +111 -0
- package/local-runtime/server/auth/aad.js +235 -0
- package/local-runtime/server/auth/login.js +156 -0
- package/local-runtime/server/auth/oauth-flow.js +277 -0
- package/local-runtime/server/auth/password-reset.js +134 -0
- package/local-runtime/server/auth/providers/alipay.js +125 -0
- package/local-runtime/server/auth/providers/github.js +82 -0
- package/local-runtime/server/auth/providers/google.js +60 -0
- package/local-runtime/server/auth/providers/microsoft.js +71 -0
- package/local-runtime/server/auth/providers/types.js +57 -0
- package/local-runtime/server/auth/providers/wechat.js +68 -0
- package/local-runtime/server/auth/register.js +91 -0
- package/local-runtime/server/auth/session-store.js +57 -0
- package/local-runtime/server/auth/token.js +85 -0
- package/local-runtime/server/auth/totp-auth.js +133 -0
- package/local-runtime/server/auth/utils.js +42 -0
- package/local-runtime/server/auth.js +8 -0
- package/local-runtime/server/check-node-version.js +74 -0
- package/local-runtime/server/config.js +298 -0
- package/local-runtime/server/context.js +140 -0
- package/local-runtime/server/create-user.js +59 -0
- package/local-runtime/server/database.js +12 -0
- package/local-runtime/server/db/connection.js +963 -0
- package/local-runtime/server/db/expert-db.js +171 -0
- package/local-runtime/server/db/identity-db.js +92 -0
- package/local-runtime/server/db/invitation-db.js +38 -0
- package/local-runtime/server/db/message-db.js +257 -0
- package/local-runtime/server/db/session-db.js +118 -0
- package/local-runtime/server/db/user-db.js +165 -0
- package/local-runtime/server/db/user-stats-db.js +185 -0
- package/local-runtime/server/db/yeaft-session-db.js +258 -0
- package/local-runtime/server/email.js +96 -0
- package/local-runtime/server/encryption.js +105 -0
- package/local-runtime/server/handlers/agent-conversation.js +347 -0
- package/local-runtime/server/handlers/agent-file-terminal.js +99 -0
- package/local-runtime/server/handlers/agent-output.js +854 -0
- package/local-runtime/server/handlers/agent-sync.js +399 -0
- package/local-runtime/server/handlers/agent-work-center.js +27 -0
- package/local-runtime/server/handlers/client-conversation.js +1182 -0
- package/local-runtime/server/handlers/client-misc.js +254 -0
- package/local-runtime/server/handlers/client-work-center.js +269 -0
- package/local-runtime/server/handlers/client-workbench.js +146 -0
- package/local-runtime/server/handlers/session-pin-router.js +61 -0
- package/local-runtime/server/heartbeat-policy.js +46 -0
- package/local-runtime/server/index.js +275 -0
- package/local-runtime/server/package.json +55 -0
- package/local-runtime/server/perf-trace.js +154 -0
- package/local-runtime/server/proxy.js +273 -0
- package/local-runtime/server/routes/admin-routes.js +207 -0
- package/local-runtime/server/routes/auth-routes.js +322 -0
- package/local-runtime/server/routes/expert-routes.js +117 -0
- package/local-runtime/server/routes/invitation-routes.js +60 -0
- package/local-runtime/server/routes/session-routes.js +112 -0
- package/local-runtime/server/routes/upload-routes.js +109 -0
- package/local-runtime/server/routes/user-routes.js +241 -0
- package/local-runtime/server/totp.js +74 -0
- package/local-runtime/server/work-item-attachment-policy.js +56 -0
- package/local-runtime/server/ws-agent.js +319 -0
- package/local-runtime/server/ws-client.js +214 -0
- package/local-runtime/server/ws-utils.js +394 -0
- package/local-runtime/server/yeaft-asset-store.js +339 -0
- package/local-runtime/version.json +1 -0
- package/local-runtime/web/app.bundle.js +7673 -0
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/assets/avatars/README.md +34 -0
- package/local-runtime/web/assets/avatars/ada.svg +1 -0
- package/local-runtime/web/assets/avatars/alan.svg +1 -0
- package/local-runtime/web/assets/avatars/alice.svg +1 -0
- package/local-runtime/web/assets/avatars/anders.svg +1 -0
- package/local-runtime/web/assets/avatars/bezos.svg +1 -0
- package/local-runtime/web/assets/avatars/borges.svg +1 -0
- package/local-runtime/web/assets/avatars/buffett.svg +1 -0
- package/local-runtime/web/assets/avatars/clausewitz.svg +1 -0
- package/local-runtime/web/assets/avatars/dalio.svg +1 -0
- package/local-runtime/web/assets/avatars/dieter.svg +1 -0
- package/local-runtime/web/assets/avatars/drucker.svg +1 -0
- package/local-runtime/web/assets/avatars/einstein.svg +1 -0
- package/local-runtime/web/assets/avatars/grace.svg +1 -0
- package/local-runtime/web/assets/avatars/harari.svg +1 -0
- package/local-runtime/web/assets/avatars/jung.svg +1 -0
- package/local-runtime/web/assets/avatars/kahneman.svg +1 -0
- package/local-runtime/web/assets/avatars/ken.svg +1 -0
- package/local-runtime/web/assets/avatars/kongzi.svg +1 -0
- package/local-runtime/web/assets/avatars/kubrick.svg +1 -0
- package/local-runtime/web/assets/avatars/linus.svg +1 -0
- package/local-runtime/web/assets/avatars/luxun.svg +1 -0
- package/local-runtime/web/assets/avatars/margaret.svg +1 -0
- package/local-runtime/web/assets/avatars/martin.svg +1 -0
- package/local-runtime/web/assets/avatars/miyazaki.svg +1 -0
- package/local-runtime/web/assets/avatars/munger.svg +1 -0
- package/local-runtime/web/assets/avatars/nietzsche.svg +1 -0
- package/local-runtime/web/assets/avatars/norman.svg +1 -0
- package/local-runtime/web/assets/avatars/shannon.svg +1 -0
- package/local-runtime/web/assets/avatars/simaqian.svg +1 -0
- package/local-runtime/web/assets/avatars/socrates.svg +1 -0
- package/local-runtime/web/assets/avatars/steve.svg +1 -0
- package/local-runtime/web/assets/avatars/sudongpo.svg +1 -0
- package/local-runtime/web/assets/avatars/sunzi.svg +1 -0
- package/local-runtime/web/docx-preview.min.js +2 -0
- package/local-runtime/web/docx-preview.min.js.gz +0 -0
- package/local-runtime/web/html-to-image.min.js +2 -0
- package/local-runtime/web/html-to-image.min.js.gz +0 -0
- package/local-runtime/web/index.html +21 -0
- package/local-runtime/web/jszip.min.js +13 -0
- package/local-runtime/web/jszip.min.js.gz +0 -0
- package/local-runtime/web/mermaid.min.js +2843 -0
- package/local-runtime/web/mermaid.min.js.gz +0 -0
- package/local-runtime/web/msal-browser.min.js +69 -0
- package/local-runtime/web/msal-browser.min.js.gz +0 -0
- package/local-runtime/web/style.bundle.css +10 -0
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/local-runtime/web/vendor.bundle.js +1522 -0
- package/local-runtime/web/vendor.bundle.js.gz +0 -0
- package/local-runtime/web/xlsx.min.js +24 -0
- package/local-runtime/web/xlsx.min.js.gz +0 -0
- package/package.json +13 -3
- package/scripts/prepare-local-runtime.js +25 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import db from './connection.js';
|
|
3
|
+
import { transaction } from './connection.js';
|
|
4
|
+
|
|
5
|
+
// Prepared statements
|
|
6
|
+
const stmts = {
|
|
7
|
+
// Roles
|
|
8
|
+
insertRole: db.prepare(`
|
|
9
|
+
INSERT INTO custom_expert_roles (id, user_id, role_id, name, full_name, title, title_en, group_id, icon, message_prefix, message_prefix_en, created_at, updated_at)
|
|
10
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
11
|
+
`),
|
|
12
|
+
updateRole: db.prepare(`
|
|
13
|
+
UPDATE custom_expert_roles
|
|
14
|
+
SET name = ?, full_name = ?, title = ?, title_en = ?, group_id = ?, icon = ?,
|
|
15
|
+
message_prefix = ?, message_prefix_en = ?, updated_at = ?
|
|
16
|
+
WHERE user_id = ? AND role_id = ?
|
|
17
|
+
`),
|
|
18
|
+
deleteRole: db.prepare(`
|
|
19
|
+
DELETE FROM custom_expert_roles WHERE user_id = ? AND role_id = ?
|
|
20
|
+
`),
|
|
21
|
+
getRolesByUser: db.prepare(`
|
|
22
|
+
SELECT * FROM custom_expert_roles WHERE user_id = ? ORDER BY created_at ASC
|
|
23
|
+
`),
|
|
24
|
+
getRoleByUserAndId: db.prepare(`
|
|
25
|
+
SELECT * FROM custom_expert_roles WHERE user_id = ? AND role_id = ?
|
|
26
|
+
`),
|
|
27
|
+
|
|
28
|
+
// Actions
|
|
29
|
+
insertAction: db.prepare(`
|
|
30
|
+
INSERT INTO custom_expert_actions (id, role_row_id, action_id, name, name_en, message_template, message_template_en, default_message, default_message_en)
|
|
31
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
32
|
+
`),
|
|
33
|
+
deleteActionsByRole: db.prepare(`
|
|
34
|
+
DELETE FROM custom_expert_actions WHERE role_row_id = ?
|
|
35
|
+
`),
|
|
36
|
+
getActionsByRole: db.prepare(`
|
|
37
|
+
SELECT * FROM custom_expert_actions WHERE role_row_id = ? ORDER BY rowid ASC
|
|
38
|
+
`)
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Transaction wrappers
|
|
42
|
+
const insertRoleWithActions = transaction((userId, roleData) => {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const rowId = `cer_${randomUUID()}`;
|
|
45
|
+
const roleId = roleData.roleId || `custom-${randomUUID().substring(0, 8)}`;
|
|
46
|
+
|
|
47
|
+
stmts.insertRole.run(
|
|
48
|
+
rowId, userId, roleId,
|
|
49
|
+
roleData.name, roleData.fullName || null,
|
|
50
|
+
roleData.title, roleData.titleEn || null,
|
|
51
|
+
roleData.groupId || 'custom', roleData.icon || null,
|
|
52
|
+
roleData.messagePrefix || null, roleData.messagePrefixEn || null,
|
|
53
|
+
now, now
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (roleData.actions && roleData.actions.length > 0) {
|
|
57
|
+
for (const action of roleData.actions) {
|
|
58
|
+
stmts.insertAction.run(
|
|
59
|
+
`cea_${randomUUID()}`, rowId,
|
|
60
|
+
action.actionId || action.id,
|
|
61
|
+
action.name, action.nameEn || null,
|
|
62
|
+
action.messageTemplate || null, action.messageTemplateEn || null,
|
|
63
|
+
action.defaultMessage || null, action.defaultMessageEn || null
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { rowId, roleId };
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const updateRoleWithActions = transaction((userId, roleId, roleData) => {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
|
|
74
|
+
stmts.updateRole.run(
|
|
75
|
+
roleData.name, roleData.fullName || null,
|
|
76
|
+
roleData.title, roleData.titleEn || null,
|
|
77
|
+
roleData.groupId || 'custom', roleData.icon || null,
|
|
78
|
+
roleData.messagePrefix || null, roleData.messagePrefixEn || null,
|
|
79
|
+
now, userId, roleId
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Get the row id to replace actions
|
|
83
|
+
const row = stmts.getRoleByUserAndId.get(userId, roleId);
|
|
84
|
+
if (!row) throw new Error(`Role not found: ${roleId}`);
|
|
85
|
+
|
|
86
|
+
// Replace all actions
|
|
87
|
+
stmts.deleteActionsByRole.run(row.id);
|
|
88
|
+
if (roleData.actions && roleData.actions.length > 0) {
|
|
89
|
+
for (const action of roleData.actions) {
|
|
90
|
+
stmts.insertAction.run(
|
|
91
|
+
`cea_${randomUUID()}`, row.id,
|
|
92
|
+
action.actionId || action.id,
|
|
93
|
+
action.name, action.nameEn || null,
|
|
94
|
+
action.messageTemplate || null, action.messageTemplateEn || null,
|
|
95
|
+
action.defaultMessage || null, action.defaultMessageEn || null
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Transform DB rows into the API response format.
|
|
103
|
+
*/
|
|
104
|
+
function transformRole(roleRow, actionRows) {
|
|
105
|
+
return {
|
|
106
|
+
id: roleRow.role_id,
|
|
107
|
+
name: roleRow.name,
|
|
108
|
+
fullName: roleRow.full_name,
|
|
109
|
+
title: roleRow.title,
|
|
110
|
+
titleEn: roleRow.title_en,
|
|
111
|
+
groupId: roleRow.group_id,
|
|
112
|
+
icon: roleRow.icon,
|
|
113
|
+
messagePrefix: roleRow.message_prefix,
|
|
114
|
+
messagePrefixEn: roleRow.message_prefix_en,
|
|
115
|
+
createdAt: roleRow.created_at,
|
|
116
|
+
updatedAt: roleRow.updated_at,
|
|
117
|
+
actions: actionRows.map(a => ({
|
|
118
|
+
id: a.action_id,
|
|
119
|
+
name: a.name,
|
|
120
|
+
nameEn: a.name_en,
|
|
121
|
+
messageTemplate: a.message_template,
|
|
122
|
+
messageTemplateEn: a.message_template_en,
|
|
123
|
+
defaultMessage: a.default_message,
|
|
124
|
+
defaultMessageEn: a.default_message_en
|
|
125
|
+
}))
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const expertDb = {
|
|
130
|
+
/**
|
|
131
|
+
* Get all custom roles for a user, with nested actions.
|
|
132
|
+
*/
|
|
133
|
+
getCustomRolesByUser(userId) {
|
|
134
|
+
const rows = stmts.getRolesByUser.all(userId);
|
|
135
|
+
return rows.map(row => {
|
|
136
|
+
const actions = stmts.getActionsByRole.all(row.id);
|
|
137
|
+
return transformRole(row, actions);
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Create a custom role with actions (transactional).
|
|
143
|
+
* @returns {{ rowId: string, roleId: string }}
|
|
144
|
+
*/
|
|
145
|
+
createCustomRole(userId, roleData) {
|
|
146
|
+
return insertRoleWithActions(userId, roleData);
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Update a custom role and replace its actions (transactional).
|
|
151
|
+
*/
|
|
152
|
+
updateCustomRole(userId, roleId, roleData) {
|
|
153
|
+
updateRoleWithActions(userId, roleId, roleData);
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Delete a custom role (CASCADE deletes actions).
|
|
158
|
+
* @returns {boolean} true if deleted
|
|
159
|
+
*/
|
|
160
|
+
deleteCustomRole(userId, roleId) {
|
|
161
|
+
const result = stmts.deleteRole.run(userId, roleId);
|
|
162
|
+
return result.changes > 0;
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check if a role exists for a user.
|
|
167
|
+
*/
|
|
168
|
+
exists(userId, roleId) {
|
|
169
|
+
return !!stmts.getRoleByUserAndId.get(userId, roleId);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { stmts } from './connection.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CRUD operations for user_identities — multi-provider SSO bindings.
|
|
6
|
+
*
|
|
7
|
+
* One internal user can have many identities (one per provider). The
|
|
8
|
+
* UNIQUE(provider, subject) constraint enforces that a single provider
|
|
9
|
+
* account (e.g. one GitHub account) can only be bound to one user.
|
|
10
|
+
*/
|
|
11
|
+
export const identityDb = {
|
|
12
|
+
/**
|
|
13
|
+
* Create a new identity row binding (user_id ↔ provider/subject).
|
|
14
|
+
* Returns the inserted row, or null if the (provider, subject) is already
|
|
15
|
+
* taken by another user (UNIQUE conflict).
|
|
16
|
+
*/
|
|
17
|
+
create({ userId, provider, subject, email = null, displayName = null }) {
|
|
18
|
+
const id = `idn_${randomUUID()}`;
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
try {
|
|
21
|
+
stmts.insertIdentity.run(id, userId, provider, subject, email, displayName, now, now);
|
|
22
|
+
return { id, user_id: userId, provider, subject, email, display_name: displayName, created_at: now, last_login_at: now };
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (err && /UNIQUE/i.test(err.message || '')) return null;
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Find the identity row for (provider, subject), or null.
|
|
31
|
+
*/
|
|
32
|
+
findBySubject(provider, subject) {
|
|
33
|
+
if (!provider || !subject) return null;
|
|
34
|
+
return stmts.getIdentityBySubject.get(provider, subject) || null;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find a user's identity row for a specific provider, or null.
|
|
39
|
+
*/
|
|
40
|
+
findForUser(userId, provider) {
|
|
41
|
+
return stmts.getIdentityForUser.get(userId, provider) || null;
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* List all identities for a user.
|
|
46
|
+
*/
|
|
47
|
+
listForUser(userId) {
|
|
48
|
+
return stmts.getIdentitiesByUser.all(userId);
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Count how many identities a user has bound.
|
|
53
|
+
*/
|
|
54
|
+
countForUser(userId) {
|
|
55
|
+
const row = stmts.countIdentitiesByUser.get(userId);
|
|
56
|
+
return Number(row?.count || 0);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Update last_login_at for an identity row.
|
|
61
|
+
*/
|
|
62
|
+
touchLogin(id) {
|
|
63
|
+
stmts.updateIdentityLogin.run(Date.now(), id);
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Remove a user's binding to a provider. Returns true if a row was deleted.
|
|
68
|
+
*/
|
|
69
|
+
removeForUser(userId, provider) {
|
|
70
|
+
const res = stmts.deleteIdentityForUser.run(userId, provider);
|
|
71
|
+
return Number(res?.changes || 0) > 0;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Idempotent upsert keyed by (provider, subject):
|
|
76
|
+
* - If a row already exists for that subject, return it (do not move it to a different user).
|
|
77
|
+
* - Otherwise create a new row bound to userId.
|
|
78
|
+
*
|
|
79
|
+
* Returns { row, created } so callers can tell whether the binding was new.
|
|
80
|
+
*/
|
|
81
|
+
upsert({ userId, provider, subject, email = null, displayName = null }) {
|
|
82
|
+
const existing = this.findBySubject(provider, subject);
|
|
83
|
+
if (existing) {
|
|
84
|
+
stmts.updateIdentityLogin.run(Date.now(), existing.id);
|
|
85
|
+
return { row: existing, created: false };
|
|
86
|
+
}
|
|
87
|
+
const row = this.create({ userId, provider, subject, email, displayName });
|
|
88
|
+
return { row, created: !!row };
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export default identityDb;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import { stmts } from './connection.js';
|
|
3
|
+
|
|
4
|
+
// 定期清理过期邀请码(每小时)
|
|
5
|
+
setInterval(() => {
|
|
6
|
+
try { stmts.cleanupExpiredInvitations.run(Date.now()); } catch (e) { /* ignore */ }
|
|
7
|
+
}, 60 * 60 * 1000);
|
|
8
|
+
|
|
9
|
+
export const invitationDb = {
|
|
10
|
+
create(createdBy, role = 'user', expiresInMs = 7 * 24 * 60 * 60 * 1000) {
|
|
11
|
+
const code = randomBytes(6).toString('hex'); // 12 字符
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
const expiresAt = now + expiresInMs;
|
|
14
|
+
stmts.insertInvitation.run(code, createdBy, now, expiresAt, role);
|
|
15
|
+
return { code, createdBy, createdAt: now, expiresAt, role };
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
get(code) {
|
|
19
|
+
return stmts.getInvitation.get(code) || null;
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
use(code, usedBy) {
|
|
23
|
+
stmts.useInvitation.run(usedBy, Date.now(), code);
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
getByUser(userId) {
|
|
27
|
+
return stmts.getInvitationsByUser.all(userId);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
delete(code, userId) {
|
|
31
|
+
const result = stmts.deleteInvitation.run(code, userId);
|
|
32
|
+
return result.changes > 0;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
cleanup() {
|
|
36
|
+
stmts.cleanupExpiredInvitations.run(Date.now());
|
|
37
|
+
}
|
|
38
|
+
};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import db from './connection.js';
|
|
2
|
+
import { stmts, transaction } from './connection.js';
|
|
3
|
+
|
|
4
|
+
export const messageDb = {
|
|
5
|
+
add(sessionId, role, content, messageType = null, toolName = null, toolInput = null, metadata = null) {
|
|
6
|
+
const now = Date.now();
|
|
7
|
+
const result = stmts.insertMessage.run(sessionId, role, content, messageType, toolName, toolInput, now, metadata);
|
|
8
|
+
// 更新会话的 updated_at
|
|
9
|
+
// fix-chat-title-sticky: updateSession statement gained a fifth
|
|
10
|
+
// placeholder for `is_custom_title` — pass null so COALESCE keeps
|
|
11
|
+
// the existing value. Forgetting this arg is a RangeError on every
|
|
12
|
+
// message insert.
|
|
13
|
+
stmts.updateSession.run(null, null, null, now, sessionId);
|
|
14
|
+
return result.lastInsertRowid;
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
getBySession(sessionId) {
|
|
18
|
+
return stmts.getMessagesBySession.all(sessionId);
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
getRecent(sessionId, limit = 50) {
|
|
22
|
+
return stmts.getRecentMessages.all(sessionId, limit).reverse();
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
getAfterId(sessionId, afterId) {
|
|
26
|
+
return stmts.getMessagesAfterId.all(sessionId, afterId || 0);
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
getBeforeId(sessionId, beforeId, limit = 50) {
|
|
30
|
+
return stmts.getMessagesBeforeId.all(sessionId, beforeId, limit).reverse();
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
getRecentTurns(sessionId, turnCount = 5) {
|
|
34
|
+
const userIds = stmts.getRecentUserMessageIds.all(sessionId, turnCount);
|
|
35
|
+
if (userIds.length === 0) return { messages: [], hasMore: false };
|
|
36
|
+
const oldestUserId = userIds[userIds.length - 1].id;
|
|
37
|
+
const messages = stmts.getMessagesFromId.all(sessionId, oldestUserId);
|
|
38
|
+
const hasMore = stmts.getMessagesBeforeId.all(sessionId, oldestUserId, 1).length > 0;
|
|
39
|
+
return { messages, hasMore };
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
getTurnsBeforeId(sessionId, beforeId, turnCount = 5) {
|
|
43
|
+
const userIds = stmts.getUserMessageIdsBeforeId.all(sessionId, beforeId, turnCount);
|
|
44
|
+
if (userIds.length === 0) return { messages: [], hasMore: false };
|
|
45
|
+
const oldestUserId = userIds[userIds.length - 1].id;
|
|
46
|
+
const messages = stmts.getMessagesBetweenIds.all(sessionId, oldestUserId, beforeId);
|
|
47
|
+
const hasMore = stmts.getMessagesBeforeId.all(sessionId, oldestUserId, 1).length > 0;
|
|
48
|
+
return { messages, hasMore };
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
getLastUserMessage(sessionId) {
|
|
52
|
+
return stmts.getLastUserMessage.get(sessionId) || null;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
bulkAddHistory(sessionId, historyMessages) {
|
|
56
|
+
function extractUserText(msg) {
|
|
57
|
+
const content = msg.message?.content;
|
|
58
|
+
if (!content) return '';
|
|
59
|
+
return typeof content === 'string'
|
|
60
|
+
? content
|
|
61
|
+
: (Array.isArray(content) ? content.map(b => b.text || '').join('') : JSON.stringify(content));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Detect compact summary messages injected by Claude CLI after context compaction.
|
|
65
|
+
// These arrive as type:'user' but contain AI-generated context summaries, not real user input.
|
|
66
|
+
function isCompactSummary(text) {
|
|
67
|
+
if (!text) return false;
|
|
68
|
+
return (text.includes('<context>') && text.includes('</context>'))
|
|
69
|
+
|| text.startsWith('Here is a summary of the conversation')
|
|
70
|
+
|| text.includes('<compact-summary>')
|
|
71
|
+
|| text.includes('This session is being continued from a previous conversation');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rowsFromHistory(msgs) {
|
|
75
|
+
const rows = [];
|
|
76
|
+
let lastTs = 0;
|
|
77
|
+
for (const msg of msgs) {
|
|
78
|
+
const rawTs = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
|
|
79
|
+
const ts = rawTs > lastTs ? rawTs : lastTs + 1;
|
|
80
|
+
lastTs = ts;
|
|
81
|
+
|
|
82
|
+
if (msg.type === 'user') {
|
|
83
|
+
const text = extractUserText(msg);
|
|
84
|
+
if (!text || isCompactSummary(text)) continue;
|
|
85
|
+
rows.push({ role: 'user', content: text, messageType: 'user', toolName: null, toolInput: null, ts });
|
|
86
|
+
} else if (msg.type === 'assistant') {
|
|
87
|
+
const content = msg.message?.content;
|
|
88
|
+
if (!content || !Array.isArray(content)) continue;
|
|
89
|
+
for (const block of content) {
|
|
90
|
+
if (block.type === 'text' && block.text) {
|
|
91
|
+
rows.push({ role: 'assistant', content: block.text, messageType: 'assistant', toolName: null, toolInput: null, ts });
|
|
92
|
+
} else if (block.type === 'tool_use') {
|
|
93
|
+
const toolInput = JSON.stringify(block.input || {});
|
|
94
|
+
rows.push({
|
|
95
|
+
role: 'assistant',
|
|
96
|
+
content: toolInput,
|
|
97
|
+
messageType: 'tool_use',
|
|
98
|
+
toolName: block.name,
|
|
99
|
+
toolInput,
|
|
100
|
+
ts,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return rows;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function rowSignature(row) {
|
|
110
|
+
return [row.role, row.content, row.messageType, row.toolName || '', row.toolInput || ''].join('\0');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function dbRowSignature(row) {
|
|
114
|
+
return [row.role, row.content, row.message_type, row.tool_name || '', row.tool_input || ''].join('\0');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function rowsMatchDbRows(dbRows, historyRows) {
|
|
118
|
+
if (dbRows.length !== historyRows.length) return false;
|
|
119
|
+
for (let i = 0; i < historyRows.length; i++) {
|
|
120
|
+
if (dbRowSignature(dbRows[i]) !== rowSignature(historyRows[i])) return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function historyRowsContainToolUse(rows) {
|
|
126
|
+
return rows.some(row => row.messageType === 'tool_use');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function tailLooksLikeStaleToolAction(dbRows) {
|
|
130
|
+
return dbRows.some(row => {
|
|
131
|
+
if (row.role !== 'assistant' || row.message_type !== 'assistant') return false;
|
|
132
|
+
const text = String(row.content || '').trimStart();
|
|
133
|
+
return /^(call|tool_call|function_call|server_tool_use)(?:\r?\n|$)/.test(text);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let msgsToInsert = historyMessages;
|
|
138
|
+
const lastUserMsg = this.getLastUserMessage(sessionId);
|
|
139
|
+
let needsRebuild = false;
|
|
140
|
+
let replaceTailAfterId = null;
|
|
141
|
+
|
|
142
|
+
if (lastUserMsg) {
|
|
143
|
+
const tsRange = stmts.getTimestampRange.get(sessionId);
|
|
144
|
+
// feat-chat-load-perf: the timestamp-range heuristic is a one-shot repair,
|
|
145
|
+
// not a per-resume hot path. The repair itself produces densely packed
|
|
146
|
+
// `ts = lastTs + 1` rows, which re-pass the (< 1000ms) test on every
|
|
147
|
+
// future resume — so without a sentinel the user pays a full DELETE +
|
|
148
|
+
// re-INSERT of the entire session on EVERY chat open. The sentinel
|
|
149
|
+
// `sessions.ts_rebuilt_at` is set to Date.now() the first (and only)
|
|
150
|
+
// time the repair fires; subsequent resumes see non-zero and skip it,
|
|
151
|
+
// falling through to the cheap anchor-based delta append below.
|
|
152
|
+
if (tsRange && tsRange.count > 5 && (tsRange.max_ts - tsRange.min_ts) < 1000) {
|
|
153
|
+
const sentinelRow = stmts.getSessionTsRebuiltAt.get(sessionId);
|
|
154
|
+
const alreadyRebuilt = sentinelRow && sentinelRow.ts_rebuilt_at > 0;
|
|
155
|
+
if (alreadyRebuilt) {
|
|
156
|
+
// Quiet by default — every once-repaired session would otherwise log
|
|
157
|
+
// this line on every future chat-resume forever, exactly inverting
|
|
158
|
+
// the perf goal. Flip DEBUG_BULK_HISTORY to see it.
|
|
159
|
+
if (process.env.DEBUG_BULK_HISTORY) {
|
|
160
|
+
console.log(`[bulkAddHistory] Skipping rebuild for ${sessionId} (one-shot guard, ts_rebuilt_at=${sentinelRow.ts_rebuilt_at})`);
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
console.log(`[bulkAddHistory] Detected bad timestamps (range: ${tsRange.max_ts - tsRange.min_ts}ms for ${tsRange.count} msgs), rebuilding for ${sessionId} (will delete ${tsRange.count} rows)`);
|
|
164
|
+
needsRebuild = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!needsRebuild) {
|
|
168
|
+
const anchor = lastUserMsg.content;
|
|
169
|
+
let anchorIndex = -1;
|
|
170
|
+
|
|
171
|
+
for (let i = historyMessages.length - 1; i >= 0; i--) {
|
|
172
|
+
const msg = historyMessages[i];
|
|
173
|
+
if (msg.type === 'user') {
|
|
174
|
+
const text = extractUserText(msg);
|
|
175
|
+
if (text === anchor) {
|
|
176
|
+
anchorIndex = i;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (anchorIndex === -1) {
|
|
183
|
+
console.log(`[bulkAddHistory] Anchor not found in history, appending all ${historyMessages.length} messages for ${sessionId}`);
|
|
184
|
+
} else {
|
|
185
|
+
let nextTurnStart = -1;
|
|
186
|
+
for (let i = anchorIndex + 1; i < historyMessages.length; i++) {
|
|
187
|
+
if (historyMessages[i].type === 'user') {
|
|
188
|
+
nextTurnStart = i;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (nextTurnStart === -1) {
|
|
194
|
+
// The active/latest turn has no following user anchor. Older code returned
|
|
195
|
+
// here, so a tail that had been stored with a stale Claude Code shape
|
|
196
|
+
// (for example raw `call`/id/argument text rows) could never heal on
|
|
197
|
+
// refresh. Compare the persisted tail after the last user row with the
|
|
198
|
+
// normalized JSONL tail; replace it only when it differs.
|
|
199
|
+
const tailMessages = historyMessages.slice(anchorIndex + 1);
|
|
200
|
+
const desiredTailRows = rowsFromHistory(tailMessages);
|
|
201
|
+
if (desiredTailRows.length === 0) return 0;
|
|
202
|
+
if (!historyRowsContainToolUse(desiredTailRows)) return 0;
|
|
203
|
+
const currentTailRows = stmts.getMessagesAfterId.all(sessionId, lastUserMsg.id);
|
|
204
|
+
if (rowsMatchDbRows(currentTailRows, desiredTailRows)) return 0;
|
|
205
|
+
if (!tailLooksLikeStaleToolAction(currentTailRows)) return 0;
|
|
206
|
+
replaceTailAfterId = lastUserMsg.id;
|
|
207
|
+
msgsToInsert = tailMessages;
|
|
208
|
+
} else {
|
|
209
|
+
msgsToInsert = historyMessages.slice(nextTurnStart);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const insertMany = transaction((msgs) => {
|
|
216
|
+
if (needsRebuild) {
|
|
217
|
+
stmts.deleteMessagesBySession.run(sessionId);
|
|
218
|
+
// Stamp the sentinel inside the same transaction as DELETE + re-INSERT.
|
|
219
|
+
// SQLite is atomic, so either everything (rows + stamp) lands or
|
|
220
|
+
// nothing does — preventing the "rows rebuilt but stamp lost ⇒
|
|
221
|
+
// rebuild loops forever on every future resume" failure mode that
|
|
222
|
+
// motivated this entire change.
|
|
223
|
+
stmts.markSessionTsRebuilt.run(Date.now(), sessionId);
|
|
224
|
+
} else if (replaceTailAfterId != null) {
|
|
225
|
+
stmts.deleteMessagesAfterId.run(sessionId, replaceTailAfterId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const rows = rowsFromHistory(msgs);
|
|
229
|
+
for (const row of rows) {
|
|
230
|
+
stmts.insertMessage.run(
|
|
231
|
+
sessionId,
|
|
232
|
+
row.role,
|
|
233
|
+
row.content,
|
|
234
|
+
row.messageType,
|
|
235
|
+
row.toolName,
|
|
236
|
+
row.toolInput,
|
|
237
|
+
row.ts,
|
|
238
|
+
null
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return rows.length;
|
|
242
|
+
});
|
|
243
|
+
return insertMany(msgsToInsert);
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
getCount(sessionId) {
|
|
247
|
+
return stmts.getMessageCount.get(sessionId)?.count || 0;
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
updateMetadata(messageId, metadata) {
|
|
251
|
+
stmts.updateMessageMetadata.run(metadata, messageId);
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
deleteBySession(sessionId) {
|
|
255
|
+
stmts.deleteMessagesBySession.run(sessionId);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { stmts } from './connection.js';
|
|
2
|
+
import { trackSession } from '../context.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Map a raw row from the `sessions` table into the camelCase shape the
|
|
6
|
+
* rest of the server uses. Every consumer that calls one of the read
|
|
7
|
+
* helpers below must go through this so the sticky `customTitle` bit
|
|
8
|
+
* is consistently surfaced.
|
|
9
|
+
*
|
|
10
|
+
* fix-chat-title-sticky: before this helper existed, callers read
|
|
11
|
+
* `row.title` directly and the `is_custom_title` column was invisible,
|
|
12
|
+
* which is why every rebuild path (agent-conversation,
|
|
13
|
+
* agent-sync, get_agents) ended up wiping the sticky bit.
|
|
14
|
+
*/
|
|
15
|
+
function mapRow(row) {
|
|
16
|
+
if (!row) return row;
|
|
17
|
+
return {
|
|
18
|
+
...row,
|
|
19
|
+
customTitle: row.is_custom_title === 1
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const sessionDb = {
|
|
24
|
+
create(id, agentId, agentName, workDir, claudeSessionId = null, title = null, userId = null, provider = null) {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
stmts.insertSession.run(id, userId, agentId, agentName, claudeSessionId, workDir, title, provider, now, now);
|
|
27
|
+
trackSession(userId);
|
|
28
|
+
return { id, userId, agentId, agentName, workDir, claudeSessionId, title, provider, customTitle: false, createdAt: now, updatedAt: now };
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Partial update. Pass `isCustomTitle: 1` (along with `title`) when the
|
|
33
|
+
* change came from the user-rename UI; pass `isCustomTitle: 0` to
|
|
34
|
+
* explicitly clear the bit. Omit it (or pass `undefined`) on auto-title
|
|
35
|
+
* writes — the `COALESCE` keeps the existing column value, which is the
|
|
36
|
+
* whole point of the sticky behaviour.
|
|
37
|
+
*/
|
|
38
|
+
update(id, updates = {}) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
stmts.updateSession.run(
|
|
41
|
+
updates.claudeSessionId ?? null,
|
|
42
|
+
updates.title ?? null,
|
|
43
|
+
updates.isCustomTitle ?? null,
|
|
44
|
+
now,
|
|
45
|
+
id
|
|
46
|
+
);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
setActive(id, active) {
|
|
50
|
+
stmts.updateSessionActive.run(active ? 1 : 0, Date.now(), id);
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* fix-session-dup: re-point a session at a different owning agent.
|
|
55
|
+
* The caller MUST have already removed the conv from the old
|
|
56
|
+
* agent's in-memory `agent.conversations` Map; this updates the
|
|
57
|
+
* persisted owner so a future `get_agents` restore re-seats the
|
|
58
|
+
* conv only on the new agent.
|
|
59
|
+
*
|
|
60
|
+
* `agentName` is denormalized in the row (legacy schema), so we
|
|
61
|
+
* write both columns together to keep them in sync — otherwise
|
|
62
|
+
* the next `agent_list` broadcast would surface a stale name.
|
|
63
|
+
*/
|
|
64
|
+
setAgent(id, agentId, agentName) {
|
|
65
|
+
stmts.updateSessionAgent.run(agentId, agentName ?? null, Date.now(), id);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
setPinned(id, pinned) {
|
|
69
|
+
stmts.updateSessionPinned.run(pinned ? 1 : 0, Date.now(), id);
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* fix-copilot-provider-persist: persist the conversation's code-agent
|
|
74
|
+
* provider (e.g. 'copilot') so it survives an agent process restart.
|
|
75
|
+
* Only meaningful for non-default providers; a null/'claude-code' value
|
|
76
|
+
* is the implicit default and need not be stored, but we still accept it
|
|
77
|
+
* so callers can clear the column if a provider ever changes.
|
|
78
|
+
*/
|
|
79
|
+
setProvider(id, provider) {
|
|
80
|
+
stmts.updateSessionProvider.run(provider ?? null, Date.now(), id);
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
get(id) {
|
|
84
|
+
return mapRow(stmts.getSession.get(id));
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
getByAgent(agentId, limit = 50) {
|
|
88
|
+
return stmts.getSessionsByAgent.all(agentId, limit).map(mapRow);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
getByUser(userId, limit = 50) {
|
|
92
|
+
return stmts.getSessionsByUser.all(userId, limit).map(mapRow);
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
getByUserAndAgent(userId, agentId, limit = 50) {
|
|
96
|
+
return stmts.getSessionsByUserAndAgent.all(userId, agentId, limit).map(mapRow);
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
getAll(limit = 100) {
|
|
100
|
+
return stmts.getAllSessions.all(limit).map(mapRow);
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
getActive() {
|
|
104
|
+
return stmts.getActiveSessions.all().map(mapRow);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
getActiveByUser(userId) {
|
|
108
|
+
return stmts.getActiveSessionsByUser.all(userId).map(mapRow);
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
delete(id) {
|
|
112
|
+
stmts.deleteSession.run(id);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
exists(id) {
|
|
116
|
+
return !!stmts.getSession.get(id);
|
|
117
|
+
}
|
|
118
|
+
};
|