refactor: 代码精简与UI优化

This commit is contained in:
ViperEkura 2026-03-26 14:43:23 +08:00
parent 31cfcd3ed2
commit d5fdbb0cb3
18 changed files with 1345 additions and 882 deletions

View File

@ -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

View File

@ -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}
``` ```
字段说明: 字段说明:

View File

@ -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",

View File

@ -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": {

View File

@ -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>

View File

@ -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}`)
},
} }

View File

@ -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>

View File

@ -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;

View File

@ -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"
/> />

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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 {

View File

@ -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,18 +267,27 @@ 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 []
const xRange = chartWidth - 2 * padding const xRange = chartWidth - 2 * padding
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,
})) }))
}) })
@ -192,16 +300,13 @@ const linePath = computed(() => {
const areaPath = computed(() => { 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>

View File

@ -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,
}
} }

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;'
}
}
}