@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.
- 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,105 @@
|
|
|
1
|
+
import tweetnacl from 'tweetnacl';
|
|
2
|
+
import tweetnaclUtil from 'tweetnacl-util';
|
|
3
|
+
import zlib from 'zlib';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
|
|
6
|
+
const gzip = promisify(zlib.gzip);
|
|
7
|
+
const gunzip = promisify(zlib.gunzip);
|
|
8
|
+
|
|
9
|
+
const { encodeBase64, decodeBase64, encodeUTF8, decodeUTF8 } = tweetnaclUtil;
|
|
10
|
+
|
|
11
|
+
// Compression threshold: only compress if data > 512 bytes
|
|
12
|
+
const COMPRESS_THRESHOLD = 512;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate a new 32-byte session key
|
|
16
|
+
* @returns {Uint8Array} 32-byte random key
|
|
17
|
+
*/
|
|
18
|
+
export function generateSessionKey() {
|
|
19
|
+
return tweetnacl.randomBytes(32);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Encrypt data using TweetNaCl secretbox (XSalsa20-Poly1305)
|
|
24
|
+
* Compresses data with gzip before encryption if > threshold
|
|
25
|
+
* @param {any} data - Data to encrypt (will be JSON stringified)
|
|
26
|
+
* @param {Uint8Array} key - 32-byte encryption key
|
|
27
|
+
* @returns {Promise<{n: string, c: string, z?: boolean}>} Object with base64 nonce, ciphertext, and optional compressed flag
|
|
28
|
+
*/
|
|
29
|
+
export async function encrypt(data, key) {
|
|
30
|
+
const nonce = tweetnacl.randomBytes(24);
|
|
31
|
+
const jsonStr = JSON.stringify(data);
|
|
32
|
+
|
|
33
|
+
let message;
|
|
34
|
+
let compressed = false;
|
|
35
|
+
|
|
36
|
+
if (jsonStr.length > COMPRESS_THRESHOLD) {
|
|
37
|
+
// Compress before encryption
|
|
38
|
+
const compressedBuf = await gzip(Buffer.from(jsonStr, 'utf8'));
|
|
39
|
+
message = new Uint8Array(compressedBuf);
|
|
40
|
+
compressed = true;
|
|
41
|
+
} else {
|
|
42
|
+
message = decodeUTF8(jsonStr);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const encrypted = tweetnacl.secretbox(message, nonce, key);
|
|
46
|
+
const result = {
|
|
47
|
+
n: encodeBase64(nonce),
|
|
48
|
+
c: encodeBase64(encrypted)
|
|
49
|
+
};
|
|
50
|
+
if (compressed) result.z = true;
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Decrypt data using TweetNaCl secretbox
|
|
56
|
+
* Decompresses data with gunzip after decryption if compressed
|
|
57
|
+
* @param {{n: string, c: string, z?: boolean}} encrypted - Object with base64 nonce, ciphertext, and optional compressed flag
|
|
58
|
+
* @param {Uint8Array} key - 32-byte encryption key
|
|
59
|
+
* @returns {Promise<any|null>} Decrypted and parsed data, or null if decryption fails
|
|
60
|
+
*/
|
|
61
|
+
export async function decrypt(encrypted, key) {
|
|
62
|
+
try {
|
|
63
|
+
const nonce = decodeBase64(encrypted.n);
|
|
64
|
+
const ciphertext = decodeBase64(encrypted.c);
|
|
65
|
+
const decrypted = tweetnacl.secretbox.open(ciphertext, nonce, key);
|
|
66
|
+
if (!decrypted) return null;
|
|
67
|
+
|
|
68
|
+
if (encrypted.z) {
|
|
69
|
+
// Decompress after decryption
|
|
70
|
+
const decompressed = await gunzip(Buffer.from(decrypted));
|
|
71
|
+
return JSON.parse(decompressed.toString('utf8'));
|
|
72
|
+
} else {
|
|
73
|
+
return JSON.parse(encodeUTF8(decrypted));
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a message is encrypted (has n and c properties)
|
|
82
|
+
* @param {any} msg - Message to check
|
|
83
|
+
* @returns {boolean} True if message appears to be encrypted
|
|
84
|
+
*/
|
|
85
|
+
export function isEncrypted(msg) {
|
|
86
|
+
return msg && typeof msg === 'object' && typeof msg.n === 'string' && typeof msg.c === 'string';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Encode session key to base64 for transmission
|
|
91
|
+
* @param {Uint8Array} key - Session key
|
|
92
|
+
* @returns {string} Base64 encoded key
|
|
93
|
+
*/
|
|
94
|
+
export function encodeKey(key) {
|
|
95
|
+
return encodeBase64(key);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Decode session key from base64
|
|
100
|
+
* @param {string} encodedKey - Base64 encoded key
|
|
101
|
+
* @returns {Uint8Array} Session key
|
|
102
|
+
*/
|
|
103
|
+
export function decodeKey(encodedKey) {
|
|
104
|
+
return decodeBase64(encodedKey);
|
|
105
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { sessionDb, messageDb } from '../database.js';
|
|
2
|
+
import {
|
|
3
|
+
broadcastAgentList, notifyConversationUpdate, forwardToClients
|
|
4
|
+
} from '../ws-utils.js';
|
|
5
|
+
import { agents } from '../context.js';
|
|
6
|
+
import { CONFIG } from '../config.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Handle conversation lifecycle messages from agent.
|
|
10
|
+
* Types: conversation_list, conversation_created, conversation_resumed,
|
|
11
|
+
* session_id_update, turn_completed, conversation_closed,
|
|
12
|
+
* conversation_deleted, history_sessions_list, folders_list,
|
|
13
|
+
* conversation_refresh, conversation_settings_updated
|
|
14
|
+
*/
|
|
15
|
+
export async function handleAgentConversation(agentId, agent, msg) {
|
|
16
|
+
switch (msg.type) {
|
|
17
|
+
case 'conversation_list': {
|
|
18
|
+
// Agent 发送的 conversation 列表 - 合并而非覆盖,保留已有的 userId/username
|
|
19
|
+
const incomingIds = new Set(msg.conversations.map(c => c.id));
|
|
20
|
+
for (const [id, conv] of agent.conversations) {
|
|
21
|
+
// 保留从 DB 恢复的历史 conversations(agent 不会上报已完成的会话)
|
|
22
|
+
if (!incomingIds.has(id) && !conv.fromDb) {
|
|
23
|
+
agent.conversations.delete(id);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const conv of msg.conversations) {
|
|
27
|
+
// fix-session-dup: if the DB says this conv now belongs to a
|
|
28
|
+
// DIFFERENT agent (the user resumed it elsewhere), drop the
|
|
29
|
+
// agent's stale local copy. Without this guard, an agent
|
|
30
|
+
// restart can re-broadcast a transferred conv under its own
|
|
31
|
+
// banner, undoing the resume-time transfer and recreating
|
|
32
|
+
// the "same conv on two agents" condition.
|
|
33
|
+
const dbForConv = sessionDb.get(conv.id);
|
|
34
|
+
if (dbForConv && dbForConv.agent_id && dbForConv.agent_id !== agentId) {
|
|
35
|
+
if (CONFIG.debug) {
|
|
36
|
+
console.log(`[conversation_list] dropping conv ${conv.id} from agent ${agentId} — DB owner is ${dbForConv.agent_id}`);
|
|
37
|
+
}
|
|
38
|
+
agent.conversations.delete(conv.id);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const existing = agent.conversations.get(conv.id);
|
|
42
|
+
if (existing) {
|
|
43
|
+
// 原地更新属性而非替换对象,避免其他持有旧引用的代码失效
|
|
44
|
+
existing.workDir = conv.workDir || existing.workDir;
|
|
45
|
+
existing.claudeSessionId = conv.claudeSessionId || existing.claudeSessionId;
|
|
46
|
+
existing.createdAt = conv.createdAt || existing.createdAt;
|
|
47
|
+
if (conv.processing !== undefined) existing.processing = conv.processing;
|
|
48
|
+
// Agent 主动上报了这个 conversation,清除 DB 恢复标记
|
|
49
|
+
delete existing.fromDb;
|
|
50
|
+
if (conv.type) existing.type = conv.type;
|
|
51
|
+
// Security: 不信任 agent 上报的 userId/username,保留 server 端已有值
|
|
52
|
+
if (!existing.userId) {
|
|
53
|
+
const dbSession = sessionDb.get(conv.id);
|
|
54
|
+
existing.userId = dbSession?.user_id || agent.ownerId || null;
|
|
55
|
+
existing.username = dbSession?.username || agent.ownerUsername || null;
|
|
56
|
+
}
|
|
57
|
+
// fix-chat-title-sticky: hydrate the persisted title +
|
|
58
|
+
// sticky-bit on every list refresh. The agent doesn't track
|
|
59
|
+
// titles, so without this the in-memory `convInfo.customTitle`
|
|
60
|
+
// is stuck at `undefined` and the per-message auto-title write
|
|
61
|
+
// happily overwrites the user's renamed title.
|
|
62
|
+
if (existing.customTitle === undefined) {
|
|
63
|
+
const dbSession = sessionDb.get(conv.id);
|
|
64
|
+
if (dbSession) {
|
|
65
|
+
if (dbSession.title) existing.title = dbSession.title;
|
|
66
|
+
existing.customTitle = !!dbSession.customTitle;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
// 新 conversation — 从 DB 或 agent.ownerId 获取 userId,不信任 agent 上报
|
|
71
|
+
const dbSession = sessionDb.get(conv.id);
|
|
72
|
+
const trustedUserId = dbSession?.user_id || agent.ownerId || null;
|
|
73
|
+
const trustedUsername = dbSession?.username || agent.ownerUsername || null;
|
|
74
|
+
agent.conversations.set(conv.id, {
|
|
75
|
+
...conv,
|
|
76
|
+
userId: trustedUserId,
|
|
77
|
+
username: trustedUsername,
|
|
78
|
+
// Hydrate sticky title bits if the DB has them.
|
|
79
|
+
title: dbSession?.title || conv.title || null,
|
|
80
|
+
customTitle: !!dbSession?.customTitle,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
await broadcastAgentList();
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case 'conversation_created':
|
|
89
|
+
case 'conversation_resumed': {
|
|
90
|
+
// 清理同 claudeSessionId 的旧条目(避免重复恢复同一个 session 累积)
|
|
91
|
+
if (msg.type === 'conversation_resumed' && msg.claudeSessionId) {
|
|
92
|
+
for (const [id, conv] of agent.conversations) {
|
|
93
|
+
if (id !== msg.conversationId && conv.claudeSessionId === msg.claudeSessionId) {
|
|
94
|
+
agent.conversations.delete(id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// fix-session-dup: if this conv is currently held in another
|
|
100
|
+
// agent's in-memory Map (e.g. user resumed it against a
|
|
101
|
+
// different machine after the original agent went offline),
|
|
102
|
+
// remove the stale copy AND re-point the DB row at the new
|
|
103
|
+
// owner. Without this:
|
|
104
|
+
// 1) the next get_agents restore would re-seat the conv
|
|
105
|
+
// under the OLD agent's Map again from DB.agent_id,
|
|
106
|
+
// 2) the next broadcastAgentList would expose the conv
|
|
107
|
+
// via two different agent entries, producing the
|
|
108
|
+
// "one conversation, two sidebar rows with different
|
|
109
|
+
// badges" symptom this fix targets.
|
|
110
|
+
// We only run the transfer when the OWNER actually changes —
|
|
111
|
+
// resuming a conv on its own agent is a no-op for ownership.
|
|
112
|
+
for (const [otherAgentId, otherAgent] of agents) {
|
|
113
|
+
if (otherAgentId === agentId) continue;
|
|
114
|
+
if (otherAgent.conversations.has(msg.conversationId)) {
|
|
115
|
+
if (CONFIG.debug) {
|
|
116
|
+
console.log(`[conversation_resumed] transferring conv ${msg.conversationId} from agent ${otherAgentId} → ${agentId}`);
|
|
117
|
+
}
|
|
118
|
+
otherAgent.conversations.delete(msg.conversationId);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Security: 使用 server 端可信来源的 userId,不信任 agent 回传
|
|
123
|
+
const existingConvData = agent.conversations.get(msg.conversationId);
|
|
124
|
+
const dbSessionData = sessionDb.get(msg.conversationId);
|
|
125
|
+
const trustedUserId = existingConvData?.userId || dbSessionData?.user_id || agent.ownerId || msg.userId || null;
|
|
126
|
+
const trustedUsername = existingConvData?.username || dbSessionData?.username || agent.ownerUsername || msg.username || null;
|
|
127
|
+
// fix-copilot-provider-persist: the agent stamps `provider` on
|
|
128
|
+
// create/resume (claude-code / copilot / ...). Carry it on the
|
|
129
|
+
// in-memory conv AND persist it (below) so an agent process restart
|
|
130
|
+
// doesn't lose the binding — without this the conv reverts to the
|
|
131
|
+
// default provider and copilot sends mis-route to Claude.
|
|
132
|
+
//
|
|
133
|
+
// Defense-in-depth against CLOBBER: the agent's resume handler
|
|
134
|
+
// DEFAULTS an absent provider to 'claude-code', so a provider-less
|
|
135
|
+
// resume (web auto-restore / recovery) reports provider:'claude-code'
|
|
136
|
+
// even for a copilot conv. Treat 'claude-code' as "no explicit
|
|
137
|
+
// provider" here: never let it overwrite a stored non-default. The
|
|
138
|
+
// server resume forward also injects the persisted provider as a
|
|
139
|
+
// belt-and-braces fix, but this guard makes the persist layer
|
|
140
|
+
// independently safe.
|
|
141
|
+
const reportedProvider = (msg.provider && msg.provider !== 'claude-code') ? msg.provider : null;
|
|
142
|
+
const resolvedProvider = reportedProvider || existingConvData?.provider || dbSessionData?.provider || null;
|
|
143
|
+
|
|
144
|
+
agent.conversations.set(msg.conversationId, {
|
|
145
|
+
id: msg.conversationId,
|
|
146
|
+
workDir: msg.workDir,
|
|
147
|
+
claudeSessionId: msg.claudeSessionId,
|
|
148
|
+
userId: trustedUserId,
|
|
149
|
+
username: trustedUsername,
|
|
150
|
+
...(resolvedProvider ? { provider: resolvedProvider } : {}),
|
|
151
|
+
// fix-chat-title-sticky: hydrate the persisted title + sticky-bit
|
|
152
|
+
// from the DB on resume/recreate. Without this, a user-renamed
|
|
153
|
+
// session that's been resumed has `customTitle = undefined`,
|
|
154
|
+
// so the next user prompt overwrites the title.
|
|
155
|
+
//
|
|
156
|
+
// The OR-merge (`existing || db`) is deliberate: the in-memory
|
|
157
|
+
// value is fresher than the DB on the rare race where
|
|
158
|
+
// update_conversation_settings has just cleared the bit but the
|
|
159
|
+
// resume payload arrived from the agent before that write
|
|
160
|
+
// settled. Both sources move in lockstep on rename and clear,
|
|
161
|
+
// so they cannot disagree for long.
|
|
162
|
+
title: dbSessionData?.title || existingConvData?.title || null,
|
|
163
|
+
customTitle: !!(existingConvData?.customTitle || dbSessionData?.customTitle),
|
|
164
|
+
createdAt: Date.now(),
|
|
165
|
+
processing: false
|
|
166
|
+
});
|
|
167
|
+
try {
|
|
168
|
+
if (msg.type === 'conversation_created') {
|
|
169
|
+
if (!sessionDb.exists(msg.conversationId)) {
|
|
170
|
+
sessionDb.create(msg.conversationId, agentId, agent.name, msg.workDir, msg.claudeSessionId, null, trustedUserId, resolvedProvider);
|
|
171
|
+
} else if (resolvedProvider) {
|
|
172
|
+
sessionDb.setProvider(msg.conversationId, resolvedProvider);
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
if (sessionDb.exists(msg.conversationId)) {
|
|
176
|
+
sessionDb.update(msg.conversationId, { claudeSessionId: msg.claudeSessionId });
|
|
177
|
+
// fix-copilot-provider-persist: keep the persisted provider in
|
|
178
|
+
// sync on resume (e.g. first resume after this column shipped, or
|
|
179
|
+
// a provider change). Null msg.provider leaves it untouched.
|
|
180
|
+
if (resolvedProvider && dbSessionData?.provider !== resolvedProvider) {
|
|
181
|
+
sessionDb.setProvider(msg.conversationId, resolvedProvider);
|
|
182
|
+
}
|
|
183
|
+
// fix-session-dup: also re-point the persisted agent_id
|
|
184
|
+
// to the resuming agent when ownership actually changed.
|
|
185
|
+
// The DB row is the source of truth that the `get_agents`
|
|
186
|
+
// restore path consults, so failing to update it would
|
|
187
|
+
// re-summon the duplicate on the next reload.
|
|
188
|
+
if (dbSessionData && dbSessionData.agent_id !== agentId) {
|
|
189
|
+
if (CONFIG.debug) {
|
|
190
|
+
console.log(`[conversation_resumed] DB.agent_id ${dbSessionData.agent_id} → ${agentId} for ${msg.conversationId}`);
|
|
191
|
+
}
|
|
192
|
+
sessionDb.setAgent(msg.conversationId, agentId, agent.name);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
sessionDb.create(msg.conversationId, agentId, agent.name, msg.workDir, msg.claudeSessionId, null, trustedUserId, resolvedProvider);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
sessionDb.setActive(msg.conversationId, true);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
console.error('Failed to save session to database:', e.message);
|
|
201
|
+
}
|
|
202
|
+
// Security: 覆盖 msg 中的 userId 为可信值
|
|
203
|
+
msg.userId = trustedUserId;
|
|
204
|
+
msg.username = trustedUsername;
|
|
205
|
+
|
|
206
|
+
// Phase 6.1: 将 historyMessages 同步到 DB(支持增量 merge)
|
|
207
|
+
if (msg.type === 'conversation_resumed' && msg.historyMessages && msg.historyMessages.length > 0) {
|
|
208
|
+
try {
|
|
209
|
+
const insertedCount = messageDb.bulkAddHistory(msg.conversationId, msg.historyMessages);
|
|
210
|
+
if (insertedCount > 0) {
|
|
211
|
+
console.log(`[conversation_resumed] Synced ${insertedCount} new messages to DB for ${msg.conversationId}`);
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {
|
|
214
|
+
console.error('Failed to sync history to DB:', e.message);
|
|
215
|
+
}
|
|
216
|
+
// 从 DB 读取最后 5 turns 发给前端
|
|
217
|
+
const { messages: recentMessages, hasMore } = messageDb.getRecentTurns(msg.conversationId, 5);
|
|
218
|
+
delete msg.historyMessages;
|
|
219
|
+
msg.dbMessages = recentMessages;
|
|
220
|
+
msg.hasMoreMessages = hasMore;
|
|
221
|
+
}
|
|
222
|
+
msg.dbMessageCount = messageDb.getCount(msg.conversationId);
|
|
223
|
+
|
|
224
|
+
await notifyConversationUpdate(agentId, msg);
|
|
225
|
+
await broadcastAgentList();
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
case 'session_id_update':
|
|
230
|
+
if (msg.conversationId && msg.claudeSessionId) {
|
|
231
|
+
const existingConv = agent.conversations.get(msg.conversationId);
|
|
232
|
+
if (existingConv) {
|
|
233
|
+
existingConv.claudeSessionId = msg.claudeSessionId;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
sessionDb.update(msg.conversationId, { claudeSessionId: msg.claudeSessionId });
|
|
237
|
+
console.log(`[session_id_update] Updated claudeSessionId for ${msg.conversationId}: ${msg.claudeSessionId}`);
|
|
238
|
+
} catch (e) {
|
|
239
|
+
console.error('Failed to update claudeSessionId in database:', e.message);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
await broadcastAgentList();
|
|
243
|
+
break;
|
|
244
|
+
|
|
245
|
+
case 'turn_completed':
|
|
246
|
+
{
|
|
247
|
+
const turnConv = agent.conversations.get(msg.conversationId);
|
|
248
|
+
// Guard: 如果 processing 已为 false,说明是重复的 turn_completed,跳过
|
|
249
|
+
if (turnConv && !turnConv.processing) {
|
|
250
|
+
console.warn(`[turn_completed] Ignoring duplicate for ${msg.conversationId}`);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
if (turnConv) {
|
|
254
|
+
turnConv.processing = false;
|
|
255
|
+
if (msg.claudeSessionId) {
|
|
256
|
+
turnConv.claudeSessionId = msg.claudeSessionId;
|
|
257
|
+
}
|
|
258
|
+
if (msg.workDir) {
|
|
259
|
+
turnConv.workDir = msg.workDir;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
if (msg.claudeSessionId) {
|
|
264
|
+
sessionDb.update(msg.conversationId, { claudeSessionId: msg.claudeSessionId });
|
|
265
|
+
}
|
|
266
|
+
} catch (e) {
|
|
267
|
+
console.error('Failed to update session in database:', e.message);
|
|
268
|
+
}
|
|
269
|
+
await forwardToClients(agentId, msg.conversationId, {
|
|
270
|
+
type: 'turn_completed',
|
|
271
|
+
conversationId: msg.conversationId,
|
|
272
|
+
claudeSessionId: msg.claudeSessionId,
|
|
273
|
+
workDir: msg.workDir
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await broadcastAgentList();
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
|
|
280
|
+
case 'conversation_closed':
|
|
281
|
+
{
|
|
282
|
+
const closedConv = agent.conversations.get(msg.conversationId);
|
|
283
|
+
if (closedConv) {
|
|
284
|
+
closedConv.processing = false;
|
|
285
|
+
if (msg.claudeSessionId) {
|
|
286
|
+
closedConv.claudeSessionId = msg.claudeSessionId;
|
|
287
|
+
}
|
|
288
|
+
if (msg.workDir) {
|
|
289
|
+
closedConv.workDir = msg.workDir;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
sessionDb.setActive(msg.conversationId, false);
|
|
294
|
+
if (msg.claudeSessionId) {
|
|
295
|
+
sessionDb.update(msg.conversationId, { claudeSessionId: msg.claudeSessionId });
|
|
296
|
+
}
|
|
297
|
+
} catch (e) {
|
|
298
|
+
console.error('Failed to update session in database:', e.message);
|
|
299
|
+
}
|
|
300
|
+
await forwardToClients(agentId, msg.conversationId, {
|
|
301
|
+
type: 'conversation_closed',
|
|
302
|
+
conversationId: msg.conversationId,
|
|
303
|
+
claudeSessionId: msg.claudeSessionId,
|
|
304
|
+
workDir: msg.workDir
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// ★ Remove from agent's in-memory conversations after marking inactive in DB.
|
|
308
|
+
// This prevents closed sessions from accumulating in agent_list broadcasts
|
|
309
|
+
// and reappearing in the sidebar. Session can be restored from DB if needed.
|
|
310
|
+
agent.conversations.delete(msg.conversationId);
|
|
311
|
+
|
|
312
|
+
await broadcastAgentList();
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
|
|
316
|
+
case 'conversation_deleted':
|
|
317
|
+
agent.conversations.delete(msg.conversationId);
|
|
318
|
+
try {
|
|
319
|
+
sessionDb.setActive(msg.conversationId, false);
|
|
320
|
+
} catch (e) {
|
|
321
|
+
console.error('Failed to update session in database:', e.message);
|
|
322
|
+
}
|
|
323
|
+
await notifyConversationUpdate(agentId, msg);
|
|
324
|
+
await broadcastAgentList();
|
|
325
|
+
break;
|
|
326
|
+
|
|
327
|
+
case 'history_sessions_list':
|
|
328
|
+
case 'folders_list':
|
|
329
|
+
case 'models_list':
|
|
330
|
+
console.log(`[${msg.type}] Received from agent ${agentId}, forwarding to clients...`);
|
|
331
|
+
console.log(`[${msg.type}] count: folders=${msg.folders?.length || 0} models=${msg.models?.length || 0} sessions=${msg.sessions?.length || 0}`);
|
|
332
|
+
await notifyConversationUpdate(agentId, msg);
|
|
333
|
+
break;
|
|
334
|
+
|
|
335
|
+
case 'conversation_refresh':
|
|
336
|
+
await notifyConversationUpdate(agentId, msg);
|
|
337
|
+
break;
|
|
338
|
+
|
|
339
|
+
case 'conversation_settings_updated':
|
|
340
|
+
await forwardToClients(agentId, msg.conversationId, msg);
|
|
341
|
+
break;
|
|
342
|
+
|
|
343
|
+
default:
|
|
344
|
+
return false; // Not handled
|
|
345
|
+
}
|
|
346
|
+
return true; // Handled
|
|
347
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { previewFiles, webClients } from '../context.js';
|
|
3
|
+
import {
|
|
4
|
+
sendToWebClient, forwardToClients,
|
|
5
|
+
setCachedDir, invalidateParentDirCache, clearAgentDirCache
|
|
6
|
+
} from '../ws-utils.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Handle file, terminal, and git messages from agent.
|
|
10
|
+
* Types: terminal_created, terminal_output, terminal_closed, terminal_error,
|
|
11
|
+
* file_content, file_saved, directory_listing, file_op_result,
|
|
12
|
+
* git_status_result, git_diff_result, git_op_result, file_search_result
|
|
13
|
+
*/
|
|
14
|
+
export async function handleAgentFileTerminal(agentId, agent, msg) {
|
|
15
|
+
switch (msg.type) {
|
|
16
|
+
// Terminal messages (forward to web clients)
|
|
17
|
+
case 'terminal_created':
|
|
18
|
+
case 'terminal_output':
|
|
19
|
+
case 'terminal_closed':
|
|
20
|
+
case 'terminal_error':
|
|
21
|
+
await forwardToClients(agentId, msg.conversationId, msg);
|
|
22
|
+
break;
|
|
23
|
+
|
|
24
|
+
// File operation messages
|
|
25
|
+
case 'file_content':
|
|
26
|
+
if (msg.binary) {
|
|
27
|
+
// Binary file: cache on server, forward fileId instead of base64 content
|
|
28
|
+
const fileId = randomUUID();
|
|
29
|
+
const token = randomUUID();
|
|
30
|
+
const filename = msg.filePath.split('/').pop() || 'file';
|
|
31
|
+
previewFiles.set(fileId, {
|
|
32
|
+
buffer: Buffer.from(msg.content, 'base64'),
|
|
33
|
+
mimeType: msg.mimeType,
|
|
34
|
+
filename,
|
|
35
|
+
createdAt: Date.now(),
|
|
36
|
+
token
|
|
37
|
+
});
|
|
38
|
+
console.log(`[Server] Cached binary preview: fileId=${fileId}, mime=${msg.mimeType}, path=${msg.filePath}`);
|
|
39
|
+
const fwdMsg = {
|
|
40
|
+
type: 'file_content',
|
|
41
|
+
conversationId: msg.conversationId,
|
|
42
|
+
_requestUserId: msg._requestUserId,
|
|
43
|
+
filePath: msg.filePath,
|
|
44
|
+
binary: true,
|
|
45
|
+
fileId,
|
|
46
|
+
previewToken: token,
|
|
47
|
+
mimeType: msg.mimeType
|
|
48
|
+
};
|
|
49
|
+
await forwardToClients(agentId, msg.conversationId, fwdMsg);
|
|
50
|
+
} else {
|
|
51
|
+
console.log(`[Server] Forwarding file_content to clients, conv=${msg.conversationId}, path=${msg.filePath}`);
|
|
52
|
+
await forwardToClients(agentId, msg.conversationId, msg);
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
|
|
56
|
+
case 'file_saved': {
|
|
57
|
+
// Phase 4: 文件保存后失效父目录缓存
|
|
58
|
+
invalidateParentDirCache(agentId, msg.filePath);
|
|
59
|
+
await forwardToClients(agentId, msg.conversationId, msg);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case 'directory_listing': {
|
|
64
|
+
// Phase 4: 缓存目录列表结果
|
|
65
|
+
if (msg.dirPath && msg.entries && !msg.error) {
|
|
66
|
+
setCachedDir(agentId, msg.dirPath, msg.entries);
|
|
67
|
+
}
|
|
68
|
+
// 优先定向发送给请求者
|
|
69
|
+
const dirTargetClientId = msg._requestClientId;
|
|
70
|
+
if (dirTargetClientId) {
|
|
71
|
+
const targetClient = webClients.get(dirTargetClientId);
|
|
72
|
+
if (targetClient?.authenticated) {
|
|
73
|
+
const { _requestClientId, ...cleanMsg } = msg;
|
|
74
|
+
await sendToWebClient(targetClient, cleanMsg);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
await forwardToClients(agentId, msg.conversationId, msg);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'file_op_result':
|
|
83
|
+
// Phase 4: 文件创建/删除/移动 — 清空该 agent 的所有目录缓存
|
|
84
|
+
clearAgentDirCache(agentId);
|
|
85
|
+
await forwardToClients(agentId, msg.conversationId, msg);
|
|
86
|
+
break;
|
|
87
|
+
|
|
88
|
+
case 'git_status_result':
|
|
89
|
+
case 'git_diff_result':
|
|
90
|
+
case 'git_op_result':
|
|
91
|
+
case 'file_search_result':
|
|
92
|
+
await forwardToClients(agentId, msg.conversationId, msg);
|
|
93
|
+
break;
|
|
94
|
+
|
|
95
|
+
default:
|
|
96
|
+
return false; // Not handled
|
|
97
|
+
}
|
|
98
|
+
return true; // Handled
|
|
99
|
+
}
|