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"
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
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"
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
with app.app_context():
# Build content JSON

View File

@ -402,7 +402,7 @@ def process_tool_calls(self, tool_calls, context=None):
| `message` | 回复内容的增量片段 |
| `tool_calls` | 工具调用信息 |
| `tool_result` | 工具执行结果 |
| `process_step` | 处理步骤按顺序thinking/tool_call/tool_result支持交替显示 |
| `process_step` | 处理步骤按顺序thinking/text/tool_call/tool_result支持穿插显示 |
| `error` | 错误信息 |
| `done` | 回复结束,携带 message_id 和 token_count |
@ -412,11 +412,14 @@ def process_tool_calls(self, tool_calls, context=None):
// 思考过程
{"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",
"katex": "^0.16.40",
"marked": "^15.0.0",
"marked-highlight": "^2.2.3",
"vue": "^3.4.0"
},
"devDependencies": {
@ -1151,6 +1152,7 @@
"resolved": "https://registry.npmmirror.com/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
@ -1158,6 +1160,15 @@
"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": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",

View File

@ -12,6 +12,7 @@
"highlight.js": "^11.10.0",
"katex": "^0.16.40",
"marked": "^15.0.0",
"marked-highlight": "^2.2.3",
"vue": "^3.4.0"
},
"devDependencies": {

View File

@ -14,7 +14,6 @@
/>
<ChatView
ref="chatViewRef"
:conversation="currentConv"
:messages="messages"
:streaming="streaming"
@ -29,16 +28,26 @@
@delete-message="deleteMessage"
@regenerate-message="regenerateMessage"
@toggle-settings="showSettings = true"
@toggle-stats="showStats = true"
@load-more-messages="loadMoreMessages"
@toggle-tools="updateToolsEnabled"
/>
<SettingsPanel
v-if="showSettings"
:visible="showSettings"
:conversation="currentConv"
@close="showSettings = false"
@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>
</template>
@ -47,10 +56,9 @@ import { ref, computed, onMounted } from 'vue'
import Sidebar from './components/Sidebar.vue'
import ChatView from './components/ChatView.vue'
import SettingsPanel from './components/SettingsPanel.vue'
import StatsPanel from './components/StatsPanel.vue'
import { conversationApi, messageApi } from './api'
const chatViewRef = ref(null)
// -- Conversations state --
const conversations = ref([])
const currentConvId = ref(null)
@ -71,6 +79,15 @@ const streamThinking = ref('')
const streamToolCalls = ref([])
const streamProcessSteps = ref([])
function resetStreamState() {
streaming.value = false
streamContent.value = ''
streamThinking.value = ''
streamToolCalls.value = []
streamProcessSteps.value = []
currentStreamPromise = null
}
//
const streamStates = new Map()
@ -79,6 +96,7 @@ let currentStreamPromise = null
// -- UI state --
const showSettings = ref(false)
const showStats = ref(false)
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') //
const currentProject = ref(null) // Current selected project
@ -267,21 +285,15 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
token_count: data.token_count,
created_at: new Date().toISOString(),
})
streamContent.value = ''
streamThinking.value = ''
streamToolCalls.value = []
streamProcessSteps.value = []
resetStreamState()
if (updateConvList) {
const idx = conversations.value.findIndex(c => c.id === convId)
if (idx > 0) {
const [conv] = conversations.value.splice(idx, 1)
if (idx >= 0) {
const conv = idx > 0 ? conversations.value.splice(idx, 1)[0] : conversations.value[0]
conv.message_count = (conv.message_count || 0) + 2
if (data.suggested_title) conv.title = data.suggested_title
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
if (idx > 0) conversations.value.unshift(conv)
}
}
} else {
@ -301,12 +313,7 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
onError(msg) {
streamStates.delete(convId)
if (currentConvId.value === convId) {
streaming.value = false
currentStreamPromise = null
streamContent.value = ''
streamThinking.value = ''
streamToolCalls.value = []
streamProcessSteps.value = []
resetStreamState()
console.error('Stream error:', msg)
}
},
@ -428,105 +435,29 @@ onMounted(() => {
</script>
<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 {
display: flex;
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>

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) {
return request(`/projects/${projectId}`, { method: 'DELETE' })
},
@ -223,9 +212,4 @@ export const projectApi = {
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-title-area">
<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>
</div>
<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="设置">
<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>
@ -23,7 +30,7 @@
</div>
</div>
<div ref="scrollContainer" class="messages-container" @scroll="onScroll">
<div ref="scrollContainer" class="messages-container">
<div v-if="hasMoreMessages" class="load-more-top">
<button @click="$emit('loadMoreMessages')" :disabled="loadingMore">
{{ loadingMore ? '加载中...' : '加载更早的消息' }}
@ -54,15 +61,9 @@
:thinking-content="streamingThinking"
:tool-calls="streamingToolCalls"
:process-steps="streamingProcessSteps"
:streaming-content="streamingContent"
: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>
@ -80,11 +81,11 @@
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { ref, watch, nextTick, onMounted } from 'vue'
import MessageBubble from './MessageBubble.vue'
import MessageInput from './MessageInput.vue'
import ProcessBlock from './ProcessBlock.vue'
import { renderMarkdown } from '../utils/markdown'
import { modelApi } from '../api'
const props = defineProps({
conversation: { type: Object, default: null },
@ -99,14 +100,27 @@ const props = defineProps({
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 inputRef = ref(null)
const modelNameMap = ref({})
const renderedStreamContent = computed(() => {
if (!props.streamingContent) return ''
return renderMarkdown(props.streamingContent)
function formatModelName(modelId) {
return modelNameMap.value[modelId] || modelId
}
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) {
@ -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, () => {
scrollToBottom()
})
@ -147,14 +155,13 @@ defineExpose({ scrollToBottom })
<style scoped>
.chat-view {
flex: 1 1 auto; /* 弹性宽度,自动填充剩余空间 */
flex: 1 1 0;
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-secondary);
min-width: 300px; /* 最小宽度保证可用性 */
min-width: 0;
overflow: hidden;
transition: background 0.2s;
}
.welcome {
@ -170,7 +177,6 @@ defineExpose({ scrollToBottom })
width: 64px;
height: 64px;
border-radius: 16px;
background: none;
display: flex;
align-items: center;
justify-content: center;
@ -216,22 +222,30 @@ defineExpose({ scrollToBottom })
text-overflow: ellipsis;
}
.model-badge {
.badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--accent-primary-light);
color: var(--accent-primary);
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 {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--success-bg);
color: var(--success-color);
flex-shrink: 0;
}
.chat-actions {
@ -302,28 +316,18 @@ defineExpose({ scrollToBottom })
}
.messages-list {
flex: 0 1 auto; /* 弹性宽度 */
width: 80%;
margin: 0 auto; /* 居中显示 */
padding: 0 16px; /* 左右内边距 */
margin: 0 auto;
padding: 0 16px;
}
.message-bubble {
display: flex;
gap: 12px;
padding: 0;
margin-bottom: 16px;
width: 100%;
}
.message-bubble.assistant {
width: 100%;
}
.message-bubble.assistant.streaming {
width: 100%;
}
.message-bubble .message-container {
display: flex;
flex-direction: column;
@ -365,26 +369,4 @@ defineExpose({ scrollToBottom })
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>

View File

@ -10,18 +10,23 @@
<span class="attachment-name">{{ file.name }}</span>
</div>
</div>
<div class="message-body">
<div ref="messageRef" class="message-body">
<!-- 新格式: processSteps 包含所有步骤 text统一通过 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"
:tool-calls="toolCalls"
:process-steps="processSteps"
/>
<div v-if="role === 'tool'" class="tool-result-content">
<div class="tool-badge">工具返回结果: {{ toolName }}</div>
<pre>{{ content }}</pre>
</div>
<div v-else class="md-content message-content" v-html="renderedContent"></div>
<!-- 旧格式: processSteps分开渲染 ProcessBlock + 文本 -->
<template v-else>
<ProcessBlock
v-if="thinkingContent || (toolCalls && toolCalls.length > 0)"
:thinking-content="thinkingContent"
:tool-calls="toolCalls"
/>
<div class="md-content message-content" v-html="renderedContent"></div>
</template>
</div>
<div class="message-footer">
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
@ -50,18 +55,16 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { renderMarkdown } from '../utils/markdown'
import { computed, watch, onMounted, ref } from 'vue'
import { renderMarkdown, enhanceCodeBlocks } from '../utils/markdown'
import ProcessBlock from './ProcessBlock.vue'
const props = defineProps({
role: { type: String, required: true },
text: { type: String, default: '' },
content: { type: String, default: '' }, // Keep for backward compatibility
thinkingContent: { type: String, default: '' },
toolCalls: { type: Array, default: () => [] },
processSteps: { type: Array, default: () => [] },
toolName: { type: String, default: '' },
tokenCount: { type: Number, default: 0 },
createdAt: { type: String, default: '' },
deletable: { type: Boolean, default: false },
@ -70,11 +73,23 @@ const props = defineProps({
defineEmits(['delete', 'regenerate'])
const messageRef = ref(null)
const renderedContent = computed(() => {
// Use 'text' field (new format), fallback to 'content' (old format/assistant messages)
const displayContent = props.text || props.content || ''
if (!displayContent) return ''
return renderMarkdown(displayContent)
if (!props.text) return ''
return renderMarkdown(props.text)
})
function enhanceCode() {
enhanceCodeBlocks(messageRef.value)
}
watch(renderedContent, () => {
enhanceCode()
})
onMounted(() => {
enhanceCode()
})
function formatTime(iso) {
@ -83,7 +98,7 @@ function formatTime(iso) {
}
function copyContent() {
navigator.clipboard.writeText(props.content).catch(() => {})
navigator.clipboard.writeText(props.text || '').catch(() => {})
}
</script>
@ -91,7 +106,6 @@ function copyContent() {
.message-bubble {
display: flex;
gap: 12px;
padding: 0;
margin-bottom: 16px;
width: 100%;
}
@ -195,39 +209,6 @@ function copyContent() {
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 {
display: flex;
align-items: center;

View File

@ -27,7 +27,7 @@
<input
ref="fileInputRef"
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"
style="display: none"
/>

View File

@ -1,6 +1,6 @@
<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 class="streaming-icon">
<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>
</div>
<!-- 正常内容 -->
<!-- 按序渲染步骤 -->
<template v-else>
<button class="process-toggle" @click="toggleAll">
<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"/>
</svg>
<span>思考与工具调用过程</span>
<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">
<template v-for="item in processItems" :key="item.key">
<!-- 思考过程 -->
<div v-if="item.type === 'thinking'" class="step-item thinking">
<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="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 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"/>
</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">
<polyline points="9 11 12 14 22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
<span class="step-label">{{ item.loading ? `执行工具: ${item.toolName}` : `调用工具: ${item.toolName}` }}</span>
<span v-if="item.summary && !item.loading" class="step-brief">{{ item.summary }}</span>
<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>
</div>
<span class="process-label">{{ item.label }}</span>
<span v-if="item.loading" class="loading-dots">...</span>
<span v-else-if="item.type === 'tool_result'" class="process-summary" :class="{ success: item.isSuccess, error: !item.isSuccess }">{{ item.summary }}</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>
<div v-if="expandedKeys[item.key] && !item.loading" class="step-content">
<div class="tool-detail" style="margin-bottom: 8px;">
<span class="detail-label">调用参数:</span>
<pre>{{ item.arguments }}</pre>
</div>
</div>
<div v-else-if="item.type === 'tool_result'" class="tool-result-detail">
<div class="result-label">返回结果:</div>
<pre>{{ item.content }}</pre>
<div v-if="item.result" class="tool-detail">
<span class="detail-label">返回结果:</span>
<pre>{{ item.result }}</pre>
</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>
</template>
</div>
</template>
<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({
thinkingContent: { type: String, default: '' },
toolCalls: { type: Array, default: () => [] },
processSteps: { type: Array, default: () => [] },
streamingContent: { type: String, default: '' },
streaming: { type: Boolean, default: false }
})
const allExpanded = ref(false)
const itemExpanded = ref({}) //
const expandedKeys = ref({})
const processItems = computed(() => {
const items = []
let idx = 0
// 使 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
//
watch(() => props.streaming, (v) => {
if (v) expandedKeys.value = {}
})
function isItemExpanded(index) {
return itemExpanded.value[index] || false
// processBlock
const processRef = ref(null)
function enhanceCode() {
enhanceCodeBlocks(processRef.value)
}
function toggleItem(index) {
itemExpanded.value[index] = !isItemExpanded(index)
onMounted(() => {
enhanceCode()
})
function toggleItem(key) {
expandedKeys.value[key] = !expandedKeys.value[key]
}
function formatArgs(args) {
if (!args) return ''
try {
const parsed = JSON.parse(args)
return JSON.stringify(parsed, null, 2)
} catch {
return args
}
function formatJson(value) {
if (value == null) return ''
const str = typeof value === 'string' ? value : JSON.stringify(value)
try { return JSON.stringify(JSON.parse(str), null, 2) } catch { return str }
}
function formatResult(result) {
if (typeof result === 'string') {
try {
const parsed = JSON.parse(result)
return JSON.stringify(parsed, null, 2)
} catch {
return result
}
}
return JSON.stringify(result, null, 2)
function truncate(text, max = 60) {
if (!text) return ''
const str = text.replace(/\s+/g, ' ').trim()
return str.length > max ? str.slice(0, max) + '…' : str
}
function getResultSummary(result) {
try {
const parsed = typeof result === 'string' ? JSON.parse(result) : result
if (parsed.success === true) {
return { text: '成功', success: true }
} else if (parsed.success === false || parsed.error) {
return { text: parsed.error || '失败', success: false }
} else if (parsed.results) {
return { text: `${parsed.results.length} 条结果`, success: true }
}
if (parsed.success === true) return { text: '成功', success: true }
if (parsed.success === false || parsed.error) return { text: parsed.error || '失败', success: false }
if (parsed.results) return { text: `${parsed.results.length} 条结果`, success: true }
return { text: '完成', success: true }
} catch {
return { text: '完成', success: true }
}
}
function toggleAll() {
allExpanded.value = !allExpanded.value
}
const processItems = computed(() => {
const items = []
//
watch(() => props.streaming, (streaming) => {
if (streaming) {
allExpanded.value = true
// 使 processSteps
if (props.processSteps && props.processSteps.length > 0) {
for (const step of props.processSteps) {
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>
<style scoped>
.process-block {
margin-bottom: 8px;
border-radius: 8px;
border: 1px solid var(--border-light);
overflow: hidden;
background: var(--bg-secondary);
max-width: 100%;
width: 100%;
}
/* 流式占位 */
.streaming-placeholder {
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-hover);
border-radius: 8px;
border: 1px solid var(--border-light);
}
.streaming-icon {
@ -312,126 +273,95 @@ watch(() => props.streaming, (streaming) => {
animation: pulse 1s ease-in-out infinite;
}
.process-toggle {
width: 100%;
padding: 10px 12px;
background: var(--bg-tertiary);
border: none;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* 步骤通用 */
.step-item {
margin-bottom: 8px;
}
.step-item:last-child {
margin-bottom: 0;
}
/* 思考过程 */
.thinking .step-header,
.tool_call .step-header {
display: flex;
align-items: center;
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;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.15s;
font-size: 13px;
transition: background 0.15s;
}
.process-header:hover {
.thinking .step-header:hover,
.tool_call .step-header:hover {
background: var(--bg-hover);
}
.process-icon {
width: 20px;
height: 20px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.thinking .process-icon {
background: #fef3c7;
.thinking .step-header svg:first-child {
color: #f59e0b;
}
.tool_call .process-icon {
background: #f3e8ff;
.tool_call .step-header svg:first-child {
color: #a855f7;
}
.tool_result .process-icon {
background: var(--success-bg);
color: var(--success-color);
}
.process-label {
flex: 1;
.step-label {
font-weight: 500;
color: var(--text-primary);
flex-shrink: 0;
min-width: 130px;
max-width: 130px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.process-time {
font-size: 11px;
.arrow {
margin-left: auto;
transition: transform 0.2s;
color: var(--text-tertiary);
flex-shrink: 0;
}
.process-summary {
.step-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.process-summary.success {
.step-badge.success {
background: var(--success-bg);
color: var(--success-color);
}
.process-summary.error {
.step-badge.error {
background: var(--danger-bg);
color: var(--danger-color);
}
.item-arrow {
transition: transform 0.2s;
.step-brief {
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);
}
@ -442,19 +372,17 @@ watch(() => props.streaming, (streaming) => {
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.process-item.loading .process-header {
.tool_call.loading .step-header {
background: var(--bg-hover);
}
.process-content {
/* 步骤展开内容 */
.step-content {
padding: 12px;
background: var(--bg-primary);
border-top: 1px solid var(--border-light);
margin-top: 4px;
background: var(--bg-code);
border: 1px solid var(--border-light);
border-radius: 8px;
overflow: hidden;
}
@ -465,53 +393,54 @@ watch(() => props.streaming, (streaming) => {
white-space: pre-wrap;
}
.tool-call-detail,
.tool-result-detail {
.tool-detail {
font-size: 13px;
}
.tool-name,
.tool-args {
margin-bottom: 8px;
}
.tool-name:last-child,
.tool-args:last-child {
margin-bottom: 0;
}
.label {
.detail-label {
color: var(--text-tertiary);
font-size: 11px;
font-weight: 600;
margin-right: 8px;
display: block;
margin-bottom: 4px;
}
.value {
color: var(--accent-primary);
font-weight: 500;
}
.tool-args pre,
.tool-result-detail pre {
margin-top: 4px;
.tool-detail pre {
padding: 8px;
background: var(--bg-code);
background: var(--bg-primary);
border-radius: 4px;
border: 1px solid var(--border-light);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
overflow-x: auto;
border: 1px solid var(--border-light);
white-space: pre-wrap;
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);
font-weight: 600;
margin-bottom: 4px;
}
</style>

View File

@ -88,7 +88,15 @@
</div>
<div class="form-group">
<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 class="form-group">
<label>描述可选</label>
@ -157,6 +165,26 @@ const uploadData = ref({
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
const userId = 1
@ -439,6 +467,39 @@ defineExpose({
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 textarea:focus {
outline: none;

View File

@ -1,5 +1,5 @@
<template>
<Transition name="slide">
<Transition name="fade">
<div v-if="visible" class="settings-overlay" @click.self="$emit('close')">
<div class="settings-panel">
<div class="settings-header">
@ -96,10 +96,6 @@
</button>
</div>
</div>
<div class="settings-stats">
<StatsPanel />
</div>
</div>
</div>
</Transition>
@ -109,7 +105,6 @@
import { reactive, ref, watch, onMounted } from 'vue'
import { modelApi, conversationApi } from '../api'
import { useTheme } from '../composables/useTheme'
import StatsPanel from './StatsPanel.vue'
const props = defineProps({
visible: { type: Boolean, default: false },
@ -142,25 +137,32 @@ async function loadModels() {
function syncFormFromConversation() {
if (props.conversation) {
form.title = props.conversation.title || ''
form.model = props.conversation.model || ''
form.system_prompt = props.conversation.system_prompt || ''
form.temperature = props.conversation.temperature ?? 1.0
form.max_tokens = props.conversation.max_tokens ?? 65536
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
watch(() => props.visible, (visible) => {
if (visible) {
// Sync form when panel opens or conversation changes
watch([() => props.visible, () => props.conversation, models], () => {
if (props.visible) {
syncFormFromConversation()
}
})
}, { deep: true })
// Auto-save with debounce when form changes
let saveTimer = null
watch(form, () => {
if (props.visible && props.conversation) {
saveChanges()
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(saveChanges, 500)
}
}, { deep: true })
@ -184,19 +186,20 @@ onMounted(loadModels)
background: var(--overlay-bg);
z-index: 100;
display: flex;
justify-content: flex-end;
transition: background 0.2s;
align-items: center;
justify-content: center;
}
.settings-panel {
width: 380px;
height: 100vh;
width: 90%;
max-width: 520px;
max-height: 85vh;
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;
flex-direction: column;
overflow-y: auto;
transition: background 0.2s, border-color 0.2s;
overflow: hidden;
}
.settings-header {
@ -231,6 +234,7 @@ onMounted(loadModels)
.settings-body {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.form-group {
@ -373,30 +377,4 @@ onMounted(loadModels)
background: var(--border-light);
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>

View File

@ -48,7 +48,6 @@
class="conversation-item"
:class="{ active: conv.id === currentId }"
@click="$emit('select', conv.id)"
@contextmenu.prevent="onContextMenu($event, conv)"
>
<div class="conv-info">
<div class="conv-title">{{ conv.title || '新对话' }}</div>
@ -123,24 +122,20 @@ function onScroll(e) {
emit('loadMore')
}
}
function onContextMenu(e, conv) {
// right-click to delete
}
</script>
<style scoped>
.sidebar {
flex: 0 1 auto; /* 弹性宽度,可收缩 */
width: 260px; /* 默认宽度 */
min-width: 180px; /* 最小宽度 */
max-width: 320px; /* 最大宽度 */
width: 20%;
min-width: 220px;
max-width: 320px;
flex-shrink: 0;
background: var(--bg-primary);
border-right: 1px solid var(--border-medium);
display: flex;
flex-direction: column;
height: 100vh;
transition: all 0.2s;
overflow: hidden;
}
.project-section {

View File

@ -1,127 +1,219 @@
<template>
<div class="stats-panel">
<div class="stats-header">
<h4>Token 使用统计</h4>
<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 }}
<div class="stats-title">
<svg width="16" height="16" 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>
<h4>使用统计</h4>
</div>
<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>
</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">
<!-- 统计卡片 -->
<div class="stats-summary">
<div class="stat-card">
<div class="stat-label">输入 Token</div>
<div class="stat-value">{{ formatNumber(stats.prompt_tokens) }}</div>
<div class="stat-icon input-icon">
<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 class="stat-card">
<div class="stat-label">输出 Token</div>
<div class="stat-value">{{ formatNumber(stats.completion_tokens) }}</div>
<div class="stat-icon output-icon">
<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 class="stat-card highlight">
<div class="stat-label">总计</div>
<div class="stat-value">{{ formatNumber(stats.total_tokens) }}</div>
<div class="stat-card total">
<div class="stat-icon total-icon">
<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 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">
<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
v-for="i in 4"
:key="'grid-' + i"
:x1="padding"
:y1="padding + (chartHeight - 2 * padding) * (i - 1) / 4"
:x2="chartWidth - padding"
:y2="padding + (chartHeight - 2 * padding) * (i - 1) / 4"
stroke="var(--border-light)"
stroke-dasharray="4,4"
/>
</g>
<!-- 填充区域 -->
<path
:d="areaPath"
fill="url(#gradient)"
opacity="0.3"
<line
v-for="i in 4"
:key="'grid-' + i"
:x1="padding"
:y1="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
:x2="chartWidth - padding"
:y2="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
stroke="var(--border-light)"
stroke-dasharray="3,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
:d="linePath"
fill="none"
stroke="var(--accent-primary)"
:stroke="accentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- 数据点 -->
<g class="data-points">
<circle
v-for="(point, idx) in chartPoints"
:key="idx"
:cx="point.x"
:cy="point.y"
r="4"
fill="var(--accent-primary)"
stroke="var(--bg-primary)"
stroke-width="2"
class="data-point"
@mouseenter="hoveredPoint = idx"
@mouseleave="hoveredPoint = null"
/>
</g>
<!-- 渐变定义 -->
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="var(--accent-primary)" />
<stop offset="100%" stop-color="var(--accent-primary)" stop-opacity="0" />
</linearGradient>
</defs>
<circle
v-for="(point, idx) in chartPoints"
:key="idx"
:cx="point.x"
:cy="point.y"
r="3"
:fill="accentColor"
stroke="var(--bg-primary)"
stroke-width="2"
class="data-point"
@mouseenter="hoveredPoint = idx"
@mouseleave="hoveredPoint = null"
/>
<!-- 竖线指示 -->
<line
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
:x1="chartPoints[hoveredPoint].x"
:y1="padding"
:x2="chartPoints[hoveredPoint].x"
:y2="chartHeight - padding"
stroke="var(--border-medium)"
stroke-dasharray="3,3"
/>
</svg>
<!-- X轴标签 -->
<div class="x-labels">
<span
v-for="(data, date) in sortedDaily"
:key="date"
v-for="(point, idx) in chartPoints"
:key="idx"
class="x-label"
:class="{ active: hoveredPoint === idx }"
>
{{ formatDateLabel(date) }}
{{ formatDateLabel(point.date) }}
</span>
</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
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
class="tooltip"
:style="{ left: chartPoints[hoveredPoint].x + 'px', top: (chartPoints[hoveredPoint].y - 40) + 'px' }"
v-for="(data, model) in stats.by_model"
:key="model"
class="model-row"
>
<div class="tooltip-date">{{ chartPoints[hoveredPoint].date }}</div>
<div class="tooltip-value">{{ formatNumber(chartPoints[hoveredPoint].value) }} tokens</div>
<div class="model-info">
<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 v-if="period === 'daily' && stats.by_model" class="stats-by-model">
<div class="model-title">按模型分布</div>
<div v-for="(data, model) in stats.by_model" :key="model" class="model-row">
<span class="model-name">{{ model }}</span>
<span class="model-value">{{ formatNumber(data.total) }}</span>
</div>
<!-- 空状态 -->
<div v-if="!stats.total_tokens" class="stats-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M18 20V10"/>
<path d="M12 20V4"/>
<path d="M6 20v-6"/>
</svg>
<span>暂无使用数据</span>
</div>
</template>
</div>
@ -130,6 +222,11 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { statsApi } from '../api'
import { useTheme } from '../composables/useTheme'
const emit = defineEmits(['close'])
const { isDark } = useTheme()
const periods = [
{ value: 'daily', label: '今日' },
@ -142,9 +239,11 @@ const stats = ref(null)
const loading = ref(false)
const hoveredPoint = ref(null)
const accentColor = computed(() => isDark.value ? '#60a5fa' : '#2563eb')
const chartWidth = 320
const chartHeight = 160
const padding = 20
const chartHeight = 140
const padding = 32
const sortedDaily = computed(() => {
if (!stats.value?.daily) return {}
@ -158,8 +257,8 @@ const chartData = computed(() => {
return Object.entries(data).map(([date, val]) => ({
date,
value: val.total,
prompt: val.prompt,
completion: val.completion,
prompt: val.prompt || 0,
completion: val.completion || 0,
}))
})
@ -168,6 +267,11 @@ const maxValue = computed(() => {
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 data = chartData.value
if (data.length === 0) return []
@ -176,10 +280,14 @@ const chartPoints = computed(() => {
const yRange = chartHeight - 2 * padding
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,
date: d.date,
value: d.value,
prompt: d.prompt,
completion: d.completion,
}))
})
@ -193,14 +301,11 @@ const areaPath = computed(() => {
const points = chartPoints.value
if (points.length === 0) return ''
const xRange = chartWidth - 2 * padding
const startX = padding
const endX = chartWidth - 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 += ` L ${endX} ${baseY} Z`
path += ` L ${points[points.length - 1].x} ${baseY} Z`
return path
})
@ -210,7 +315,13 @@ function formatDateLabel(dateStr) {
return `${d.getMonth() + 1}/${d.getDate()}`
}
function formatFullDate(dateStr) {
const d = new Date(dateStr)
return `${d.getMonth() + 1}${d.getDate()}`
}
function formatNumber(num) {
if (!num) return '0'
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
return num.toString()
@ -230,6 +341,7 @@ async function loadStats() {
function changePeriod(p) {
period.value = p
hoveredPoint.value = null
loadStats()
}
@ -238,25 +350,60 @@ onMounted(loadStats)
<style scoped>
.stats-panel {
padding: 16px 0;
padding: 0;
}
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
margin-bottom: 20px;
}
.stats-header h4 {
margin: 0;
font-size: 14px;
.stats-title {
display: flex;
align-items: center;
gap: 8px;
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 {
display: flex;
gap: 4px;
gap: 2px;
background: var(--bg-input);
padding: 3px;
border-radius: 8px;
@ -266,79 +413,140 @@ onMounted(loadStats)
padding: 4px 12px;
border: none;
background: none;
color: var(--text-secondary);
color: var(--text-tertiary);
font-size: 12px;
cursor: pointer;
border-radius: 6px;
transition: all 0.15s;
transition: all 0.2s;
}
.tab:hover {
color: var(--text-secondary);
}
.tab.active {
background: var(--accent-primary);
color: white;
box-shadow: 0 1px 3px rgba(37, 99, 235, 0.3);
}
.stats-loading {
text-align: center;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
color: var(--text-tertiary);
font-size: 13px;
}
/* 统计卡片 */
.stats-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
gap: 10px;
margin-bottom: 16px;
}
.stat-card {
display: flex;
align-items: center;
gap: 10px;
background: var(--bg-input);
border-radius: 8px;
border: 1px solid var(--border-light);
border-radius: 10px;
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);
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 {
font-size: 11px;
color: var(--text-tertiary);
margin-bottom: 4px;
font-weight: 500;
}
.stat-value {
font-size: 16px;
font-weight: 600;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
}
.stat-card.highlight .stat-value {
color: var(--accent-primary);
}
/* 趋势图 */
.stats-chart {
margin-top: 16px;
margin-bottom: 16px;
}
.chart-title,
.model-title {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 12px;
font-weight: 500;
margin-bottom: 10px;
}
.chart-container {
background: var(--bg-input);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--border-light);
border-radius: 10px;
padding: 12px 8px 8px 8px;
position: relative;
overflow: hidden;
}
.line-chart {
width: 100%;
height: 160px;
height: 140px;
}
.y-label {
fill: var(--text-tertiary);
font-size: 9px;
}
.data-point {
@ -347,65 +555,149 @@ onMounted(loadStats)
}
.data-point:hover {
r: 6;
r: 5;
}
.x-labels {
display: flex;
justify-content: space-between;
margin-top: 8px;
padding: 0 20px;
margin-top: 6px;
padding: 0 28px 0 32px;
}
.x-label {
font-size: 10px;
color: var(--text-tertiary);
transition: color 0.15s;
}
.x-label.active {
color: var(--text-primary);
font-weight: 500;
}
/* 提示框 */
.tooltip {
position: absolute;
background: var(--bg-primary);
color: var(--text-primary);
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--border-medium);
padding: 8px 10px;
border-radius: 8px;
font-size: 11px;
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%);
z-index: 10;
min-width: 120px;
}
.tooltip-date {
color: var(--text-tertiary);
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;
color: var(--accent-primary);
color: var(--text-primary);
font-size: 12px;
}
/* 模型分布 */
.stats-by-model {
margin-top: 16px;
margin-bottom: 4px;
}
.model-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.model-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.model-info {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-input);
border-radius: 6px;
margin-bottom: 6px;
align-items: baseline;
}
.model-name {
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 {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
color: var(--text-secondary);
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>

View File

@ -2,7 +2,6 @@ import { ref, watch } from 'vue'
const isDark = ref(false)
// 初始化时从 localStorage 读取
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('theme')
isDark.value = saved === 'dark'
@ -15,18 +14,14 @@ function applyTheme() {
}
}
export function useTheme() {
watch(isDark, (val) => {
localStorage.setItem('theme', val ? 'dark' : 'light')
applyTheme()
})
watch(isDark, (val) => {
localStorage.setItem('theme', val ? 'dark' : 'light')
applyTheme()
})
export function useTheme() {
function toggleTheme() {
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 {
font-size: 15px;
line-height: 1.7;
@ -6,32 +121,126 @@
word-break: break-word;
}
.md-content :deep(p) {
.md-content p {
margin: 0 0 8px;
}
.md-content :deep(p:last-child) {
.md-content p:last-child {
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);
border: 1px solid var(--border-light);
border-radius: 8px;
padding: 16px;
padding: 12px;
overflow-x: auto;
margin: 8px 0;
max-width: 100%;
position: relative;
}
.md-content :deep(pre code) {
.md-content pre code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
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);
color: var(--accent-primary);
padding: 2px 6px;
@ -40,43 +249,43 @@
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.md-content :deep(pre code) {
.md-content pre code {
background: none;
color: inherit;
padding: 0;
}
.md-content :deep(ul),
.md-content :deep(ol) {
.md-content ul,
.md-content ol {
padding-left: 20px;
margin: 8px 0;
}
.md-content :deep(blockquote) {
.md-content blockquote {
border-left: 3px solid rgba(59, 130, 246, 0.4);
padding-left: 12px;
color: var(--text-secondary);
margin: 8px 0;
}
.md-content :deep(table) {
.md-content table {
border-collapse: collapse;
margin: 8px 0;
width: 100%;
}
.md-content :deep(th),
.md-content :deep(td) {
.md-content th,
.md-content td {
border: 1px solid var(--border-medium);
padding: 8px 12px;
text-align: left;
}
.md-content :deep(th) {
.md-content th {
background: var(--bg-code);
}
.md-content :deep(.math-block) {
.md-content .math-block {
display: block;
text-align: center;
padding: 12px 0;
@ -84,26 +293,7 @@
overflow-x: auto;
}
/* 共享滚动条样式 */
.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 滚动条 */
/* ============ Scrollbar ============ */
textarea::-webkit-scrollbar {
width: 6px;
height: 6px;
@ -122,48 +312,63 @@ textarea::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* Textarea resize 手柄修复 */
textarea::-webkit-resizer {
background: transparent;
}
/* Range 滑块样式 */
/* ============ Range Slider ============ */
input[type="range"] {
width: 100%;
height: 6px;
height: 8px;
min-height: 8px;
-webkit-appearance: none;
appearance: none;
background: var(--border-medium);
border-radius: 3px;
border-radius: 4px;
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 8px;
border-radius: 4px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--accent-primary);
border: 3px solid var(--bg-primary);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
cursor: pointer;
margin-top: -6px;
transition: transform 0.15s;
}
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 {
width: 16px;
height: 16px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--accent-primary);
border: 3px solid var(--bg-primary);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
cursor: pointer;
border: none;
}
/* 按钮基础样式 */
/* ============ Button Base ============ */
.btn-icon {
display: flex;
align-items: center;
@ -185,32 +390,3 @@ input[type="range"]::-moz-range-thumb {
opacity: 0.5;
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;
}
.hljs-string,
.hljs-addition {
.hljs-string {
color: #0a3069;
}
.hljs-addition {
color: #116329;
background: rgba(46, 160, 67, 0.1);
}
.hljs-number,
.hljs-literal {
color: #0550ae;
@ -61,7 +65,67 @@
background: rgba(248, 81, 73, 0.1);
}
.hljs-addition {
color: #116329;
/* Dark theme for code blocks */
[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);
}
[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 { markedHighlight } from 'marked-highlight'
import katex from 'katex'
import hljs from 'highlight.js'
@ -19,12 +20,10 @@ const mathExtension = {
name: 'math',
level: 'inline',
start(src) {
// Find $ not followed by $ (to avoid matching $$)
const idx = src.search(/(?<!\$)\$(?!\$)/)
return idx === -1 ? undefined : idx
},
tokenizer(src) {
// Match $...$ (single $, not $$)
const match = src.match(/^(?<!\$)\$(?!\$)([^\$\n]+?)\$(?!\$)/)
if (match) {
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({
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
breaks: true,
gfm: true,
})
@ -70,3 +74,69 @@ marked.setOptions({
export function renderMarkdown(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;'
}
}
}