@yeaft/webchat-agent 0.1.417 → 0.1.419

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 { handleUnifyInput } from './unify/web-bridge.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
+ mode: state.mode || null
297
299
  };
298
300
  list.push(entry);
299
301
  }
@@ -354,7 +356,7 @@ export function sendError(conversationId, message) {
354
356
 
355
357
  // 创建新的 conversation (延迟启动 Claude,等待用户发送第一条消息)
356
358
  export async function createConversation(msg) {
357
- const { conversationId, workDir, userId, username, disallowedTools } = msg;
359
+ const { conversationId, workDir, userId, username, disallowedTools, mode } = msg;
358
360
  const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
359
361
 
360
362
  console.log(`Creating conversation: ${conversationId} in ${effectiveWorkDir} (lazy start)`);
@@ -375,6 +377,7 @@ export async function createConversation(msg) {
375
377
  userId,
376
378
  username,
377
379
  disallowedTools: disallowedTools || null, // null = 使用全局默认
380
+ mode: mode || 'claude', // 'claude' (default) or 'unify'
378
381
  usage: {
379
382
  inputTokens: 0,
380
383
  outputTokens: 0,
@@ -390,7 +393,8 @@ export async function createConversation(msg) {
390
393
  workDir: effectiveWorkDir,
391
394
  userId,
392
395
  username,
393
- disallowedTools: disallowedTools || null
396
+ disallowedTools: disallowedTools || null,
397
+ mode: mode || 'claude'
394
398
  });
395
399
 
396
400
  // 立即发送 agent 级别的 MCP servers 列表(从 ~/.claude.json 读取的)
@@ -413,8 +417,11 @@ export async function createConversation(msg) {
413
417
  sendConversationList();
414
418
 
415
419
  // ★ Prestart Claude CLI in background to eagerly fetch skills/tools/model
416
- // Fire-and-forget: failure just degrades to lazy-start behavior
417
- prestartClaude(conversationId, effectiveWorkDir, null);
420
+ // Skip prestart for unify mode — it doesn't use Claude CLI
421
+ if (mode !== 'unify') {
422
+ // Fire-and-forget: failure just degrades to lazy-start behavior
423
+ prestartClaude(conversationId, effectiveWorkDir, null);
424
+ }
418
425
  }
419
426
 
420
427
  // Resume 历史 conversation (延迟启动 Claude,等待用户发送第一条消息)
@@ -662,6 +669,12 @@ export async function handleCancelExecution(msg) {
662
669
  export async function handleUserInput(msg) {
663
670
  const { conversationId, prompt, workDir, claudeSessionId } = msg;
664
671
 
672
+ // ★ Unify mode: route to Unify Engine instead of Claude CLI
673
+ const state = ctx.conversations.get(conversationId);
674
+ if (state?.mode === 'unify') {
675
+ return handleUnifyInput(msg);
676
+ }
677
+
665
678
  // 解析斜杠命令
666
679
  const slashCommand = parseSlashCommand(prompt);
667
680
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.417",
3
+ "version": "0.1.419",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -12,6 +12,12 @@
12
12
  * Response: delta.tool_calls[i].function.arguments (JSON string) → accumulate → JSON.parse → UnifiedToolCall
13
13
  * Result: UnifiedToolResult → { role: "tool", tool_call_id, content }
14
14
  * Finish: "tool_calls" → "tool_use", "stop" → "end_turn", "length" → "max_tokens"
15
+ *
16
+ * max_tokens strategy (based on model ID):
17
+ * OpenAI models (gpt-*, o1*, o3*, o4*) use "max_completion_tokens" (new standard).
18
+ * All other models (DeepSeek, Gemini, etc.) use "max_tokens" (legacy/compat).
19
+ * CopilotProxy transparently forwards whatever the client sends.
20
+ * Callers can override via extraBody to pass any parameter directly.
15
21
  */
16
22
 
17
23
  import {
@@ -23,6 +29,26 @@ import {
23
29
  LLMAbortError,
24
30
  } from './adapter.js';
25
31
 
32
+ /**
33
+ * Check if a model ID is an OpenAI model that supports max_completion_tokens.
34
+ * OpenAI introduced max_completion_tokens with o1 and made it standard for
35
+ * GPT-4.1+, o-series, and GPT-5+. Other OpenAI-compatible APIs (DeepSeek,
36
+ * Gemini, Ollama) still only understand max_tokens.
37
+ *
38
+ * @param {string} model — The model ID (e.g. "gpt-5", "deepseek-chat", "o3")
39
+ * @returns {boolean} true = use max_completion_tokens
40
+ */
41
+ export function useNewMaxTokensParam(model) {
42
+ if (!model) return false;
43
+ const m = model.toLowerCase();
44
+ // GPT-4.1+ and GPT-5+
45
+ if (m.startsWith('gpt-')) return true;
46
+ // o-series reasoning models (o1, o3, o4-mini, etc.)
47
+ if (/^o\d/.test(m)) return true;
48
+ // Everything else (deepseek-*, gemini-*, claude-*, custom models): legacy
49
+ return false;
50
+ }
51
+
26
52
  /**
27
53
  * ChatCompletionsAdapter — Talks to OpenAI Chat Completions API and compatibles.
28
54
  */
@@ -39,6 +65,24 @@ export class ChatCompletionsAdapter extends LLMAdapter {
39
65
  this.#baseUrl = baseUrl.replace(/\/+$/, ''); // strip trailing slash
40
66
  }
41
67
 
68
+ /** Expose baseUrl for testing. */
69
+ get baseUrl() { return this.#baseUrl; }
70
+
71
+ /**
72
+ * Build the max-tokens portion of the request body.
73
+ * Uses max_completion_tokens for OpenAI models, max_tokens for others.
74
+ *
75
+ * @param {string} model
76
+ * @param {number} maxTokens
77
+ * @returns {object}
78
+ */
79
+ #maxTokensBody(model, maxTokens) {
80
+ if (useNewMaxTokensParam(model)) {
81
+ return { max_completion_tokens: maxTokens };
82
+ }
83
+ return { max_tokens: maxTokens };
84
+ }
85
+
42
86
  /**
43
87
  * Translate UnifiedToolDef[] → Chat Completions tool format.
44
88
  * @param {import('./adapter.js').UnifiedToolDef[]} tools
@@ -140,16 +184,16 @@ export class ChatCompletionsAdapter extends LLMAdapter {
140
184
  }
141
185
 
142
186
  /**
143
- * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, signal?: AbortSignal }} params
187
+ * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, extraBody?: object, signal?: AbortSignal }} params
144
188
  * @returns {AsyncGenerator<import('./adapter.js').StreamEvent>}
145
189
  */
146
- async *stream({ model, system, messages, tools, maxTokens = 16384, signal }) {
190
+ async *stream({ model, system, messages, tools, maxTokens = 16384, extraBody, signal }) {
147
191
  if (signal?.aborted) throw new LLMAbortError();
148
192
 
149
193
  const body = {
150
194
  model,
151
195
  messages: this.#translateMessages(system, messages),
152
- max_tokens: maxTokens,
196
+ ...this.#maxTokensBody(model, maxTokens),
153
197
  stream: true,
154
198
  stream_options: { include_usage: true },
155
199
  };
@@ -157,6 +201,9 @@ export class ChatCompletionsAdapter extends LLMAdapter {
157
201
  const translatedTools = this.#translateTools(tools);
158
202
  if (translatedTools) body.tools = translatedTools;
159
203
 
204
+ // extraBody allows callers to pass through any additional/override parameters
205
+ if (extraBody) Object.assign(body, extraBody);
206
+
160
207
  const response = await fetch(`${this.#baseUrl}/chat/completions`, {
161
208
  method: 'POST',
162
209
  headers: {
@@ -277,15 +324,18 @@ export class ChatCompletionsAdapter extends LLMAdapter {
277
324
  /**
278
325
  * Non-streaming call for side queries.
279
326
  */
280
- async call({ model, system, messages, maxTokens = 4096, signal }) {
327
+ async call({ model, system, messages, maxTokens = 4096, extraBody, signal }) {
281
328
  if (signal?.aborted) throw new LLMAbortError();
282
329
 
283
330
  const body = {
284
331
  model,
285
332
  messages: this.#translateMessages(system, messages),
286
- max_tokens: maxTokens,
333
+ ...this.#maxTokensBody(model, maxTokens),
287
334
  };
288
335
 
336
+ // extraBody allows callers to pass through any additional/override parameters
337
+ if (extraBody) Object.assign(body, extraBody);
338
+
289
339
  const response = await fetch(`${this.#baseUrl}/chat/completions`, {
290
340
  method: 'POST',
291
341
  headers: {
@@ -0,0 +1,204 @@
1
+ /**
2
+ * web-bridge.js — Bridge between Yeaft Unify Engine and the Web UI
3
+ *
4
+ * Translates Unify Engine streaming events into the sendOutput wire format
5
+ * that the existing web client already understands. This means the UI needs
6
+ * zero rendering changes — Unify conversations look identical to Claude ones.
7
+ *
8
+ * Architecture: Agent-side only. UI and Server are transparent relays.
9
+ *
10
+ * Lifecycle per conversation:
11
+ * 1. First user message → lazy-load a Unify Session (loadSession)
12
+ * 2. Each user message → engine.query({ prompt }) → stream events → sendOutput
13
+ * 3. On turn complete → send turn_completed so UI exits processing state
14
+ */
15
+
16
+ import ctx from '../context.js';
17
+ import { sendOutput, sendConversationList } from '../conversation.js';
18
+ import { loadSession } from './session.js';
19
+
20
+ /**
21
+ * Handle a user message for a Unify-mode conversation.
22
+ *
23
+ * Called from conversation.js handleUserInput() when state.mode === 'unify'.
24
+ * Reuses the same sendOutput() wire format as Claude conversations so the
25
+ * web client renders messages identically.
26
+ *
27
+ * @param {{ conversationId: string, prompt: string }} msg
28
+ */
29
+ export async function handleUnifyInput(msg) {
30
+ const { conversationId, prompt } = msg;
31
+ const state = ctx.conversations.get(conversationId);
32
+
33
+ if (!state) {
34
+ console.error(`[Unify] No conversation state for ${conversationId}`);
35
+ return;
36
+ }
37
+
38
+ // ─── Lazy-init Unify Session ──────────────────────────────
39
+ if (!state._unifySession) {
40
+ try {
41
+ console.log(`[Unify] Loading session for ${conversationId}...`);
42
+ state._unifySession = await loadSession({
43
+ skipMCP: true,
44
+ skipSkills: true,
45
+ });
46
+ state._unifyMessages = [];
47
+ console.log(`[Unify] Session loaded for ${conversationId} (model: ${state._unifySession.config.model})`);
48
+ } catch (err) {
49
+ console.error(`[Unify] Failed to load session for ${conversationId}:`, err);
50
+ sendOutput(conversationId, {
51
+ type: 'assistant',
52
+ message: {
53
+ role: 'assistant',
54
+ content: [{
55
+ type: 'text',
56
+ text: `Unify session initialization failed: ${err.message}`
57
+ }]
58
+ }
59
+ });
60
+ ctx.sendToServer({
61
+ type: 'turn_completed',
62
+ conversationId,
63
+ workDir: state.workDir
64
+ });
65
+ return;
66
+ }
67
+ }
68
+
69
+ const session = state._unifySession;
70
+
71
+ // ─── Send user message to UI ──────────────────────────────
72
+ sendOutput(conversationId, {
73
+ type: 'user',
74
+ message: {
75
+ role: 'user',
76
+ content: prompt
77
+ }
78
+ });
79
+
80
+ // Track messages for multi-turn context
81
+ state._unifyMessages.push({ role: 'user', content: prompt });
82
+
83
+ // ─── Run Engine query and stream back ─────────────────────
84
+ let fullText = '';
85
+ let hasError = false;
86
+
87
+ try {
88
+ for await (const event of session.engine.query({
89
+ prompt,
90
+ messages: state._unifyMessages.slice(0, -1), // pass history (excluding current prompt which engine adds)
91
+ })) {
92
+ switch (event.type) {
93
+ case 'text_delta':
94
+ fullText += event.text;
95
+ sendOutput(conversationId, {
96
+ type: 'assistant',
97
+ message: {
98
+ role: 'assistant',
99
+ content: [{
100
+ type: 'text',
101
+ text: event.text
102
+ }]
103
+ },
104
+ subtype: 'text_delta'
105
+ });
106
+ break;
107
+
108
+ case 'tool_call':
109
+ sendOutput(conversationId, {
110
+ type: 'assistant',
111
+ message: {
112
+ role: 'assistant',
113
+ content: [{
114
+ type: 'tool_use',
115
+ id: event.id,
116
+ name: event.name,
117
+ input: event.input
118
+ }]
119
+ }
120
+ });
121
+ break;
122
+
123
+ case 'tool_result':
124
+ sendOutput(conversationId, {
125
+ type: 'assistant',
126
+ message: {
127
+ role: 'tool',
128
+ tool_use_id: event.toolCallId,
129
+ content: typeof event.result === 'string'
130
+ ? event.result
131
+ : JSON.stringify(event.result)
132
+ }
133
+ });
134
+ break;
135
+
136
+ case 'usage':
137
+ // Update conversation usage tracking
138
+ if (state.usage) {
139
+ state.usage.inputTokens += event.inputTokens || 0;
140
+ state.usage.outputTokens += event.outputTokens || 0;
141
+ }
142
+ break;
143
+
144
+ case 'error':
145
+ hasError = true;
146
+ sendOutput(conversationId, {
147
+ type: 'assistant',
148
+ message: {
149
+ role: 'assistant',
150
+ content: [{
151
+ type: 'text',
152
+ text: `Error: ${event.error?.message || 'Unknown error'}`
153
+ }]
154
+ }
155
+ });
156
+ break;
157
+
158
+ case 'stop':
159
+ case 'turn_start':
160
+ case 'turn_end':
161
+ case 'recall':
162
+ case 'consolidate':
163
+ case 'fallback':
164
+ case 'thinking_delta':
165
+ // These are engine-internal events; no UI action needed
166
+ break;
167
+ }
168
+ }
169
+ } catch (err) {
170
+ hasError = true;
171
+ console.error(`[Unify] Query error for ${conversationId}:`, err);
172
+ sendOutput(conversationId, {
173
+ type: 'assistant',
174
+ message: {
175
+ role: 'assistant',
176
+ content: [{
177
+ type: 'text',
178
+ text: `Unify query error: ${err.message}`
179
+ }]
180
+ }
181
+ });
182
+ }
183
+
184
+ // ─── Track assistant response for multi-turn ──────────────
185
+ if (fullText) {
186
+ state._unifyMessages.push({ role: 'assistant', content: fullText });
187
+ }
188
+
189
+ // ─── Send result + turn_completed ─────────────────────────
190
+ // Emit a result message to match Claude's wire format (UI uses this for state management)
191
+ sendOutput(conversationId, {
192
+ type: 'result',
193
+ subtype: hasError ? 'error' : 'success',
194
+ result_text: fullText
195
+ });
196
+
197
+ ctx.sendToServer({
198
+ type: 'turn_completed',
199
+ conversationId,
200
+ workDir: state.workDir
201
+ });
202
+
203
+ sendConversationList();
204
+ }