feat: 实现增量渲染逻辑
This commit is contained in:
parent
e93ec6d94d
commit
c1788f1ba3
|
|
@ -22,6 +22,7 @@ luxx/
|
||||||
│ ├── auth.py # 认证
|
│ ├── auth.py # 认证
|
||||||
│ ├── conversations.py # 会话管理
|
│ ├── conversations.py # 会话管理
|
||||||
│ ├── messages.py # 消息处理
|
│ ├── messages.py # 消息处理
|
||||||
|
│ ├── providers.py # LLM 提供商管理
|
||||||
│ └── tools.py # 工具管理
|
│ └── tools.py # 工具管理
|
||||||
├── services/ # 服务层
|
├── services/ # 服务层
|
||||||
│ ├── chat.py # 聊天服务
|
│ ├── chat.py # 聊天服务
|
||||||
|
|
@ -101,15 +102,63 @@ erDiagram
|
||||||
string id PK
|
string id PK
|
||||||
string conversation_id FK
|
string conversation_id FK
|
||||||
string role
|
string role
|
||||||
longtext content
|
longtext content "JSON 格式"
|
||||||
int token_count
|
int token_count
|
||||||
datetime created_at
|
datetime created_at
|
||||||
}
|
}
|
||||||
|
|
||||||
USER ||--o{ CONVERSATION : "has"
|
USER ||--o{ CONVERSATION : "has"
|
||||||
CONVERSATION ||--o{ MESSAGE : "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. 工具系统
|
### 5. 工具系统
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|
@ -191,6 +240,9 @@ LLM API 客户端:
|
||||||
| `/conversations` | GET/POST | 会话列表/创建 |
|
| `/conversations` | GET/POST | 会话列表/创建 |
|
||||||
| `/conversations/{id}` | GET/DELETE | 会话详情/删除 |
|
| `/conversations/{id}` | GET/DELETE | 会话详情/删除 |
|
||||||
| `/messages/stream` | POST | 流式消息发送 |
|
| `/messages/stream` | POST | 流式消息发送 |
|
||||||
|
| `/providers` | GET/POST | LLM 提供商列表/创建 |
|
||||||
|
| `/providers/{id}` | GET/PUT/DELETE | 提供商详情/更新/删除 |
|
||||||
|
| `/providers/{id}/test` | POST | 测试提供商连接 |
|
||||||
| `/tools` | GET | 可用工具列表 |
|
| `/tools` | GET | 可用工具列表 |
|
||||||
|
|
||||||
## 数据流
|
## 数据流
|
||||||
|
|
@ -227,12 +279,29 @@ sequenceDiagram
|
||||||
|
|
||||||
| 事件 | 说明 |
|
| 事件 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `text` | 文本内容增量 |
|
| `process_step` | 结构化步骤(thinking/text/tool_call/tool_result),携带 `id`、`index` 确保渲染顺序 |
|
||||||
| `tool_call` | 工具调用请求 |
|
|
||||||
| `tool_result` | 工具执行结果 |
|
|
||||||
| `done` | 响应完成 |
|
| `done` | 响应完成 |
|
||||||
| `error` | 错误信息 |
|
| `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
|
### config.yaml
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# 配置文件
|
# 配置文件
|
||||||
app:
|
app:
|
||||||
secret_key: ${APP_SECRET_KEY}
|
secret_key: ${APP_SECRET_KEY}
|
||||||
debug: true
|
debug: false
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8000
|
port: 8000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>dashboard</title>
|
<title>Luxx Dashboard</title>
|
||||||
|
<!-- KaTeX CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
||||||
|
<!-- Highlight.js CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.10.0/styles/github.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
|
"katex": "^0.16.11",
|
||||||
|
"marked": "^15.0.0",
|
||||||
|
"marked-highlight": "^2.2.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
|
|
@ -586,6 +589,15 @@
|
||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/copy-anything": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz",
|
"resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||||
|
|
@ -887,6 +899,22 @@
|
||||||
"url": "https://github.com/sponsors/mesqueeb"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
|
|
@ -1157,6 +1185,28 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
|
"katex": "^0.16.11",
|
||||||
|
"marked": "^15.0.0",
|
||||||
|
"marked-highlight": "^2.2.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
<template>
|
||||||
|
<div class="message-bubble" :class="[role]">
|
||||||
|
<div v-if="role === 'user'" class="avatar">user</div>
|
||||||
|
<div v-else class="avatar">Luxx</div>
|
||||||
|
<div class="message-container">
|
||||||
|
<!-- File attachments list -->
|
||||||
|
<div v-if="attachments && attachments.length > 0" class="attachments-list">
|
||||||
|
<div v-for="(file, index) in attachments" :key="index" class="attachment-item">
|
||||||
|
<span class="attachment-icon">{{ file.extension }}</span>
|
||||||
|
<span class="attachment-name">{{ file.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref="messageRef" class="message-body">
|
||||||
|
<!-- Primary rendering path: processSteps contains all ordered steps -->
|
||||||
|
<ProcessBlock
|
||||||
|
v-if="processSteps && processSteps.length > 0"
|
||||||
|
:process-steps="processSteps"
|
||||||
|
/>
|
||||||
|
<!-- Fallback path: old messages without processSteps in DB -->
|
||||||
|
<template v-else>
|
||||||
|
<ProcessBlock
|
||||||
|
v-if="toolCalls && toolCalls.length > 0"
|
||||||
|
:tool-calls="toolCalls"
|
||||||
|
/>
|
||||||
|
<div class="md-content message-content" v-html="renderedContent"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="message-footer">
|
||||||
|
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
||||||
|
<span class="message-time">{{ formatTime(createdAt) }}</span>
|
||||||
|
<button v-if="role === 'assistant'" class="ghost-btn accent" @click="copyContent" title="复制">
|
||||||
|
<span v-html="copyIcon"></span>
|
||||||
|
</button>
|
||||||
|
<button v-if="deletable" class="ghost-btn danger" @click="$emit('delete')" title="删除">
|
||||||
|
<span v-html="trashIcon"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { renderMarkdown } from '../utils/markdown.js'
|
||||||
|
import ProcessBlock from './ProcessBlock.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
role: { type: String, required: true },
|
||||||
|
text: { type: String, default: '' },
|
||||||
|
toolCalls: { type: Array, default: () => [] },
|
||||||
|
processSteps: { type: Array, default: () => [] },
|
||||||
|
tokenCount: { type: Number, default: 0 },
|
||||||
|
createdAt: { type: String, default: '' },
|
||||||
|
deletable: { type: Boolean, default: false },
|
||||||
|
attachments: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['delete'])
|
||||||
|
|
||||||
|
const messageRef = ref(null)
|
||||||
|
|
||||||
|
const renderedContent = computed(() => {
|
||||||
|
if (!props.text) return ''
|
||||||
|
return renderMarkdown(props.text)
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(time) {
|
||||||
|
if (!time) return ''
|
||||||
|
return new Date(time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyContent() {
|
||||||
|
let text = props.text || ''
|
||||||
|
if (props.processSteps && props.processSteps.length > 0) {
|
||||||
|
const parts = props.processSteps
|
||||||
|
.filter(s => s && s.type === 'text')
|
||||||
|
.map(s => s.content)
|
||||||
|
if (parts.length > 0) text = parts.join('\n\n')
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const copyIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`
|
||||||
|
|
||||||
|
const trashIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>`
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.attachments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: var(--bg-code);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-icon {
|
||||||
|
background: var(--attachment-bg);
|
||||||
|
color: var(--attachment-color);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-count,
|
||||||
|
.message-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,373 @@
|
||||||
|
<template>
|
||||||
|
<div ref="processRef" class="process-block" :class="{ 'is-streaming': streaming }">
|
||||||
|
<!-- Thinking Steps -->
|
||||||
|
<div
|
||||||
|
v-for="item in thinkingItems"
|
||||||
|
:key="item.key"
|
||||||
|
class="step-item thinking"
|
||||||
|
>
|
||||||
|
<div class="step-header" @click="toggleExpand(item.key)">
|
||||||
|
<span v-html="brainIcon"></span>
|
||||||
|
<span class="step-label">思考中</span>
|
||||||
|
<span class="step-brief">{{ item.brief || '正在思考...' }}</span>
|
||||||
|
<span v-if="streaming && item.key === lastThinkingKey" class="loading-dots">...</span>
|
||||||
|
<span class="arrow" :class="{ open: expandedKeys.has(item.key) }" v-html="chevronDown"></span>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedKeys.has(item.key)" class="step-content">
|
||||||
|
<div class="thinking-text">{{ item.content }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tool Call Steps -->
|
||||||
|
<div
|
||||||
|
v-for="item in toolCallItems"
|
||||||
|
:key="item.key"
|
||||||
|
class="step-item tool_call"
|
||||||
|
:class="{ loading: item.loading }"
|
||||||
|
>
|
||||||
|
<div class="step-header" @click="toggleExpand(item.key)">
|
||||||
|
<span v-html="toolIcon"></span>
|
||||||
|
<span class="step-label">{{ item.name || '工具调用' }}</span>
|
||||||
|
<span class="step-brief">{{ item.brief || '' }}</span>
|
||||||
|
<span v-if="item.loading" class="loading-dots">...</span>
|
||||||
|
<span v-else-if="item.isSuccess === true" class="step-badge success">成功</span>
|
||||||
|
<span v-else-if="item.isSuccess === false" class="step-badge error">失败</span>
|
||||||
|
<span class="arrow" :class="{ open: expandedKeys.has(item.key) }" v-html="chevronDown"></span>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedKeys.has(item.key)" class="step-content">
|
||||||
|
<div class="tool-detail">
|
||||||
|
<span class="detail-label">参数</span>
|
||||||
|
<pre>{{ formatArgs(item.args) }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.resultSummary || item.fullResult" class="tool-detail" style="margin-top: 8px;">
|
||||||
|
<span class="detail-label">结果</span>
|
||||||
|
<pre>{{ item.fullResult || item.resultSummary }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Text Steps -->
|
||||||
|
<div
|
||||||
|
v-for="item in textItems"
|
||||||
|
:key="item.key"
|
||||||
|
class="text-content"
|
||||||
|
v-html="renderMarkdown(item.content)"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Streaming indicator -->
|
||||||
|
<div v-if="streaming && !hasContent" class="streaming-indicator">
|
||||||
|
<span v-html="sparkleIcon"></span>
|
||||||
|
<span>AI 正在输入...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { renderMarkdown } from '../utils/markdown.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
processSteps: { type: Array, default: () => [] },
|
||||||
|
toolCalls: { type: Array, default: () => [] },
|
||||||
|
streaming: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const processRef = ref(null)
|
||||||
|
const expandedKeys = ref(new Set())
|
||||||
|
|
||||||
|
// 构建 processItems 从 processSteps
|
||||||
|
const allItems = computed(() => {
|
||||||
|
const items = []
|
||||||
|
|
||||||
|
if (props.processSteps && props.processSteps.length > 0) {
|
||||||
|
for (const step of props.processSteps) {
|
||||||
|
if (step.type === 'thinking') {
|
||||||
|
items.push({
|
||||||
|
key: step.id || `thinking-${step.index}`,
|
||||||
|
type: 'thinking',
|
||||||
|
content: step.content || '',
|
||||||
|
brief: step.content ? step.content.slice(0, 50) + (step.content.length > 50 ? '...' : '') : '',
|
||||||
|
})
|
||||||
|
} else if (step.type === 'tool_call') {
|
||||||
|
items.push({
|
||||||
|
key: step.id || `tool-${step.index}`,
|
||||||
|
type: 'tool_call',
|
||||||
|
id: step.id,
|
||||||
|
name: step.name,
|
||||||
|
args: step.args,
|
||||||
|
brief: step.name || '',
|
||||||
|
loading: step.loading,
|
||||||
|
isSuccess: step.isSuccess,
|
||||||
|
resultSummary: step.resultSummary,
|
||||||
|
fullResult: step.fullResult,
|
||||||
|
})
|
||||||
|
} else if (step.type === 'tool_result') {
|
||||||
|
// 合并 tool_result 到对应的 tool_call
|
||||||
|
const toolId = step.id_ref || step.id
|
||||||
|
const match = items.findLast(it => it.type === 'tool_call' && it.id === toolId)
|
||||||
|
if (match) {
|
||||||
|
match.resultSummary = step.content ? step.content.slice(0, 200) : ''
|
||||||
|
match.fullResult = step.content || ''
|
||||||
|
match.isSuccess = step.success !== false
|
||||||
|
match.loading = false
|
||||||
|
} else {
|
||||||
|
// 如果没有找到对应的 tool_call,创建一个占位符
|
||||||
|
items.push({
|
||||||
|
key: `result-${step.id || step.index}`,
|
||||||
|
type: 'tool_call',
|
||||||
|
id: step.id_ref || step.id,
|
||||||
|
name: step.name || '工具结果',
|
||||||
|
args: '{}',
|
||||||
|
brief: step.name || '工具结果',
|
||||||
|
loading: false,
|
||||||
|
isSuccess: true,
|
||||||
|
resultSummary: step.content ? step.content.slice(0, 200) : '',
|
||||||
|
fullResult: step.content || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (step.type === 'text') {
|
||||||
|
items.push({
|
||||||
|
key: step.id || `text-${step.index}`,
|
||||||
|
type: 'text',
|
||||||
|
content: step.content || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (props.toolCalls && props.toolCalls.length > 0) {
|
||||||
|
// 兼容旧的 toolCalls 格式
|
||||||
|
for (const tc of props.toolCalls) {
|
||||||
|
items.push({
|
||||||
|
key: tc.id || `tool-${tc.index}`,
|
||||||
|
type: 'tool_call',
|
||||||
|
id: tc.id,
|
||||||
|
name: tc.name,
|
||||||
|
args: tc.arguments,
|
||||||
|
brief: tc.name || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const thinkingItems = computed(() => allItems.value.filter(i => i.type === 'thinking'))
|
||||||
|
const toolCallItems = computed(() => allItems.value.filter(i => i.type === 'tool_call'))
|
||||||
|
const textItems = computed(() => allItems.value.filter(i => i.type === 'text'))
|
||||||
|
|
||||||
|
const hasContent = computed(() => allItems.value.length > 0)
|
||||||
|
const lastThinkingKey = computed(() => {
|
||||||
|
const thinkingItems = allItems.value.filter(i => i.type === 'thinking')
|
||||||
|
return thinkingItems.length > 0 ? thinkingItems[thinkingItems.length - 1].key : null
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleExpand(key) {
|
||||||
|
if (expandedKeys.value.has(key)) {
|
||||||
|
expandedKeys.value.delete(key)
|
||||||
|
} else {
|
||||||
|
expandedKeys.value.add(key)
|
||||||
|
}
|
||||||
|
expandedKeys.value = new Set(expandedKeys.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatArgs(args) {
|
||||||
|
if (!args) return '{}'
|
||||||
|
if (typeof args === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(args), null, 2)
|
||||||
|
} catch {
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(args, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const brainIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"></path><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"></path><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"></path><path d="M17.599 6.5a3 3 0 0 0 .399-1.375"></path><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"></path><path d="M3.477 10.896a4 4 0 0 1 .585-.396"></path><path d="M19.938 10.5a4 4 0 0 1 .585.396"></path><path d="M6 18a4 4 0 0 1-1.967-.516"></path><path d="M19.967 17.484A4 4 0 0 1 18 18"></path></svg>`
|
||||||
|
|
||||||
|
const toolIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`
|
||||||
|
|
||||||
|
const chevronDown = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`
|
||||||
|
|
||||||
|
const sparkleIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"></path></svg>`
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.process-block {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step items (shared) */
|
||||||
|
.step-item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step header (shared by thinking and tool_call) */
|
||||||
|
.thinking .step-header,
|
||||||
|
.tool_call .step-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking .step-header:hover,
|
||||||
|
.tool_call .step-header:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking .step-header svg:first-child {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool_call .step-header svg:first-child {
|
||||||
|
color: var(--tool-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 130px;
|
||||||
|
max-width: 130px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-badge.success {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-badge.error {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-brief {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--tool-color);
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool_call.loading .step-header {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expandable step content panel */
|
||||||
|
.step-content {
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: var(--bg-code);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-detail {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-detail pre {
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text content */
|
||||||
|
.text-content {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content :deep(.placeholder) {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Streaming cursor indicator */
|
||||||
|
.streaming-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add separator only when there are step items above the indicator */
|
||||||
|
.process-block:has(.step-item) .streaming-indicator {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 0 0;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -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 = {
|
export const authAPI = {
|
||||||
// 用户登录
|
|
||||||
login: (data) => api.post('/auth/login', data),
|
login: (data) => api.post('/auth/login', data),
|
||||||
|
|
||||||
// 用户注册
|
|
||||||
register: (data) => api.post('/auth/register', data),
|
register: (data) => api.post('/auth/register', data),
|
||||||
|
|
||||||
// 用户登出
|
|
||||||
logout: () => api.post('/auth/logout'),
|
logout: () => api.post('/auth/logout'),
|
||||||
|
|
||||||
// 获取当前用户信息
|
|
||||||
getMe: () => api.get('/auth/me')
|
getMe: () => api.get('/auth/me')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 会话接口 ============
|
// ============ 会话接口 ============
|
||||||
|
|
||||||
export const conversationsAPI = {
|
export const conversationsAPI = {
|
||||||
// 获取会话列表
|
|
||||||
list: (params) => api.get('/conversations/', { params }),
|
list: (params) => api.get('/conversations/', { params }),
|
||||||
|
|
||||||
// 创建会话
|
|
||||||
create: (data) => api.post('/conversations/', data),
|
create: (data) => api.post('/conversations/', data),
|
||||||
|
|
||||||
// 获取会话详情
|
|
||||||
get: (id) => api.get(`/conversations/${id}`),
|
get: (id) => api.get(`/conversations/${id}`),
|
||||||
|
|
||||||
// 更新会话
|
|
||||||
update: (id, data) => api.put(`/conversations/${id}`, data),
|
update: (id, data) => api.put(`/conversations/${id}`, data),
|
||||||
|
|
||||||
// 删除会话
|
|
||||||
delete: (id) => api.delete(`/conversations/${id}`)
|
delete: (id) => api.delete(`/conversations/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 消息接口 ============
|
// ============ 消息接口 ============
|
||||||
|
|
||||||
export const messagesAPI = {
|
export const messagesAPI = {
|
||||||
// 获取消息列表
|
|
||||||
list: (conversationId, params) => api.get('/messages/', { params: { conversation_id: conversationId, ...params } }),
|
list: (conversationId, params) => api.get('/messages/', { params: { conversation_id: conversationId, ...params } }),
|
||||||
|
|
||||||
// 发送消息(非流式)
|
|
||||||
send: (data) => api.post('/messages/', data),
|
send: (data) => api.post('/messages/', data),
|
||||||
|
|
||||||
// 发送消息(流式)- 使用原生 fetch 避免 axios 拦截
|
// 发送消息(流式)
|
||||||
sendStream: (data) => {
|
sendStream: (data, callbacks) => {
|
||||||
const token = localStorage.getItem('access_token')
|
return createSSEStream('/messages/stream', {
|
||||||
return fetch('/api/messages/stream', {
|
conversation_id: data.conversation_id,
|
||||||
method: 'POST',
|
content: data.content,
|
||||||
headers: {
|
tools_enabled: callbacks.toolsEnabled !== false
|
||||||
'Content-Type': 'application/json',
|
}, callbacks)
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 删除消息
|
|
||||||
delete: (id) => api.delete(`/messages/${id}`)
|
delete: (id) => api.delete(`/messages/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 工具接口 ============
|
// ============ 工具接口 ============
|
||||||
|
|
||||||
export const toolsAPI = {
|
export const toolsAPI = {
|
||||||
// 获取工具列表
|
|
||||||
list: (params) => api.get('/tools/', { params }),
|
list: (params) => api.get('/tools/', { params }),
|
||||||
|
|
||||||
// 获取工具详情
|
|
||||||
get: (name) => api.get(`/tools/${name}`),
|
get: (name) => api.get(`/tools/${name}`),
|
||||||
|
|
||||||
// 执行工具
|
|
||||||
execute: (name, data) => api.post(`/tools/${name}/execute`, data)
|
execute: (name, data) => api.post(`/tools/${name}/execute`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ LLM Provider 接口 ============
|
// ============ LLM Provider 接口 ============
|
||||||
|
|
||||||
export const providersAPI = {
|
export const providersAPI = {
|
||||||
// 获取提供商列表
|
|
||||||
list: () => api.get('/providers/'),
|
list: () => api.get('/providers/'),
|
||||||
|
|
||||||
// 创建提供商
|
|
||||||
create: (data) => api.post('/providers/', data),
|
create: (data) => api.post('/providers/', data),
|
||||||
|
|
||||||
// 获取提供商详情
|
|
||||||
get: (id) => api.get(`/providers/${id}`),
|
get: (id) => api.get(`/providers/${id}`),
|
||||||
|
|
||||||
// 更新提供商
|
|
||||||
update: (id, data) => api.put(`/providers/${id}`, data),
|
update: (id, data) => api.put(`/providers/${id}`, data),
|
||||||
|
|
||||||
// 删除提供商
|
|
||||||
delete: (id) => api.delete(`/providers/${id}`),
|
delete: (id) => api.delete(`/providers/${id}`),
|
||||||
|
|
||||||
// 测试连接
|
|
||||||
test: (id) => api.post(`/providers/${id}/test`)
|
test: (id) => api.post(`/providers/${id}/test`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认导出
|
|
||||||
export default api
|
export default api
|
||||||
|
|
@ -1,294 +1,455 @@
|
||||||
|
/* ============ Global Reset & Base ============ */
|
||||||
:root {
|
:root {
|
||||||
--text: #6b6375;
|
/* 背景色 */
|
||||||
--text-h: #08060d;
|
--bg-primary: #ffffff;
|
||||||
--bg: #fff;
|
--bg-secondary: #f8fafc;
|
||||||
--border: #e5e4e7;
|
--bg-tertiary: #f0f4f8;
|
||||||
--code-bg: #f4f3ec;
|
--bg-hover: rgba(37, 99, 235, 0.06);
|
||||||
--accent: #2563eb;
|
--bg-active: rgba(37, 99, 235, 0.12);
|
||||||
--accent-bg: rgba(37, 99, 235, 0.1);
|
--bg-input: #f8fafc;
|
||||||
--accent-border: rgba(37, 99, 235, 0.5);
|
--bg-code: #f1f5f9;
|
||||||
--social-bg: rgba(244, 243, 236, 0.5);
|
--bg-thinking: #f1f5f9;
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
|
||||||
|
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
/* 文字颜色 */
|
||||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
--text-primary: #1e293b;
|
||||||
--mono: ui-monospace, Consolas, monospace;
|
--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);
|
font: 18px/145% var(--sans);
|
||||||
letter-spacing: 0.18px;
|
letter-spacing: 0.18px;
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
color: var(--text);
|
color: var(--text-primary);
|
||||||
background: var(--bg);
|
background: var(--bg-primary);
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
[data-theme="dark"] {
|
||||||
:root {
|
--bg-primary: #1a1a1a;
|
||||||
--text: #9ca3af;
|
--bg-secondary: #141414;
|
||||||
--text-h: #f3f4f6;
|
--bg-tertiary: #0a0a0a;
|
||||||
--bg: #16171d;
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--border: #2e303a;
|
--bg-active: rgba(255, 255, 255, 0.12);
|
||||||
--code-bg: #1f2028;
|
--bg-input: #141414;
|
||||||
--accent: #60a5fa;
|
--bg-code: #141414;
|
||||||
--accent-bg: rgba(96, 165, 250, 0.15);
|
--bg-thinking: #141414;
|
||||||
--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;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
--text-primary: #f0f0f0;
|
||||||
filter: invert(1) brightness(2);
|
--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 {
|
body {
|
||||||
margin: 0;
|
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 {
|
#app {
|
||||||
width: 100%;
|
height: 100%;
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#center {
|
/* ============ Scrollbar ============ */
|
||||||
display: flex;
|
::-webkit-scrollbar {
|
||||||
flex-direction: column;
|
width: 6px;
|
||||||
gap: 25px;
|
height: 6px;
|
||||||
place-content: center;
|
|
||||||
place-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 32px 20px 24px;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#next-steps {
|
::-webkit-scrollbar-track {
|
||||||
display: flex;
|
background: transparent;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#docs {
|
::-webkit-scrollbar-thumb {
|
||||||
border-right: 1px solid var(--border);
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 3px;
|
||||||
@media (max-width: 1024px) {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#next-steps ul {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
list-style: none;
|
background: var(--border-medium);
|
||||||
padding: 0;
|
}
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 32px 0 0;
|
|
||||||
|
|
||||||
.logo {
|
/* ============ Ghost Button ============ */
|
||||||
height: 18px;
|
.ghost-btn {
|
||||||
}
|
background: none;
|
||||||
|
border: none;
|
||||||
a {
|
color: var(--text-tertiary);
|
||||||
color: var(--text-h);
|
cursor: pointer;
|
||||||
font-size: 16px;
|
padding: 4px 6px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
background: var(--social-bg);
|
font-size: 14px;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
padding: 6px 12px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content blockquote {
|
||||||
|
border-left: 3px solid var(--border-medium);
|
||||||
|
padding-left: 12px;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content ul,
|
||||||
|
.md-content ol {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content a {
|
||||||
|
color: var(--accent-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: box-shadow 0.3s;
|
}
|
||||||
|
|
||||||
&:hover {
|
.md-content a:hover {
|
||||||
box-shadow: var(--shadow);
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.button-icon {
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
.md-content img {
|
||||||
margin-top: 20px;
|
max-width: 100%;
|
||||||
flex-wrap: wrap;
|
border-radius: 8px;
|
||||||
justify-content: center;
|
}
|
||||||
|
|
||||||
li {
|
.md-content table {
|
||||||
flex: 1 1 calc(50% - 8px);
|
border-collapse: collapse;
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
margin: 0.5em 0;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#spacer {
|
.md-content th,
|
||||||
height: 88px;
|
.md-content td {
|
||||||
border-top: 1px solid var(--border);
|
border: 1px solid var(--border-light);
|
||||||
@media (max-width: 1024px) {
|
padding: 8px 12px;
|
||||||
height: 48px;
|
text-align: left;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticks {
|
.md-content th {
|
||||||
position: relative;
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Message Bubble Shared ============ */
|
||||||
|
.message-bubble {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
}
|
||||||
&::before,
|
|
||||||
&::after {
|
.message-bubble.user {
|
||||||
content: '';
|
flex-direction: row-reverse;
|
||||||
position: absolute;
|
}
|
||||||
top: -4.5px;
|
|
||||||
border: 5px solid transparent;
|
.message-container {
|
||||||
}
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
&::before {
|
min-width: 200px;
|
||||||
left: 0;
|
width: 100%;
|
||||||
border-left-color: var(--border);
|
}
|
||||||
}
|
|
||||||
&::after {
|
.message-bubble.user .message-container {
|
||||||
right: 0;
|
align-items: flex-end;
|
||||||
border-right-color: var(--border);
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(/(?<!\$)\$(?!\$)/)
|
||||||
|
return idx === -1 ? undefined : idx
|
||||||
|
},
|
||||||
|
tokenizer(src) {
|
||||||
|
const match = src.match(/^\$\s*([^\$\n]+?)\s*\$/)
|
||||||
|
if (match) {
|
||||||
|
return { type: 'math', raw: match[0], text: match[1].trim(), displayMode: false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token) {
|
||||||
|
return renderMath(token.text, token.displayMode)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// marked extension for block math $$...$$
|
||||||
|
const blockMathExtension = {
|
||||||
|
name: 'blockMath',
|
||||||
|
level: 'block',
|
||||||
|
start(src) {
|
||||||
|
const idx = src.indexOf('$$')
|
||||||
|
return idx === -1 ? undefined : idx
|
||||||
|
},
|
||||||
|
tokenizer(src) {
|
||||||
|
const match = src.match(/^\$\$\s*([\s\S]+?)\s*\$\$\n?/)
|
||||||
|
if (match) {
|
||||||
|
return { type: 'blockMath', raw: match[0], text: match[1].trim() }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token) {
|
||||||
|
return `<div class="math-block">${renderMath(token.text, true)}</div>`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
marked.use({
|
||||||
|
extensions: [blockMathExtension, mathExtension],
|
||||||
|
breaks: true,
|
||||||
|
gfm: true
|
||||||
|
})
|
||||||
|
|
||||||
|
export function renderMarkdown(text) {
|
||||||
|
return marked.parse(text)
|
||||||
|
}
|
||||||
|
|
@ -1,55 +1,131 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="chat-view">
|
<div class="chat-view main-panel">
|
||||||
<div class="chat-container">
|
<div v-if="!conversationId" class="welcome">
|
||||||
<div class="messages" ref="messagesContainer">
|
<div class="welcome-icon">
|
||||||
<div v-if="loading" class="loading">加载中...</div>
|
<svg viewBox="0 0 64 64" width="36" height="36">
|
||||||
<div v-else-if="!messages.length" class="empty">
|
<rect width="64" height="64" rx="14" fill="url(#favBg)"/>
|
||||||
<p>开始对话吧!</p>
|
<defs>
|
||||||
|
<linearGradient id="favBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#2563eb"/>
|
||||||
|
<stop offset="100%" stop-color="#60a5fa"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<text x="32" y="40" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,sans-serif" font-size="18" font-weight="800" fill="#fff" letter-spacing="-0.5">Luxx</text>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="msg in messages" :key="msg.id" :class="['message', msg.role]">
|
<h1>Chat</h1>
|
||||||
<div class="message-avatar">{{ msg.role === 'user' ? 'U' : 'A' }}</div>
|
<p>选择一个对话开始,或创建新对话</p>
|
||||||
<div class="message-content">
|
</div>
|
||||||
<div class="message-text">{{ msg.content }}</div>
|
|
||||||
<div class="message-time">{{ formatTime(msg.created_at) }}</div>
|
<template v-else>
|
||||||
|
<div class="chat-header">
|
||||||
|
<div class="chat-title-area">
|
||||||
|
<h2 class="chat-title">{{ conversationTitle || '新对话' }}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="streaming" class="message assistant streaming">
|
|
||||||
<div class="message-avatar">A</div>
|
<div ref="messagesContainer" class="messages-container">
|
||||||
<div class="message-content">
|
<div v-if="loading" class="load-more-top">
|
||||||
<div class="message-text">{{ streamContent }}<span class="cursor">▋</span></div>
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="messages-list">
|
||||||
|
<div
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
:data-msg-id="msg.id"
|
||||||
|
>
|
||||||
|
<MessageBubble
|
||||||
|
:role="msg.role"
|
||||||
|
:text="msg.text || msg.content"
|
||||||
|
:tool-calls="msg.tool_calls"
|
||||||
|
:process-steps="msg.process_steps"
|
||||||
|
:token-count="msg.token_count"
|
||||||
|
:created-at="msg.created_at"
|
||||||
|
:deletable="msg.role === 'user'"
|
||||||
|
:attachments="msg.attachments"
|
||||||
|
@delete="deleteMessage(msg.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 流式消息 -->
|
||||||
|
<div v-if="streamingMessage" class="message-bubble assistant streaming">
|
||||||
|
<div class="avatar">Luxx</div>
|
||||||
|
<div class="message-body">
|
||||||
|
<ProcessBlock
|
||||||
|
:process-steps="streamingMessage.process_steps"
|
||||||
|
:streaming="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-area">
|
<div class="message-input">
|
||||||
|
<div class="input-container">
|
||||||
<textarea
|
<textarea
|
||||||
|
ref="textareaRef"
|
||||||
v-model="inputMessage"
|
v-model="inputMessage"
|
||||||
@keydown.enter.exact.prevent="sendMessage"
|
:placeholder="sending ? 'AI 正在回复中...' : '输入消息... (Shift+Enter 换行)'"
|
||||||
placeholder="输入消息..."
|
|
||||||
rows="1"
|
rows="1"
|
||||||
|
@input="autoResize"
|
||||||
|
@keydown="onKeydown"
|
||||||
></textarea>
|
></textarea>
|
||||||
<button @click="sendMessage" :disabled="!inputMessage.trim() || sending" class="send-btn">
|
<div class="input-footer">
|
||||||
{{ sending ? '发送中...' : '发送' }}
|
<div class="input-actions">
|
||||||
|
<button
|
||||||
|
class="btn-send"
|
||||||
|
:class="{ active: canSend }"
|
||||||
|
:disabled="!canSend || sending"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
<span v-html="sendIcon"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="input-hint">AI 助手回复内容仅供参考</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { conversationsAPI, messagesAPI } from '../services/api.js'
|
import { conversationsAPI, messagesAPI } from '../services/api.js'
|
||||||
|
import ProcessBlock from '../components/ProcessBlock.vue'
|
||||||
|
import MessageBubble from '../components/MessageBubble.vue'
|
||||||
|
import { renderMarkdown } from '../utils/markdown.js'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const messages = ref([])
|
const messages = ref([])
|
||||||
const inputMessage = ref('')
|
const inputMessage = ref('')
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
const streaming = ref(false)
|
const streamingMessage = ref(null)
|
||||||
const streamContent = ref('')
|
|
||||||
const messagesContainer = ref(null)
|
const messagesContainer = ref(null)
|
||||||
|
const textareaRef = ref(null)
|
||||||
const conversationId = ref(route.params.id)
|
const conversationId = ref(route.params.id)
|
||||||
|
const conversationTitle = ref('')
|
||||||
|
|
||||||
|
const canSend = computed(() => inputMessage.value.trim().length > 0)
|
||||||
|
|
||||||
|
const sendIcon = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>`
|
||||||
|
|
||||||
|
function autoResize() {
|
||||||
|
const el = textareaRef.value
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 200) + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
sendMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadMessages = async () => {
|
const loadMessages = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
@ -57,6 +133,9 @@ const loadMessages = async () => {
|
||||||
const res = await messagesAPI.list(conversationId.value)
|
const res = await messagesAPI.list(conversationId.value)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messages.value = res.data.messages || []
|
messages.value = res.data.messages || []
|
||||||
|
if (messages.value.length > 0) {
|
||||||
|
conversationTitle.value = res.data.title || ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
@ -66,6 +145,15 @@ const loadMessages = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteMessage = async (msgId) => {
|
||||||
|
try {
|
||||||
|
await messagesAPI.delete(msgId)
|
||||||
|
messages.value = messages.value.filter(m => m.id !== msgId)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
if (!inputMessage.value.trim() || sending.value) return
|
if (!inputMessage.value.trim() || sending.value) return
|
||||||
|
|
||||||
|
|
@ -73,119 +161,92 @@ const sendMessage = async () => {
|
||||||
inputMessage.value = ''
|
inputMessage.value = ''
|
||||||
sending.value = true
|
sending.value = true
|
||||||
|
|
||||||
|
// 清空输入框
|
||||||
|
nextTick(() => {
|
||||||
|
autoResize()
|
||||||
|
})
|
||||||
|
|
||||||
// 添加用户消息
|
// 添加用户消息
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: content,
|
content: content,
|
||||||
|
text: content,
|
||||||
|
attachments: [],
|
||||||
|
process_steps: [],
|
||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString()
|
||||||
})
|
})
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
|
|
||||||
try {
|
// 初始化流式消息
|
||||||
streaming.value = true
|
streamingMessage.value = {
|
||||||
streamContent.value = ''
|
|
||||||
|
|
||||||
const response = await messagesAPI.sendStream({
|
|
||||||
conversation_id: conversationId.value,
|
|
||||||
content: content,
|
|
||||||
tools_enabled: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const reader = response.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
const chunk = decoder.decode(value)
|
|
||||||
const lines = chunk.split('\n')
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
const data = line.slice(6)
|
|
||||||
if (data === '[DONE]') continue
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data)
|
|
||||||
if (parsed.type === 'text') {
|
|
||||||
streamContent.value += parsed.content
|
|
||||||
} else if (parsed.type === 'tool_call') {
|
|
||||||
// 工具调用(只在完整结果时显示一次)
|
|
||||||
const data = parsed.data
|
|
||||||
if (data && Array.isArray(data) && data.length > 0) {
|
|
||||||
// 检查是否有完整的函数名
|
|
||||||
const hasFunctionName = data.some(tc => tc.function && tc.function.name)
|
|
||||||
if (hasFunctionName) {
|
|
||||||
streamContent.value += '\n\n[调用工具] '
|
|
||||||
data.forEach(tc => {
|
|
||||||
if (tc.function && tc.function.name) {
|
|
||||||
streamContent.value += `${tc.function.name} `
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (parsed.type === 'tool_result') {
|
|
||||||
// 工具结果
|
|
||||||
streamContent.value += '\n\n[工具结果]\n'
|
|
||||||
if (Array.isArray(parsed.data)) {
|
|
||||||
parsed.data.forEach(tr => {
|
|
||||||
if (tr.content) {
|
|
||||||
try {
|
|
||||||
const result = JSON.parse(tr.content)
|
|
||||||
if (result.success && result.data && result.data.results) {
|
|
||||||
result.data.results.forEach(r => {
|
|
||||||
streamContent.value += `• ${r.title}\n${r.snippet}\n\n`
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
streamContent.value += tr.content.substring(0, 500)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
streamContent.value += tr.content.substring(0, 500)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
streamContent.value += '无结果'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else if (parsed.type === 'error') {
|
|
||||||
streamContent.value += '\n\n[错误] ' + (parsed.error || '未知错误')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Parse error:', e, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加助手消息
|
|
||||||
if (streamContent.value) {
|
|
||||||
messages.value.push({
|
|
||||||
id: Date.now() + 1,
|
id: Date.now() + 1,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: streamContent.value,
|
process_steps: [],
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE 流式请求
|
||||||
|
messagesAPI.sendStream(
|
||||||
|
{ conversation_id: conversationId.value, content },
|
||||||
|
{
|
||||||
|
onProcessStep: (step) => {
|
||||||
|
if (!streamingMessage.value) return
|
||||||
|
// 按 id 更新或追加步骤
|
||||||
|
const idx = streamingMessage.value.process_steps.findIndex(s => s.id === step.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
streamingMessage.value.process_steps[idx] = step
|
||||||
|
} else {
|
||||||
|
streamingMessage.value.process_steps.push(step)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () => {
|
||||||
|
// 完成,添加到消息列表
|
||||||
|
if (streamingMessage.value) {
|
||||||
|
messages.value.push({
|
||||||
|
...streamingMessage.value,
|
||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString()
|
||||||
})
|
})
|
||||||
|
streamingMessage.value = null
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error('发送失败:', e)
|
|
||||||
alert('发送失败: ' + e.message)
|
|
||||||
} finally {
|
|
||||||
sending.value = false
|
sending.value = false
|
||||||
streaming.value = false
|
},
|
||||||
scrollToBottom()
|
onError: (error) => {
|
||||||
|
console.error('Stream error:', error)
|
||||||
|
if (streamingMessage.value) {
|
||||||
|
streamingMessage.value.process_steps.push({
|
||||||
|
id: 'error-' + Date.now(),
|
||||||
|
index: streamingMessage.value.process_steps.length,
|
||||||
|
type: 'text',
|
||||||
|
content: `[错误] ${error}`
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (messagesContainer.value) {
|
if (messagesContainer.value) {
|
||||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
messagesContainer.value.scrollTo({
|
||||||
|
top: messagesContainer.value.scrollHeight,
|
||||||
|
behavior: streamingMessage.value ? 'instant' : 'smooth'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听流式消息变化,自动滚动
|
||||||
|
watch(() => streamingMessage.value?.process_steps?.length, () => {
|
||||||
|
if (streamingMessage.value) {
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const formatTime = (time) => {
|
const formatTime = (time) => {
|
||||||
if (!time) return ''
|
if (!time) return ''
|
||||||
return new Date(time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
return new Date(time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
@ -195,23 +256,191 @@ onMounted(loadMessages)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chat-view { height: calc(100vh - 70px); display: flex; flex-direction: column; }
|
.chat-view {
|
||||||
.chat-container { flex: 1; display: flex; flex-direction: column; max-width: 900px; margin: 0 auto; width: 100%; }
|
flex: 1 1 0;
|
||||||
.messages { flex: 1; overflow-y: auto; padding: 1rem; }
|
display: flex;
|
||||||
.loading, .empty { text-align: center; padding: 4rem; color: var(--text); }
|
flex-direction: column;
|
||||||
.message { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
|
height: 100vh;
|
||||||
.message.user { flex-direction: row-reverse; }
|
overflow: hidden;
|
||||||
.message-avatar { width: 40px; height: 40px; background: var(--code-bg); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; flex-shrink: 0; }
|
min-width: 0;
|
||||||
.message.user .message-avatar { background: var(--accent-bg); }
|
}
|
||||||
.message-content { max-width: 70%; }
|
|
||||||
.message-text { padding: 1rem; background: var(--code-bg); border-radius: 12px; line-height: 1.6; white-space: pre-wrap; }
|
.welcome {
|
||||||
.message.user .message-text { background: var(--accent); color: white; }
|
flex: 1;
|
||||||
.message-time { font-size: 0.75rem; color: var(--text); margin-top: 0.25rem; }
|
display: flex;
|
||||||
.cursor { animation: blink 1s infinite; }
|
flex-direction: column;
|
||||||
@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
|
align-items: center;
|
||||||
.input-area { display: flex; gap: 0.75rem; padding: 1rem; border-top: 1px solid var(--border); }
|
justify-content: center;
|
||||||
.input-area textarea { flex: 1; padding: 0.875rem 1rem; border: 1px solid var(--border); border-radius: 12px; resize: none; font-size: 1rem; background: var(--bg); color: var(--text); }
|
color: var(--text-tertiary);
|
||||||
.input-area textarea:focus { outline: none; border-color: var(--accent); }
|
}
|
||||||
.send-btn { padding: 0.875rem 1.5rem; background: var(--accent); color: white; border: none; border-radius: 12px; font-size: 1rem; cursor: pointer; white-space: nowrap; }
|
|
||||||
.send-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
.welcome-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome p {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 70%, transparent);
|
||||||
|
backdrop-filter: blur(40px);
|
||||||
|
-webkit-backdrop-filter: blur(40px);
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-title-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-top {
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-list {
|
||||||
|
width: 80%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message Input */
|
||||||
|
.message-input {
|
||||||
|
padding: 16px 24px 12px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-input);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container:focus-within {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
min-height: 36px;
|
||||||
|
max-height: 200px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-code);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send.active {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send.active:hover {
|
||||||
|
background: var(--accent-primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send.active:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-hint {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -130,13 +130,36 @@ class Conversation(Base):
|
||||||
|
|
||||||
|
|
||||||
class Message(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"
|
__tablename__ = "messages"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
conversation_id: Mapped[str] = mapped_column(String(64), ForeignKey("conversations.id"), nullable=False)
|
conversation_id: Mapped[str] = mapped_column(String(64), ForeignKey("conversations.id"), nullable=False)
|
||||||
role: Mapped[str] = mapped_column(String(16), nullable=False)
|
role: Mapped[str] = mapped_column(String(16), nullable=False) # user, assistant, system, tool
|
||||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
token_count: Mapped[int] = mapped_column(Integer, default=0)
|
token_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
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")
|
conversation: Mapped["Conversation"] = relationship("Conversation", back_populates="messages")
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
"""Convert to dictionary, extracting process_steps for frontend"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
result = {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"conversation_id": self.conversation_id,
|
"conversation_id": self.conversation_id,
|
||||||
"role": self.role,
|
"role": self.role,
|
||||||
"content": self.content,
|
|
||||||
"token_count": self.token_count,
|
"token_count": self.token_count,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
"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
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@ def list_messages(
|
||||||
).order_by(Message.created_at).all()
|
).order_by(Message.created_at).all()
|
||||||
|
|
||||||
return success_response(data={
|
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()
|
db.commit()
|
||||||
|
|
||||||
async def event_generator():
|
async def event_generator():
|
||||||
full_response = ""
|
async for sse_str in chat_service.stream_response(
|
||||||
|
|
||||||
async for event in chat_service.stream_response(
|
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
user_message=data.content,
|
user_message=data.content,
|
||||||
tools_enabled=tools_enabled
|
tools_enabled=tools_enabled
|
||||||
):
|
):
|
||||||
event_type = event.get("type")
|
# Chat service returns raw SSE strings (including done event)
|
||||||
|
yield sse_str
|
||||||
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"
|
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
event_generator(),
|
event_generator(),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Chat service module"""
|
"""Chat service module"""
|
||||||
import json
|
import json
|
||||||
|
import uuid
|
||||||
from typing import List, Dict, Any, AsyncGenerator
|
from typing import List, Dict, Any, AsyncGenerator
|
||||||
|
|
||||||
from luxx.models import Conversation, Message
|
from luxx.models import Conversation, Message
|
||||||
|
|
@ -13,6 +14,11 @@ from luxx.config import config
|
||||||
MAX_ITERATIONS = 10
|
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):
|
def get_llm_client(conversation: Conversation = None):
|
||||||
"""Get LLM client, optionally using conversation's provider"""
|
"""Get LLM client, optionally using conversation's provider"""
|
||||||
if conversation and conversation.provider_id:
|
if conversation and conversation.provider_id:
|
||||||
|
|
@ -37,7 +43,7 @@ def get_llm_client(conversation: Conversation = None):
|
||||||
|
|
||||||
|
|
||||||
class ChatService:
|
class ChatService:
|
||||||
"""Chat service"""
|
"""Chat service with tool support"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.tool_executor = ToolExecutor()
|
self.tool_executor = ToolExecutor()
|
||||||
|
|
@ -66,9 +72,19 @@ class ChatService:
|
||||||
).order_by(Message.created_at).all()
|
).order_by(Message.created_at).all()
|
||||||
|
|
||||||
for msg in db_messages:
|
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({
|
messages.append({
|
||||||
"role": msg.role,
|
"role": msg.role,
|
||||||
"content": msg.content
|
"content": content
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
@ -80,163 +96,273 @@ class ChatService:
|
||||||
conversation: Conversation,
|
conversation: Conversation,
|
||||||
user_message: str,
|
user_message: str,
|
||||||
tools_enabled: bool = True
|
tools_enabled: bool = True
|
||||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
) -> AsyncGenerator[Dict[str, str], None]:
|
||||||
"""
|
"""
|
||||||
Streaming response generator
|
Streaming response generator
|
||||||
|
|
||||||
Event types:
|
Yields raw SSE event strings for direct forwarding.
|
||||||
- process_step: thinking/text/tool_call/tool_result step
|
|
||||||
- done: final response complete
|
|
||||||
- error: on error
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
messages = self.build_messages(conversation)
|
messages = self.build_messages(conversation)
|
||||||
|
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": user_message
|
"content": json.dumps({"text": user_message, "attachments": []})
|
||||||
})
|
})
|
||||||
|
|
||||||
tools = registry.list_all() if tools_enabled else None
|
tools = registry.list_all() if tools_enabled else None
|
||||||
|
|
||||||
iteration = 0
|
|
||||||
|
|
||||||
llm = get_llm_client(conversation)
|
llm = get_llm_client(conversation)
|
||||||
model = conversation.model or llm.default_model or "gpt-4"
|
model = conversation.model or llm.default_model or "gpt-4"
|
||||||
|
|
||||||
while iteration < MAX_ITERATIONS:
|
# State tracking
|
||||||
iteration += 1
|
all_steps = []
|
||||||
print(f"[CHAT DEBUG] ====== Starting iteration {iteration} ======")
|
all_tool_calls = []
|
||||||
print(f"[CHAT DEBUG] Messages count: {len(messages)}")
|
all_tool_results = []
|
||||||
|
step_index = 0
|
||||||
|
|
||||||
tool_calls_this_round = None
|
# 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
|
||||||
|
|
||||||
async for event in llm.stream_call(
|
for iteration in range(MAX_ITERATIONS):
|
||||||
|
print(f"[CHAT] Starting iteration {iteration + 1}, messages: {len(messages)}")
|
||||||
|
|
||||||
|
# Stream from LLM
|
||||||
|
full_content = ""
|
||||||
|
full_thinking = ""
|
||||||
|
tool_calls_list = []
|
||||||
|
|
||||||
|
# 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,
|
model=model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
temperature=conversation.temperature,
|
temperature=conversation.temperature,
|
||||||
max_tokens=conversation.max_tokens
|
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":
|
for line in sse_line.strip().split('\n'):
|
||||||
content = event.get("content", "")
|
if line.startswith('event: '):
|
||||||
if content:
|
event_type = line[7:].strip()
|
||||||
print(f"[CHAT DEBUG] Iteration {iteration} content: {content[:100]}...")
|
elif line.startswith('data: '):
|
||||||
yield {"type": "text", "content": content}
|
data_str = line[6:].strip()
|
||||||
|
|
||||||
elif event_type == "tool_call_delta":
|
if data_str is None:
|
||||||
tool_call = event.get("tool_call", {})
|
continue
|
||||||
yield {"type": "tool_call", "data": tool_call}
|
|
||||||
|
|
||||||
elif event_type == "done":
|
# Handle error events from LLM
|
||||||
tool_calls_this_round = event.get("tool_calls")
|
if event_type == 'error':
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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:
|
try:
|
||||||
messages = self.build_messages(conversation)
|
error_data = json.loads(data_str)
|
||||||
messages.append({
|
yield _sse_event("error", {"content": error_data.get("content", "Unknown error")})
|
||||||
"role": "user",
|
except json.JSONDecodeError:
|
||||||
"content": user_message
|
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
|
||||||
})
|
})
|
||||||
|
|
||||||
tools = registry.list_all() if tools_enabled else None
|
# 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
|
||||||
|
})
|
||||||
|
|
||||||
iteration = 0
|
# 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": ""}
|
||||||
|
})
|
||||||
|
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"]
|
||||||
|
|
||||||
llm_client = get_llm_client(conversation)
|
# Save thinking step
|
||||||
model = conversation.model or llm_client.default_model or "gpt-4"
|
if thinking_step_id is not None:
|
||||||
|
all_steps.append({
|
||||||
|
"id": thinking_step_id,
|
||||||
|
"index": thinking_step_idx,
|
||||||
|
"type": "thinking",
|
||||||
|
"content": full_thinking
|
||||||
|
})
|
||||||
|
|
||||||
while iteration < MAX_ITERATIONS:
|
# Save text step
|
||||||
iteration += 1
|
if text_step_id is not None:
|
||||||
|
all_steps.append({
|
||||||
|
"id": text_step_id,
|
||||||
|
"index": text_step_idx,
|
||||||
|
"type": "text",
|
||||||
|
"content": full_content
|
||||||
|
})
|
||||||
|
|
||||||
response = llm_client.sync_call(
|
# Handle tool calls
|
||||||
model=model,
|
if tool_calls_list:
|
||||||
messages=messages,
|
all_tool_calls.extend(tool_calls_list)
|
||||||
tools=tools,
|
|
||||||
temperature=conversation.temperature,
|
# Yield tool_call steps
|
||||||
max_tokens=conversation.max_tokens
|
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, {}
|
||||||
)
|
)
|
||||||
|
|
||||||
tool_calls = response.tool_calls
|
# 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
|
||||||
|
|
||||||
if tool_calls and tools_enabled:
|
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({
|
messages.append({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": response.content,
|
"content": full_content or "",
|
||||||
"tool_calls": tool_calls
|
"tool_calls": tool_calls_list
|
||||||
})
|
})
|
||||||
|
messages.extend(all_tool_results[-len(tool_results):])
|
||||||
|
all_tool_results = []
|
||||||
|
continue
|
||||||
|
|
||||||
tool_results = self.tool_executor.process_tool_calls_parallel(tool_calls)
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
for tr in tool_results:
|
yield _sse_event("done", {
|
||||||
messages.append({
|
"message_id": msg_id,
|
||||||
"role": "tool",
|
"token_count": len(full_content) // 4
|
||||||
"tool_call_id": tr.get("tool_call_id"),
|
|
||||||
"content": str(tr.get("result", ""))
|
|
||||||
})
|
})
|
||||||
else:
|
return
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"content": response.content
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
# Max iterations exceeded
|
||||||
"success": True,
|
yield _sse_event("error", {"content": "Exceeded maximum tool call iterations"})
|
||||||
"content": "Max iterations reached"
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
print(f"[CHAT] Exception: {type(e).__name__}: {str(e)}")
|
||||||
"success": False,
|
yield _sse_event("error", {"content": str(e)})
|
||||||
"error": 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
|
# Global chat service
|
||||||
|
|
|
||||||
|
|
@ -136,82 +136,39 @@ class LLMClient:
|
||||||
messages: List[Dict],
|
messages: List[Dict],
|
||||||
tools: Optional[List[Dict]] = None,
|
tools: Optional[List[Dict]] = None,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""Stream call LLM API"""
|
"""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)
|
body = self._build_body(model, messages, tools, stream=True, **kwargs)
|
||||||
|
|
||||||
# Accumulators for tool calls (need to collect from delta chunks)
|
print(f"[LLM] Starting stream_call for model: {model}")
|
||||||
accumulated_tool_calls = {}
|
print(f"[LLM] Messages count: {len(messages)}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
print(f"[LLM] Sending request to {self.api_url}")
|
||||||
async with client.stream(
|
async with client.stream(
|
||||||
"POST",
|
"POST",
|
||||||
self.api_url,
|
self.api_url,
|
||||||
headers=self._build_headers(),
|
headers=self._build_headers(),
|
||||||
json=body
|
json=body
|
||||||
) as response:
|
) as response:
|
||||||
|
print(f"[LLM] Response status: {response.status_code}")
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
async for line in response.aiter_lines():
|
async for line in response.aiter_lines():
|
||||||
if not line.strip():
|
if line.strip():
|
||||||
continue
|
yield line + "\n"
|
||||||
|
|
||||||
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}
|
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
# Return error as an event instead of raising
|
status_code = e.response.status_code if e.response else "?"
|
||||||
error_text = e.response.text if e.response else str(e)
|
print(f"[LLM] HTTP error: {status_code}")
|
||||||
yield {"type": "error", "error": f"HTTP {e.response.status_code}: {error_text}"}
|
yield f"event: error\ndata: {json.dumps({'content': f'HTTP {status_code}: Request failed'})}\n\n"
|
||||||
except Exception as e:
|
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
|
# Global LLM client
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue