refactor: 代码精简与UI优化
This commit is contained in:
parent
31cfcd3ed2
commit
d5fdbb0cb3
|
|
@ -127,6 +127,11 @@ class ChatService:
|
|||
yield f"event: process_step\ndata: {json.dumps({'index': step_index, 'type': 'thinking', 'content': full_thinking}, ensure_ascii=False)}\n\n"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="expandedKeys[item.key]" class="step-content">
|
||||
<div class="thinking-text">{{ item.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"/>
|
||||
</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">
|
||||
<!-- 工具调用 -->
|
||||
<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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="process-label">{{ item.label }}</span>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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 v-if="item.result" class="tool-detail">
|
||||
<span class="detail-label">返回结果:</span>
|
||||
<pre>{{ item.result }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === 'tool_result'" class="tool-result-detail">
|
||||
<div class="result-label">返回结果:</div>
|
||||
<pre>{{ item.content }}</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>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
<template>
|
||||
<div class="stats-panel">
|
||||
<div class="stats-header">
|
||||
<h4>Token 使用统计</h4>
|
||||
<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"
|
||||
|
|
@ -12,116 +20,200 @@
|
|||
{{ 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 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 class="stat-card highlight">
|
||||
<div class="stat-label">总计</div>
|
||||
<div class="stat-value">{{ formatNumber(stats.total_tokens) }}</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"
|
||||
:y1="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
|
||||
:x2="chartWidth - padding"
|
||||
:y2="padding + (chartHeight - 2 * padding) * (i - 1) / 4"
|
||||
:y2="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
|
||||
stroke="var(--border-light)"
|
||||
stroke-dasharray="4,4"
|
||||
stroke-dasharray="3,3"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- 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(#gradient)"
|
||||
opacity="0.3"
|
||||
/>
|
||||
|
||||
<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)"
|
||||
r="3"
|
||||
:fill="accentColor"
|
||||
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>
|
||||
<!-- 竖线指示 -->
|
||||
<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 - 40) + 'px' }"
|
||||
:style="{
|
||||
left: chartPoints[hoveredPoint].x + 'px',
|
||||
top: (chartPoints[hoveredPoint].y - 52) + 'px'
|
||||
}"
|
||||
>
|
||||
<div class="tooltip-date">{{ chartPoints[hoveredPoint].date }}</div>
|
||||
<div class="tooltip-value">{{ formatNumber(chartPoints[hoveredPoint].value) }} tokens</div>
|
||||
<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-for="(data, model) in stats.by_model"
|
||||
:key="model"
|
||||
class="model-row"
|
||||
>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.setOptions({
|
||||
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({
|
||||
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;'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue