@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 +18 -5
- package/package.json +1 -1
- package/unify/llm/chat-completions.js +55 -5
- package/unify/web-bridge.js +204 -0
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
|
-
//
|
|
417
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|