refactor: 代码精简与UI优化
This commit is contained in:
parent
31cfcd3ed2
commit
d5fdbb0cb3
|
|
@ -127,6 +127,11 @@ class ChatService:
|
||||||
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'thinking', 'content': full_thinking}, ensure_ascii=False)}\n\n"
|
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'thinking', 'content': full_thinking}, ensure_ascii=False)}\n\n"
|
||||||
step_index += 1
|
step_index += 1
|
||||||
|
|
||||||
|
# Send text as a step if exists (text before tool calls)
|
||||||
|
if full_content:
|
||||||
|
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'text', 'content': full_content}, ensure_ascii=False)}\n\n"
|
||||||
|
step_index += 1
|
||||||
|
|
||||||
# Also send legacy tool_calls event for backward compatibility
|
# Also send legacy tool_calls event for backward compatibility
|
||||||
yield f"event: tool_calls\ndata: {json.dumps({'calls': tool_calls_list}, ensure_ascii=False)}\n\n"
|
yield f"event: tool_calls\ndata: {json.dumps({'calls': tool_calls_list}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
@ -169,6 +174,11 @@ class ChatService:
|
||||||
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'thinking', 'content': full_thinking}, ensure_ascii=False)}\n\n"
|
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'thinking', 'content': full_thinking}, ensure_ascii=False)}\n\n"
|
||||||
step_index += 1
|
step_index += 1
|
||||||
|
|
||||||
|
# Send text as a step if exists
|
||||||
|
if full_content:
|
||||||
|
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'text', 'content': full_content}, ensure_ascii=False)}\n\n"
|
||||||
|
step_index += 1
|
||||||
|
|
||||||
suggested_title = None
|
suggested_title = None
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
# Build content JSON
|
# Build content JSON
|
||||||
|
|
|
||||||
|
|
@ -402,7 +402,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
| `message` | 回复内容的增量片段 |
|
| `message` | 回复内容的增量片段 |
|
||||||
| `tool_calls` | 工具调用信息 |
|
| `tool_calls` | 工具调用信息 |
|
||||||
| `tool_result` | 工具执行结果 |
|
| `tool_result` | 工具执行结果 |
|
||||||
| `process_step` | 处理步骤(按顺序:thinking/tool_call/tool_result),支持交替显示 |
|
| `process_step` | 处理步骤(按顺序:thinking/text/tool_call/tool_result),支持穿插显示 |
|
||||||
| `error` | 错误信息 |
|
| `error` | 错误信息 |
|
||||||
| `done` | 回复结束,携带 message_id 和 token_count |
|
| `done` | 回复结束,携带 message_id 和 token_count |
|
||||||
|
|
||||||
|
|
@ -412,11 +412,14 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
// 思考过程
|
// 思考过程
|
||||||
{"index": 0, "type": "thinking", "content": "完整思考内容..."}
|
{"index": 0, "type": "thinking", "content": "完整思考内容..."}
|
||||||
|
|
||||||
|
// 回复文本(可穿插在任意步骤之间)
|
||||||
|
{"index": 1, "type": "text", "content": "回复文本内容..."}
|
||||||
|
|
||||||
// 工具调用
|
// 工具调用
|
||||||
{"index": 1, "type": "tool_call", "id": "call_abc123", "name": "web_search", "arguments": "{\"query\": \"...\"}"}
|
{"index": 2, "type": "tool_call", "id": "call_abc123", "name": "web_search", "arguments": "{\"query\": \"...\"}"}
|
||||||
|
|
||||||
// 工具返回
|
// 工具返回
|
||||||
{"index": 2, "type": "tool_result", "id": "call_abc123", "name": "web_search", "content": "{\"success\": true, ...}", "skipped": false}
|
{"index": 3, "type": "tool_result", "id": "call_abc123", "name": "web_search", "content": "{\"success\": true, ...}", "skipped": false}
|
||||||
```
|
```
|
||||||
|
|
||||||
字段说明:
|
字段说明:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"highlight.js": "^11.10.0",
|
"highlight.js": "^11.10.0",
|
||||||
"katex": "^0.16.40",
|
"katex": "^0.16.40",
|
||||||
"marked": "^15.0.0",
|
"marked": "^15.0.0",
|
||||||
|
"marked-highlight": "^2.2.3",
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1151,6 +1152,7 @@
|
||||||
"resolved": "https://registry.npmmirror.com/marked/-/marked-15.0.12.tgz",
|
"resolved": "https://registry.npmmirror.com/marked/-/marked-15.0.12.tgz",
|
||||||
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
|
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"marked": "bin/marked.js"
|
"marked": "bin/marked.js"
|
||||||
},
|
},
|
||||||
|
|
@ -1158,6 +1160,15 @@
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked-highlight": {
|
||||||
|
"version": "2.2.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/marked-highlight/-/marked-highlight-2.2.3.tgz",
|
||||||
|
"integrity": "sha512-FCfZRxW/msZAiasCML4isYpxyQWKEEx44vOgdn5Kloae+Qc3q4XR7WjpKKf8oMLk7JP9ZCRd2vhtclJFdwxlWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"marked": ">=4 <18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"highlight.js": "^11.10.0",
|
"highlight.js": "^11.10.0",
|
||||||
"katex": "^0.16.40",
|
"katex": "^0.16.40",
|
||||||
"marked": "^15.0.0",
|
"marked": "^15.0.0",
|
||||||
|
"marked-highlight": "^2.2.3",
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatView
|
<ChatView
|
||||||
ref="chatViewRef"
|
|
||||||
:conversation="currentConv"
|
:conversation="currentConv"
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:streaming="streaming"
|
:streaming="streaming"
|
||||||
|
|
@ -29,16 +28,26 @@
|
||||||
@delete-message="deleteMessage"
|
@delete-message="deleteMessage"
|
||||||
@regenerate-message="regenerateMessage"
|
@regenerate-message="regenerateMessage"
|
||||||
@toggle-settings="showSettings = true"
|
@toggle-settings="showSettings = true"
|
||||||
|
@toggle-stats="showStats = true"
|
||||||
@load-more-messages="loadMoreMessages"
|
@load-more-messages="loadMoreMessages"
|
||||||
@toggle-tools="updateToolsEnabled"
|
@toggle-tools="updateToolsEnabled"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
|
v-if="showSettings"
|
||||||
:visible="showSettings"
|
:visible="showSettings"
|
||||||
:conversation="currentConv"
|
:conversation="currentConv"
|
||||||
@close="showSettings = false"
|
@close="showSettings = false"
|
||||||
@save="saveSettings"
|
@save="saveSettings"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="showStats" class="modal-overlay" @click.self="showStats = false">
|
||||||
|
<div class="modal-content">
|
||||||
|
<StatsPanel @close="showStats = false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -47,10 +56,9 @@ import { ref, computed, onMounted } from 'vue'
|
||||||
import Sidebar from './components/Sidebar.vue'
|
import Sidebar from './components/Sidebar.vue'
|
||||||
import ChatView from './components/ChatView.vue'
|
import ChatView from './components/ChatView.vue'
|
||||||
import SettingsPanel from './components/SettingsPanel.vue'
|
import SettingsPanel from './components/SettingsPanel.vue'
|
||||||
|
import StatsPanel from './components/StatsPanel.vue'
|
||||||
import { conversationApi, messageApi } from './api'
|
import { conversationApi, messageApi } from './api'
|
||||||
|
|
||||||
const chatViewRef = ref(null)
|
|
||||||
|
|
||||||
// -- Conversations state --
|
// -- Conversations state --
|
||||||
const conversations = ref([])
|
const conversations = ref([])
|
||||||
const currentConvId = ref(null)
|
const currentConvId = ref(null)
|
||||||
|
|
@ -71,6 +79,15 @@ const streamThinking = ref('')
|
||||||
const streamToolCalls = ref([])
|
const streamToolCalls = ref([])
|
||||||
const streamProcessSteps = ref([])
|
const streamProcessSteps = ref([])
|
||||||
|
|
||||||
|
function resetStreamState() {
|
||||||
|
streaming.value = false
|
||||||
|
streamContent.value = ''
|
||||||
|
streamThinking.value = ''
|
||||||
|
streamToolCalls.value = []
|
||||||
|
streamProcessSteps.value = []
|
||||||
|
currentStreamPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
// 保存每个对话的流式状态
|
// 保存每个对话的流式状态
|
||||||
const streamStates = new Map()
|
const streamStates = new Map()
|
||||||
|
|
||||||
|
|
@ -79,6 +96,7 @@ let currentStreamPromise = null
|
||||||
|
|
||||||
// -- UI state --
|
// -- UI state --
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
|
const showStats = ref(false)
|
||||||
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') // 默认开启
|
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') // 默认开启
|
||||||
const currentProject = ref(null) // Current selected project
|
const currentProject = ref(null) // Current selected project
|
||||||
|
|
||||||
|
|
@ -267,21 +285,15 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
||||||
token_count: data.token_count,
|
token_count: data.token_count,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
streamContent.value = ''
|
resetStreamState()
|
||||||
streamThinking.value = ''
|
|
||||||
streamToolCalls.value = []
|
|
||||||
streamProcessSteps.value = []
|
|
||||||
|
|
||||||
if (updateConvList) {
|
if (updateConvList) {
|
||||||
const idx = conversations.value.findIndex(c => c.id === convId)
|
const idx = conversations.value.findIndex(c => c.id === convId)
|
||||||
if (idx > 0) {
|
if (idx >= 0) {
|
||||||
const [conv] = conversations.value.splice(idx, 1)
|
const conv = idx > 0 ? conversations.value.splice(idx, 1)[0] : conversations.value[0]
|
||||||
conv.message_count = (conv.message_count || 0) + 2
|
conv.message_count = (conv.message_count || 0) + 2
|
||||||
if (data.suggested_title) conv.title = data.suggested_title
|
if (data.suggested_title) conv.title = data.suggested_title
|
||||||
conversations.value.unshift(conv)
|
if (idx > 0) conversations.value.unshift(conv)
|
||||||
} else if (idx === 0) {
|
|
||||||
conversations.value[0].message_count = (conversations.value[0].message_count || 0) + 2
|
|
||||||
if (data.suggested_title) conversations.value[0].title = data.suggested_title
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -301,12 +313,7 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
||||||
onError(msg) {
|
onError(msg) {
|
||||||
streamStates.delete(convId)
|
streamStates.delete(convId)
|
||||||
if (currentConvId.value === convId) {
|
if (currentConvId.value === convId) {
|
||||||
streaming.value = false
|
resetStreamState()
|
||||||
currentStreamPromise = null
|
|
||||||
streamContent.value = ''
|
|
||||||
streamThinking.value = ''
|
|
||||||
streamToolCalls.value = []
|
|
||||||
streamProcessSteps.value = []
|
|
||||||
console.error('Stream error:', msg)
|
console.error('Stream error:', msg)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -428,105 +435,29 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
|
||||||
/* Light theme */
|
|
||||||
--bg-primary: #ffffff;
|
|
||||||
--bg-secondary: #f8fafc;
|
|
||||||
--bg-tertiary: #f0f4f8;
|
|
||||||
--bg-hover: rgba(37, 99, 235, 0.06);
|
|
||||||
--bg-active: rgba(37, 99, 235, 0.12);
|
|
||||||
--bg-input: #f8fafc;
|
|
||||||
--bg-code: #f1f5f9;
|
|
||||||
--bg-thinking: #f1f5f9;
|
|
||||||
|
|
||||||
--text-primary: #1e293b;
|
|
||||||
--text-secondary: #64748b;
|
|
||||||
--text-tertiary: #94a3b8;
|
|
||||||
|
|
||||||
--border-light: rgba(0, 0, 0, 0.06);
|
|
||||||
--border-medium: rgba(0, 0, 0, 0.08);
|
|
||||||
--border-input: rgba(0, 0, 0, 0.08);
|
|
||||||
|
|
||||||
--accent-primary: #2563eb;
|
|
||||||
--accent-primary-hover: #3b82f6;
|
|
||||||
--accent-primary-light: rgba(37, 99, 235, 0.08);
|
|
||||||
--accent-primary-medium: rgba(37, 99, 235, 0.15);
|
|
||||||
|
|
||||||
--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);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
/* Dark theme - 保持与浅色模式相同的相对色差 */
|
|
||||||
--bg-primary: #1a1a1a; /* 聊天框,最浅(对应浅色 #ffffff) */
|
|
||||||
--bg-secondary: #141414; /* 侧边栏,中等(对应浅色 #f8fafc) */
|
|
||||||
--bg-tertiary: #0a0a0a; /* 整体背景,最深(对应浅色 #f0f4f8) */
|
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
|
||||||
--bg-active: rgba(255, 255, 255, 0.12);
|
|
||||||
--bg-input: #141414;
|
|
||||||
--bg-code: #141414;
|
|
||||||
--bg-thinking: #141414;
|
|
||||||
|
|
||||||
--text-primary: #f0f0f0;
|
|
||||||
--text-secondary: #a0a0a0;
|
|
||||||
--text-tertiary: #606060;
|
|
||||||
|
|
||||||
--border-light: rgba(255, 255, 255, 0.08);
|
|
||||||
--border-medium: rgba(255, 255, 255, 0.12);
|
|
||||||
--border-input: rgba(255, 255, 255, 0.1);
|
|
||||||
|
|
||||||
--accent-primary: #3b82f6;
|
|
||||||
--accent-primary-hover: #60a5fa;
|
|
||||||
--accent-primary-light: rgba(59, 130, 246, 0.15);
|
|
||||||
--accent-primary-medium: rgba(59, 130, 246, 0.25);
|
|
||||||
|
|
||||||
--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);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
transition: background 0.2s, color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--overlay-bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -202,17 +202,6 @@ export const projectApi = {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
get(projectId) {
|
|
||||||
return request(`/projects/${projectId}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
update(projectId, data) {
|
|
||||||
return request(`/projects/${projectId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: data,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
delete(projectId) {
|
delete(projectId) {
|
||||||
return request(`/projects/${projectId}`, { method: 'DELETE' })
|
return request(`/projects/${projectId}`, { method: 'DELETE' })
|
||||||
},
|
},
|
||||||
|
|
@ -223,9 +212,4 @@ export const projectApi = {
|
||||||
body: data,
|
body: data,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
listFiles(projectId, path = '') {
|
|
||||||
const params = path ? `?path=${encodeURIComponent(path)}` : ''
|
|
||||||
return request(`/projects/${projectId}/files${params}`)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,17 @@
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<div class="chat-title-area">
|
<div class="chat-title-area">
|
||||||
<h2 class="chat-title">{{ conversation.title || '新对话' }}</h2>
|
<h2 class="chat-title">{{ conversation.title || '新对话' }}</h2>
|
||||||
<span class="model-badge">{{ conversation.model }}</span>
|
<span class="model-badge">{{ formatModelName(conversation.model) }}</span>
|
||||||
<span v-if="conversation.thinking_enabled" class="thinking-badge">思考</span>
|
<span v-if="conversation.thinking_enabled" class="thinking-badge">思考</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-actions">
|
<div class="chat-actions">
|
||||||
|
<button class="btn-icon" @click="$emit('toggleStats')" title="使用统计">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 20V10"/>
|
||||||
|
<path d="M12 20V4"/>
|
||||||
|
<path d="M6 20v-6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="btn-icon" @click="$emit('toggleSettings')" title="设置">
|
<button class="btn-icon" @click="$emit('toggleSettings')" title="设置">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="12" cy="12" r="3"></circle>
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
|
@ -23,7 +30,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="scrollContainer" class="messages-container" @scroll="onScroll">
|
<div ref="scrollContainer" class="messages-container">
|
||||||
<div v-if="hasMoreMessages" class="load-more-top">
|
<div v-if="hasMoreMessages" class="load-more-top">
|
||||||
<button @click="$emit('loadMoreMessages')" :disabled="loadingMore">
|
<button @click="$emit('loadMoreMessages')" :disabled="loadingMore">
|
||||||
{{ loadingMore ? '加载中...' : '加载更早的消息' }}
|
{{ loadingMore ? '加载中...' : '加载更早的消息' }}
|
||||||
|
|
@ -54,15 +61,9 @@
|
||||||
:thinking-content="streamingThinking"
|
:thinking-content="streamingThinking"
|
||||||
:tool-calls="streamingToolCalls"
|
:tool-calls="streamingToolCalls"
|
||||||
:process-steps="streamingProcessSteps"
|
:process-steps="streamingProcessSteps"
|
||||||
|
:streaming-content="streamingContent"
|
||||||
:streaming="streaming"
|
:streaming="streaming"
|
||||||
/>
|
/>
|
||||||
<div class="md-content streaming-content" v-html="renderedStreamContent || '<span class=\'placeholder\'>...</span>'"></div>
|
|
||||||
<div class="streaming-indicator">
|
|
||||||
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
||||||
</svg>
|
|
||||||
<span>正在生成...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -80,11 +81,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, nextTick } from 'vue'
|
import { ref, watch, nextTick, onMounted } from 'vue'
|
||||||
import MessageBubble from './MessageBubble.vue'
|
import MessageBubble from './MessageBubble.vue'
|
||||||
import MessageInput from './MessageInput.vue'
|
import MessageInput from './MessageInput.vue'
|
||||||
import ProcessBlock from './ProcessBlock.vue'
|
import ProcessBlock from './ProcessBlock.vue'
|
||||||
import { renderMarkdown } from '../utils/markdown'
|
import { modelApi } from '../api'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
conversation: { type: Object, default: null },
|
conversation: { type: Object, default: null },
|
||||||
|
|
@ -99,14 +100,27 @@ const props = defineProps({
|
||||||
toolsEnabled: { type: Boolean, default: true },
|
toolsEnabled: { type: Boolean, default: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['sendMessage', 'deleteMessage', 'regenerateMessage', 'toggleSettings', 'loadMoreMessages', 'toggleTools'])
|
const emit = defineEmits(['sendMessage', 'deleteMessage', 'regenerateMessage', 'toggleSettings', 'toggleStats', 'loadMoreMessages', 'toggleTools'])
|
||||||
|
|
||||||
const scrollContainer = ref(null)
|
const scrollContainer = ref(null)
|
||||||
const inputRef = ref(null)
|
const inputRef = ref(null)
|
||||||
|
const modelNameMap = ref({})
|
||||||
|
|
||||||
const renderedStreamContent = computed(() => {
|
function formatModelName(modelId) {
|
||||||
if (!props.streamingContent) return ''
|
return modelNameMap.value[modelId] || modelId
|
||||||
return renderMarkdown(props.streamingContent)
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await modelApi.getCached()
|
||||||
|
const map = {}
|
||||||
|
for (const m of res.data) {
|
||||||
|
if (m.id && m.name) map[m.id] = m.name
|
||||||
|
}
|
||||||
|
modelNameMap.value = map
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load model names:', e)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleSend(data) {
|
function handleSend(data) {
|
||||||
|
|
@ -122,12 +136,6 @@ function scrollToBottom(smooth = true) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function onScroll(e) {
|
|
||||||
if (e.target.scrollTop < 50 && props.hasMoreMessages && !props.loadingMore) {
|
|
||||||
// emit loadMore if needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.messages.length, () => {
|
watch(() => props.messages.length, () => {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
})
|
})
|
||||||
|
|
@ -147,14 +155,13 @@ defineExpose({ scrollToBottom })
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chat-view {
|
.chat-view {
|
||||||
flex: 1 1 auto; /* 弹性宽度,自动填充剩余空间 */
|
flex: 1 1 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
min-width: 300px; /* 最小宽度保证可用性 */
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome {
|
.welcome {
|
||||||
|
|
@ -170,7 +177,6 @@ defineExpose({ scrollToBottom })
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: none;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -216,22 +222,30 @@ defineExpose({ scrollToBottom })
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-badge {
|
.badge {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--accent-primary-light);
|
|
||||||
color: var(--accent-primary);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.model-badge {
|
||||||
|
background: var(--accent-primary-medium);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.thinking-badge {
|
.thinking-badge {
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--success-bg);
|
background: var(--success-bg);
|
||||||
color: var(--success-color);
|
color: var(--success-color);
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-actions {
|
.chat-actions {
|
||||||
|
|
@ -302,28 +316,18 @@ defineExpose({ scrollToBottom })
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-list {
|
.messages-list {
|
||||||
flex: 0 1 auto; /* 弹性宽度 */
|
|
||||||
width: 80%;
|
width: 80%;
|
||||||
margin: 0 auto; /* 居中显示 */
|
margin: 0 auto;
|
||||||
padding: 0 16px; /* 左右内边距 */
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble {
|
.message-bubble {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 0;
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble.assistant {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-bubble.assistant.streaming {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-bubble .message-container {
|
.message-bubble .message-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -365,26 +369,4 @@ defineExpose({ scrollToBottom })
|
||||||
transition: background 0.2s, border-color 0.2s;
|
transition: background 0.2s, border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble.streaming .message-body {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content {
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-indicator {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid var(--border-light);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content :deep(.placeholder) {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,23 @@
|
||||||
<span class="attachment-name">{{ file.name }}</span>
|
<span class="attachment-name">{{ file.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-body">
|
<div ref="messageRef" class="message-body">
|
||||||
|
<!-- 新格式: processSteps 包含所有步骤(含 text),统一通过 ProcessBlock 渲染 -->
|
||||||
<ProcessBlock
|
<ProcessBlock
|
||||||
v-if="thinkingContent || (toolCalls && toolCalls.length > 0) || (processSteps && processSteps.length > 0)"
|
v-if="processSteps && processSteps.length > 0"
|
||||||
|
:process-steps="processSteps"
|
||||||
:thinking-content="thinkingContent"
|
:thinking-content="thinkingContent"
|
||||||
:tool-calls="toolCalls"
|
:tool-calls="toolCalls"
|
||||||
:process-steps="processSteps"
|
|
||||||
/>
|
/>
|
||||||
<div v-if="role === 'tool'" class="tool-result-content">
|
<!-- 旧格式: 无 processSteps,分开渲染 ProcessBlock + 文本 -->
|
||||||
<div class="tool-badge">工具返回结果: {{ toolName }}</div>
|
<template v-else>
|
||||||
<pre>{{ content }}</pre>
|
<ProcessBlock
|
||||||
</div>
|
v-if="thinkingContent || (toolCalls && toolCalls.length > 0)"
|
||||||
<div v-else class="md-content message-content" v-html="renderedContent"></div>
|
:thinking-content="thinkingContent"
|
||||||
|
:tool-calls="toolCalls"
|
||||||
|
/>
|
||||||
|
<div class="md-content message-content" v-html="renderedContent"></div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-footer">
|
<div class="message-footer">
|
||||||
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
||||||
|
|
@ -50,18 +55,16 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { computed, watch, onMounted, ref } from 'vue'
|
||||||
import { renderMarkdown } from '../utils/markdown'
|
import { renderMarkdown, enhanceCodeBlocks } from '../utils/markdown'
|
||||||
import ProcessBlock from './ProcessBlock.vue'
|
import ProcessBlock from './ProcessBlock.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
role: { type: String, required: true },
|
role: { type: String, required: true },
|
||||||
text: { type: String, default: '' },
|
text: { type: String, default: '' },
|
||||||
content: { type: String, default: '' }, // Keep for backward compatibility
|
|
||||||
thinkingContent: { type: String, default: '' },
|
thinkingContent: { type: String, default: '' },
|
||||||
toolCalls: { type: Array, default: () => [] },
|
toolCalls: { type: Array, default: () => [] },
|
||||||
processSteps: { type: Array, default: () => [] },
|
processSteps: { type: Array, default: () => [] },
|
||||||
toolName: { type: String, default: '' },
|
|
||||||
tokenCount: { type: Number, default: 0 },
|
tokenCount: { type: Number, default: 0 },
|
||||||
createdAt: { type: String, default: '' },
|
createdAt: { type: String, default: '' },
|
||||||
deletable: { type: Boolean, default: false },
|
deletable: { type: Boolean, default: false },
|
||||||
|
|
@ -70,11 +73,23 @@ const props = defineProps({
|
||||||
|
|
||||||
defineEmits(['delete', 'regenerate'])
|
defineEmits(['delete', 'regenerate'])
|
||||||
|
|
||||||
|
const messageRef = ref(null)
|
||||||
|
|
||||||
const renderedContent = computed(() => {
|
const renderedContent = computed(() => {
|
||||||
// Use 'text' field (new format), fallback to 'content' (old format/assistant messages)
|
if (!props.text) return ''
|
||||||
const displayContent = props.text || props.content || ''
|
return renderMarkdown(props.text)
|
||||||
if (!displayContent) return ''
|
})
|
||||||
return renderMarkdown(displayContent)
|
|
||||||
|
function enhanceCode() {
|
||||||
|
enhanceCodeBlocks(messageRef.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(renderedContent, () => {
|
||||||
|
enhanceCode()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
enhanceCode()
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatTime(iso) {
|
function formatTime(iso) {
|
||||||
|
|
@ -83,7 +98,7 @@ function formatTime(iso) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyContent() {
|
function copyContent() {
|
||||||
navigator.clipboard.writeText(props.content).catch(() => {})
|
navigator.clipboard.writeText(props.text || '').catch(() => {})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -91,7 +106,6 @@ function copyContent() {
|
||||||
.message-bubble {
|
.message-bubble {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 0;
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
@ -195,39 +209,6 @@ function copyContent() {
|
||||||
transition: background 0.2s, border-color 0.2s;
|
transition: background 0.2s, border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-content {
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-result-content {
|
|
||||||
background: var(--bg-code);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-badge {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--success-color);
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
background: var(--success-bg);
|
|
||||||
border-radius: 4px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-result-content pre {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-footer {
|
.message-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
<input
|
<input
|
||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.py,.java,.c,.cpp,.h,.hpp,.yaml,.yml,.toml,.ini,.csv,.sql,.sh,.bat,.log,.vue,.svelte,.go,.rs,.rb,.php,.swift,.kt,.scala,.lua,.r,.dart,.scala"
|
accept=".txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.py,.java,.c,.cpp,.h,.hpp,.yaml,.yml,.toml,.ini,.csv,.sql,.sh,.bat,.log,.vue,.svelte,.go,.rs,.rb,.php,.swift,.kt,.scala,.lua,.r,.dart"
|
||||||
@change="handleFileUpload"
|
@change="handleFileUpload"
|
||||||
style="display: none"
|
style="display: none"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="process-block" :class="{ 'is-streaming': streaming }">
|
<div ref="processRef" class="process-block" :class="{ 'is-streaming': streaming }">
|
||||||
<!-- 流式加载状态 -->
|
<!-- 流式加载:还没有任何步骤时 -->
|
||||||
<div v-if="streaming && processItems.length === 0" class="streaming-placeholder">
|
<div v-if="streaming && processItems.length === 0" class="streaming-placeholder">
|
||||||
<div class="streaming-icon">
|
<div class="streaming-icon">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
|
@ -10,284 +10,245 @@
|
||||||
<span class="streaming-text">正在思考中<span class="dots">...</span></span>
|
<span class="streaming-text">正在思考中<span class="dots">...</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 正常内容 -->
|
<!-- 按序渲染步骤 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button class="process-toggle" @click="toggleAll">
|
<template v-for="item in processItems" :key="item.key">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<!-- 思考过程 -->
|
||||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
|
<div v-if="item.type === 'thinking'" class="step-item thinking">
|
||||||
</svg>
|
<div class="step-header" @click="toggleItem(item.key)">
|
||||||
<span>思考与工具调用过程</span>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<span class="process-count">{{ processItems.length }} 步</span>
|
|
||||||
<svg class="arrow" :class="{ open: allExpanded }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<polyline points="6 9 12 15 18 9"></polyline>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="allExpanded" class="process-list">
|
|
||||||
<div v-for="item in processItems" :key="item.key" class="process-item" :class="[item.type, { loading: item.loading }]">
|
|
||||||
<div class="process-header" @click="toggleItem(item.index)">
|
|
||||||
<div class="process-icon">
|
|
||||||
<svg v-if="item.type === 'thinking'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
|
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else-if="item.type === 'tool_call'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="step-label">思考过程</span>
|
||||||
|
<span v-if="item.summary" class="step-brief">{{ item.summary }}</span>
|
||||||
|
<svg class="arrow" :class="{ open: expandedKeys[item.key] }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedKeys[item.key]" class="step-content">
|
||||||
|
<div class="thinking-text">{{ item.content }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工具调用 -->
|
||||||
|
<div v-else-if="item.type === 'tool_call'" class="step-item tool_call" :class="{ loading: item.loading }">
|
||||||
|
<div class="step-header" @click="toggleItem(item.key)">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<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 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"/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else-if="item.type === 'tool_result'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="step-label">{{ item.loading ? `执行工具: ${item.toolName}` : `调用工具: ${item.toolName}` }}</span>
|
||||||
<polyline points="9 11 12 14 22 4"/>
|
<span v-if="item.summary && !item.loading" class="step-brief">{{ item.summary }}</span>
|
||||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
<span v-if="item.resultSummary" class="step-badge" :class="{ success: item.isSuccess, error: !item.isSuccess }">{{ item.resultSummary }}</span>
|
||||||
|
<span v-if="item.loading" class="loading-dots">...</span>
|
||||||
|
<svg v-if="!item.loading" class="arrow" :class="{ open: expandedKeys[item.key] }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="process-label">{{ item.label }}</span>
|
<div v-if="expandedKeys[item.key] && !item.loading" class="step-content">
|
||||||
<span v-if="item.loading" class="loading-dots">...</span>
|
<div class="tool-detail" style="margin-bottom: 8px;">
|
||||||
<span v-else-if="item.type === 'tool_result'" class="process-summary" :class="{ success: item.isSuccess, error: !item.isSuccess }">{{ item.summary }}</span>
|
<span class="detail-label">调用参数:</span>
|
||||||
<span class="process-time">{{ item.time }}</span>
|
|
||||||
<svg v-if="!item.loading" class="item-arrow" :class="{ open: isItemExpanded(item.index) }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<polyline points="6 9 12 15 18 9"></polyline>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isItemExpanded(item.index) && !item.loading" class="process-content">
|
|
||||||
<div v-if="item.type === 'thinking'" class="thinking-text">{{ item.content }}</div>
|
|
||||||
|
|
||||||
<div v-else-if="item.type === 'tool_call'" class="tool-call-detail">
|
|
||||||
<div class="tool-name">
|
|
||||||
<span class="label">工具名称:</span>
|
|
||||||
<span class="value">{{ item.toolName }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="item.arguments" class="tool-args">
|
|
||||||
<span class="label">调用参数:</span>
|
|
||||||
<pre>{{ item.arguments }}</pre>
|
<pre>{{ item.arguments }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-if="item.result" class="tool-detail">
|
||||||
|
<span class="detail-label">返回结果:</span>
|
||||||
<div v-else-if="item.type === 'tool_result'" class="tool-result-detail">
|
<pre>{{ item.result }}</pre>
|
||||||
<div class="result-label">返回结果:</div>
|
</div>
|
||||||
<pre>{{ item.content }}</pre>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 文本内容 - 直接渲染 markdown -->
|
||||||
|
<div v-else-if="item.type === 'text'" class="step-item text-content md-content" v-html="item.rendered"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 流式进行中指示器 -->
|
||||||
|
<div v-if="streaming" class="streaming-indicator">
|
||||||
|
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
</svg>
|
||||||
|
<span>正在生成...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
||||||
|
import { renderMarkdown, enhanceCodeBlocks } from '../utils/markdown'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
thinkingContent: { type: String, default: '' },
|
thinkingContent: { type: String, default: '' },
|
||||||
toolCalls: { type: Array, default: () => [] },
|
toolCalls: { type: Array, default: () => [] },
|
||||||
processSteps: { type: Array, default: () => [] },
|
processSteps: { type: Array, default: () => [] },
|
||||||
|
streamingContent: { type: String, default: '' },
|
||||||
streaming: { type: Boolean, default: false }
|
streaming: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
const allExpanded = ref(false)
|
const expandedKeys = ref({})
|
||||||
const itemExpanded = ref({}) // 存储每个项目的展开状态
|
|
||||||
|
|
||||||
const processItems = computed(() => {
|
// 流式时自动展开
|
||||||
const items = []
|
watch(() => props.streaming, (v) => {
|
||||||
let idx = 0
|
if (v) expandedKeys.value = {}
|
||||||
|
|
||||||
// 优先使用新的 processSteps(按顺序的步骤列表)
|
|
||||||
if (props.processSteps && props.processSteps.length > 0) {
|
|
||||||
props.processSteps.forEach((step, stepIdx) => {
|
|
||||||
if (!step) return
|
|
||||||
|
|
||||||
if (step.type === 'thinking') {
|
|
||||||
items.push({
|
|
||||||
type: 'thinking',
|
|
||||||
label: '思考过程',
|
|
||||||
content: step.content,
|
|
||||||
time: '',
|
|
||||||
index: idx,
|
|
||||||
key: `thinking-${idx}`,
|
|
||||||
loading: false
|
|
||||||
})
|
|
||||||
idx++
|
|
||||||
} else if (step.type === 'tool_call') {
|
|
||||||
items.push({
|
|
||||||
type: 'tool_call',
|
|
||||||
label: `调用工具: ${step.name || '未知工具'}`,
|
|
||||||
toolName: step.name || '未知工具',
|
|
||||||
arguments: formatArgs(step.arguments),
|
|
||||||
id: step.id,
|
|
||||||
index: idx,
|
|
||||||
key: `tool_call-${step.id || idx}`,
|
|
||||||
loading: false
|
|
||||||
})
|
|
||||||
idx++
|
|
||||||
} else if (step.type === 'tool_result') {
|
|
||||||
const resultSummary = getResultSummary(step.content)
|
|
||||||
items.push({
|
|
||||||
type: 'tool_result',
|
|
||||||
label: `工具返回: ${step.name || '未知工具'}`,
|
|
||||||
content: formatResult(step.content),
|
|
||||||
summary: resultSummary.text,
|
|
||||||
isSuccess: resultSummary.success,
|
|
||||||
id: step.id,
|
|
||||||
index: idx,
|
|
||||||
key: `tool_result-${step.id || idx}`,
|
|
||||||
loading: false
|
|
||||||
})
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 如果正在流式传输,检查是否需要添加加载状态
|
|
||||||
if (props.streaming && items.length > 0) {
|
|
||||||
const lastItem = items[items.length - 1]
|
|
||||||
// 最后一个工具调用还没有结果,显示执行中
|
|
||||||
if (lastItem.type === 'tool_call') {
|
|
||||||
lastItem.loading = true
|
|
||||||
lastItem.label = `执行工具: ${lastItem.toolName}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回退到旧逻辑:先添加思考过程,再添加工具调用
|
|
||||||
if (props.thinkingContent) {
|
|
||||||
items.push({
|
|
||||||
type: 'thinking',
|
|
||||||
label: '思考过程',
|
|
||||||
content: props.thinkingContent,
|
|
||||||
time: '',
|
|
||||||
index: idx,
|
|
||||||
key: `thinking-${idx}`,
|
|
||||||
loading: false
|
|
||||||
})
|
|
||||||
idx++
|
|
||||||
} else if (props.streaming && items.length === 0) {
|
|
||||||
// 正在思考中
|
|
||||||
items.push({
|
|
||||||
type: 'thinking',
|
|
||||||
label: '思考中',
|
|
||||||
content: '',
|
|
||||||
time: '',
|
|
||||||
index: idx,
|
|
||||||
key: `thinking-loading`,
|
|
||||||
loading: true
|
|
||||||
})
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.toolCalls && props.toolCalls.length > 0) {
|
|
||||||
props.toolCalls.forEach((call, i) => {
|
|
||||||
const toolName = call.function?.name || '未知工具'
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
type: 'tool_call',
|
|
||||||
label: `调用工具: ${toolName}`,
|
|
||||||
toolName: toolName,
|
|
||||||
arguments: formatArgs(call.function?.arguments),
|
|
||||||
id: call.id,
|
|
||||||
index: idx,
|
|
||||||
key: `tool_call-${call.id || idx}`,
|
|
||||||
loading: false
|
|
||||||
})
|
|
||||||
idx++
|
|
||||||
|
|
||||||
if (call.result) {
|
|
||||||
const resultSummary = getResultSummary(call.result)
|
|
||||||
items.push({
|
|
||||||
type: 'tool_result',
|
|
||||||
label: `工具返回: ${toolName}`,
|
|
||||||
content: formatResult(call.result),
|
|
||||||
summary: resultSummary.text,
|
|
||||||
isSuccess: resultSummary.success,
|
|
||||||
id: call.id,
|
|
||||||
index: idx,
|
|
||||||
key: `tool_result-${call.id || idx}`,
|
|
||||||
loading: false
|
|
||||||
})
|
|
||||||
idx++
|
|
||||||
} else if (props.streaming) {
|
|
||||||
// 工具正在执行中
|
|
||||||
items[items.length - 1].loading = true
|
|
||||||
items[items.length - 1].label = `执行工具: ${toolName}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function isItemExpanded(index) {
|
// 增强 processBlock 内代码块
|
||||||
return itemExpanded.value[index] || false
|
const processRef = ref(null)
|
||||||
|
|
||||||
|
function enhanceCode() {
|
||||||
|
enhanceCodeBlocks(processRef.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleItem(index) {
|
onMounted(() => {
|
||||||
itemExpanded.value[index] = !isItemExpanded(index)
|
enhanceCode()
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleItem(key) {
|
||||||
|
expandedKeys.value[key] = !expandedKeys.value[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatArgs(args) {
|
function formatJson(value) {
|
||||||
if (!args) return ''
|
if (value == null) return ''
|
||||||
try {
|
const str = typeof value === 'string' ? value : JSON.stringify(value)
|
||||||
const parsed = JSON.parse(args)
|
try { return JSON.stringify(JSON.parse(str), null, 2) } catch { return str }
|
||||||
return JSON.stringify(parsed, null, 2)
|
|
||||||
} catch {
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatResult(result) {
|
function truncate(text, max = 60) {
|
||||||
if (typeof result === 'string') {
|
if (!text) return ''
|
||||||
try {
|
const str = text.replace(/\s+/g, ' ').trim()
|
||||||
const parsed = JSON.parse(result)
|
return str.length > max ? str.slice(0, max) + '…' : str
|
||||||
return JSON.stringify(parsed, null, 2)
|
|
||||||
} catch {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return JSON.stringify(result, null, 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResultSummary(result) {
|
function getResultSummary(result) {
|
||||||
try {
|
try {
|
||||||
const parsed = typeof result === 'string' ? JSON.parse(result) : result
|
const parsed = typeof result === 'string' ? JSON.parse(result) : result
|
||||||
if (parsed.success === true) {
|
if (parsed.success === true) return { text: '成功', success: true }
|
||||||
return { text: '成功', success: true }
|
if (parsed.success === false || parsed.error) return { text: parsed.error || '失败', success: false }
|
||||||
} else if (parsed.success === false || parsed.error) {
|
if (parsed.results) return { text: `${parsed.results.length} 条结果`, success: true }
|
||||||
return { text: parsed.error || '失败', success: false }
|
|
||||||
} else if (parsed.results) {
|
|
||||||
return { text: `${parsed.results.length} 条结果`, success: true }
|
|
||||||
}
|
|
||||||
return { text: '完成', success: true }
|
return { text: '完成', success: true }
|
||||||
} catch {
|
} catch {
|
||||||
return { text: '完成', success: true }
|
return { text: '完成', success: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAll() {
|
const processItems = computed(() => {
|
||||||
allExpanded.value = !allExpanded.value
|
const items = []
|
||||||
}
|
|
||||||
|
|
||||||
// 自动展开流式内容(只展开外层面板,不展开内部项目)
|
// 优先使用 processSteps(按顺序)
|
||||||
watch(() => props.streaming, (streaming) => {
|
if (props.processSteps && props.processSteps.length > 0) {
|
||||||
if (streaming) {
|
for (const step of props.processSteps) {
|
||||||
allExpanded.value = true
|
if (!step) continue
|
||||||
|
|
||||||
|
if (step.type === 'thinking') {
|
||||||
|
items.push({ type: 'thinking', content: step.content, summary: truncate(step.content), key: `thinking-${step.index}` })
|
||||||
|
} else if (step.type === 'tool_call') {
|
||||||
|
items.push({
|
||||||
|
type: 'tool_call',
|
||||||
|
toolName: step.name || '未知工具',
|
||||||
|
arguments: formatJson(step.arguments),
|
||||||
|
summary: truncate(step.arguments),
|
||||||
|
id: step.id,
|
||||||
|
key: `tool_call-${step.id || step.index}`,
|
||||||
|
loading: false,
|
||||||
|
result: null,
|
||||||
|
})
|
||||||
|
} else if (step.type === 'tool_result') {
|
||||||
|
const summary = getResultSummary(step.content)
|
||||||
|
const match = items.findLast(it => it.type === 'tool_call' && it.id === step.id)
|
||||||
|
if (match) {
|
||||||
|
match.result = formatJson(step.content)
|
||||||
|
match.resultSummary = summary.text
|
||||||
|
match.isSuccess = summary.success
|
||||||
|
match.loading = false
|
||||||
|
}
|
||||||
|
} else if (step.type === 'text') {
|
||||||
|
items.push({
|
||||||
|
type: 'text',
|
||||||
|
content: step.content,
|
||||||
|
rendered: renderMarkdown(step.content),
|
||||||
|
key: `text-${step.index}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式中最后一个 tool_call 标记为 loading
|
||||||
|
if (props.streaming && items.length > 0) {
|
||||||
|
const last = items[items.length - 1]
|
||||||
|
if (last.type === 'tool_call') {
|
||||||
|
last.loading = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式中追加正在增长的文本(仅当最后步骤不是 text 类型时)
|
||||||
|
if (props.streaming && props.streamingContent) {
|
||||||
|
const lastStep = items[items.length - 1]
|
||||||
|
if (!lastStep || lastStep.type !== 'text') {
|
||||||
|
items.push({
|
||||||
|
type: 'text',
|
||||||
|
content: props.streamingContent,
|
||||||
|
rendered: renderMarkdown(props.streamingContent) || '<span class="placeholder">...</span>',
|
||||||
|
key: 'text-streaming',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 回退逻辑:旧版 thinking + toolCalls
|
||||||
|
if (props.thinkingContent) {
|
||||||
|
items.push({ type: 'thinking', content: props.thinkingContent, summary: truncate(props.thinkingContent), key: 'thinking-0' })
|
||||||
|
} else if (props.streaming && items.length === 0) {
|
||||||
|
items.push({ type: 'thinking', content: '', key: 'thinking-loading' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.toolCalls && props.toolCalls.length > 0) {
|
||||||
|
props.toolCalls.forEach((call, i) => {
|
||||||
|
const toolName = call.function?.name || '未知工具'
|
||||||
|
const result = call.result ? getResultSummary(call.result) : null
|
||||||
|
items.push({
|
||||||
|
type: 'tool_call',
|
||||||
|
toolName,
|
||||||
|
arguments: formatJson(call.function?.arguments),
|
||||||
|
summary: truncate(call.function?.arguments),
|
||||||
|
id: call.id,
|
||||||
|
key: `tool_call-${call.id || i}`,
|
||||||
|
loading: !call.result && props.streaming,
|
||||||
|
result: call.result ? formatJson(call.result) : null,
|
||||||
|
resultSummary: result ? result.text : null,
|
||||||
|
isSuccess: result ? result.success : undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旧模式下追加流式文本
|
||||||
|
if (props.streaming && props.streamingContent) {
|
||||||
|
items.push({
|
||||||
|
type: 'text',
|
||||||
|
content: props.streamingContent,
|
||||||
|
rendered: renderMarkdown(props.streamingContent) || '<span class="placeholder">...</span>',
|
||||||
|
key: 'text-streaming',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(processItems, () => {
|
||||||
|
nextTick(() => enhanceCode())
|
||||||
|
}, { deep: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.process-block {
|
.process-block {
|
||||||
margin-bottom: 8px;
|
width: 100%;
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 流式占位 */
|
||||||
.streaming-placeholder {
|
.streaming-placeholder {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.streaming-icon {
|
.streaming-icon {
|
||||||
|
|
@ -312,126 +273,95 @@ watch(() => props.streaming, (streaming) => {
|
||||||
animation: pulse 1s ease-in-out infinite;
|
animation: pulse 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.process-toggle {
|
@keyframes pulse {
|
||||||
width: 100%;
|
0%, 100% { opacity: 0.4; }
|
||||||
padding: 10px 12px;
|
50% { opacity: 1; }
|
||||||
background: var(--bg-tertiary);
|
}
|
||||||
border: none;
|
|
||||||
color: var(--text-secondary);
|
/* 步骤通用 */
|
||||||
font-size: 13px;
|
.step-item {
|
||||||
cursor: pointer;
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 思考过程 */
|
||||||
|
.thinking .step-header,
|
||||||
|
.tool_call .step-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.process-toggle:hover {
|
|
||||||
background: var(--bg-code);
|
|
||||||
}
|
|
||||||
|
|
||||||
.process-count {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
background: var(--accent-primary-light);
|
|
||||||
color: var(--accent-primary);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow {
|
|
||||||
margin-left: 8px;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow.open {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.process-list {
|
|
||||||
border-top: 1px solid var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.process-item {
|
|
||||||
border-bottom: 1px solid var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.process-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.process-header {
|
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
transition: background 0.15s;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.process-header:hover {
|
.thinking .step-header:hover,
|
||||||
|
.tool_call .step-header:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.process-icon {
|
.thinking .step-header svg:first-child {
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thinking .process-icon {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #f59e0b;
|
color: #f59e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool_call .process-icon {
|
.tool_call .step-header svg:first-child {
|
||||||
background: #f3e8ff;
|
|
||||||
color: #a855f7;
|
color: #a855f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool_result .process-icon {
|
.step-label {
|
||||||
background: var(--success-bg);
|
|
||||||
color: var(--success-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.process-label {
|
|
||||||
flex: 1;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 130px;
|
||||||
|
max-width: 130px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.process-time {
|
.arrow {
|
||||||
font-size: 11px;
|
margin-left: auto;
|
||||||
|
transition: transform 0.2s;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.process-summary {
|
.step-badge {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.process-summary.success {
|
.step-badge.success {
|
||||||
background: var(--success-bg);
|
background: var(--success-bg);
|
||||||
color: var(--success-color);
|
color: var(--success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.process-summary.error {
|
.step-badge.error {
|
||||||
background: var(--danger-bg);
|
background: var(--danger-bg);
|
||||||
color: var(--danger-color);
|
color: var(--danger-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-arrow {
|
.step-brief {
|
||||||
transition: transform 0.2s;
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-arrow.open {
|
|
||||||
|
.arrow.open {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -442,19 +372,17 @@ watch(() => props.streaming, (streaming) => {
|
||||||
animation: pulse 1s ease-in-out infinite;
|
animation: pulse 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
.tool_call.loading .step-header {
|
||||||
0%, 100% { opacity: 0.4; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.process-item.loading .process-header {
|
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.process-content {
|
/* 步骤展开内容 */
|
||||||
|
.step-content {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: var(--bg-primary);
|
margin-top: 4px;
|
||||||
border-top: 1px solid var(--border-light);
|
background: var(--bg-code);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -465,53 +393,54 @@ watch(() => props.streaming, (streaming) => {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-detail,
|
.tool-detail {
|
||||||
.tool-result-detail {
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-name,
|
.detail-label {
|
||||||
.tool-args {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-name:last-child,
|
|
||||||
.tool-args:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-right: 8px;
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.tool-detail pre {
|
||||||
color: var(--accent-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-args pre,
|
|
||||||
.tool-result-detail pre {
|
|
||||||
margin-top: 4px;
|
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: var(--bg-code);
|
background: var(--bg-primary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-label {
|
/* 文本内容直接渲染 */
|
||||||
font-size: 11px;
|
.text-content {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content :deep(.placeholder) {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 流式指示器 */
|
||||||
|
.streaming-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 0 0;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
font-size: 12px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>文件夹路径</label>
|
<label>文件夹路径</label>
|
||||||
<input v-model="uploadData.folderPath" type="text" placeholder="输入文件夹绝对路径" />
|
<div class="input-with-action">
|
||||||
|
<input v-model="uploadData.folderPath" type="text" placeholder="输入文件夹绝对路径或点击右侧按钮选择" />
|
||||||
|
<button class="btn-browse" @click="selectFolder" type="button">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
选择文件夹
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>描述(可选)</label>
|
<label>描述(可选)</label>
|
||||||
|
|
@ -157,6 +165,26 @@ const uploadData = ref({
|
||||||
description: '',
|
description: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function selectFolder() {
|
||||||
|
try {
|
||||||
|
if ('showDirectoryPicker' in window) {
|
||||||
|
const dirHandle = await window.showDirectoryPicker()
|
||||||
|
// 将文件夹名称自动填入项目名(如未填写)
|
||||||
|
if (!uploadData.value.name.trim()) {
|
||||||
|
uploadData.value.name = dirHandle.name
|
||||||
|
}
|
||||||
|
// 提示用户手动确认服务器路径
|
||||||
|
if (!uploadData.value.folderPath.trim()) {
|
||||||
|
uploadData.value.folderPath = dirHandle.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name !== 'AbortError') {
|
||||||
|
console.error('Failed to select folder:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 固定用户ID(实际应用中应从登录状态获取)
|
// 固定用户ID(实际应用中应从登录状态获取)
|
||||||
const userId = 1
|
const userId = 1
|
||||||
|
|
||||||
|
|
@ -439,6 +467,39 @@ defineExpose({
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-with-action {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-action input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-browse {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-input);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-browse:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.form-group input:focus,
|
.form-group input:focus,
|
||||||
.form-group textarea:focus {
|
.form-group textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<Transition name="slide">
|
<Transition name="fade">
|
||||||
<div v-if="visible" class="settings-overlay" @click.self="$emit('close')">
|
<div v-if="visible" class="settings-overlay" @click.self="$emit('close')">
|
||||||
<div class="settings-panel">
|
<div class="settings-panel">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
|
|
@ -96,10 +96,6 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-stats">
|
|
||||||
<StatsPanel />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
@ -109,7 +105,6 @@
|
||||||
import { reactive, ref, watch, onMounted } from 'vue'
|
import { reactive, ref, watch, onMounted } from 'vue'
|
||||||
import { modelApi, conversationApi } from '../api'
|
import { modelApi, conversationApi } from '../api'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
import StatsPanel from './StatsPanel.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: { type: Boolean, default: false },
|
visible: { type: Boolean, default: false },
|
||||||
|
|
@ -142,25 +137,32 @@ async function loadModels() {
|
||||||
function syncFormFromConversation() {
|
function syncFormFromConversation() {
|
||||||
if (props.conversation) {
|
if (props.conversation) {
|
||||||
form.title = props.conversation.title || ''
|
form.title = props.conversation.title || ''
|
||||||
form.model = props.conversation.model || ''
|
|
||||||
form.system_prompt = props.conversation.system_prompt || ''
|
form.system_prompt = props.conversation.system_prompt || ''
|
||||||
form.temperature = props.conversation.temperature ?? 1.0
|
form.temperature = props.conversation.temperature ?? 1.0
|
||||||
form.max_tokens = props.conversation.max_tokens ?? 65536
|
form.max_tokens = props.conversation.max_tokens ?? 65536
|
||||||
form.thinking_enabled = props.conversation.thinking_enabled ?? false
|
form.thinking_enabled = props.conversation.thinking_enabled ?? false
|
||||||
|
// model: 优先使用 conversation 的值,其次 models 列表第一个
|
||||||
|
if (props.conversation.model) {
|
||||||
|
form.model = props.conversation.model
|
||||||
|
} else if (models.value.length > 0) {
|
||||||
|
form.model = models.value[0].id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync form when panel opens
|
// Sync form when panel opens or conversation changes
|
||||||
watch(() => props.visible, (visible) => {
|
watch([() => props.visible, () => props.conversation, models], () => {
|
||||||
if (visible) {
|
if (props.visible) {
|
||||||
syncFormFromConversation()
|
syncFormFromConversation()
|
||||||
}
|
}
|
||||||
})
|
}, { deep: true })
|
||||||
|
|
||||||
// Auto-save with debounce when form changes
|
// Auto-save with debounce when form changes
|
||||||
|
let saveTimer = null
|
||||||
watch(form, () => {
|
watch(form, () => {
|
||||||
if (props.visible && props.conversation) {
|
if (props.visible && props.conversation) {
|
||||||
saveChanges()
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(saveChanges, 500)
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
|
@ -184,19 +186,20 @@ onMounted(loadModels)
|
||||||
background: var(--overlay-bg);
|
background: var(--overlay-bg);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
align-items: center;
|
||||||
transition: background 0.2s;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
width: 380px;
|
width: 90%;
|
||||||
height: 100vh;
|
max-width: 520px;
|
||||||
|
max-height: 85vh;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-left: 1px solid var(--border-light);
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
transition: background 0.2s, border-color 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header {
|
.settings-header {
|
||||||
|
|
@ -231,6 +234,7 @@ onMounted(loadModels)
|
||||||
.settings-body {
|
.settings-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
|
|
@ -373,30 +377,4 @@ onMounted(loadModels)
|
||||||
background: var(--border-light);
|
background: var(--border-light);
|
||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-stats {
|
|
||||||
padding: 16px 24px 24px;
|
|
||||||
border-top: 1px solid var(--border-light);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-enter-active,
|
|
||||||
.slide-leave-active {
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-enter-active .settings-panel,
|
|
||||||
.slide-leave-active .settings-panel {
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-enter-from,
|
|
||||||
.slide-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-enter-from .settings-panel,
|
|
||||||
.slide-leave-to .settings-panel {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@
|
||||||
class="conversation-item"
|
class="conversation-item"
|
||||||
:class="{ active: conv.id === currentId }"
|
:class="{ active: conv.id === currentId }"
|
||||||
@click="$emit('select', conv.id)"
|
@click="$emit('select', conv.id)"
|
||||||
@contextmenu.prevent="onContextMenu($event, conv)"
|
|
||||||
>
|
>
|
||||||
<div class="conv-info">
|
<div class="conv-info">
|
||||||
<div class="conv-title">{{ conv.title || '新对话' }}</div>
|
<div class="conv-title">{{ conv.title || '新对话' }}</div>
|
||||||
|
|
@ -123,24 +122,20 @@ function onScroll(e) {
|
||||||
emit('loadMore')
|
emit('loadMore')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onContextMenu(e, conv) {
|
|
||||||
// right-click to delete
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.sidebar {
|
.sidebar {
|
||||||
flex: 0 1 auto; /* 弹性宽度,可收缩 */
|
width: 20%;
|
||||||
width: 260px; /* 默认宽度 */
|
min-width: 220px;
|
||||||
min-width: 180px; /* 最小宽度 */
|
max-width: 320px;
|
||||||
max-width: 320px; /* 最大宽度 */
|
flex-shrink: 0;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-right: 1px solid var(--border-medium);
|
border-right: 1px solid var(--border-medium);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
transition: all 0.2s;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-section {
|
.project-section {
|
||||||
|
|
|
||||||
|
|
@ -1,127 +1,219 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="stats-panel">
|
<div class="stats-panel">
|
||||||
<div class="stats-header">
|
<div class="stats-header">
|
||||||
<h4>Token 使用统计</h4>
|
<div class="stats-title">
|
||||||
<div class="period-tabs">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<button
|
<path d="M18 20V10"/>
|
||||||
v-for="p in periods"
|
<path d="M12 20V4"/>
|
||||||
:key="p.value"
|
<path d="M6 20v-6"/>
|
||||||
:class="['tab', { active: period === p.value }]"
|
</svg>
|
||||||
@click="changePeriod(p.value)"
|
<h4>使用统计</h4>
|
||||||
>
|
</div>
|
||||||
{{ p.label }}
|
<div class="header-actions">
|
||||||
|
<div class="period-tabs">
|
||||||
|
<button
|
||||||
|
v-for="p in periods"
|
||||||
|
:key="p.value"
|
||||||
|
:class="['tab', { active: period === p.value }]"
|
||||||
|
@click="changePeriod(p.value)"
|
||||||
|
>
|
||||||
|
{{ p.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn-close" @click="$emit('close')">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="stats-loading">加载中...</div>
|
<div v-if="loading" class="stats-loading">
|
||||||
|
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
</svg>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-else-if="stats">
|
<template v-else-if="stats">
|
||||||
|
<!-- 统计卡片 -->
|
||||||
<div class="stats-summary">
|
<div class="stats-summary">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">输入 Token</div>
|
<div class="stat-icon input-icon">
|
||||||
<div class="stat-value">{{ formatNumber(stats.prompt_tokens) }}</div>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||||
|
<path d="M18.375 2.625a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-label">输入</span>
|
||||||
|
<span class="stat-value">{{ formatNumber(stats.prompt_tokens) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">输出 Token</div>
|
<div class="stat-icon output-icon">
|
||||||
<div class="stat-value">{{ formatNumber(stats.completion_tokens) }}</div>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-label">输出</span>
|
||||||
|
<span class="stat-value">{{ formatNumber(stats.completion_tokens) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card highlight">
|
<div class="stat-card total">
|
||||||
<div class="stat-label">总计</div>
|
<div class="stat-icon total-icon">
|
||||||
<div class="stat-value">{{ formatNumber(stats.total_tokens) }}</div>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<span class="stat-label">总计</span>
|
||||||
|
<span class="stat-value">{{ formatNumber(stats.total_tokens) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="period !== 'daily' && stats.daily" class="stats-chart">
|
<!-- 趋势图 -->
|
||||||
<div class="chart-title">每日使用趋势</div>
|
<div v-if="period !== 'daily' && stats.daily && chartData.length > 0" class="stats-chart">
|
||||||
|
<div class="chart-title">每日趋势</div>
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<svg class="line-chart" :viewBox="`0 0 ${chartWidth} ${chartHeight}`">
|
<svg class="line-chart" :viewBox="`0 0 ${chartWidth} ${chartHeight}`">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="areaGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" :stop-color="accentColor" stop-opacity="0.25"/>
|
||||||
|
<stop offset="100%" :stop-color="accentColor" stop-opacity="0.02"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
<!-- 网格线 -->
|
<!-- 网格线 -->
|
||||||
<g class="grid-lines">
|
<line
|
||||||
<line
|
v-for="i in 4"
|
||||||
v-for="i in 4"
|
:key="'grid-' + i"
|
||||||
:key="'grid-' + i"
|
:x1="padding"
|
||||||
:x1="padding"
|
:y1="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
|
||||||
:y1="padding + (chartHeight - 2 * padding) * (i - 1) / 4"
|
:x2="chartWidth - padding"
|
||||||
:x2="chartWidth - padding"
|
:y2="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
|
||||||
:y2="padding + (chartHeight - 2 * padding) * (i - 1) / 4"
|
stroke="var(--border-light)"
|
||||||
stroke="var(--border-light)"
|
stroke-dasharray="3,3"
|
||||||
stroke-dasharray="4,4"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- 填充区域 -->
|
|
||||||
<path
|
|
||||||
:d="areaPath"
|
|
||||||
fill="url(#gradient)"
|
|
||||||
opacity="0.3"
|
|
||||||
/>
|
/>
|
||||||
|
<!-- Y轴标签 -->
|
||||||
|
<text
|
||||||
|
v-for="i in 4"
|
||||||
|
:key="'yl-' + i"
|
||||||
|
:x="padding - 4"
|
||||||
|
:y="padding + (chartHeight - 2 * padding) * (i - 1) / 3 + 3"
|
||||||
|
text-anchor="end"
|
||||||
|
class="y-label"
|
||||||
|
>{{ formatNumber(maxValue - (maxValue * (i - 1)) / 3) }}</text>
|
||||||
|
<!-- 填充区域 -->
|
||||||
|
<path :d="areaPath" fill="url(#areaGradient)"/>
|
||||||
<!-- 折线 -->
|
<!-- 折线 -->
|
||||||
<path
|
<path
|
||||||
:d="linePath"
|
:d="linePath"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="var(--accent-primary)"
|
:stroke="accentColor"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 数据点 -->
|
<!-- 数据点 -->
|
||||||
<g class="data-points">
|
<circle
|
||||||
<circle
|
v-for="(point, idx) in chartPoints"
|
||||||
v-for="(point, idx) in chartPoints"
|
:key="idx"
|
||||||
:key="idx"
|
:cx="point.x"
|
||||||
:cx="point.x"
|
:cy="point.y"
|
||||||
:cy="point.y"
|
r="3"
|
||||||
r="4"
|
:fill="accentColor"
|
||||||
fill="var(--accent-primary)"
|
stroke="var(--bg-primary)"
|
||||||
stroke="var(--bg-primary)"
|
stroke-width="2"
|
||||||
stroke-width="2"
|
class="data-point"
|
||||||
class="data-point"
|
@mouseenter="hoveredPoint = idx"
|
||||||
@mouseenter="hoveredPoint = idx"
|
@mouseleave="hoveredPoint = null"
|
||||||
@mouseleave="hoveredPoint = null"
|
/>
|
||||||
/>
|
<!-- 竖线指示 -->
|
||||||
</g>
|
<line
|
||||||
|
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
|
||||||
<!-- 渐变定义 -->
|
:x1="chartPoints[hoveredPoint].x"
|
||||||
<defs>
|
:y1="padding"
|
||||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
:x2="chartPoints[hoveredPoint].x"
|
||||||
<stop offset="0%" stop-color="var(--accent-primary)" />
|
:y2="chartHeight - padding"
|
||||||
<stop offset="100%" stop-color="var(--accent-primary)" stop-opacity="0" />
|
stroke="var(--border-medium)"
|
||||||
</linearGradient>
|
stroke-dasharray="3,3"
|
||||||
</defs>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<!-- X轴标签 -->
|
<!-- X轴标签 -->
|
||||||
<div class="x-labels">
|
<div class="x-labels">
|
||||||
<span
|
<span
|
||||||
v-for="(data, date) in sortedDaily"
|
v-for="(point, idx) in chartPoints"
|
||||||
:key="date"
|
:key="idx"
|
||||||
class="x-label"
|
class="x-label"
|
||||||
|
:class="{ active: hoveredPoint === idx }"
|
||||||
>
|
>
|
||||||
{{ formatDateLabel(date) }}
|
{{ formatDateLabel(point.date) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 悬浮提示 -->
|
<!-- 悬浮提示 -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
|
||||||
|
class="tooltip"
|
||||||
|
:style="{
|
||||||
|
left: chartPoints[hoveredPoint].x + 'px',
|
||||||
|
top: (chartPoints[hoveredPoint].y - 52) + 'px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="tooltip-date">{{ formatFullDate(chartPoints[hoveredPoint].date) }}</div>
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-dot prompt"></span>
|
||||||
|
输入 {{ formatNumber(chartPoints[hoveredPoint].prompt) }}
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-dot completion"></span>
|
||||||
|
输出 {{ formatNumber(chartPoints[hoveredPoint].completion) }}
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-total">{{ formatNumber(chartPoints[hoveredPoint].value) }} tokens</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 按模型分布 -->
|
||||||
|
<div v-if="stats.by_model" class="stats-by-model">
|
||||||
|
<div class="model-title">模型分布</div>
|
||||||
|
<div class="model-list">
|
||||||
<div
|
<div
|
||||||
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
|
v-for="(data, model) in stats.by_model"
|
||||||
class="tooltip"
|
:key="model"
|
||||||
:style="{ left: chartPoints[hoveredPoint].x + 'px', top: (chartPoints[hoveredPoint].y - 40) + 'px' }"
|
class="model-row"
|
||||||
>
|
>
|
||||||
<div class="tooltip-date">{{ chartPoints[hoveredPoint].date }}</div>
|
<div class="model-info">
|
||||||
<div class="tooltip-value">{{ formatNumber(chartPoints[hoveredPoint].value) }} tokens</div>
|
<span class="model-name">{{ model }}</span>
|
||||||
|
<span class="model-value">{{ formatNumber(data.total) }} <span class="model-unit">tokens</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="model-bar-bg">
|
||||||
|
<div
|
||||||
|
class="model-bar-fill"
|
||||||
|
:style="{ width: (data.total / maxModelTokens * 100) + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="period === 'daily' && stats.by_model" class="stats-by-model">
|
<!-- 空状态 -->
|
||||||
<div class="model-title">按模型分布</div>
|
<div v-if="!stats.total_tokens" class="stats-empty">
|
||||||
<div v-for="(data, model) in stats.by_model" :key="model" class="model-row">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<span class="model-name">{{ model }}</span>
|
<path d="M18 20V10"/>
|
||||||
<span class="model-value">{{ formatNumber(data.total) }}</span>
|
<path d="M12 20V4"/>
|
||||||
</div>
|
<path d="M6 20v-6"/>
|
||||||
|
</svg>
|
||||||
|
<span>暂无使用数据</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -130,6 +222,11 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { statsApi } from '../api'
|
import { statsApi } from '../api'
|
||||||
|
import { useTheme } from '../composables/useTheme'
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
|
||||||
const periods = [
|
const periods = [
|
||||||
{ value: 'daily', label: '今日' },
|
{ value: 'daily', label: '今日' },
|
||||||
|
|
@ -142,9 +239,11 @@ const stats = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const hoveredPoint = ref(null)
|
const hoveredPoint = ref(null)
|
||||||
|
|
||||||
|
const accentColor = computed(() => isDark.value ? '#60a5fa' : '#2563eb')
|
||||||
|
|
||||||
const chartWidth = 320
|
const chartWidth = 320
|
||||||
const chartHeight = 160
|
const chartHeight = 140
|
||||||
const padding = 20
|
const padding = 32
|
||||||
|
|
||||||
const sortedDaily = computed(() => {
|
const sortedDaily = computed(() => {
|
||||||
if (!stats.value?.daily) return {}
|
if (!stats.value?.daily) return {}
|
||||||
|
|
@ -158,8 +257,8 @@ const chartData = computed(() => {
|
||||||
return Object.entries(data).map(([date, val]) => ({
|
return Object.entries(data).map(([date, val]) => ({
|
||||||
date,
|
date,
|
||||||
value: val.total,
|
value: val.total,
|
||||||
prompt: val.prompt,
|
prompt: val.prompt || 0,
|
||||||
completion: val.completion,
|
completion: val.completion || 0,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -168,6 +267,11 @@ const maxValue = computed(() => {
|
||||||
return Math.max(100, ...chartData.value.map(d => d.value))
|
return Math.max(100, ...chartData.value.map(d => d.value))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const maxModelTokens = computed(() => {
|
||||||
|
if (!stats.value?.by_model) return 1
|
||||||
|
return Math.max(1, ...Object.values(stats.value.by_model).map(d => d.total))
|
||||||
|
})
|
||||||
|
|
||||||
const chartPoints = computed(() => {
|
const chartPoints = computed(() => {
|
||||||
const data = chartData.value
|
const data = chartData.value
|
||||||
if (data.length === 0) return []
|
if (data.length === 0) return []
|
||||||
|
|
@ -176,10 +280,14 @@ const chartPoints = computed(() => {
|
||||||
const yRange = chartHeight - 2 * padding
|
const yRange = chartHeight - 2 * padding
|
||||||
|
|
||||||
return data.map((d, i) => ({
|
return data.map((d, i) => ({
|
||||||
x: padding + (i / Math.max(1, data.length - 1)) * xRange,
|
x: data.length === 1
|
||||||
|
? chartWidth / 2
|
||||||
|
: padding + (i / Math.max(1, data.length - 1)) * xRange,
|
||||||
y: chartHeight - padding - (d.value / maxValue.value) * yRange,
|
y: chartHeight - padding - (d.value / maxValue.value) * yRange,
|
||||||
date: d.date,
|
date: d.date,
|
||||||
value: d.value,
|
value: d.value,
|
||||||
|
prompt: d.prompt,
|
||||||
|
completion: d.completion,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -193,14 +301,11 @@ const areaPath = computed(() => {
|
||||||
const points = chartPoints.value
|
const points = chartPoints.value
|
||||||
if (points.length === 0) return ''
|
if (points.length === 0) return ''
|
||||||
|
|
||||||
const xRange = chartWidth - 2 * padding
|
|
||||||
const startX = padding
|
|
||||||
const endX = chartWidth - padding
|
|
||||||
const baseY = chartHeight - padding
|
const baseY = chartHeight - padding
|
||||||
|
|
||||||
let path = `M ${startX} ${baseY} `
|
let path = `M ${points[0].x} ${baseY} `
|
||||||
path += points.map(p => `L ${p.x} ${p.y}`).join(' ')
|
path += points.map(p => `L ${p.x} ${p.y}`).join(' ')
|
||||||
path += ` L ${endX} ${baseY} Z`
|
path += ` L ${points[points.length - 1].x} ${baseY} Z`
|
||||||
|
|
||||||
return path
|
return path
|
||||||
})
|
})
|
||||||
|
|
@ -210,7 +315,13 @@ function formatDateLabel(dateStr) {
|
||||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatFullDate(dateStr) {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||||
|
}
|
||||||
|
|
||||||
function formatNumber(num) {
|
function formatNumber(num) {
|
||||||
|
if (!num) return '0'
|
||||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
||||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
||||||
return num.toString()
|
return num.toString()
|
||||||
|
|
@ -230,6 +341,7 @@ async function loadStats() {
|
||||||
|
|
||||||
function changePeriod(p) {
|
function changePeriod(p) {
|
||||||
period.value = p
|
period.value = p
|
||||||
|
hoveredPoint.value = null
|
||||||
loadStats()
|
loadStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,25 +350,60 @@ onMounted(loadStats)
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.stats-panel {
|
.stats-panel {
|
||||||
padding: 16px 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-header {
|
.stats-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-header h4 {
|
.stats-title {
|
||||||
margin: 0;
|
display: flex;
|
||||||
font-size: 14px;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-title svg {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-title h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.period-tabs {
|
.period-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
@ -266,79 +413,140 @@ onMounted(loadStats)
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text-secondary);
|
color: var(--text-tertiary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
transition: all 0.15s;
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.active {
|
.tab.active {
|
||||||
background: var(--accent-primary);
|
background: var(--accent-primary);
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(37, 99, 235, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-loading {
|
.stats-loading {
|
||||||
text-align: center;
|
display: flex;
|
||||||
padding: 20px;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 24px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 统计卡片 */
|
||||||
.stats-summary {
|
.stats-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
border-radius: 8px;
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
text-align: center;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.highlight {
|
.stat-card:hover {
|
||||||
|
border-color: var(--border-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-icon {
|
||||||
|
background: rgba(168, 85, 247, 0.1);
|
||||||
|
color: #a855f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-icon {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.total {
|
||||||
background: var(--accent-primary-light);
|
background: var(--accent-primary-light);
|
||||||
|
border-color: rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.total .total-icon {
|
||||||
|
background: rgba(37, 99, 235, 0.15);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
margin-bottom: 4px;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card.highlight .stat-value {
|
/* 趋势图 */
|
||||||
color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-chart {
|
.stats-chart {
|
||||||
margin-top: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-title,
|
.chart-title,
|
||||||
.model-title {
|
.model-title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: 12px;
|
font-weight: 500;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
border-radius: 8px;
|
border: 1px solid var(--border-light);
|
||||||
padding: 16px;
|
border-radius: 10px;
|
||||||
|
padding: 12px 8px 8px 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-chart {
|
.line-chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 160px;
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.y-label {
|
||||||
|
fill: var(--text-tertiary);
|
||||||
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-point {
|
.data-point {
|
||||||
|
|
@ -347,65 +555,149 @@ onMounted(loadStats)
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-point:hover {
|
.data-point:hover {
|
||||||
r: 6;
|
r: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.x-labels {
|
.x-labels {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 8px;
|
margin-top: 6px;
|
||||||
padding: 0 20px;
|
padding: 0 28px 0 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.x-label {
|
.x-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.x-label.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 提示框 */
|
||||||
.tooltip {
|
.tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
border: 1px solid var(--border-medium);
|
||||||
padding: 6px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-date {
|
.tooltip-date {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-value {
|
.tooltip-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-dot.prompt {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-dot.completion {
|
||||||
|
background: #a855f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-total {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-primary);
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 模型分布 */
|
||||||
.stats-by-model {
|
.stats-by-model {
|
||||||
margin-top: 16px;
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-row {
|
.model-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 12px;
|
align-items: baseline;
|
||||||
background: var(--bg-input);
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-name {
|
.model-name {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 60%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-value {
|
.model-value {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
color: var(--text-secondary);
|
||||||
color: var(--text-primary);
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-unit {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-bar-bg {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent-primary), #a855f7);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
min-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.stats-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { ref, watch } from 'vue'
|
||||||
|
|
||||||
const isDark = ref(false)
|
const isDark = ref(false)
|
||||||
|
|
||||||
// 初始化时从 localStorage 读取
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const saved = localStorage.getItem('theme')
|
const saved = localStorage.getItem('theme')
|
||||||
isDark.value = saved === 'dark'
|
isDark.value = saved === 'dark'
|
||||||
|
|
@ -15,18 +14,14 @@ function applyTheme() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
watch(isDark, (val) => {
|
||||||
watch(isDark, (val) => {
|
localStorage.setItem('theme', val ? 'dark' : 'light')
|
||||||
localStorage.setItem('theme', val ? 'dark' : 'light')
|
applyTheme()
|
||||||
applyTheme()
|
})
|
||||||
})
|
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
isDark.value = !isDark.value
|
isDark.value = !isDark.value
|
||||||
}
|
}
|
||||||
|
return { isDark, toggleTheme }
|
||||||
return {
|
|
||||||
isDark,
|
|
||||||
toggleTheme,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,119 @@
|
||||||
/* Markdown content shared styles */
|
/* ============ Global Reset & Base ============ */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f8fafc;
|
||||||
|
--bg-tertiary: #f0f4f8;
|
||||||
|
--bg-hover: rgba(37, 99, 235, 0.06);
|
||||||
|
--bg-active: rgba(37, 99, 235, 0.12);
|
||||||
|
--bg-input: #f8fafc;
|
||||||
|
--bg-code: #f1f5f9;
|
||||||
|
--bg-thinking: #f1f5f9;
|
||||||
|
|
||||||
|
--text-primary: #1e293b;
|
||||||
|
--text-secondary: #64748b;
|
||||||
|
--text-tertiary: #94a3b8;
|
||||||
|
|
||||||
|
--border-light: rgba(0, 0, 0, 0.06);
|
||||||
|
--border-medium: rgba(0, 0, 0, 0.08);
|
||||||
|
--border-input: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
--accent-primary: #2563eb;
|
||||||
|
--accent-primary-hover: #3b82f6;
|
||||||
|
--accent-primary-light: rgba(37, 99, 235, 0.08);
|
||||||
|
--accent-primary-medium: rgba(37, 99, 235, 0.15);
|
||||||
|
|
||||||
|
--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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-primary: #1a1a1a;
|
||||||
|
--bg-secondary: #141414;
|
||||||
|
--bg-tertiary: #0a0a0a;
|
||||||
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
|
--bg-active: rgba(255, 255, 255, 0.12);
|
||||||
|
--bg-input: #141414;
|
||||||
|
--bg-code: #141414;
|
||||||
|
--bg-thinking: #141414;
|
||||||
|
|
||||||
|
--text-primary: #f0f0f0;
|
||||||
|
--text-secondary: #a0a0a0;
|
||||||
|
--text-tertiary: #606060;
|
||||||
|
|
||||||
|
--border-light: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-medium: rgba(255, 255, 255, 0.12);
|
||||||
|
--border-input: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
--accent-primary: #3b82f6;
|
||||||
|
--accent-primary-hover: #60a5fa;
|
||||||
|
--accent-primary-light: rgba(59, 130, 246, 0.15);
|
||||||
|
--accent-primary-medium: rgba(59, 130, 246, 0.25);
|
||||||
|
|
||||||
|
--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);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Transitions ============ */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Animations ============ */
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Markdown content shared styles ============ */
|
||||||
.md-content {
|
.md-content {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
|
@ -6,32 +121,126 @@
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content :deep(p) {
|
.md-content p {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content :deep(p:last-child) {
|
.md-content p:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content :deep(pre) {
|
.md-content hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content pre {
|
||||||
background: var(--bg-code);
|
background: var(--bg-code);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content :deep(pre code) {
|
.md-content pre code {
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content :deep(code) {
|
/* 代码块滚动条 */
|
||||||
|
.md-content pre::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content pre::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content pre::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content pre::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码块头部:语言标签 + 复制按钮 */
|
||||||
|
.code-block {
|
||||||
|
background: var(--bg-code);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block pre code {
|
||||||
|
display: block;
|
||||||
|
padding: 12px 12px 12px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block pre code::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block pre code::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block pre code::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block pre code::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 12px 6px 16px;
|
||||||
|
background: var(--bg-code);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-lang {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-copy-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-copy-btn:hover {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
background: var(--accent-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content code {
|
||||||
background: var(--accent-primary-light);
|
background: var(--accent-primary-light);
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
|
|
@ -40,43 +249,43 @@
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content :deep(pre code) {
|
.md-content pre code {
|
||||||
background: none;
|
background: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content :deep(ul),
|
.md-content ul,
|
||||||
.md-content :deep(ol) {
|
.md-content ol {
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content :deep(blockquote) {
|
.md-content blockquote {
|
||||||
border-left: 3px solid rgba(59, 130, 246, 0.4);
|
border-left: 3px solid rgba(59, 130, 246, 0.4);
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content :deep(table) {
|
.md-content table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content :deep(th),
|
.md-content th,
|
||||||
.md-content :deep(td) {
|
.md-content td {
|
||||||
border: 1px solid var(--border-medium);
|
border: 1px solid var(--border-medium);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content :deep(th) {
|
.md-content th {
|
||||||
background: var(--bg-code);
|
background: var(--bg-code);
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content :deep(.math-block) {
|
.md-content .math-block {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
|
|
@ -84,26 +293,7 @@
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 共享滚动条样式 */
|
/* ============ Scrollbar ============ */
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--scrollbar-thumb);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Textarea 滚动条 */
|
|
||||||
textarea::-webkit-scrollbar {
|
textarea::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
|
|
@ -122,48 +312,63 @@ textarea::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--text-tertiary);
|
background: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Textarea resize 手柄修复 */
|
|
||||||
textarea::-webkit-resizer {
|
textarea::-webkit-resizer {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Range 滑块样式 */
|
/* ============ Range Slider ============ */
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 6px;
|
height: 8px;
|
||||||
|
min-height: 8px;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: var(--border-medium);
|
background: var(--border-medium);
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-runnable-track {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="range"]::-webkit-slider-thumb {
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 16px;
|
width: 20px;
|
||||||
height: 16px;
|
height: 20px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent-primary);
|
background: var(--accent-primary);
|
||||||
|
border: 3px solid var(--bg-primary);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
margin-top: -6px;
|
||||||
transition: transform 0.15s;
|
transition: transform 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="range"]::-webkit-slider-thumb:hover {
|
input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
transform: scale(1.1);
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-track {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--border-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="range"]::-moz-range-thumb {
|
input[type="range"]::-moz-range-thumb {
|
||||||
width: 16px;
|
width: 20px;
|
||||||
height: 16px;
|
height: 20px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent-primary);
|
background: var(--accent-primary);
|
||||||
|
border: 3px solid var(--bg-primary);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 按钮基础样式 */
|
/* ============ Button Base ============ */
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -185,32 +390,3 @@ input[type="range"]::-moz-range-thumb {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表单元素基础样式 */
|
|
||||||
.form-input {
|
|
||||||
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;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s, background 0.2s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input:focus {
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 动画 */
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,15 @@
|
||||||
color: #cf222e;
|
color: #cf222e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-string,
|
.hljs-string {
|
||||||
.hljs-addition {
|
|
||||||
color: #0a3069;
|
color: #0a3069;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hljs-addition {
|
||||||
|
color: #116329;
|
||||||
|
background: rgba(46, 160, 67, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.hljs-number,
|
.hljs-number,
|
||||||
.hljs-literal {
|
.hljs-literal {
|
||||||
color: #0550ae;
|
color: #0550ae;
|
||||||
|
|
@ -61,7 +65,67 @@
|
||||||
background: rgba(248, 81, 73, 0.1);
|
background: rgba(248, 81, 73, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-addition {
|
/* Dark theme for code blocks */
|
||||||
color: #116329;
|
[data-theme="dark"] .hljs {
|
||||||
|
color: #e6edf3;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .hljs-comment,
|
||||||
|
[data-theme="dark"] .hljs-quote {
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .hljs-keyword,
|
||||||
|
[data-theme="dark"] .hljs-selector-tag,
|
||||||
|
[data-theme="dark"] .hljs-type {
|
||||||
|
color: #ff7b72;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .hljs-string {
|
||||||
|
color: #a5d6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .hljs-addition {
|
||||||
|
color: #7ee787;
|
||||||
background: rgba(46, 160, 67, 0.1);
|
background: rgba(46, 160, 67, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .hljs-number,
|
||||||
|
[data-theme="dark"] .hljs-literal {
|
||||||
|
color: #79c0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .hljs-built_in,
|
||||||
|
[data-theme="dark"] .hljs-builtin-name {
|
||||||
|
color: #ffa657;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .hljs-function .hljs-title,
|
||||||
|
[data-theme="dark"] .hljs-title.function_ {
|
||||||
|
color: #d2a8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .hljs-variable,
|
||||||
|
[data-theme="dark"] .hljs-template-variable {
|
||||||
|
color: #ffa657;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .hljs-attr,
|
||||||
|
[data-theme="dark"] .hljs-attribute {
|
||||||
|
color: #79c0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .hljs-selector-class {
|
||||||
|
color: #7ee787;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .hljs-meta {
|
||||||
|
color: #79c0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .hljs-deletion {
|
||||||
|
color: #ffa198;
|
||||||
|
background: rgba(248, 81, 73, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
|
import { markedHighlight } from 'marked-highlight'
|
||||||
import katex from 'katex'
|
import katex from 'katex'
|
||||||
import hljs from 'highlight.js'
|
import hljs from 'highlight.js'
|
||||||
|
|
||||||
|
|
@ -19,12 +20,10 @@ const mathExtension = {
|
||||||
name: 'math',
|
name: 'math',
|
||||||
level: 'inline',
|
level: 'inline',
|
||||||
start(src) {
|
start(src) {
|
||||||
// Find $ not followed by $ (to avoid matching $$)
|
|
||||||
const idx = src.search(/(?<!\$)\$(?!\$)/)
|
const idx = src.search(/(?<!\$)\$(?!\$)/)
|
||||||
return idx === -1 ? undefined : idx
|
return idx === -1 ? undefined : idx
|
||||||
},
|
},
|
||||||
tokenizer(src) {
|
tokenizer(src) {
|
||||||
// Match $...$ (single $, not $$)
|
|
||||||
const match = src.match(/^(?<!\$)\$(?!\$)([^\$\n]+?)\$(?!\$)/)
|
const match = src.match(/^(?<!\$)\$(?!\$)([^\$\n]+?)\$(?!\$)/)
|
||||||
if (match) {
|
if (match) {
|
||||||
return { type: 'math', raw: match[0], text: match[1].trim(), displayMode: false }
|
return { type: 'math', raw: match[0], text: match[1].trim(), displayMode: false }
|
||||||
|
|
@ -54,15 +53,20 @@ const blockMathExtension = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
marked.use({ extensions: [blockMathExtension, mathExtension] })
|
marked.use({
|
||||||
|
extensions: [blockMathExtension, mathExtension],
|
||||||
|
...markedHighlight({
|
||||||
|
langPrefix: 'hljs language-',
|
||||||
|
highlight(code, lang) {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
return hljs.highlight(code, { language: lang }).value
|
||||||
|
}
|
||||||
|
return hljs.highlightAuto(code).value
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
highlight(code, lang) {
|
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
|
||||||
return hljs.highlight(code, { language: lang }).value
|
|
||||||
}
|
|
||||||
return hljs.highlightAuto(code).value
|
|
||||||
},
|
|
||||||
breaks: true,
|
breaks: true,
|
||||||
gfm: true,
|
gfm: true,
|
||||||
})
|
})
|
||||||
|
|
@ -70,3 +74,69 @@ marked.setOptions({
|
||||||
export function renderMarkdown(text) {
|
export function renderMarkdown(text) {
|
||||||
return marked.parse(text)
|
return marked.parse(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后处理 HTML:为所有代码块包裹 .code-block 容器,
|
||||||
|
* 添加语言标签和复制按钮。在组件 onMounted / updated 中调用。
|
||||||
|
*/
|
||||||
|
export function enhanceCodeBlocks(container) {
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const pres = container.querySelectorAll('pre')
|
||||||
|
for (const pre of pres) {
|
||||||
|
// 跳过已处理过的
|
||||||
|
if (pre.parentElement.classList.contains('code-block')) continue
|
||||||
|
|
||||||
|
const code = pre.querySelector('code')
|
||||||
|
const langClass = code?.className || ''
|
||||||
|
const lang = langClass.replace(/hljs\s+language-/, '').trim() || 'code'
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div')
|
||||||
|
wrapper.className = 'code-block'
|
||||||
|
|
||||||
|
const header = document.createElement('div')
|
||||||
|
header.className = 'code-header'
|
||||||
|
|
||||||
|
const langSpan = document.createElement('span')
|
||||||
|
langSpan.className = 'code-lang'
|
||||||
|
langSpan.textContent = lang
|
||||||
|
|
||||||
|
const copyBtn = document.createElement('button')
|
||||||
|
copyBtn.className = 'code-copy-btn'
|
||||||
|
copyBtn.title = '复制'
|
||||||
|
copyBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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 checkSvg = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>'
|
||||||
|
|
||||||
|
copyBtn.addEventListener('click', () => {
|
||||||
|
const raw = code?.textContent || ''
|
||||||
|
navigator.clipboard.writeText(raw).then(() => {
|
||||||
|
copyBtn.innerHTML = checkSvg
|
||||||
|
setTimeout(() => { copyBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>' }, 1500)
|
||||||
|
}).catch(() => {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = raw
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.opacity = '0'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
copyBtn.innerHTML = checkSvg
|
||||||
|
setTimeout(() => { copyBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>' }, 1500)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
header.appendChild(langSpan)
|
||||||
|
header.appendChild(copyBtn)
|
||||||
|
pre.parentNode.insertBefore(wrapper, pre)
|
||||||
|
wrapper.appendChild(header)
|
||||||
|
wrapper.appendChild(pre)
|
||||||
|
|
||||||
|
// 重置 pre 的内联样式,确保由 .code-block 系列样式控制
|
||||||
|
pre.style.cssText = 'margin:0;padding:0;border:none;border-radius:0;background:transparent;'
|
||||||
|
if (code) {
|
||||||
|
code.style.cssText = 'display:block;padding:12px 12px 12px 16px;overflow-x:auto;font-family:JetBrains Mono,Fira Code,monospace;font-size:13px;line-height:1.5;'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue