From 7ddb460afc4f1ac6c2e2dffc2010bc7ec10af1a4 Mon Sep 17 00:00:00 2001 From: LiRuoChen <571244399@qq.com> Date: Fri, 7 Aug 2026 14:58:56 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=B0=83=E8=AF=95=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E7=9A=84=E5=85=A8=E9=87=8F=E6=97=A5=E5=BF=97=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E3=80=82=20=E4=BF=AE=E5=A4=8D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- halo-openai-bridge/halo-openai-bridge.user.js | 314 +++++------------- halo-openai-bridge/server.js | 145 ++++---- halo-openai-bridge/调试启动桥接服务.bat | 5 +- 3 files changed, 161 insertions(+), 303 deletions(-) diff --git a/halo-openai-bridge/halo-openai-bridge.user.js b/halo-openai-bridge/halo-openai-bridge.user.js index 2bd7a1d..75a86d1 100644 --- a/halo-openai-bridge/halo-openai-bridge.user.js +++ b/halo-openai-bridge/halo-openai-bridge.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Halo WebUI OpenAI 本机桥接 // @namespace http://tampermonkey.net/ -// @version 2.0.0 +// @version 2.1.0 // @description 使用当前 Halo 登录会话代理 OpenAI Chat Completions,并实时转发回复。 // @match http://192.168.0.87:3000/* // @grant none @@ -10,7 +10,6 @@ (function () { 'use strict'; - const BRIDGE_URL = 'http://127.0.0.1:8787'; const POLL_INTERVAL_MS = 1000; const SOCKET_PATH = '/ws/socket.io/?EIO=4&transport=websocket'; @@ -19,275 +18,130 @@ let socketReady; let socketSessionId = ''; let activeHaloTask; - - function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - async function bridgeRequest(path, options = {}) { - const response = await fetch(`${BRIDGE_URL}${path}`, { - ...options, - headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } - }); - const data = await response.json(); - if (!response.ok) throw new Error(data?.error?.message || `本机桥接请求失败 (${response.status})`); - return data; - } + let debugSequence = 0; function uuid() { if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; + const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 15) | 64; bytes[8] = (bytes[8] & 63) | 128; const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } + async function bridgeRequest(path, options = {}) { + const response = await fetch(`${BRIDGE_URL}${path}`, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } }); + const text = await response.text(); + let data; try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; } + if (!response.ok) throw new Error(data?.error?.message || `本机桥接请求失败 (${response.status})`); + return data; + } + + function pageDebug(event, data = {}) { + return bridgeRequest('/bridge/debug', { method: 'POST', body: JSON.stringify({ event, taskId: activeHaloTask?.bridgeId || null, sequence: ++debugSequence, at: new Date().toISOString(), socketSessionId, data }) }).catch(() => {}); + } + function findJwt(value, visited = new Set()) { - if (typeof value === 'string') { - const match = value.match(/eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/); - return match?.[0] || ''; - } + if (typeof value === 'string') return value.match(/eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/)?.[0] || ''; if (!value || typeof value !== 'object' || visited.has(value)) return ''; - visited.add(value); - for (const item of Object.values(value)) { - const token = findJwt(item, visited); - if (token) return token; - } - return ''; + visited.add(value); for (const item of Object.values(value)) { const token = findJwt(item, visited); if (token) return token; } return ''; } function getStoredToken() { - for (const storage of [localStorage, sessionStorage]) { - for (let index = 0; index < storage.length; index += 1) { - const value = storage.getItem(storage.key(index)); - try { - const token = findJwt(JSON.parse(value)); - if (token) return token; - } catch (_) { - const token = findJwt(value); - if (token) return token; - } - } + for (const storage of [localStorage, sessionStorage]) for (let index = 0; index < storage.length; index += 1) { + const value = storage.getItem(storage.key(index)); + try { const token = findJwt(JSON.parse(value)); if (token) return token; } catch (_) { const token = findJwt(value); if (token) return token; } } return findJwt(document.cookie); } + function socketSend(packet) { pageDebug('socket-outbound-raw', { packet }); socket.send(packet); } + function connectSocket() { if (socketReady) return socketReady; socketReady = new Promise((resolve, reject) => { const token = getStoredToken(); - if (!token) { - socketReady = null; - reject(new Error('未在页面存储中找到 Halo 登录令牌;请刷新已登录的 Halo 页面后重试。')); - return; - } + pageDebug('socket-connect-start', { token }); + if (!token) { socketReady = null; reject(new Error('未在页面存储中找到 Halo 登录令牌。')); return; } socket = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}${SOCKET_PATH}`); - const timer = setTimeout(() => reject(new Error('Halo Socket.IO 连接超时。')), 10000); + const timer = setTimeout(() => { pageDebug('socket-timeout'); reject(new Error('Halo Socket.IO 连接超时。')); }, 10000); + socket.addEventListener('open', () => { pageDebug('socket-open'); socketSend(`40${JSON.stringify({ token })}`); }); socket.addEventListener('message', event => { - const packet = String(event.data); + const packet = String(event.data); pageDebug('socket-inbound-raw', { packet }); if (packet.startsWith('0')) return; - if (packet.startsWith('40')) { - try { socketSessionId = JSON.parse(packet.slice(2)).sid || socketSessionId; } catch (_) {} - clearTimeout(timer); - resolve(); - return; - } - if (packet.startsWith('2')) { - socket.send('3'); - return; - } + if (packet.startsWith('40')) { try { socketSessionId = JSON.parse(packet.slice(2)).sid || socketSessionId; } catch (error) { pageDebug('socket-sid-parse-error', { packet, error: error.message }); } clearTimeout(timer); pageDebug('socket-ready', { socketSessionId }); resolve(); return; } + if (packet.startsWith('2')) { socketSend('3'); return; } if (!packet.startsWith('42')) return; - try { - const [eventName, payload] = JSON.parse(packet.slice(2)); - if (eventName === 'chat-events') handleChatEvent(payload); - } catch (error) { - console.warn('[Halo Bridge] 无法解析 Socket.IO 消息', error); - } + try { const [eventName, payload] = JSON.parse(packet.slice(2)); const matches = Boolean(activeHaloTask && payload?.chat_id === activeHaloTask.chatId && payload?.message_id === activeHaloTask.messageId); pageDebug('socket-event', { eventName, payload, matches }); if (eventName === 'chat-events' && matches) handleChatEvent(payload); } + catch (error) { pageDebug('socket-parse-error', { packet, error: error.message }); } }); - socket.addEventListener('open', () => socket.send(`40${JSON.stringify({ token })}`)); - socket.addEventListener('close', () => { - socketReady = null; - socket = null; - socketSessionId = ''; - if (activeHaloTask) failActiveTask('Halo Socket.IO 连接已断开。'); - }); - socket.addEventListener('error', () => { - clearTimeout(timer); - socketReady = null; - reject(new Error('无法建立 Halo Socket.IO 连接。')); - }); - }); - return socketReady; + socket.addEventListener('close', event => { pageDebug('socket-close', { code: event.code, reason: event.reason }); socketReady = null; socket = null; socketSessionId = ''; if (activeHaloTask) failActiveTask('Halo Socket.IO 连接已断开。'); }); + socket.addEventListener('error', () => { clearTimeout(timer); socketReady = null; pageDebug('socket-error'); reject(new Error('无法建立 Halo Socket.IO 连接。')); }); + }); return socketReady; } - function plainContent(html) { - return String(html || '') - .replace(/\s*/gi, '') - .replace(/<[^>]+>/g, '') - .trim(); - } + function plainContent(html) { return String(html || '').replace(/\s*/gi, '').replace(/<[^>]+>/g, '').replace(/<||[^|]+||>/g, '').trim(); } + function appendDelta(previous, current) { if (!previous) return current; if (current.startsWith(previous)) return current.slice(previous.length); let index = 0; while (index < Math.min(previous.length, current.length) && previous[index] === current[index]) index += 1; return current.slice(index); } - function appendDelta(previous, current) { - if (!previous) return current; - if (current.startsWith(previous)) return current.slice(previous.length); - let index = 0; - const limit = Math.min(previous.length, current.length); - while (index < limit && previous[index] === current[index]) index += 1; - return current.slice(index); - } - - async function failActiveTask(message) { - const task = activeHaloTask; - if (!task || task.finished) return; - task.finished = true; - activeHaloTask = null; - try { - await bridgeRequest(`/bridge/tasks/${task.bridgeId}/failed`, { - method: 'POST', body: JSON.stringify({ message }) - }); - } catch (error) { - console.error('[Halo Bridge] 无法回传失败状态', error); + function parseHaloToolCalls(source) { + source = String(source || ''); const marker = source.search(/(?:DSML.*tool_calls|tool_calls)/i); + if (marker < 0) return { content: plainContent(source), toolCalls: [] }; + const visible = source.slice(0, source.lastIndexOf('<', marker)); const block = source.slice(Math.max(0, source.lastIndexOf('<', marker))); + const toolCalls = []; const invokePattern = /(?:invoke|tool_call)\s+name\s*=\s*["=]?\s*([^"<\s|]+)/gi; let match; + while ((match = invokePattern.exec(block))) { + const name = match[1].trim(); const start = match.index + match[0].length; const end = block.search(/(?:\/invoke|\/tool_call)/i); const body = end >= 0 ? block.slice(start, end) : block.slice(start); + const args = {}; const parameterPattern = /parameter\s+name\s*=\s*["=]?\s*([^"<\s|]+)[^>|]*[>|]?([\s\S]*?)(?=(?:parameter\s+name|\/parameter|\/invoke|\/tool_call|$))/gi; let parameter; + while ((parameter = parameterPattern.exec(body))) args[parameter[1].trim()] = plainContent(parameter[2]).trim(); + toolCalls.push({ id: `call_${uuid().replace(/-/g, '').slice(0, 24)}`, type: 'function', function: { name, arguments: JSON.stringify(args) } }); } + return { content: plainContent(visible), toolCalls }; } + async function failActiveTask(message) { const task = activeHaloTask; if (!task || task.finished) return; task.finished = true; activeHaloTask = null; pageDebug('halo-task-failed', { message }); await bridgeRequest(`/bridge/tasks/${task.bridgeId}/failed`, { method: 'POST', body: JSON.stringify({ message }) }).catch(() => {}); } + function handleChatEvent(event) { - const task = activeHaloTask; - if (!task || task.finished || event.chat_id !== task.chatId || event.message_id !== task.messageId) return; - const eventData = event.data; - if (eventData?.type !== 'chat:completion') return; - const data = eventData.data || {}; - if (typeof data.content === 'string') { - const content = plainContent(data.content); - const delta = appendDelta(task.content, content); - task.content = content; - if (delta) { - bridgeRequest(`/bridge/tasks/${task.bridgeId}/delta`, { - method: 'POST', body: JSON.stringify({ content: delta }) - }).catch(error => failActiveTask(error.message)); - } - } - const finishReason = data.choices?.[0]?.finish_reason; - if (data.done || finishReason === 'stop') { - task.finished = true; - activeHaloTask = null; - bridgeRequest(`/bridge/tasks/${task.bridgeId}/complete`, { - method: 'POST', - body: JSON.stringify({ content: task.content, usage: data.usage || task.usage }) - }).catch(error => console.error('[Halo Bridge] 无法回传完成状态', error)); - } else if (data.usage) { - task.usage = data.usage; - } - } - - function getChatId() { - const match = location.pathname.match(/^\/c\/([^/?#]+)/); - if (!match) throw new Error('请打开一个 Halo 对话页面(URL 应为 /c/{chat_id})。'); - return match[1]; + const task = activeHaloTask; if (!task || task.finished) return; + const data = event.data?.data || {}; const finishReason = data.choices?.[0]?.finish_reason; + pageDebug('halo-chat-event-consumed', { event, finishReason }); + if (typeof data.content === 'string') { task.rawContent = data.content; const parsed = parseHaloToolCalls(data.content); const delta = appendDelta(task.content, parsed.content); task.content = parsed.content; task.toolCalls = parsed.toolCalls; if (delta) bridgeRequest(`/bridge/tasks/${task.bridgeId}/delta`, { method: 'POST', body: JSON.stringify({ content: delta }) }).catch(error => failActiveTask(error.message)); } + if (data.usage) task.usage = data.usage; + if (data.done || finishReason === 'stop' || finishReason === 'tool_calls') { task.finished = true; activeHaloTask = null; pageDebug('halo-complete', { finishReason, rawContent: task.rawContent, parsedContent: task.content, toolCalls: task.toolCalls }); bridgeRequest(`/bridge/tasks/${task.bridgeId}/complete`, { method: 'POST', body: JSON.stringify({ content: task.content, raw_content: task.rawContent, tool_calls: task.toolCalls, usage: data.usage || task.usage, halo_finish_reason: finishReason }) }).catch(error => console.error('[Halo Bridge]', error)); } } async function haloJson(path, options = {}) { - const response = await fetch(path, { - ...options, - credentials: 'include', - headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } - }); - const data = await response.json(); - if (!response.ok) throw new Error(data?.detail || data?.message || `Halo API 请求失败 (${response.status})`); - return data; - } - - async function getModelItem(modelId) { - const models = await haloJson('/api/models'); - const list = Array.isArray(models) ? models : models.data || []; - return list.find(item => item?.id === modelId || item?.model_id === modelId) || { id: modelId, name: modelId }; + const response = await fetch(path, { ...options, credentials: 'include', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } }); + const raw = await response.text(); pageDebug('halo-http-response', { path, method: options.method || 'GET', requestBody: options.body || null, status: response.status, headers: Object.fromEntries(response.headers.entries()), raw }); + let data; try { data = raw ? JSON.parse(raw) : {}; } catch (error) { throw new Error(`Halo 响应不是 JSON:${error.message}`); } + if (!response.ok) throw new Error(data?.detail || data?.message || `Halo API 请求失败 (${response.status})`); return data; } + function textContent(content) { return typeof content === 'string' ? content : Array.isArray(content) ? content.filter(part => part?.type === 'text').map(part => part.text).join('\n') : ''; } function normalizeMessages(messages) { - return messages.map(message => ({ - role: message.role, - content: typeof message.content === 'string' - ? message.content - : Array.isArray(message.content) - ? message.content.filter(part => part?.type === 'text').map(part => part.text).join('\n') - : '' - })); + const toolSchemas = []; + const normalized = messages.map(message => { + const text = textContent(message.content); + if (message.role === 'tool') return { role: 'user', content: `<||DSML||tool_result call_id="${message.tool_call_id}">\n${text}\n<||DSML||/tool_result>` }; + if (!Array.isArray(message.tool_calls) || !message.tool_calls.length) return { role: message.role, content: text }; + const calls = message.tool_calls.map(call => `<||DSML||invoke id="${call.id}" name="${call.function?.name}">\n${call.function?.arguments || '{}'}\n<||DSML||/invoke>`).join('\n'); + return { role: message.role, content: [text, '<||DSML||tool_calls>', calls, '<||DSML||/tool_calls>'].filter(Boolean).join('\n') }; + }); + return { normalized, toolSchemas }; } + function getChatId() { const match = location.pathname.match(/^\/c\/([^/?#]+)/); if (!match) throw new Error('请打开一个 Halo 对话页面(URL 应为 /c/{chat_id})。'); return match[1]; } + async function getModelItem(modelId) { const models = await haloJson('/api/models'); const list = Array.isArray(models) ? models : models.data || []; return list.find(item => item?.id === modelId || item?.model_id === modelId) || { id: modelId, name: modelId }; } + async function processTask(task) { - await connectSocket(); - if (activeHaloTask) throw new Error('Halo 页面正在处理另一条请求。'); - const chatId = getChatId(); - const chat = await haloJson(`/api/v1/chats/${chatId}`); - const chatState = chat.chat || {}; - const model = chatState.models?.[0]; - if (!model) throw new Error('当前 Halo 对话未选择模型。'); - const messages = normalizeMessages(task.request.messages || []); - const system = messages.filter(message => message.role === 'system').map(message => message.content).filter(Boolean).join('\n\n'); - const messageId = uuid(); - const modelItem = await getModelItem(model); - activeHaloTask = { - bridgeId: task.id, - chatId, - messageId, - content: '', - usage: null, - finished: false - }; - await bridgeRequest(`/bridge/tasks/${task.id}/started`, { - method: 'POST', - body: JSON.stringify({ chat_id: chatId, message_id: messageId, model }) - }); - const payload = { - // 必须启用流式响应,才能通过 Socket.IO 实时转发回复。 - stream: true, - model, - messages, - params: { function_calling: null, system }, - files: [], - tool_servers: [], - features: { memory: false, image_generation: false, code_interpreter: false, web_search: false }, - variables: {}, - model_item: modelItem, - session_id: socketSessionId, - chat_id: chatId, - id: messageId, - background_tasks: { follow_up_generation: true }, - stream_options: { include_usage: true } - }; - const submitted = await haloJson('/api/chat/completions', { - method: 'POST', body: JSON.stringify(payload) - }); - if (!submitted.status) throw new Error(submitted.message || 'Halo 未接受模型请求。'); + await connectSocket(); if (activeHaloTask) throw new Error('Halo 页面正在处理另一条请求。'); + const chatId = getChatId(); const chat = await haloJson(`/api/v1/chats/${chatId}`); const model = chat.chat?.models?.[0]; if (!model) throw new Error('当前 Halo 对话未选择模型。'); + const { normalized: messages } = normalizeMessages(task.request.messages || []); const system = messages.filter(message => message.role === 'system').map(message => message.content).filter(Boolean).join('\n\n'); const messageId = uuid(); const modelItem = await getModelItem(model); + activeHaloTask = { bridgeId: task.id, chatId, messageId, content: '', rawContent: '', toolCalls: [], usage: null, finished: false }; + await bridgeRequest(`/bridge/tasks/${task.id}/started`, { method: 'POST', body: JSON.stringify({ chat_id: chatId, message_id: messageId, model }) }); + const toolContext = Array.isArray(task.request.tools) && task.request.tools.length ? `\n\n可用工具定义(当需要工具时,必须只输出 DSML tool_calls,等待 tool_result 后继续):\n${JSON.stringify(task.request.tools)}` : ''; + const payload = { stream: true, model, messages: messages.map(message => message.role === 'system' ? { ...message, content: `${message.content}${toolContext}` } : message), params: { function_calling: null, system: `${system}${toolContext}` }, files: [], tool_servers: [], features: { memory: false, image_generation: false, code_interpreter: false, web_search: false }, variables: {}, model_item: modelItem, session_id: socketSessionId, chat_id: chatId, id: messageId, background_tasks: { follow_up_generation: true }, stream_options: { include_usage: true } }; + pageDebug('halo-http-request', { path: '/api/chat/completions', method: 'POST', payload, tool_choice: task.request.tool_choice, parallel_tool_calls: task.request.parallel_tool_calls }); + const submitted = await haloJson('/api/chat/completions', { method: 'POST', body: JSON.stringify(payload) }); if (!submitted.status) throw new Error(submitted.message || 'Halo 未接受模型请求。'); } - async function poll() { - if (running) return; - try { - const { task } = await bridgeRequest('/bridge/tasks/next'); - if (!task) return; - running = true; - console.log('[Halo Bridge] 开始代理 Halo API 会话', task.id); - try { - await processTask(task); - } catch (error) { - await failActiveTask(error instanceof Error ? error.message : String(error)); - if (!activeHaloTask) { - await bridgeRequest(`/bridge/tasks/${task.id}/failed`, { - method: 'POST', body: JSON.stringify({ message: error instanceof Error ? error.message : String(error) }) - }).catch(() => {}); - } - } - } catch (error) { - console.error('[Halo Bridge]', error); - } finally { - running = false; - } - } - - connectSocket().catch(error => console.warn('[Halo Bridge] Socket.IO 尚未就绪:', error.message)); - setInterval(poll, POLL_INTERVAL_MS); - poll(); - console.log('[Halo Bridge] 网页会话代理脚本已启动。'); + async function poll() { if (running) return; try { const { task } = await bridgeRequest('/bridge/tasks/next'); if (!task) return; running = true; console.log('[Halo Bridge] 开始代理 Halo API 会话', task.id); try { await processTask(task); } catch (error) { await failActiveTask(error.message); await bridgeRequest(`/bridge/tasks/${task.id}/failed`, { method: 'POST', body: JSON.stringify({ message: error.message }) }).catch(() => {}); } } catch (error) { console.error('[Halo Bridge]', error); } finally { running = false; } } + connectSocket().catch(error => console.warn('[Halo Bridge] Socket.IO 尚未就绪:', error.message)); setInterval(poll, POLL_INTERVAL_MS); poll(); console.log('[Halo Bridge] 网页会话代理脚本已启动。'); })(); diff --git a/halo-openai-bridge/server.js b/halo-openai-bridge/server.js index 895db3b..7b1d301 100644 --- a/halo-openai-bridge/server.js +++ b/halo-openai-bridge/server.js @@ -6,19 +6,21 @@ const { randomUUID } = require('crypto'); const HOST = '127.0.0.1'; const PORT = Number(process.env.HALO_BRIDGE_PORT || 8787); const TASK_TIMEOUT_MS = Number(process.env.HALO_BRIDGE_TIMEOUT_MS || 300000); +const MAX_BODY_BYTES = Number(process.env.HALO_BRIDGE_MAX_BODY_BYTES || 1024 * 1024); const DEBUG = process.env.HALO_BRIDGE_DEBUG === '1'; const tasks = new Map(); let activeTaskId = null; let lastPagePollAt = 0; +let debugSequence = 0; function log(message) { console.log(`[${new Date().toLocaleTimeString('zh-CN', { hour12: false })}] ${message}`); } -function debug(message, data) { +function debugEvent(taskId, direction, type, data) { if (!DEBUG) return; - const suffix = data === undefined ? '' : ` | ${JSON.stringify(data)}`; - log(`[DEBUG] ${message}${suffix}`); + const event = { sequence: ++debugSequence, at: new Date().toISOString(), taskId: taskId || null, direction, type, data }; + console.log(`[DEBUG] ${JSON.stringify(event, null, 2)}`); } function preview(text, maxLength = 120) { @@ -41,19 +43,30 @@ function sendJson(response, statusCode, data) { } function openAiError(response, statusCode, message, type = 'invalid_request_error') { - if (!response.headersSent) sendJson(response, statusCode, { error: { message, type, param: null, code: null } }); + const data = { error: { message, type, param: null, code: null } }; + if (!response.headersSent) sendJson(response, statusCode, data); } -function sendSse(response, data) { - if (!response.writableEnded) response.write(`data: ${JSON.stringify(data)}\n\n`); +function sendSse(task, data) { + debugEvent(task.id, 'bridge->openai', 'sse', data); + if (!task.response.writableEnded) task.response.write(`data: ${JSON.stringify(data)}\n\n`); } -function readJson(request) { +function sendDone(task) { + debugEvent(task.id, 'bridge->openai', 'sse-done', '[DONE]'); + if (!task.response.writableEnded) task.response.write('data: [DONE]\n\n'); +} + +function readJson(request, path) { return new Promise((resolve, reject) => { let body = ''; request.on('data', chunk => { body += chunk; - if (body.length > 1024 * 1024) request.destroy(new Error('请求体过大')); + if (Buffer.byteLength(body) > MAX_BODY_BYTES) { + const error = new Error(`请求体过大,限制为 ${MAX_BODY_BYTES} 字节。`); + debugEvent(null, 'openai->bridge', 'body-too-large', { path, bytes: Buffer.byteLength(body), maxBytes: MAX_BODY_BYTES }); + request.destroy(error); + } }); request.on('end', () => { try { resolve(body ? JSON.parse(body) : {}); } @@ -66,11 +79,7 @@ function readJson(request) { function messageText(content) { if (typeof content === 'string') return content.trim(); if (!Array.isArray(content)) return ''; - return content - .filter(part => part?.type === 'text' && typeof part.text === 'string') - .map(part => part.text) - .join('\n') - .trim(); + return content.filter(part => part?.type === 'text' && typeof part.text === 'string').map(part => part.text).join('\n').trim(); } function latestUserText(messages) { @@ -81,13 +90,22 @@ function latestUserText(messages) { return ''; } +function validateToolHistory(messages) { + const calls = new Set(); + for (const message of messages) { + if (message?.role === 'assistant' && Array.isArray(message.tool_calls)) { + for (const call of message.tool_calls) if (call?.id) calls.add(call.id); + } + if (message?.role === 'tool') { + if (!message.tool_call_id) return 'role: tool 消息缺少 tool_call_id。'; + if (!calls.has(message.tool_call_id)) return `tool_call_id ${message.tool_call_id} 未匹配前序 assistant.tool_calls。`; + } + } + return ''; +} + function taskView(task) { - return { - id: task.id, - model: task.model, - request: task.request, - createdAt: task.createdAt - }; + return { id: task.id, model: task.model, request: task.request, createdAt: task.createdAt }; } function sseChunk(task, delta, finishReason = null, usage) { @@ -107,23 +125,31 @@ function sendCompletion(task, result) { clearTimeout(task.timeout); if (activeTaskId === task.id) activeTaskId = null; const content = typeof result.content === 'string' ? result.content : task.content; + const toolCalls = Array.isArray(result.tool_calls) ? result.tool_calls : []; + const finishReason = toolCalls.length ? 'tool_calls' : 'stop'; + debugEvent(task.id, 'page->bridge', 'task-complete', result); if (task.wantsStream) { - sendSse(task.response, sseChunk(task, {}, 'stop', result.usage)); - if (!task.response.writableEnded) { - task.response.write('data: [DONE]\n\n'); - task.response.end(); + if (toolCalls.length) { + sendSse(task, sseChunk(task, { + tool_calls: toolCalls.map((call, index) => ({ index, id: call.id, type: 'function', function: call.function })) + })); } + sendSse(task, sseChunk(task, {}, finishReason, result.usage)); + sendDone(task); + task.response.end(); } else if (!task.response.writableEnded) { - sendJson(task.response, 200, { + const response = { id: `chatcmpl-${task.id}`, object: 'chat.completion', created: task.created, model: task.model, - choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }], + choices: [{ index: 0, message: { role: 'assistant', content: content || null, ...(toolCalls.length ? { tool_calls: toolCalls } : {}) }, finish_reason: finishReason }], usage: result.usage || undefined, halo: { task_id: task.id, chat_id: task.chatId, message_id: task.messageId } - }); + }; + debugEvent(task.id, 'bridge->openai', 'json-completion', response); + sendJson(task.response, 200, response); } log(`任务完成 ${task.id} | 回复: ${preview(content)}`); setTimeout(() => tasks.delete(task.id), 10 * 60 * 1000).unref(); @@ -134,10 +160,11 @@ function failTask(task, message) { task.finished = true; clearTimeout(task.timeout); if (activeTaskId === task.id) activeTaskId = null; + debugEvent(task.id, 'bridge', 'task-failed', { message, accumulatedContent: task.content }); log(`任务失败 ${task.id} | ${message}`); if (task.wantsStream && task.response.headersSent && !task.response.writableEnded) { - sendSse(task.response, { error: { message, type: 'server_error', param: null, code: null } }); - task.response.write('data: [DONE]\n\n'); + sendSse(task, { error: { message, type: 'server_error', param: null, code: null } }); + sendDone(task); task.response.end(); } else if (!task.response.writableEnded) { openAiError(task.response, 504, message, 'server_error'); @@ -146,78 +173,55 @@ function failTask(task, message) { } async function handleChatCompletions(request, response) { - const payload = await readJson(request); + const payload = await readJson(request, '/v1/chat/completions'); + debugEvent(null, 'openai->bridge', 'chat-completions-request', { headers: request.headers, bytes: Buffer.byteLength(JSON.stringify(payload)), payload }); + if (!Array.isArray(payload.messages)) return openAiError(response, 400, 'messages 必须是数组。'); + const toolHistoryError = validateToolHistory(payload.messages); + if (toolHistoryError) return openAiError(response, 400, toolHistoryError); const prompt = latestUserText(payload.messages); if (!prompt) return openAiError(response, 400, 'messages 中缺少非空 user 文本消息。'); - if (!Array.isArray(payload.messages) || payload.messages.some(message => message?.role === 'tool' || message?.tool_calls)) { - return openAiError(response, 400, '当前网页会话代理尚不支持 OpenAI tool_calls / tool 消息。'); - } if (activeTaskId) return openAiError(response, 429, 'Halo 页面正在处理另一条请求,请等待当前任务完成。', 'rate_limit_error'); const id = randomUUID(); - const task = { - id, - prompt, - request: payload, - model: payload.model || 'halo-webui', - wantsStream: payload.stream === true, - response, - createdAt: Date.now(), - created: Math.floor(Date.now() / 1000), - claimed: false, - finished: false, - content: '' - }; + const task = { id, prompt, request: payload, model: payload.model || 'halo-webui', wantsStream: payload.stream === true, response, createdAt: Date.now(), created: Math.floor(Date.now() / 1000), claimed: false, finished: false, content: '' }; tasks.set(id, task); activeTaskId = id; task.timeout = setTimeout(() => failTask(task, '等待 Halo 网页会话回复超时。请确认页面已打开、已登录且猴脚本正在运行。'), TASK_TIMEOUT_MS); + debugEvent(id, 'bridge', 'task-created', { roles: payload.messages.map(message => message.role), tools: payload.tools, tool_choice: payload.tool_choice, parallel_tool_calls: payload.parallel_tool_calls }); log(`收到请求 ${id} | 模型: ${task.model} | 内容: ${preview(prompt)}`); if (task.wantsStream) { - response.writeHead(200, { - ...headers('text/event-stream; charset=utf-8'), - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive' - }); - sendSse(response, sseChunk(task, { role: 'assistant' })); + response.writeHead(200, { ...headers('text/event-stream; charset=utf-8'), 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' }); + sendSse(task, sseChunk(task, { role: 'assistant' })); } } async function route(request, response) { const url = new URL(request.url, `http://${request.headers.host}`); if (request.method === 'OPTIONS') return sendJson(response, 204, {}); - if (request.method === 'GET' && url.pathname === '/health') { - return sendJson(response, 200, { - ok: true, - activeTaskId, - taskCount: tasks.size, - pageConnected: Date.now() - lastPagePollAt < 5000, - lastPagePollAt: lastPagePollAt || null, - activeTask: activeTaskId ? taskView(tasks.get(activeTaskId)) : null - }); - } + if (request.method === 'GET' && url.pathname === '/health') return sendJson(response, 200, { ok: true, debug: DEBUG, activeTaskId, taskCount: tasks.size, pageConnected: Date.now() - lastPagePollAt < 5000, lastPagePollAt: lastPagePollAt || null, activeTask: activeTaskId ? taskView(tasks.get(activeTaskId)) : null }); if (request.method === 'POST' && url.pathname === '/v1/chat/completions') return handleChatCompletions(request, response); if (request.method === 'GET' && url.pathname === '/bridge/tasks/next') { lastPagePollAt = Date.now(); const task = activeTaskId ? tasks.get(activeTaskId) : null; if (!task || task.claimed || task.finished) return sendJson(response, 200, { task: null }); task.claimed = true; + debugEvent(task.id, 'bridge->page', 'task-issued', taskView(task)); log(`网页已接收任务 ${task.id}`); return sendJson(response, 200, { task: taskView(task) }); } - if (request.method === 'POST' && url.pathname === '/bridge/debug') { - const event = await readJson(request); - debug(`页面 ${event.event || '事件'}`, event.data); + const event = await readJson(request, '/bridge/debug'); + debugEvent(event.taskId, 'page->bridge', event.event || 'page-event', event); return sendJson(response, 200, { ok: true }); } - const match = url.pathname.match(/^\/bridge\/tasks\/([^/]+)\/(started|delta|complete|failed)$/); if (request.method === 'POST' && match) { const [, taskId, action] = match; const task = tasks.get(taskId); if (!task || task.finished) return openAiError(response, 404, '任务不存在或已完成。'); - const result = await readJson(request); + const result = await readJson(request, url.pathname); + debugEvent(taskId, 'page->bridge', `task-${action}`, result); if (action === 'started') { task.chatId = result.chat_id; task.messageId = result.message_id; @@ -227,13 +231,11 @@ async function route(request, response) { const content = typeof result.content === 'string' ? result.content : ''; if (content) { task.content += content; - if (task.wantsStream) sendSse(task.response, sseChunk(task, { content })); + if (task.wantsStream) sendSse(task, sseChunk(task, { content })); } } else if (action === 'complete') { sendCompletion(task, { ...result, content: result.content || task.content }); - } else { - failTask(task, result.message || 'Halo 网页会话代理失败。'); - } + } else failTask(task, result.message || 'Halo 网页会话代理失败。'); return sendJson(response, 200, { ok: true }); } return openAiError(response, 404, '接口不存在。'); @@ -241,12 +243,13 @@ async function route(request, response) { http.createServer((request, response) => { route(request, response).catch(error => { + debugEvent(null, 'bridge', 'route-error', { message: error.message, stack: error.stack }); console.error('[Halo Bridge]', error); if (!response.headersSent) openAiError(response, 400, error.message); else response.end(); }); }).listen(PORT, HOST, () => { log(`服务已启动:http://${HOST}:${PORT}`); - log(`日志模式:${DEBUG ? 'DEBUG(显示请求与增量详情)' : 'Release(精简)'}`); + log(`日志模式:${DEBUG ? 'DEBUG(完整原样输出,含认证字段)' : 'Release(精简)'}`); log('等待 Halo 页面猴脚本连接;请保持页面已登录并打开。'); }); diff --git a/halo-openai-bridge/调试启动桥接服务.bat b/halo-openai-bridge/调试启动桥接服务.bat index 643ae62..02e9b6a 100644 --- a/halo-openai-bridge/调试启动桥接服务.bat +++ b/halo-openai-bridge/调试启动桥接服务.bat @@ -24,8 +24,9 @@ echo Halo OpenAI Bridge DEBUG is starting echo URL: http://127.0.0.1:8787 echo Health: http://127.0.0.1:8787/health echo ================================================ -echo DEBUG logs include request metadata, Halo submission, -echo Socket.IO events, and forwarded content deltas. +echo DEBUG prints complete raw OpenAI, Halo HTTP, Socket.IO, +echo and SSE data. It includes tokens and other credentials. +echo Do not share this console output or screenshots. echo Press Ctrl+C to stop. echo. set "HALO_BRIDGE_DEBUG=1"