@yeaft/webchat-agent 0.1.857 → 0.1.860

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/conversation.js CHANGED
@@ -6,6 +6,7 @@ import { query } from './sdk/index.js';
6
6
  import { loadSessionHistory } from './history.js';
7
7
  import { startClaudeQuery } from './claude.js';
8
8
  import { crewSessions, loadCrewIndex } from './crew.js';
9
+ import { getProvider, DEFAULT_PROVIDER, isValidProvider } from './providers/index.js';
9
10
 
10
11
  // 不支持的斜杠命令(真正需要交互式 CLI 的命令)
11
12
  const UNSUPPORTED_SLASH_COMMANDS = ['/help', '/bug', '/login', '/logout', '/terminal-setup', '/vim', '/config'];
@@ -293,7 +294,8 @@ export async function sendConversationList() {
293
294
  createdAt: state.createdAt,
294
295
  processing: !!state.turnActive,
295
296
  userId: state.userId,
296
- username: state.username
297
+ username: state.username,
298
+ provider: state.providerName || DEFAULT_PROVIDER
297
299
  };
298
300
  list.push(entry);
299
301
  }
@@ -356,33 +358,42 @@ export function sendError(conversationId, message) {
356
358
  export async function createConversation(msg) {
357
359
  const { conversationId, workDir, userId, username, disallowedTools } = msg;
358
360
  const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
361
+ const provider = isValidProvider(msg.provider) ? msg.provider : DEFAULT_PROVIDER;
359
362
 
360
- console.log(`Creating conversation: ${conversationId} in ${effectiveWorkDir} (lazy start)`);
363
+ console.log(`Creating conversation: ${conversationId} in ${effectiveWorkDir} (lazy start, provider=${provider})`);
361
364
  if (username) console.log(` User: ${username} (${userId})`);
362
365
 
363
- // 只创建 conversation 状态,不启动 Claude 进程
364
- // Claude 进程会在用户发送第一条消息时启动 (见 handleUserInput)
365
- ctx.conversations.set(conversationId, {
366
- query: null,
367
- inputStream: null,
368
- workDir: effectiveWorkDir,
369
- claudeSessionId: null,
370
- createdAt: Date.now(),
371
- abortController: null,
372
- tools: [],
373
- slashCommands: [],
374
- model: null,
375
- userId,
376
- username,
377
- disallowedTools: disallowedTools || null, // null = 使用全局默认
378
- usage: {
379
- inputTokens: 0,
380
- outputTokens: 0,
381
- cacheRead: 0,
382
- cacheCreation: 0,
383
- totalCostUsd: 0
384
- }
385
- });
366
+ if (provider === 'claude-code') {
367
+ // Claude path: lazy-init state, real CLI boots on first message.
368
+ ctx.conversations.set(conversationId, {
369
+ query: null,
370
+ inputStream: null,
371
+ workDir: effectiveWorkDir,
372
+ claudeSessionId: null,
373
+ createdAt: Date.now(),
374
+ abortController: null,
375
+ tools: [],
376
+ slashCommands: [],
377
+ model: null,
378
+ userId,
379
+ username,
380
+ providerName: provider,
381
+ disallowedTools: disallowedTools || null,
382
+ usage: { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalCostUsd: 0 }
383
+ });
384
+ } else {
385
+ // Non-Claude providers own their own state construction.
386
+ const driver = getProvider(provider);
387
+ const state = await driver.start({
388
+ conversationId,
389
+ workDir: effectiveWorkDir,
390
+ resumeSessionId: null,
391
+ userId,
392
+ username,
393
+ providerOptions: msg.providerOptions || {},
394
+ });
395
+ state.disallowedTools = disallowedTools || null;
396
+ }
386
397
 
387
398
  ctx.sendToServer({
388
399
  type: 'conversation_created',
@@ -390,6 +401,7 @@ export async function createConversation(msg) {
390
401
  workDir: effectiveWorkDir,
391
402
  userId,
392
403
  username,
404
+ provider,
393
405
  disallowedTools: disallowedTools || null
394
406
  });
395
407
 
@@ -414,22 +426,27 @@ export async function createConversation(msg) {
414
426
 
415
427
  // ★ Prestart Claude CLI in background to eagerly fetch skills/tools/model
416
428
  // Fire-and-forget: failure just degrades to lazy-start behavior
417
- prestartClaude(conversationId, effectiveWorkDir, null);
429
+ if (provider === 'claude-code') {
430
+ prestartClaude(conversationId, effectiveWorkDir, null);
431
+ }
418
432
  }
419
433
 
420
434
  // Resume 历史 conversation (延迟启动 Claude,等待用户发送第一条消息)
421
435
  export async function resumeConversation(msg) {
422
436
  const { conversationId, claudeSessionId, workDir, userId, username, disallowedTools } = msg;
423
437
  const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
438
+ const provider = isValidProvider(msg.provider) ? msg.provider : DEFAULT_PROVIDER;
424
439
 
425
440
  console.log(`[Resume] conversationId: ${conversationId}`);
426
441
  console.log(`[Resume] claudeSessionId: ${claudeSessionId}`);
427
442
  console.log(`[Resume] workDir: ${effectiveWorkDir} (lazy start)`);
428
443
 
429
444
  // 清理旧条目:同 conversationId 或同 claudeSessionId 的条目(避免重复恢复同一个 session 累积)
445
+ let priorProviderOptions = null;
430
446
  for (const [id, conv] of ctx.conversations) {
431
447
  if (id === conversationId || (claudeSessionId && conv.claudeSessionId === claudeSessionId)) {
432
448
  console.log(`[Resume] Cleaning up old conversation: ${id} (claudeSessionId: ${conv.claudeSessionId})`);
449
+ if (conv.providerOptions && !priorProviderOptions) priorProviderOptions = conv.providerOptions;
433
450
  if (conv.abortController) {
434
451
  conv.abortController.abort();
435
452
  }
@@ -440,7 +457,15 @@ export async function resumeConversation(msg) {
440
457
  }
441
458
  }
442
459
 
443
- const historyMessages = loadSessionHistory(effectiveWorkDir, claudeSessionId);
460
+ let driverForHistory;
461
+ try { driverForHistory = getProvider(provider); }
462
+ catch (err) {
463
+ console.warn(`[Resume] unknown provider "${provider}", falling back to claude-code history loader:`, err?.message || err);
464
+ driverForHistory = {};
465
+ }
466
+ const historyMessages = typeof driverForHistory.loadHistory === 'function'
467
+ ? await driverForHistory.loadHistory(effectiveWorkDir, claudeSessionId)
468
+ : loadSessionHistory(effectiveWorkDir, claudeSessionId);
444
469
  if (username) console.log(`[Resume] User: ${username} (${userId})`);
445
470
  console.log(`Loaded ${historyMessages.length} history messages`);
446
471
 
@@ -458,6 +483,7 @@ export async function resumeConversation(msg) {
458
483
  model: null,
459
484
  userId,
460
485
  username,
486
+ providerName: provider,
461
487
  disallowedTools: disallowedTools || null, // null = 使用全局默认
462
488
  usage: {
463
489
  inputTokens: 0,
@@ -468,6 +494,20 @@ export async function resumeConversation(msg) {
468
494
  }
469
495
  });
470
496
 
497
+ // Non-Claude providers: re-init state via driver so sessionId/providerName are set correctly.
498
+ if (provider !== 'claude-code') {
499
+ const driver = getProvider(provider);
500
+ const state = await driver.start({
501
+ conversationId,
502
+ workDir: effectiveWorkDir,
503
+ resumeSessionId: claudeSessionId || null,
504
+ userId,
505
+ username,
506
+ providerOptions: msg.providerOptions || priorProviderOptions || {},
507
+ });
508
+ state.disallowedTools = disallowedTools || null;
509
+ }
510
+
471
511
  ctx.sendToServer({
472
512
  type: 'conversation_resumed',
473
513
  conversationId,
@@ -475,7 +515,8 @@ export async function resumeConversation(msg) {
475
515
  workDir: effectiveWorkDir,
476
516
  historyMessages,
477
517
  userId,
478
- username
518
+ username,
519
+ provider
479
520
  });
480
521
 
481
522
  // 立即发送 agent 级别的 MCP servers 列表
@@ -498,7 +539,7 @@ export async function resumeConversation(msg) {
498
539
  // ★ Prestart Claude CLI in background to eagerly fetch skills/tools/model
499
540
  // Skip if conversation already has an active query (shouldn't happen, but safety check)
500
541
  const resumeState = ctx.conversations.get(conversationId);
501
- if (!resumeState?.query) {
542
+ if (provider === 'claude-code' && !resumeState?.query) {
502
543
  prestartClaude(conversationId, effectiveWorkDir, claudeSessionId);
503
544
  }
504
545
  }
@@ -627,9 +668,15 @@ export async function handleCancelExecution(msg) {
627
668
  // 标记为取消状态,防止 processClaudeOutput 的 finally 发送 conversation_closed
628
669
  state.cancelled = true;
629
670
 
630
- // 中止当前查询
631
- if (state.abortController) {
632
- state.abortController.abort();
671
+ // 通过 driver 中止当前查询(Claude 走 abortController;Copilot 走 SIGTERM)
672
+ try {
673
+ const driver = getProvider(state.providerName || DEFAULT_PROVIDER);
674
+ if (typeof driver.abort === 'function') driver.abort(state);
675
+ } catch (err) {
676
+ console.warn(`[${conversationId}] driver.abort failed:`, err?.message || err);
677
+ if (state.abortController) {
678
+ try { state.abortController.abort(); } catch { /* noop */ }
679
+ }
633
680
  }
634
681
 
635
682
  // 关闭输入流
@@ -704,6 +751,41 @@ export async function handleUserInput(msg) {
704
751
 
705
752
  let state = ctx.conversations.get(conversationId);
706
753
 
754
+ // ★ Non-Claude providers: dispatch to driver and return
755
+ const providerName = state?.providerName || DEFAULT_PROVIDER;
756
+ if (providerName !== 'claude-code') {
757
+ const driver = getProvider(providerName);
758
+ const effectiveWorkDir = workDir || state?.workDir || ctx.CONFIG.workDir;
759
+ if (!state) {
760
+ state = await driver.start({
761
+ conversationId,
762
+ workDir: effectiveWorkDir,
763
+ resumeSessionId: claudeSessionId || null,
764
+ userId: msg.userId,
765
+ username: msg.username,
766
+ });
767
+ }
768
+ if (workDir) state.workDir = workDir;
769
+ sendOutput(conversationId, { type: 'user', message: { role: 'user', content: prompt } });
770
+ state.turnActive = true;
771
+ sendConversationList();
772
+ try {
773
+ await driver.sendInput(state, prompt, { conversationId, raw: msg });
774
+ } catch (err) {
775
+ sendOutput(conversationId, {
776
+ type: 'result',
777
+ subtype: 'error',
778
+ session_id: state.sessionId || null,
779
+ is_error: true,
780
+ error: `${providerName} error: ${err?.message || err}`,
781
+ });
782
+ } finally {
783
+ state.turnActive = false;
784
+ sendConversationList();
785
+ }
786
+ return;
787
+ }
788
+
707
789
  // 如果没有活跃的查询,启动新的
708
790
  if (!state || !state.query || !state.inputStream) {
709
791
  const resumeSessionId = claudeSessionId || state?.claudeSessionId || null;
package/history.js CHANGED
@@ -2,6 +2,7 @@ import { homedir } from 'os';
2
2
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import ctx from './context.js';
5
+ import { getProvider, DEFAULT_PROVIDER } from './providers/index.js';
5
6
 
6
7
  // Claude 项目目录
7
8
  export function getClaudeProjectsDir() {
@@ -184,18 +185,23 @@ export function loadSessionHistory(workDir, claudeSessionId, limit = 500) {
184
185
  }
185
186
 
186
187
  export async function handleListHistorySessions(msg) {
187
- const { workDir, requestId, _requestClientId } = msg;
188
+ const { workDir, requestId, _requestClientId, provider } = msg;
188
189
  const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
190
+ const providerName = provider || DEFAULT_PROVIDER;
189
191
 
190
- console.log(`Listing history sessions for: ${effectiveWorkDir}`);
192
+ console.log(`Listing history sessions for: ${effectiveWorkDir} (provider=${providerName})`);
191
193
 
192
194
  try {
193
- const sessions = await getHistorySessions(effectiveWorkDir);
195
+ const driver = getProvider(providerName);
196
+ const sessions = typeof driver.listSessions === 'function'
197
+ ? await driver.listSessions(effectiveWorkDir)
198
+ : await getHistorySessions(effectiveWorkDir);
194
199
  ctx.sendToServer({
195
200
  type: 'history_sessions_list',
196
201
  requestId,
197
202
  _requestClientId,
198
203
  workDir: effectiveWorkDir,
204
+ provider: providerName,
199
205
  sessions
200
206
  });
201
207
  } catch (e) {
@@ -205,85 +211,42 @@ export async function handleListHistorySessions(msg) {
205
211
  requestId,
206
212
  _requestClientId,
207
213
  workDir: effectiveWorkDir,
214
+ provider: providerName,
208
215
  sessions: [],
209
216
  error: e.message
210
217
  });
211
218
  }
212
219
  }
213
220
 
214
- // 列出 Claude projects 目录下的所有 folder (工作目录)
221
+ // 列出指定 provider 下所有 folder (工作目录)
215
222
  export async function handleListFolders(msg) {
216
- const { requestId, _requestClientId } = msg;
217
- const projectsDir = getClaudeProjectsDir();
223
+ const { requestId, _requestClientId, provider } = msg;
224
+ const providerName = provider || DEFAULT_PROVIDER;
218
225
 
219
- console.log(`Listing folders from: ${projectsDir}`);
226
+ console.log(`Listing folders for provider=${providerName}`);
220
227
 
221
228
  try {
222
- const folders = [];
223
-
224
- if (existsSync(projectsDir)) {
225
- const entries = readdirSync(projectsDir);
226
-
227
- for (const entry of entries) {
228
- const entryPath = join(projectsDir, entry);
229
- const stats = statSync(entryPath);
230
-
231
- if (stats.isDirectory()) {
232
- // 过滤掉 crew 角色的 session 文件夹
233
- // crew 角色的 cwd 在 .crew/roles/{roleName} 下,对应的文件夹名包含 --crew-roles-
234
- if (entry.includes('--crew-roles-')) {
235
- continue;
236
- }
237
-
238
- // 从 session 文件读取真实的工作目录路径
239
- const originalPath = getWorkDirFromProjectFolder(entryPath, entry);
240
-
241
- // 快速计数:只数 .jsonl 文件数量,不读取文件内容
242
- let sessionCount = 0;
243
- let lastModified = stats.mtime.getTime();
244
-
245
- try {
246
- const files = readdirSync(entryPath);
247
-
248
- for (const file of files) {
249
- if (file.endsWith('.jsonl')) {
250
- sessionCount++;
251
- try {
252
- const fileStats = statSync(join(entryPath, file));
253
- if (fileStats.mtime.getTime() > lastModified) {
254
- lastModified = fileStats.mtime.getTime();
255
- }
256
- } catch {}
257
- }
258
- }
259
- } catch {}
260
-
261
- folders.push({
262
- name: entry,
263
- path: originalPath,
264
- sessionCount,
265
- lastModified
266
- });
267
- }
268
- }
229
+ const driver = getProvider(providerName);
230
+ let folders = [];
231
+ if (typeof driver.listFolders === 'function') {
232
+ folders = await driver.listFolders();
269
233
  }
270
-
271
- folders.sort((a, b) => b.lastModified - a.lastModified);
272
-
273
- console.log(`Found ${folders.length} folders, sending response...`);
234
+ folders.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
235
+ console.log(`Found ${folders.length} folders (provider=${providerName}), sending response...`);
274
236
  ctx.sendToServer({
275
237
  type: 'folders_list',
276
238
  requestId,
277
239
  _requestClientId,
240
+ provider: providerName,
278
241
  folders
279
242
  });
280
- console.log(`folders_list sent with ${folders.length} folders`);
281
243
  } catch (e) {
282
244
  console.error('Error listing folders:', e);
283
245
  ctx.sendToServer({
284
246
  type: 'folders_list',
285
247
  requestId,
286
248
  _requestClientId,
249
+ provider: providerName,
287
250
  folders: [],
288
251
  error: e.message
289
252
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.857",
3
+ "version": "0.1.860",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Chat provider abstraction.
3
+ *
4
+ * Wire-protocol note: `claude_output` is now a PROTOCOL name, not a
5
+ * vendor name. Every driver MUST emit events on
6
+ * `ctx.sendToServer({ type: 'claude_output', conversationId, data })`,
7
+ * where `data` follows the Claude stream-json envelope:
8
+ * - { type: 'assistant', message: { role, content: [...] } }
9
+ * - { type: 'user', message: { role, content: [...] } }
10
+ * - { type: 'result', subtype, session_id, is_error, ... }
11
+ * - { type: 'system', subtype, ... }
12
+ *
13
+ * Non-Claude drivers (e.g. Copilot) MUST translate their native event
14
+ * streams into this envelope so the existing renderer needs no changes.
15
+ *
16
+ * @typedef {Object} ChatProvider
17
+ * @property {string} name
18
+ * @property {(opts: StartOpts) => Promise<Object>} start
19
+ * @property {(state: Object, prompt: string, opts?: Object) => Promise<void>} sendInput
20
+ * @property {(state: Object) => void} abort
21
+ * @property {() => Promise<FolderInfo[]>} listFolders
22
+ * Return the list of work-directories that this provider has sessions for.
23
+ * @property {(workDir: string) => Promise<SessionInfo[]>} listSessions
24
+ * Return resumable sessions for a given work-directory.
25
+ * @property {(workDir: string, sessionId: string, limit?: number) => Promise<HistoryMessage[]>} loadHistory
26
+ * Return the resumable transcript as an array of `claude_output`-compatible
27
+ * envelopes (the same shape the live stream would have produced).
28
+ *
29
+ * @typedef {Object} StartOpts
30
+ * @property {string} conversationId
31
+ * @property {string} workDir
32
+ * @property {string|null} [resumeSessionId]
33
+ * @property {string} [userId]
34
+ * @property {string} [username]
35
+ * @property {Object} [providerOptions] per-provider knobs (model, allowAllTools, ...)
36
+ *
37
+ * @typedef {Object} FolderInfo
38
+ * @property {string} name opaque folder identifier (provider-specific)
39
+ * @property {string} path original cwd path
40
+ * @property {number} sessionCount
41
+ * @property {number} lastModified epoch ms
42
+ *
43
+ * @typedef {Object} SessionInfo
44
+ * @property {string} sessionId
45
+ * @property {string} workDir
46
+ * @property {string} title
47
+ * @property {string} [preview]
48
+ * @property {number} lastModified
49
+ * @property {number} [size]
50
+ *
51
+ * @typedef {Object} HistoryMessage a single claude_output `data` envelope
52
+ */
53
+
54
+ export const PROVIDER_NAMES = Object.freeze(['claude-code', 'copilot']);
55
+ export const DEFAULT_PROVIDER = 'claude-code';
56
+ export function isValidProvider(name) {
57
+ return typeof name === 'string' && PROVIDER_NAMES.includes(name);
58
+ }
@@ -0,0 +1,84 @@
1
+ import { existsSync, readdirSync, statSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { startClaudeQuery } from '../claude.js';
4
+ import {
5
+ getClaudeProjectsDir,
6
+ getHistorySessions,
7
+ loadSessionHistory,
8
+ getWorkDirFromProjectFolder,
9
+ } from '../history.js';
10
+
11
+ export const name = 'claude-code';
12
+
13
+ /**
14
+ * Start (or resume) a Claude Code CLI session.
15
+ * Returns the same state object that startClaudeQuery stores in ctx.conversations.
16
+ */
17
+ export async function start(opts) {
18
+ const state = await startClaudeQuery(
19
+ opts.conversationId,
20
+ opts.workDir,
21
+ opts.resumeSessionId || null
22
+ );
23
+ state.providerName = name;
24
+ return state;
25
+ }
26
+
27
+ /**
28
+ * Claude CLI handles input via the persistent stdin Stream that
29
+ * conversation.js manages directly, so this driver's sendInput is a no-op.
30
+ * conversation.js's existing branch keeps owning the Claude path.
31
+ */
32
+ export async function sendInput(_state, _prompt, _opts) {
33
+ /* handled inline by conversation.js for the Claude branch */
34
+ }
35
+
36
+ export function abort(state) {
37
+ if (state?.abortController) {
38
+ try { state.abortController.abort(); } catch { /* noop */ }
39
+ }
40
+ }
41
+
42
+ // ---------- history surface ----------
43
+
44
+ export async function listFolders() {
45
+ const projectsDir = getClaudeProjectsDir();
46
+ const folders = [];
47
+ if (!existsSync(projectsDir)) return folders;
48
+
49
+ for (const entry of readdirSync(projectsDir)) {
50
+ const entryPath = join(projectsDir, entry);
51
+ let stats;
52
+ try { stats = statSync(entryPath); } catch { continue; }
53
+ if (!stats.isDirectory()) continue;
54
+ if (entry.includes('--crew-roles-')) continue;
55
+
56
+ const originalPath = getWorkDirFromProjectFolder(entryPath, entry);
57
+ let sessionCount = 0;
58
+ let lastModified = stats.mtime.getTime();
59
+ try {
60
+ for (const file of readdirSync(entryPath)) {
61
+ if (!file.endsWith('.jsonl')) continue;
62
+ sessionCount++;
63
+ try {
64
+ const fs = statSync(join(entryPath, file));
65
+ if (fs.mtime.getTime() > lastModified) lastModified = fs.mtime.getTime();
66
+ } catch { /* noop */ }
67
+ }
68
+ } catch { /* noop */ }
69
+
70
+ folders.push({ name: entry, path: originalPath, sessionCount, lastModified });
71
+ }
72
+ folders.sort((a, b) => b.lastModified - a.lastModified);
73
+ return folders;
74
+ }
75
+
76
+ export async function listSessions(workDir) {
77
+ return await getHistorySessions(workDir);
78
+ }
79
+
80
+ export async function loadHistory(workDir, sessionId, limit = 500) {
81
+ return loadSessionHistory(workDir, sessionId, limit);
82
+ }
83
+
84
+ export default { name, start, sendInput, abort, listFolders, listSessions, loadHistory };