fix: 修复前后端同步问题
This commit is contained in:
parent
6aea98554f
commit
836ee8ac9d
|
|
@ -1,29 +1,38 @@
|
|||
"""Token statistics API routes"""
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, timedelta, datetime, timezone
|
||||
from flask import Blueprint, request, g
|
||||
from sqlalchemy import func
|
||||
from backend.models import TokenUsage
|
||||
from sqlalchemy import func, extract
|
||||
from backend.models import TokenUsage, Message, Conversation
|
||||
from backend.utils.helpers import ok, err
|
||||
from backend import db
|
||||
|
||||
bp = Blueprint("stats", __name__)
|
||||
|
||||
|
||||
def _utc_today():
|
||||
"""Get today's date in UTC to match stored timestamps."""
|
||||
return datetime.now(timezone.utc).date()
|
||||
|
||||
|
||||
@bp.route("/api/stats/tokens", methods=["GET"])
|
||||
def token_stats():
|
||||
"""Get token usage statistics"""
|
||||
user = g.current_user
|
||||
period = request.args.get("period", "daily")
|
||||
|
||||
today = date.today()
|
||||
|
||||
|
||||
today = _utc_today()
|
||||
|
||||
if period == "daily":
|
||||
stats = TokenUsage.query.filter_by(user_id=user.id, date=today).all()
|
||||
# Hourly breakdown from Message table
|
||||
hourly = _build_hourly_stats(user.id, today)
|
||||
result = {
|
||||
"period": "daily",
|
||||
"date": today.isoformat(),
|
||||
"prompt_tokens": sum(s.prompt_tokens for s in stats),
|
||||
"completion_tokens": sum(s.completion_tokens for s in stats),
|
||||
"total_tokens": sum(s.total_tokens for s in stats),
|
||||
"hourly": hourly,
|
||||
"by_model": {
|
||||
s.model: {
|
||||
"prompt": s.prompt_tokens,
|
||||
|
|
@ -92,3 +101,37 @@ def _build_period_result(stats, period, start_date, end_date, days):
|
|||
"daily": daily_data,
|
||||
"by_model": by_model,
|
||||
}
|
||||
|
||||
|
||||
def _build_hourly_stats(user_id, day):
|
||||
"""Build hourly token breakdown for a given day (UTC) from Message table."""
|
||||
day_start = datetime.combine(day, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||
day_end = datetime.combine(day, datetime.max.time()).replace(tzinfo=timezone.utc)
|
||||
|
||||
conv_ids = (
|
||||
db.session.query(Conversation.id)
|
||||
.filter(Conversation.user_id == user_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.session.query(
|
||||
extract("hour", Message.created_at).label("hour"),
|
||||
func.sum(Message.token_count).label("total"),
|
||||
)
|
||||
.filter(
|
||||
Message.conversation_id.in_(conv_ids),
|
||||
Message.role == "assistant",
|
||||
Message.created_at >= day_start,
|
||||
Message.created_at <= day_end,
|
||||
)
|
||||
.group_by(extract("hour", Message.created_at))
|
||||
.all()
|
||||
)
|
||||
|
||||
hourly = {}
|
||||
for r in rows:
|
||||
h = int(r.hour)
|
||||
hourly[str(h)] = {"total": r.total or 0}
|
||||
|
||||
return hourly
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ Usage:
|
|||
result = registry.execute("web_search", {"query": "Python"})
|
||||
"""
|
||||
|
||||
from backend.tools.core import ToolDefinition, ToolResult, ToolRegistry, registry
|
||||
from backend.tools.factory import tool, register_tool
|
||||
from backend.tools.core import registry
|
||||
from backend.tools.factory import tool
|
||||
from backend.tools.executor import ToolExecutor
|
||||
|
||||
|
||||
|
|
@ -47,16 +47,12 @@ def init_tools() -> None:
|
|||
|
||||
# Public API exports
|
||||
__all__ = [
|
||||
# Core classes
|
||||
"ToolDefinition",
|
||||
"ToolResult",
|
||||
"ToolRegistry",
|
||||
"ToolExecutor",
|
||||
# Instances
|
||||
"registry",
|
||||
# Factory functions
|
||||
"tool",
|
||||
"register_tool",
|
||||
# Classes
|
||||
"ToolExecutor",
|
||||
# Initialization
|
||||
"init_tools",
|
||||
# Service locator
|
||||
|
|
|
|||
|
|
@ -51,10 +51,6 @@ class ToolExecutor:
|
|||
return record["result"]
|
||||
return None
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear call history (call this at start of new conversation turn)"""
|
||||
self._call_history.clear()
|
||||
|
||||
@staticmethod
|
||||
def _inject_context(name: str, args: dict, context: Optional[dict]) -> None:
|
||||
"""Inject context fields into tool arguments in-place.
|
||||
|
|
|
|||
|
|
@ -34,30 +34,3 @@ def tool(
|
|||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
def register_tool(
|
||||
name: str,
|
||||
handler: Callable,
|
||||
description: str,
|
||||
parameters: dict,
|
||||
category: str = "general"
|
||||
) -> None:
|
||||
"""
|
||||
Register a tool directly (without decorator)
|
||||
|
||||
Usage:
|
||||
register_tool(
|
||||
name="my_tool",
|
||||
handler=my_function,
|
||||
description="Description",
|
||||
parameters={...}
|
||||
)
|
||||
"""
|
||||
tool_def = ToolDefinition(
|
||||
name=name,
|
||||
description=description,
|
||||
parameters=parameters,
|
||||
handler=handler,
|
||||
category=category
|
||||
)
|
||||
registry.register(tool_def)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"""Backend utilities"""
|
||||
from backend.utils.helpers import ok, err, to_dict, get_or_create_default_user, record_token_usage, build_messages
|
||||
from backend.utils.helpers import ok, err, to_dict, message_to_dict, record_token_usage, build_messages
|
||||
|
||||
__all__ = [
|
||||
"ok",
|
||||
"err",
|
||||
"err",
|
||||
"to_dict",
|
||||
"get_or_create_default_user",
|
||||
"message_to_dict",
|
||||
"record_token_usage",
|
||||
"build_messages",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.40",
|
||||
|
|
@ -791,6 +792,12 @@
|
|||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.1.tgz",
|
||||
|
|
@ -1430,6 +1437,18 @@
|
|||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -9,21 +9,22 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"codemirror": "^6.0.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/lang-markdown": "^6.3.2",
|
||||
"@codemirror/lang-javascript": "^6.2.3",
|
||||
"@codemirror/lang-python": "^6.1.7",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/lang-java": "^6.0.1",
|
||||
"@codemirror/lang-cpp": "^6.0.2",
|
||||
"@codemirror/lang-rust": "^6.0.1",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-java": "^6.0.1",
|
||||
"@codemirror/lang-javascript": "^6.2.3",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-markdown": "^6.3.2",
|
||||
"@codemirror/lang-python": "^6.1.7",
|
||||
"@codemirror/lang-rust": "^6.0.1",
|
||||
"@codemirror/lang-sql": "^6.8.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.40",
|
||||
"marked": "^15.0.12",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
:messages="messages"
|
||||
:streaming="streaming"
|
||||
:streaming-process-steps="streamProcessSteps"
|
||||
:model-name-map="modelNameMap"
|
||||
:has-more-messages="hasMoreMessages"
|
||||
:loading-more="loadingMessages"
|
||||
:tools-enabled="toolsEnabled"
|
||||
|
|
@ -59,8 +60,11 @@
|
|||
<div v-if="showSettings" class="modal-overlay" @click.self="showSettings = false">
|
||||
<div class="modal-content">
|
||||
<SettingsPanel
|
||||
:key="currentConvId || '__none__'"
|
||||
:visible="showSettings"
|
||||
:conversation="currentConv"
|
||||
:models="models"
|
||||
:default-model="defaultModel"
|
||||
@close="showSettings = false"
|
||||
@save="saveSettings"
|
||||
/>
|
||||
|
|
@ -120,10 +124,29 @@ import { useModal } from './composables/useModal'
|
|||
|
||||
const SettingsPanel = defineAsyncComponent(() => import('./components/SettingsPanel.vue'))
|
||||
const StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue'))
|
||||
import { conversationApi, messageApi, projectApi } from './api'
|
||||
import { conversationApi, messageApi, projectApi, modelApi } from './api'
|
||||
|
||||
const modal = useModal()
|
||||
|
||||
// -- Models state (preloaded) --
|
||||
const models = ref([])
|
||||
const modelNameMap = ref({})
|
||||
const defaultModel = computed(() => models.value.length > 0 ? models.value[0].id : '')
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
const res = await modelApi.getCached()
|
||||
models.value = res.data || []
|
||||
const map = {}
|
||||
for (const m of models.value) {
|
||||
if (m.id && m.name) map[m.id] = m.name
|
||||
}
|
||||
modelNameMap.value = map
|
||||
} catch (e) {
|
||||
console.error('Failed to load models:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Conversations state --
|
||||
const conversations = shallowRef([])
|
||||
const currentConvId = ref(null)
|
||||
|
|
@ -258,6 +281,7 @@ async function createConversationInProject(project) {
|
|||
const res = await conversationApi.create({
|
||||
title: '新对话',
|
||||
project_id: project.id || null,
|
||||
model: defaultModel.value || undefined,
|
||||
})
|
||||
conversations.value = [res.data, ...conversations.value]
|
||||
await selectConversation(res.data.id)
|
||||
|
|
@ -602,7 +626,8 @@ async function deleteProject(project) {
|
|||
}
|
||||
|
||||
// -- Init --
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
await loadModels()
|
||||
loadProjects()
|
||||
loadConversations()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -79,13 +79,13 @@ import MessageBubble from './MessageBubble.vue'
|
|||
import MessageInput from './MessageInput.vue'
|
||||
import MessageNav from './MessageNav.vue'
|
||||
import ProcessBlock from './ProcessBlock.vue'
|
||||
import { modelApi } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
conversation: { type: Object, default: null },
|
||||
messages: { type: Array, required: true },
|
||||
streaming: { type: Boolean, default: false },
|
||||
streamingProcessSteps: { type: Array, default: () => [] },
|
||||
modelNameMap: { type: Object, default: () => ({}) },
|
||||
hasMoreMessages: { type: Boolean, default: false },
|
||||
loadingMore: { type: Boolean, default: false },
|
||||
toolsEnabled: { type: Boolean, default: true },
|
||||
|
|
@ -95,27 +95,15 @@ const emit = defineEmits(['sendMessage', 'stopStreaming', 'deleteMessage', 'rege
|
|||
|
||||
const scrollContainer = ref(null)
|
||||
const inputRef = ref(null)
|
||||
const modelNameMap = ref({})
|
||||
const activeMessageId = ref(null)
|
||||
let scrollObserver = null
|
||||
const observedElements = new WeakSet()
|
||||
|
||||
function formatModelName(modelId) {
|
||||
return modelNameMap.value[modelId] || modelId
|
||||
return props.modelNameMap[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)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (scrollContainer.value) {
|
||||
scrollObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
|
|
@ -257,16 +245,6 @@ watch(() => props.conversation?.id, () => {
|
|||
line-height: 1;
|
||||
}
|
||||
|
||||
.thinking-badge {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .thinking-badge {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
|
|
@ -313,4 +291,5 @@ watch(() => props.conversation?.id, () => {
|
|||
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -123,19 +123,20 @@
|
|||
|
||||
<script setup>
|
||||
import { reactive, ref, watch, onMounted } from 'vue'
|
||||
import { modelApi, conversationApi } from '../api'
|
||||
import { conversationApi } from '../api'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { icons } from '../utils/icons'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
conversation: { type: Object, default: null },
|
||||
models: { type: Array, default: () => [] },
|
||||
defaultModel: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const models = ref([])
|
||||
|
||||
const tabs = [
|
||||
{ value: 'basic', label: '基本' },
|
||||
|
|
@ -154,15 +155,6 @@ const form = reactive({
|
|||
thinking_enabled: false,
|
||||
})
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
const res = await modelApi.getCached()
|
||||
models.value = res.data || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load models:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function syncFormFromConversation() {
|
||||
if (props.conversation) {
|
||||
form.title = props.conversation.title || ''
|
||||
|
|
@ -170,27 +162,61 @@ function syncFormFromConversation() {
|
|||
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 列表第一个
|
||||
// model: 优先使用 conversation 的值,其次 defaultModel,最后 models 列表第一个
|
||||
if (props.conversation.model) {
|
||||
form.model = props.conversation.model
|
||||
} else if (models.value.length > 0) {
|
||||
form.model = models.value[0].id
|
||||
} else if (props.defaultModel) {
|
||||
form.model = props.defaultModel
|
||||
} else if (props.models.length > 0) {
|
||||
form.model = props.models[0].id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync form when panel opens or conversation changes
|
||||
watch([() => props.visible, () => props.conversation, models], () => {
|
||||
if (props.visible) {
|
||||
activeTab.value = 'basic'
|
||||
syncFormFromConversation()
|
||||
}
|
||||
}, { deep: true })
|
||||
// Track which conversation the form is synced to, to avoid saving stale data
|
||||
let syncedConvId = null
|
||||
let isSyncing = false
|
||||
|
||||
// Auto-save with debounce when form changes
|
||||
function doSync() {
|
||||
if (!props.conversation) return
|
||||
isSyncing = true
|
||||
syncFormFromConversation()
|
||||
syncedConvId = props.conversation.id
|
||||
// Defer resetting flag to after all watchers flush
|
||||
setTimeout(() => { isSyncing = false }, 0)
|
||||
}
|
||||
|
||||
// Sync form when panel opens or conversation switches
|
||||
watch([() => props.visible, () => props.conversation?.id, () => props.models, () => props.defaultModel], () => {
|
||||
if (props.visible && props.conversation) {
|
||||
activeTab.value = 'basic'
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
doSync()
|
||||
} else if (!props.visible) {
|
||||
syncedConvId = null
|
||||
}
|
||||
})
|
||||
|
||||
// Sync when conversation data updates (e.g. auto-generated title after stream)
|
||||
watch(
|
||||
() => props.conversation,
|
||||
(conv) => {
|
||||
if (!props.visible || !conv || syncedConvId !== conv.id) return
|
||||
doSync()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// Initial sync on mount (component may be recreated via :key)
|
||||
onMounted(() => {
|
||||
if (props.visible && props.conversation) doSync()
|
||||
})
|
||||
|
||||
// Auto-save with debounce when user edits form
|
||||
let saveTimer = null
|
||||
watch(form, () => {
|
||||
if (props.visible && props.conversation) {
|
||||
if (props.visible && props.conversation && syncedConvId === props.conversation.id && !isSyncing) {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(saveChanges, 500)
|
||||
}
|
||||
|
|
@ -205,8 +231,6 @@ async function saveChanges() {
|
|||
console.error('Failed to save settings:', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadModels)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -60,107 +60,10 @@
|
|||
</div>
|
||||
|
||||
<!-- 趋势图 -->
|
||||
<div v-if="period !== 'daily' && stats.daily && chartData.length > 0" class="stats-chart">
|
||||
<div class="chart-title">每日趋势</div>
|
||||
<div v-if="chartData.length > 0" class="stats-chart">
|
||||
<div class="chart-title">{{ period === 'daily' ? '今日趋势' : '每日趋势' }}</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>
|
||||
<!-- 网格线 -->
|
||||
<line
|
||||
v-for="i in 4"
|
||||
:key="'grid-' + i"
|
||||
:x1="padding"
|
||||
:y1="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
|
||||
:x2="chartWidth - padding"
|
||||
:y2="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
|
||||
stroke="var(--border-light)"
|
||||
stroke-dasharray="3,3"
|
||||
/>
|
||||
<!-- Y轴标签 -->
|
||||
<text
|
||||
v-for="i in 4"
|
||||
:key="'yl-' + i"
|
||||
:x="padding - 4"
|
||||
:y="padding + (chartHeight - 2 * padding) * (i - 1) / 3 + 3"
|
||||
text-anchor="end"
|
||||
class="y-label"
|
||||
>{{ formatNumber(maxValue - (maxValue * (i - 1)) / 3) }}</text>
|
||||
<!-- 填充区域 -->
|
||||
<path :d="areaPath" fill="url(#areaGradient)"/>
|
||||
<!-- 折线 -->
|
||||
<path
|
||||
:d="linePath"
|
||||
fill="none"
|
||||
:stroke="accentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<!-- 数据点 -->
|
||||
<circle
|
||||
v-for="(point, idx) in chartPoints"
|
||||
:key="idx"
|
||||
:cx="point.x"
|
||||
:cy="point.y"
|
||||
r="3"
|
||||
:fill="accentColor"
|
||||
stroke="var(--bg-primary)"
|
||||
stroke-width="2"
|
||||
class="data-point"
|
||||
@mouseenter="hoveredPoint = idx"
|
||||
@mouseleave="hoveredPoint = null"
|
||||
/>
|
||||
<!-- 竖线指示 -->
|
||||
<line
|
||||
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
|
||||
:x1="chartPoints[hoveredPoint].x"
|
||||
:y1="padding"
|
||||
:x2="chartPoints[hoveredPoint].x"
|
||||
:y2="chartHeight - padding"
|
||||
stroke="var(--border-medium)"
|
||||
stroke-dasharray="3,3"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- X轴标签 -->
|
||||
<div class="x-labels">
|
||||
<span
|
||||
v-for="(point, idx) in chartPoints"
|
||||
:key="idx"
|
||||
class="x-label"
|
||||
:class="{ active: hoveredPoint === idx }"
|
||||
>
|
||||
{{ formatDateLabel(point.date) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 悬浮提示 -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
|
||||
class="tooltip"
|
||||
:style="{
|
||||
left: chartPoints[hoveredPoint].x + 'px',
|
||||
top: (chartPoints[hoveredPoint].y - 52) + 'px'
|
||||
}"
|
||||
>
|
||||
<div class="tooltip-date">{{ formatFullDate(chartPoints[hoveredPoint].date) }}</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-dot prompt"></span>
|
||||
输入 {{ formatNumber(chartPoints[hoveredPoint].prompt) }}
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-dot completion"></span>
|
||||
输出 {{ formatNumber(chartPoints[hoveredPoint].completion) }}
|
||||
</div>
|
||||
<div class="tooltip-total">{{ formatNumber(chartPoints[hoveredPoint].value) }} tokens</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<canvas ref="chartCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -197,11 +100,14 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
import { statsApi } from '../api'
|
||||
import { formatNumber } from '../utils/format'
|
||||
import { icons } from '../utils/icons'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
defineEmits(['close'])
|
||||
|
||||
const periods = [
|
||||
|
|
@ -213,15 +119,8 @@ const periods = [
|
|||
const period = ref('daily')
|
||||
const stats = ref(null)
|
||||
const loading = ref(false)
|
||||
const hoveredPoint = ref(null)
|
||||
|
||||
const accentColor = computed(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--accent-primary').trim() || '#2563eb'
|
||||
})
|
||||
|
||||
const chartWidth = 320
|
||||
const chartHeight = 140
|
||||
const padding = 32
|
||||
const chartCanvas = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
const sortedDaily = computed(() => {
|
||||
if (!stats.value?.daily) return {}
|
||||
|
|
@ -231,18 +130,36 @@ const sortedDaily = computed(() => {
|
|||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
const data = sortedDaily.value
|
||||
return Object.entries(data).map(([date, val]) => ({
|
||||
date,
|
||||
value: val.total,
|
||||
prompt: val.prompt || 0,
|
||||
completion: val.completion || 0,
|
||||
}))
|
||||
})
|
||||
if (period.value === 'daily' && stats.value?.hourly) {
|
||||
const hourly = stats.value.hourly
|
||||
let minH = 24, maxH = -1
|
||||
for (const h of Object.keys(hourly)) {
|
||||
const hour = parseInt(h)
|
||||
if (hour < minH) minH = hour
|
||||
if (hour > maxH) maxH = hour
|
||||
}
|
||||
if (minH > maxH) return []
|
||||
const start = Math.max(0, minH)
|
||||
const end = Math.min(23, maxH)
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => {
|
||||
const h = start + i
|
||||
return {
|
||||
label: `${h}:00`,
|
||||
value: hourly[String(h)]?.total || 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const maxValue = computed(() => {
|
||||
if (chartData.value.length === 0) return 100
|
||||
return Math.max(100, ...chartData.value.map(d => d.value))
|
||||
const data = sortedDaily.value
|
||||
return Object.entries(data).map(([date, val]) => {
|
||||
const d = new Date(date)
|
||||
return {
|
||||
label: `${d.getMonth() + 1}/${d.getDate()}`,
|
||||
value: val.total,
|
||||
prompt: val.prompt || 0,
|
||||
completion: val.completion || 0,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const maxModelTokens = computed(() => {
|
||||
|
|
@ -250,54 +167,136 @@ const maxModelTokens = computed(() => {
|
|||
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 []
|
||||
|
||||
const xRange = chartWidth - 2 * padding
|
||||
const yRange = chartHeight - 2 * padding
|
||||
|
||||
return data.map((d, i) => ({
|
||||
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,
|
||||
}))
|
||||
})
|
||||
|
||||
const linePath = computed(() => {
|
||||
const points = chartPoints.value
|
||||
if (points.length === 0) return ''
|
||||
return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
||||
})
|
||||
|
||||
const areaPath = computed(() => {
|
||||
const points = chartPoints.value
|
||||
if (points.length === 0) return ''
|
||||
|
||||
const baseY = chartHeight - padding
|
||||
|
||||
let path = `M ${points[0].x} ${baseY} `
|
||||
path += points.map(p => `L ${p.x} ${p.y}`).join(' ')
|
||||
path += ` L ${points[points.length - 1].x} ${baseY} Z`
|
||||
|
||||
return path
|
||||
})
|
||||
|
||||
function formatDateLabel(dateStr) {
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
function getAccentColor() {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--accent-primary').trim() || '#2563eb'
|
||||
}
|
||||
|
||||
function formatFullDate(dateStr) {
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
function getTextColor(alpha = 1) {
|
||||
const c = getComputedStyle(document.documentElement).getPropertyValue('--text-tertiary').trim() || '#888'
|
||||
if (alpha === 1) return c
|
||||
// Convert hex to rgba
|
||||
if (c.startsWith('#')) {
|
||||
const r = parseInt(c.slice(1, 3), 16)
|
||||
const g = parseInt(c.slice(3, 5), 16)
|
||||
const b = parseInt(c.slice(5, 7), 16)
|
||||
return `rgba(${r},${g},${b},${alpha})`
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
function destroyChart() {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
chartInstance = null
|
||||
}
|
||||
}
|
||||
|
||||
function buildChart() {
|
||||
if (!chartCanvas.value || chartData.value.length === 0) return
|
||||
|
||||
destroyChart()
|
||||
|
||||
const accent = getAccentColor()
|
||||
const ctx = chartCanvas.value.getContext('2d')
|
||||
|
||||
// Gradient fill
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 200)
|
||||
gradient.addColorStop(0, accent + '40')
|
||||
gradient.addColorStop(1, accent + '05')
|
||||
|
||||
const labels = chartData.value.map(d => d.label)
|
||||
const values = chartData.value.map(d => d.value)
|
||||
|
||||
// Determine max ticks for x-axis
|
||||
const maxTicks = chartData.value.length <= 8 ? chartData.value.length : 6
|
||||
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
borderColor: accent,
|
||||
backgroundColor: gradient,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: accent,
|
||||
pointHoverBorderColor: '#fff',
|
||||
pointHoverBorderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 300 },
|
||||
layout: {
|
||||
padding: { top: 4, right: 4, bottom: 0, left: 0 },
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
border: { display: false },
|
||||
ticks: {
|
||||
color: getTextColor(),
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: maxTicks,
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: getTextColor(0.15),
|
||||
drawBorder: false,
|
||||
},
|
||||
border: { display: false },
|
||||
ticks: {
|
||||
color: getTextColor(),
|
||||
font: { size: 9 },
|
||||
maxTicksLimit: 4,
|
||||
callback: (v) => formatNumber(v),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#ccc',
|
||||
titleFont: { size: 11, weight: '500' },
|
||||
bodyFont: { size: 11 },
|
||||
padding: 8,
|
||||
cornerRadius: 6,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
title: (items) => {
|
||||
const idx = items[0].dataIndex
|
||||
const d = chartData.value[idx]
|
||||
if (period.value === 'daily') {
|
||||
return `${d.label} - ${parseInt(d.label) + 1}:00`
|
||||
}
|
||||
return d.label
|
||||
},
|
||||
label: (item) => `${formatNumber(item.raw)} tokens`,
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
watch(chartData, () => {
|
||||
nextTick(buildChart)
|
||||
})
|
||||
|
||||
async function loadStats() {
|
||||
loading.value = true
|
||||
try {
|
||||
|
|
@ -312,11 +311,11 @@ async function loadStats() {
|
|||
|
||||
function changePeriod(p) {
|
||||
period.value = p
|
||||
hoveredPoint.value = null
|
||||
loadStats()
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
onBeforeUnmount(destroyChart)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -324,8 +323,6 @@ onMounted(loadStats)
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
/* panel-header, panel-title, header-actions now in global.css */
|
||||
|
||||
.stats-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -430,99 +427,9 @@ onMounted(loadStats)
|
|||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
padding: 12px 8px 8px 8px;
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-chart {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.y-label {
|
||||
fill: var(--text-tertiary);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.data-point {
|
||||
cursor: pointer;
|
||||
transition: r 0.15s;
|
||||
}
|
||||
|
||||
.data-point:hover {
|
||||
r: 5;
|
||||
}
|
||||
|
||||
.x-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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);
|
||||
border: 1px solid var(--border-medium);
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
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-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(--text-primary);
|
||||
font-size: 12px;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
/* 模型分布 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue