diff --git a/asserts/ARCHITECTURE.md b/asserts/ARCHITECTURE.md index 55b9601..e9dfc60 100644 --- a/asserts/ARCHITECTURE.md +++ b/asserts/ARCHITECTURE.md @@ -22,6 +22,7 @@ luxx/ │ ├── auth.py # 认证 │ ├── conversations.py # 会话管理 │ ├── messages.py # 消息处理 +│ ├── providers.py # LLM 提供商管理 │ └── tools.py # 工具管理 ├── services/ # 服务层 │ ├── chat.py # 聊天服务 @@ -101,15 +102,63 @@ erDiagram string id PK string conversation_id FK string role - longtext content + longtext content "JSON 格式" int token_count datetime created_at } USER ||--o{ CONVERSATION : "has" CONVERSATION ||--o{ MESSAGE : "has" + + USER ||--o{ LLM_PROVIDER : "configures" + + LLM_PROVIDER { + int id PK + int user_id FK + string name + string provider_type + string base_url + string api_key + string default_model + boolean is_default + boolean enabled + datetime created_at + datetime updated_at + } ``` +### Message Content JSON 结构 + +`content` 字段统一使用 JSON 格式存储: + +**User 消息:** + +```json +{ + "text": "用户输入的文本内容", + "attachments": [ + {"name": "utils.py", "extension": "py", "content": "..."} + ] +} +``` + +**Assistant 消息:** + +```json +{ + "text": "AI 回复的文本内容", + "tool_calls": [...], + "steps": [ + {"id": "step-0", "index": 0, "type": "thinking", "content": "..."}, + {"id": "step-1", "index": 1, "type": "text", "content": "..."}, + {"id": "step-2", "index": 2, "type": "tool_call", "id_ref": "call_xxx", "name": "...", "arguments": "..."}, + {"id": "step-3", "index": 3, "type": "tool_result", "id_ref": "call_xxx", "name": "...", "content": "..."} + ] +} +``` + +`steps` 字段是**渲染顺序的唯一数据源**,按 `index` 顺序排列。thinking、text、tool_call、tool_result 可以在多轮迭代中穿插出现。 + ### 5. 工具系统 ```mermaid @@ -191,6 +240,9 @@ LLM API 客户端: | `/conversations` | GET/POST | 会话列表/创建 | | `/conversations/{id}` | GET/DELETE | 会话详情/删除 | | `/messages/stream` | POST | 流式消息发送 | +| `/providers` | GET/POST | LLM 提供商列表/创建 | +| `/providers/{id}` | GET/PUT/DELETE | 提供商详情/更新/删除 | +| `/providers/{id}/test` | POST | 测试提供商连接 | | `/tools` | GET | 可用工具列表 | ## 数据流 @@ -227,12 +279,29 @@ sequenceDiagram | 事件 | 说明 | |------|------| -| `text` | 文本内容增量 | -| `tool_call` | 工具调用请求 | -| `tool_result` | 工具执行结果 | +| `process_step` | 结构化步骤(thinking/text/tool_call/tool_result),携带 `id`、`index` 确保渲染顺序 | | `done` | 响应完成 | | `error` | 错误信息 | +### process_step 事件格式 + +```json +{"type": "process_step", "step": {"id": "step-0", "index": 0, "type": "thinking", "content": "..."}} +{"type": "process_step", "step": {"id": "step-1", "index": 1, "type": "text", "content": "回复文本..."}} +{"type": "process_step", "step": {"id": "step-2", "index": 2, "type": "tool_call", "id_ref": "call_abc", "name": "web_search", "arguments": "{\"query\": \"...\"}"}} +{"type": "process_step", "step": {"id": "step-3", "index": 3, "type": "tool_result", "id_ref": "call_abc", "name": "web_search", "content": "{\"success\": true, ...}"}} +``` + +| 字段 | 说明 | +|------|------| +| `id` | 步骤唯一标识(格式 `step-{index}`) | +| `index` | 步骤序号,确保按正确顺序显示 | +| `type` | 步骤类型:`thinking` / `text` / `tool_call` / `tool_result` | +| `id_ref` | 工具调用引用 ID(仅 tool_call/tool_result) | +| `name` | 工具名称(仅 tool_call/tool_result) | +| `arguments` | 工具调用参数 JSON 字符串(仅 tool_call) | +| `content` | 内容(thinking 的思考内容、text 的文本、tool_result 的返回结果) | + ## 配置示例 ### config.yaml diff --git a/config.yaml b/config.yaml index a59ab84..f13c4e2 100644 --- a/config.yaml +++ b/config.yaml @@ -1,7 +1,7 @@ # 配置文件 app: secret_key: ${APP_SECRET_KEY} - debug: true + debug: false host: 0.0.0.0 port: 8000 diff --git a/dashboard/index.html b/dashboard/index.html index 2412468..efa808b 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -4,7 +4,11 @@ - dashboard + Luxx Dashboard + + + +
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 602e7a0..365c9d5 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -9,6 +9,9 @@ "version": "0.0.0", "dependencies": { "axios": "^1.15.0", + "katex": "^0.16.11", + "marked": "^15.0.0", + "marked-highlight": "^2.2.1", "pinia": "^3.0.4", "vue": "^3.5.32", "vue-router": "^4.6.4" @@ -586,6 +589,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", @@ -887,6 +899,22 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/katex": { + "version": "0.16.45", + "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.45.tgz", + "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1157,6 +1185,28 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmmirror.com/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/marked-highlight": { + "version": "2.2.4", + "resolved": "https://registry.npmmirror.com/marked-highlight/-/marked-highlight-2.2.4.tgz", + "integrity": "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ==", + "license": "MIT", + "peerDependencies": { + "marked": ">=4 <19" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index bc505fd..6e61846 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -10,6 +10,9 @@ }, "dependencies": { "axios": "^1.15.0", + "katex": "^0.16.11", + "marked": "^15.0.0", + "marked-highlight": "^2.2.1", "pinia": "^3.0.4", "vue": "^3.5.32", "vue-router": "^4.6.4" diff --git a/dashboard/src/components/MessageBubble.vue b/dashboard/src/components/MessageBubble.vue new file mode 100644 index 0000000..34addad --- /dev/null +++ b/dashboard/src/components/MessageBubble.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/dashboard/src/components/ProcessBlock.vue b/dashboard/src/components/ProcessBlock.vue new file mode 100644 index 0000000..ef26c63 --- /dev/null +++ b/dashboard/src/components/ProcessBlock.vue @@ -0,0 +1,373 @@ + + + + + diff --git a/dashboard/src/services/api.js b/dashboard/src/services/api.js index ccc9289..9d20737 100644 --- a/dashboard/src/services/api.js +++ b/dashboard/src/services/api.js @@ -36,101 +36,133 @@ api.interceptors.response.use( } ) +/** + * SSE 流式请求处理器 + * @param {string} url - API URL (不含 baseURL 前缀) + * @param {object} body - 请求体 + * @param {object} callbacks - 事件回调: { onProcessStep, onDone, onError } + * @returns {{ abort: () => void }} + */ +export function createSSEStream(url, body, { onProcessStep, onDone, onError }) { + const token = localStorage.getItem('access_token') + const controller = new AbortController() + + const promise = (async () => { + try { + const res = await fetch(`/api${url}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(body), + signal: controller.signal + }) + + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.message || `HTTP ${res.status}`) + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let completed = false + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + let currentEvent = '' + for (const line of lines) { + if (line.startsWith('event: ')) { + currentEvent = line.slice(7).trim() + } else if (line.startsWith('data: ')) { + const data = JSON.parse(line.slice(6)) + if (currentEvent === 'process_step' && onProcessStep) { + onProcessStep(data) + } else if (currentEvent === 'done' && onDone) { + completed = true + onDone(data) + } else if (currentEvent === 'error' && onError) { + onError(data.content) + } + } + } + } + + if (!completed && onError) { + onError('stream ended unexpectedly') + } + } catch (e) { + if (e.name !== 'AbortError' && onError) { + onError(e.message) + } + } + })() + + promise.abort = () => controller.abort() + return promise +} + // ============ 认证接口 ============ export const authAPI = { - // 用户登录 login: (data) => api.post('/auth/login', data), - - // 用户注册 register: (data) => api.post('/auth/register', data), - - // 用户登出 logout: () => api.post('/auth/logout'), - - // 获取当前用户信息 getMe: () => api.get('/auth/me') } // ============ 会话接口 ============ export const conversationsAPI = { - // 获取会话列表 list: (params) => api.get('/conversations/', { params }), - - // 创建会话 create: (data) => api.post('/conversations/', data), - - // 获取会话详情 get: (id) => api.get(`/conversations/${id}`), - - // 更新会话 update: (id, data) => api.put(`/conversations/${id}`, data), - - // 删除会话 delete: (id) => api.delete(`/conversations/${id}`) } // ============ 消息接口 ============ export const messagesAPI = { - // 获取消息列表 list: (conversationId, params) => api.get('/messages/', { params: { conversation_id: conversationId, ...params } }), - - // 发送消息(非流式) send: (data) => api.post('/messages/', data), - // 发送消息(流式)- 使用原生 fetch 避免 axios 拦截 - sendStream: (data) => { - const token = localStorage.getItem('access_token') - return fetch('/api/messages/stream', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(data) - }) + // 发送消息(流式) + sendStream: (data, callbacks) => { + return createSSEStream('/messages/stream', { + conversation_id: data.conversation_id, + content: data.content, + tools_enabled: callbacks.toolsEnabled !== false + }, callbacks) }, - // 删除消息 delete: (id) => api.delete(`/messages/${id}`) } // ============ 工具接口 ============ export const toolsAPI = { - // 获取工具列表 list: (params) => api.get('/tools/', { params }), - - // 获取工具详情 get: (name) => api.get(`/tools/${name}`), - - // 执行工具 execute: (name, data) => api.post(`/tools/${name}/execute`, data) } // ============ LLM Provider 接口 ============ export const providersAPI = { - // 获取提供商列表 list: () => api.get('/providers/'), - - // 创建提供商 create: (data) => api.post('/providers/', data), - - // 获取提供商详情 get: (id) => api.get(`/providers/${id}`), - - // 更新提供商 update: (id, data) => api.put(`/providers/${id}`, data), - - // 删除提供商 delete: (id) => api.delete(`/providers/${id}`), - - // 测试连接 test: (id) => api.post(`/providers/${id}/test`) } -// 默认导出 -export default api \ No newline at end of file +export default api diff --git a/dashboard/src/style.css b/dashboard/src/style.css index 9ac0253..9d7ae49 100644 --- a/dashboard/src/style.css +++ b/dashboard/src/style.css @@ -1,294 +1,455 @@ +/* ============ Global Reset & Base ============ */ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #2563eb; - --accent-bg: rgba(37, 99, 235, 0.1); - --accent-border: rgba(37, 99, 235, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + /* 背景色 */ + --bg-primary: #ffffff; + --bg-secondary: #f8fafc; + --bg-tertiary: #f0f4f8; + --bg-hover: rgba(37, 99, 235, 0.06); + --bg-active: rgba(37, 99, 235, 0.12); + --bg-input: #f8fafc; + --bg-code: #f1f5f9; + --bg-thinking: #f1f5f9; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; + /* 文字颜色 */ + --text-primary: #1e293b; + --text-secondary: #64748b; + --text-tertiary: #94a3b8; + + /* 边框颜色 */ + --border-light: rgba(0, 0, 0, 0.06); + --border-medium: rgba(0, 0, 0, 0.08); + --border-input: rgba(0, 0, 0, 0.08); + + /* 主题色 */ + --accent-primary: #2563eb; + --accent-primary-hover: #3b82f6; + --accent-primary-light: rgba(37, 99, 235, 0.08); + --accent-primary-medium: rgba(37, 99, 235, 0.15); + + /* 工具调用颜色 */ + --tool-color: #5478FF; + --tool-color-hover: #3d5ce0; + --tool-bg: rgba(84, 120, 255, 0.18); + --tool-bg-hover: rgba(84, 120, 255, 0.28); + --tool-border: rgba(84, 120, 255, 0.22); + + /* 附件颜色 */ + --attachment-color: #ca8a04; + --attachment-color-hover: #a16207; + --attachment-bg: rgba(202, 138, 4, 0.15); + + /* 状态颜色 */ + --success-color: #059669; + --success-bg: rgba(16, 185, 129, 0.1); + --danger-color: #ef4444; + --danger-bg: rgba(239, 68, 68, 0.08); + + /* 滚动条颜色 */ + --scrollbar-thumb: rgba(0, 0, 0, 0.08); + --scrollbar-thumb-sidebar: rgba(0, 0, 0, 0.1); + + /* 遮罩背景 */ + --overlay-bg: rgba(0, 0, 0, 0.3); + --avatar-gradient: linear-gradient(135deg, #3b82f6, #60a5fa); + + /* 兼容旧变量 */ + --text: var(--text-primary); + --text-h: var(--text-primary); + --bg: var(--bg-primary); + --border: var(--border-light); + --accent: var(--accent-primary); + --accent-bg: var(--accent-primary-light); + --accent-border: var(--accent-primary-medium); + --social-bg: rgba(244, 243, 236, 0.5); + + --sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, 'JetBrains Mono', monospace; font: 18px/145% var(--sans); letter-spacing: 0.18px; color-scheme: light dark; - color: var(--text); - background: var(--bg); + color: var(--text-primary); + background: var(--bg-primary); font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #60a5fa; - --accent-bg: rgba(96, 165, 250, 0.15); - --accent-border: rgba(96, 165, 250, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } +[data-theme="dark"] { + --bg-primary: #1a1a1a; + --bg-secondary: #141414; + --bg-tertiary: #0a0a0a; + --bg-hover: rgba(255, 255, 255, 0.08); + --bg-active: rgba(255, 255, 255, 0.12); + --bg-input: #141414; + --bg-code: #141414; + --bg-thinking: #141414; - #social .button-icon { - filter: invert(1) brightness(2); - } + --text-primary: #f0f0f0; + --text-secondary: #a0a0a0; + --text-tertiary: #606060; + + --border-light: rgba(255, 255, 255, 0.08); + --border-medium: rgba(255, 255, 255, 0.12); + --border-input: rgba(255, 255, 255, 0.1); + + --accent-primary: #3b82f6; + --accent-primary-hover: #60a5fa; + --accent-primary-light: rgba(59, 130, 246, 0.15); + --accent-primary-medium: rgba(59, 130, 246, 0.25); + + --tool-color: #5478FF; + --tool-color-hover: #7a96ff; + --tool-bg: rgba(84, 120, 255, 0.28); + --tool-bg-hover: rgba(84, 120, 255, 0.40); + --tool-border: rgba(84, 120, 255, 0.32); + + --attachment-color: #facc15; + --attachment-color-hover: #fde047; + --attachment-bg: rgba(250, 204, 21, 0.22); + + --success-color: #34d399; + --success-bg: rgba(52, 211, 153, 0.15); + --danger-color: #f87171; + --danger-bg: rgba(248, 113, 113, 0.15); + + --scrollbar-thumb: rgba(255, 255, 255, 0.1); + --scrollbar-thumb-sidebar: rgba(255, 255, 255, 0.15); + + --overlay-bg: rgba(0, 0, 0, 0.6); + --avatar-gradient: linear-gradient(135deg, #3b82f6, #60a5fa); + + /* 兼容旧变量 */ + --text: var(--text-primary); + --text-h: var(--text-primary); + --bg: var(--bg-primary); + --border: var(--border-light); + --accent: var(--accent-primary); + --accent-bg: var(--accent-primary-light); + --accent-border: var(--accent-primary-medium); + --social-bg: rgba(47, 48, 58, 0.5); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + overflow: hidden; } body { margin: 0; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -} - -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - #app { - width: 100%; - max-width: 100%; - margin: 0 auto; - min-height: 100vh; - display: flex; - flex-direction: column; - box-sizing: border-box; + height: 100%; } -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } +/* ============ Scrollbar ============ */ +::-webkit-scrollbar { + width: 6px; + height: 6px; } -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } +::-webkit-scrollbar-track { + background: transparent; } -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 3px; } -#next-steps ul { - list-style: none; +::-webkit-scrollbar-thumb:hover { + background: var(--border-medium); +} + +/* ============ Ghost Button ============ */ +.ghost-btn { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px 6px; + border-radius: 4px; + font-size: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} + +.ghost-btn:hover { + background: var(--bg-hover); + color: var(--text-secondary); +} + +.ghost-btn.danger:hover { + background: var(--danger-bg); + color: var(--danger-color); +} + +.ghost-btn.success:hover { + background: var(--success-bg); + color: var(--success-color); +} + +.ghost-btn.accent:hover { + background: var(--accent-primary-light); + color: var(--accent-primary); +} + +/* ============ Markdown Content ============ */ +.md-content { + font-size: 15px; + line-height: 1.7; + color: var(--text-primary); + word-break: break-word; +} + +.md-content h1, +.md-content h2, +.md-content h3 { + margin: 0.8em 0 0.4em; + font-weight: 600; +} + +.md-content h1 { font-size: 1.4em; } +.md-content h2 { font-size: 1.2em; } +.md-content h3 { font-size: 1.1em; } + +.md-content p { + margin: 0.4em 0; +} + +.md-content code { + background: var(--bg-code); + padding: 2px 6px; + border-radius: 4px; + font-family: var(--mono); + font-size: 0.9em; +} + +.md-content pre { + background: var(--bg-code); + padding: 12px; + border-radius: 8px; + overflow-x: auto; + margin: 0.5em 0; +} + +.md-content pre code { + background: transparent; padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } } -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } +.md-content blockquote { + border-left: 3px solid var(--border-medium); + padding-left: 12px; + margin: 0.5em 0; + color: var(--text-secondary); } -.ticks { - position: relative; +.md-content ul, +.md-content ol { + padding-left: 20px; + margin: 0.5em 0; +} + +.md-content a { + color: var(--accent-primary); + text-decoration: none; +} + +.md-content a:hover { + text-decoration: underline; +} + +.md-content img { + max-width: 100%; + border-radius: 8px; +} + +.md-content table { + border-collapse: collapse; width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } + margin: 0.5em 0; +} + +.md-content th, +.md-content td { + border: 1px solid var(--border-light); + padding: 8px 12px; + text-align: left; +} + +.md-content th { + background: var(--bg-secondary); +} + +/* ============ Message Bubble Shared ============ */ +.message-bubble { + display: flex; + gap: 12px; + margin-bottom: 16px; + width: 100%; +} + +.message-bubble.user { + flex-direction: row-reverse; +} + +.message-container { + display: flex; + flex-direction: column; + min-width: 200px; + width: 100%; +} + +.message-bubble.user .message-container { + align-items: flex-end; + width: fit-content; + max-width: 85%; +} + +.message-bubble.assistant .message-container { + align-items: flex-start; + flex: 1 1 auto; + width: 100%; + min-width: 0; +} + +.message-bubble.assistant .message-body { + width: 100%; +} + +.avatar { + width: 32px; + height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + letter-spacing: -0.3px; + flex-shrink: 0; +} + +.user .avatar { + background: linear-gradient(135deg, #2563eb, #3b82f6); + color: white; + font-size: 12px; +} + +.assistant .avatar { + background: var(--avatar-gradient); + color: white; + font-size: 12px; +} + +.message-body { + flex: 1; + min-width: 0; + padding: 16px; + border: 1px solid var(--border-light); + border-radius: 12px; + background: var(--bg-primary); + transition: background 0.2s, border-color 0.2s; +} + +/* ============ App Layout ============ */ +.app { + height: 100vh; + display: flex; + overflow: hidden; +} + +.main-panel { + flex: 1 1 0; + min-width: 0; + overflow: hidden; + transition: all 0.2s; +} + +/* ============ Transitions ============ */ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.2s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +/* ============ Modal Overlay ============ */ +.modal-overlay { + position: fixed; + inset: 0; + background: var(--overlay-bg); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal-content { + background: var(--bg-primary); + border-radius: 12px; + max-width: 600px; + width: 90%; + max-height: 80vh; + overflow: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +/* ============ Form ============ */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 10px 12px; + background: var(--bg-input); + border: 1px solid var(--border-input); + border-radius: 8px; + color: var(--text-primary); + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + border-color: var(--accent-primary); +} + +.form-group textarea { + resize: vertical; + min-height: 80px; +} + +.form-group .hint { + font-size: 12px; + color: var(--text-tertiary); + margin-top: 4px; } diff --git a/dashboard/src/utils/markdown.js b/dashboard/src/utils/markdown.js new file mode 100644 index 0000000..998ed8c --- /dev/null +++ b/dashboard/src/utils/markdown.js @@ -0,0 +1,62 @@ +import { marked } from 'marked' +import katex from 'katex' + +function renderMath(text, displayMode) { + try { + return katex.renderToString(text, { + displayMode, + throwOnError: false, + strict: false, + }) + } catch { + return text + } +} + +// marked extension for inline math $...$ +const mathExtension = { + name: 'math', + level: 'inline', + start(src) { + const idx = src.search(/(?${renderMath(token.text, true)}` + }, +} + +marked.use({ + extensions: [blockMathExtension, mathExtension], + breaks: true, + gfm: true +}) + +export function renderMarkdown(text) { + return marked.parse(text) +} diff --git a/dashboard/src/views/ConversationDetailView.vue b/dashboard/src/views/ConversationDetailView.vue index 37fc613..d03e546 100644 --- a/dashboard/src/views/ConversationDetailView.vue +++ b/dashboard/src/views/ConversationDetailView.vue @@ -1,55 +1,131 @@ diff --git a/luxx/models.py b/luxx/models.py index e8b171b..8e73bd7 100644 --- a/luxx/models.py +++ b/luxx/models.py @@ -130,13 +130,36 @@ class Conversation(Base): class Message(Base): - """Message model""" + """Message model + + content 字段统一使用 JSON 格式存储: + + **User 消息:** + { + "text": "用户输入的文本内容", + "attachments": [ + {"name": "utils.py", "extension": "py", "content": "..."} + ] + } + + **Assistant 消息:** + { + "text": "AI 回复的文本内容", + "tool_calls": [...], // 遗留的扁平结构 + "steps": [ // 有序步骤,用于渲染(主要数据源) + {"id": "step-0", "index": 0, "type": "thinking", "content": "..."}, + {"id": "step-1", "index": 1, "type": "text", "content": "..."}, + {"id": "step-2", "index": 2, "type": "tool_call", "id_ref": "call_xxx", "name": "...", "arguments": "..."}, + {"id": "step-3", "index": 3, "type": "tool_result", "id_ref": "call_xxx", "name": "...", "content": "..."} + ] + } + """ __tablename__ = "messages" id: Mapped[str] = mapped_column(String(64), primary_key=True) conversation_id: Mapped[str] = mapped_column(String(64), ForeignKey("conversations.id"), nullable=False) - role: Mapped[str] = mapped_column(String(16), nullable=False) - content: Mapped[str] = mapped_column(Text, nullable=False) + role: Mapped[str] = mapped_column(String(16), nullable=False) # user, assistant, system, tool + content: Mapped[str] = mapped_column(Text, nullable=False, default="") token_count: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) @@ -144,11 +167,39 @@ class Message(Base): conversation: Mapped["Conversation"] = relationship("Conversation", back_populates="messages") def to_dict(self): - return { + """Convert to dictionary, extracting process_steps for frontend""" + import json + + result = { "id": self.id, "conversation_id": self.conversation_id, "role": self.role, - "content": self.content, "token_count": self.token_count, "created_at": self.created_at.isoformat() if self.created_at else None } + + # Parse content JSON + try: + content_obj = json.loads(self.content) if self.content else {} + except json.JSONDecodeError: + # Legacy plain text content + result["content"] = self.content + result["text"] = self.content + result["attachments"] = [] + result["tool_calls"] = [] + result["process_steps"] = [] + return result + + # Extract common fields + result["text"] = content_obj.get("text", "") + result["attachments"] = content_obj.get("attachments", []) + result["tool_calls"] = content_obj.get("tool_calls", []) + + # Extract steps as process_steps for frontend rendering + result["process_steps"] = content_obj.get("steps", []) + + # For backward compatibility + if "content" not in result: + result["content"] = result["text"] + + return result diff --git a/luxx/routes/messages.py b/luxx/routes/messages.py index 1882040..2f112eb 100644 --- a/luxx/routes/messages.py +++ b/luxx/routes/messages.py @@ -50,7 +50,8 @@ def list_messages( ).order_by(Message.created_at).all() return success_response(data={ - "messages": [m.to_dict() for m in messages] + "messages": [m.to_dict() for m in messages], + "title": conversation.title }) @@ -136,46 +137,13 @@ async def stream_message( db.commit() async def event_generator(): - full_response = "" - - async for event in chat_service.stream_response( + async for sse_str in chat_service.stream_response( conversation=conversation, user_message=data.content, tools_enabled=tools_enabled ): - event_type = event.get("type") - - if event_type == "text": - content = event.get("content", "") - full_response += content - yield f"data: {json.dumps({'type': 'text', 'content': content})}\n\n" - - elif event_type == "tool_call": - yield f"data: {json.dumps({'type': 'tool_call', 'data': event.get('data')})}\n\n" - - elif event_type == "tool_result": - yield f"data: {json.dumps({'type': 'tool_result', 'data': event.get('data')})}\n\n" - - elif event_type == "done": - try: - ai_message = Message( - id=generate_id("msg"), - conversation_id=data.conversation_id, - role="assistant", - content=full_response, - token_count=len(full_response) // 4 - ) - db.add(ai_message) - db.commit() - except Exception: - pass - - yield f"data: {json.dumps({'type': 'done', 'message_id': ai_message.id if 'ai_message' in dir() else None})}\n\n" - - elif event_type == "error": - yield f"data: {json.dumps({'type': 'error', 'error': event.get('error')})}\n\n" - - yield "data: [DONE]\n\n" + # Chat service returns raw SSE strings (including done event) + yield sse_str return StreamingResponse( event_generator(), diff --git a/luxx/services/chat.py b/luxx/services/chat.py index 77e1ae0..7d8fe79 100644 --- a/luxx/services/chat.py +++ b/luxx/services/chat.py @@ -1,5 +1,6 @@ """Chat service module""" import json +import uuid from typing import List, Dict, Any, AsyncGenerator from luxx.models import Conversation, Message @@ -13,6 +14,11 @@ from luxx.config import config MAX_ITERATIONS = 10 +def _sse_event(event: str, data: dict) -> str: + """Format a Server-Sent Event string.""" + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + def get_llm_client(conversation: Conversation = None): """Get LLM client, optionally using conversation's provider""" if conversation and conversation.provider_id: @@ -37,7 +43,7 @@ def get_llm_client(conversation: Conversation = None): class ChatService: - """Chat service""" + """Chat service with tool support""" def __init__(self): self.tool_executor = ToolExecutor() @@ -66,9 +72,19 @@ class ChatService: ).order_by(Message.created_at).all() for msg in db_messages: + # Parse JSON content if possible + try: + content_obj = json.loads(msg.content) if msg.content else {} + if isinstance(content_obj, dict): + content = content_obj.get("text", msg.content) + else: + content = msg.content + except (json.JSONDecodeError, TypeError): + content = msg.content + messages.append({ "role": msg.role, - "content": msg.content + "content": content }) finally: db.close() @@ -80,163 +96,273 @@ class ChatService: conversation: Conversation, user_message: str, tools_enabled: bool = True - ) -> AsyncGenerator[Dict[str, Any], None]: + ) -> AsyncGenerator[Dict[str, str], None]: """ Streaming response generator - Event types: - - process_step: thinking/text/tool_call/tool_result step - - done: final response complete - - error: on error + Yields raw SSE event strings for direct forwarding. """ try: messages = self.build_messages(conversation) messages.append({ "role": "user", - "content": user_message + "content": json.dumps({"text": user_message, "attachments": []}) }) tools = registry.list_all() if tools_enabled else None - iteration = 0 - llm = get_llm_client(conversation) model = conversation.model or llm.default_model or "gpt-4" - while iteration < MAX_ITERATIONS: - iteration += 1 - print(f"[CHAT DEBUG] ====== Starting iteration {iteration} ======") - print(f"[CHAT DEBUG] Messages count: {len(messages)}") + # State tracking + all_steps = [] + all_tool_calls = [] + all_tool_results = [] + step_index = 0 + + # Global step IDs for thinking and text (persist across iterations) + thinking_step_id = None + thinking_step_idx = None + text_step_id = None + text_step_idx = None + + for iteration in range(MAX_ITERATIONS): + print(f"[CHAT] Starting iteration {iteration + 1}, messages: {len(messages)}") - tool_calls_this_round = None + # Stream from LLM + full_content = "" + full_thinking = "" + tool_calls_list = [] - async for event in llm.stream_call( + # Generate new step IDs for each iteration to track multiple thoughts/tools + iteration_thinking_step_id = f"thinking-{iteration}" + iteration_text_step_id = f"text-{iteration}" + + async for sse_line in llm.stream_call( model=model, messages=messages, tools=tools, temperature=conversation.temperature, max_tokens=conversation.max_tokens ): - event_type = event.get("type") + # Parse SSE line + # Format: "event: xxx\ndata: {...}\n\n" + event_type = None + data_str = None - if event_type == "content_delta": - content = event.get("content", "") - if content: - print(f"[CHAT DEBUG] Iteration {iteration} content: {content[:100]}...") - yield {"type": "text", "content": content} + for line in sse_line.strip().split('\n'): + if line.startswith('event: '): + event_type = line[7:].strip() + elif line.startswith('data: '): + data_str = line[6:].strip() - elif event_type == "tool_call_delta": - tool_call = event.get("tool_call", {}) - yield {"type": "tool_call", "data": tool_call} + if data_str is None: + continue - elif event_type == "done": - tool_calls_this_round = event.get("tool_calls") - print(f"[CHAT DEBUG] Done event, tool_calls: {tool_calls_this_round}") - - if tool_calls_this_round and tools_enabled: - print(f"[CHAT DEBUG] Executing tools: {tool_calls_this_round}") - yield {"type": "tool_call", "data": tool_calls_this_round} - - tool_results = self.tool_executor.process_tool_calls_parallel( - tool_calls_this_round, - {} - ) - - messages.append({ - "role": "assistant", - "content": "", - "tool_calls": tool_calls_this_round + # Handle error events from LLM + if event_type == 'error': + try: + error_data = json.loads(data_str) + yield _sse_event("error", {"content": error_data.get("content", "Unknown error")}) + except json.JSONDecodeError: + yield _sse_event("error", {"content": data_str}) + return + + # Parse the data + try: + chunk = json.loads(data_str) + except json.JSONDecodeError: + continue + + # Get delta + choices = chunk.get("choices", []) + if not choices: + continue + + delta = choices[0].get("delta", {}) + + # Handle reasoning (thinking) + reasoning = delta.get("reasoning_content", "") + if reasoning: + full_thinking += reasoning + if thinking_step_id is None: + thinking_step_id = iteration_thinking_step_id + thinking_step_idx = step_index + step_index += 1 + yield _sse_event("process_step", { + "id": thinking_step_id, + "index": thinking_step_idx, + "type": "thinking", + "content": full_thinking + }) + + # Handle content + content = delta.get("content", "") + if content: + full_content += content + if text_step_id is None: + text_step_idx = step_index + text_step_id = iteration_text_step_id + step_index += 1 + yield _sse_event("process_step", { + "id": text_step_id, + "index": text_step_idx, + "type": "text", + "content": full_content + }) + + # Accumulate tool calls + tool_calls_delta = delta.get("tool_calls", []) + for tc in tool_calls_delta: + idx = tc.get("index", 0) + if idx >= len(tool_calls_list): + tool_calls_list.append({ + "id": tc.get("id", ""), + "type": "function", + "function": {"name": "", "arguments": ""} }) - - for tr in tool_results: - messages.append({ - "role": "tool", - "tool_call_id": tr.get("tool_call_id"), - "content": str(tr.get("result", "")) - }) - - yield {"type": "tool_result", "data": tool_results} - else: - break + func = tc.get("function", {}) + if func.get("name"): + tool_calls_list[idx]["function"]["name"] += func["name"] + if func.get("arguments"): + tool_calls_list[idx]["function"]["arguments"] += func["arguments"] - if not tool_calls_this_round or not tools_enabled: - break - - yield {"type": "done"} - - except Exception as e: - print(f"[CHAT ERROR] Exception in stream_response: {type(e).__name__}: {str(e)}") - yield {"type": "error", "error": str(e)} - - except Exception as e: - yield {"type": "error", "error": str(e)} - - def non_stream_response( - self, - conversation: Conversation, - user_message: str, - tools_enabled: bool = False - ) -> Dict[str, Any]: - """Non-streaming response""" - try: - messages = self.build_messages(conversation) - messages.append({ - "role": "user", - "content": user_message - }) - - tools = registry.list_all() if tools_enabled else None - - iteration = 0 - - llm_client = get_llm_client(conversation) - model = conversation.model or llm_client.default_model or "gpt-4" - - while iteration < MAX_ITERATIONS: - iteration += 1 + # Save thinking step + if thinking_step_id is not None: + all_steps.append({ + "id": thinking_step_id, + "index": thinking_step_idx, + "type": "thinking", + "content": full_thinking + }) - response = llm_client.sync_call( - model=model, - messages=messages, - tools=tools, - temperature=conversation.temperature, - max_tokens=conversation.max_tokens - ) + # Save text step + if text_step_id is not None: + all_steps.append({ + "id": text_step_id, + "index": text_step_idx, + "type": "text", + "content": full_content + }) - tool_calls = response.tool_calls - - if tool_calls and tools_enabled: + # Handle tool calls + if tool_calls_list: + all_tool_calls.extend(tool_calls_list) + + # Yield tool_call steps + tool_call_step_ids = [] # Track step IDs for tool calls + for tc in tool_calls_list: + call_step_id = f"tool-{iteration}-{tc.get('function', {}).get('name', 'unknown')}" + tool_call_step_ids.append(call_step_id) + call_step = { + "id": call_step_id, + "index": step_index, + "type": "tool_call", + "id_ref": tc.get("id", ""), + "name": tc["function"]["name"], + "arguments": tc["function"]["arguments"] + } + all_steps.append(call_step) + yield _sse_event("process_step", call_step) + step_index += 1 + + # Execute tools + tool_results = self.tool_executor.process_tool_calls_parallel( + tool_calls_list, {} + ) + + # Yield tool_result steps + for i, tr in enumerate(tool_results): + tool_call_step_id = tool_call_step_ids[i] if i < len(tool_call_step_ids) else f"tool-{i}" + result_step = { + "id": f"result-{iteration}-{tr.get('name', 'unknown')}", + "index": step_index, + "type": "tool_result", + "id_ref": tool_call_step_id, # Reference to the tool_call step + "name": tr.get("name", ""), + "content": tr.get("content", "") + } + all_steps.append(result_step) + yield _sse_event("process_step", result_step) + step_index += 1 + + all_tool_results.append({ + "role": "tool", + "tool_call_id": tr.get("tool_call_id", ""), + "content": tr.get("content", "") + }) + + # Add assistant message with tool calls for next iteration messages.append({ "role": "assistant", - "content": response.content, - "tool_calls": tool_calls + "content": full_content or "", + "tool_calls": tool_calls_list }) - - tool_results = self.tool_executor.process_tool_calls_parallel(tool_calls) - - for tr in tool_results: - messages.append({ - "role": "tool", - "tool_call_id": tr.get("tool_call_id"), - "content": str(tr.get("result", "")) - }) - else: - return { - "success": True, - "content": response.content - } + messages.extend(all_tool_results[-len(tool_results):]) + all_tool_results = [] + continue + + # No tool calls - final iteration, save message + msg_id = str(uuid.uuid4()) + self._save_message( + conversation.id, + msg_id, + full_content, + all_tool_calls, + all_tool_results, + all_steps + ) + + yield _sse_event("done", { + "message_id": msg_id, + "token_count": len(full_content) // 4 + }) + return - return { - "success": True, - "content": "Max iterations reached" - } + # Max iterations exceeded + yield _sse_event("error", {"content": "Exceeded maximum tool call iterations"}) except Exception as e: - return { - "success": False, - "error": str(e) - } + print(f"[CHAT] Exception: {type(e).__name__}: {str(e)}") + yield _sse_event("error", {"content": str(e)}) + + def _save_message( + self, + conversation_id: str, + msg_id: str, + full_content: str, + all_tool_calls: list, + all_tool_results: list, + all_steps: list + ): + """Save the assistant message to database.""" + from luxx.database import SessionLocal + from luxx.models import Message + + content_json = { + "text": full_content, + "steps": all_steps + } + if all_tool_calls: + content_json["tool_calls"] = all_tool_calls + + db = SessionLocal() + try: + msg = Message( + id=msg_id, + conversation_id=conversation_id, + role="assistant", + content=json.dumps(content_json, ensure_ascii=False), + token_count=len(full_content) // 4 + ) + db.add(msg) + db.commit() + except Exception as e: + print(f"[CHAT] Failed to save message: {e}") + db.rollback() + finally: + db.close() # Global chat service diff --git a/luxx/services/llm_client.py b/luxx/services/llm_client.py index 32cbded..257f0a9 100644 --- a/luxx/services/llm_client.py +++ b/luxx/services/llm_client.py @@ -136,82 +136,39 @@ class LLMClient: messages: List[Dict], tools: Optional[List[Dict]] = None, **kwargs - ) -> AsyncGenerator[Dict[str, Any], None]: - """Stream call LLM API""" + ) -> AsyncGenerator[str, None]: + """Stream call LLM API - yields raw SSE event lines + + Yields: + str: Raw SSE event lines for direct forwarding + """ body = self._build_body(model, messages, tools, stream=True, **kwargs) - # Accumulators for tool calls (need to collect from delta chunks) - accumulated_tool_calls = {} + print(f"[LLM] Starting stream_call for model: {model}") + print(f"[LLM] Messages count: {len(messages)}") try: async with httpx.AsyncClient(timeout=120.0) as client: + print(f"[LLM] Sending request to {self.api_url}") async with client.stream( "POST", self.api_url, headers=self._build_headers(), json=body ) as response: + print(f"[LLM] Response status: {response.status_code}") response.raise_for_status() async for line in response.aiter_lines(): - if not line.strip(): - continue - - if line.startswith("data: "): - data_str = line[6:] - - if data_str == "[DONE]": - yield {"type": "done"} - continue - - try: - chunk = json.loads(data_str) - except json.JSONDecodeError: - continue - - if "choices" not in chunk: - continue - - delta = chunk.get("choices", [{}])[0].get("delta", {}) - - # DeepSeek reasoner: use content if available, otherwise fall back to reasoning_content - content = delta.get("content") - reasoning = delta.get("reasoning_content", "") - if content and isinstance(content, str) and content.strip(): - yield {"type": "content_delta", "content": content} - elif reasoning: - yield {"type": "content_delta", "content": reasoning} - - # Accumulate tool calls from delta chunks (DeepSeek sends them incrementally) - tool_calls_delta = delta.get("tool_calls", []) - for tc in tool_calls_delta: - idx = tc.get("index", 0) - if idx not in accumulated_tool_calls: - accumulated_tool_calls[idx] = {"index": idx} - if "function" in tc: - if "function" not in accumulated_tool_calls[idx]: - accumulated_tool_calls[idx]["function"] = {"name": "", "arguments": ""} - if "name" in tc["function"]: - accumulated_tool_calls[idx]["function"]["name"] += tc["function"]["name"] - if "arguments" in tc["function"]: - accumulated_tool_calls[idx]["function"]["arguments"] += tc["function"]["arguments"] - - if tool_calls_delta: - yield {"type": "tool_call_delta", "tool_call": tool_calls_delta} - - # Check for finish_reason to signal end of stream - choice = chunk.get("choices", [{}])[0] - finish_reason = choice.get("finish_reason") - if finish_reason: - # Build final tool_calls list from accumulated chunks - final_tool_calls = list(accumulated_tool_calls.values()) if accumulated_tool_calls else None - yield {"type": "done", "tool_calls": final_tool_calls} + if line.strip(): + yield line + "\n" except httpx.HTTPStatusError as e: - # Return error as an event instead of raising - error_text = e.response.text if e.response else str(e) - yield {"type": "error", "error": f"HTTP {e.response.status_code}: {error_text}"} + status_code = e.response.status_code if e.response else "?" + print(f"[LLM] HTTP error: {status_code}") + yield f"event: error\ndata: {json.dumps({'content': f'HTTP {status_code}: Request failed'})}\n\n" except Exception as e: - yield {"type": "error", "error": str(e)} + print(f"[LLM] Exception: {type(e).__name__}: {str(e)}") + yield f"event: error\ndata: {json.dumps({'content': str(e)})}\n\n" # Global LLM client