feat: add personal usage stats
This commit is contained in:
parent
77b96623ac
commit
d71525a3c6
@ -331,6 +331,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section v-else-if="activeTab === 'usage'" key="usage" class="personal-page usage-summary-page">
|
||||||
|
<div class="usage-summary-card">
|
||||||
|
<div class="usage-summary-header">
|
||||||
|
<div>
|
||||||
|
<p class="usage-summary-eyebrow">对话用量</p>
|
||||||
|
<h3>用量统计</h3>
|
||||||
|
<p class="usage-summary-desc">累计统计所有对话的输入/输出 Token、对话数量、用户消息与工具调用。</p>
|
||||||
|
<p class="usage-summary-note">宿主机模式同样统计 data/conversations 内的记录。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="usage-summary-grid usage-summary-grid--tokens">
|
||||||
|
<div class="usage-summary-item">
|
||||||
|
<div class="label">累计输入</div>
|
||||||
|
<div class="value value--success">{{ formatTokenCount(usageSummary.total_input_tokens) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="usage-summary-item">
|
||||||
|
<div class="label">累计输出</div>
|
||||||
|
<div class="value value--warning">{{ formatTokenCount(usageSummary.total_output_tokens) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="usage-summary-grid usage-summary-grid--counts">
|
||||||
|
<div class="usage-summary-item">
|
||||||
|
<div class="label">总对话数</div>
|
||||||
|
<div class="value">{{ formatTokenCount(usageSummary.total_conversations) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="usage-summary-item">
|
||||||
|
<div class="label">用户消息数</div>
|
||||||
|
<div class="value">{{ formatTokenCount(usageSummary.total_user_messages) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="usage-summary-item">
|
||||||
|
<div class="label">工具调用次数</div>
|
||||||
|
<div class="value">{{ formatTokenCount(usageSummary.total_tools) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="usage-summary-meta">
|
||||||
|
<span v-if="usageError" class="usage-summary-error">{{ usageError }}</span>
|
||||||
|
<span v-else-if="usageLoading">正在同步最新统计...</span>
|
||||||
|
<span v-else>最近更新:{{ usageUpdatedText }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usage-summary-refresh"
|
||||||
|
@click="fetchUsageSummary"
|
||||||
|
:disabled="usageLoading"
|
||||||
|
>
|
||||||
|
{{ usageLoading ? '刷新中...' : '刷新数据' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section v-else-if="activeTab === 'behavior'" key="behavior" class="personal-page behavior-page">
|
<section v-else-if="activeTab === 'behavior'" key="behavior" class="personal-page behavior-page">
|
||||||
<div class="behavior-section">
|
<div class="behavior-section">
|
||||||
<div class="behavior-field">
|
<div class="behavior-field">
|
||||||
@ -672,12 +721,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { usePersonalizationStore } from '@/stores/personalization';
|
import { usePersonalizationStore } from '@/stores/personalization';
|
||||||
import { useResourceStore } from '@/stores/resource';
|
import { useResourceStore } from '@/stores/resource';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
import { usePolicyStore } from '@/stores/policy';
|
import { usePolicyStore } from '@/stores/policy';
|
||||||
|
import { formatTokenCount } from '@/utils/formatters';
|
||||||
import { useTheme } from '@/utils/theme';
|
import { useTheme } from '@/utils/theme';
|
||||||
import type { ThemeKey } from '@/utils/theme';
|
import type { ThemeKey } from '@/utils/theme';
|
||||||
|
|
||||||
@ -707,6 +757,7 @@ const {
|
|||||||
const baseTabs = [
|
const baseTabs = [
|
||||||
{ id: 'preferences', label: '个性化设置', description: '称呼、语气与注意事项' },
|
{ id: 'preferences', label: '个性化设置', description: '称呼、语气与注意事项' },
|
||||||
{ id: 'model', label: '模型偏好', description: '默认模型选择' },
|
{ id: 'model', label: '模型偏好', description: '默认模型选择' },
|
||||||
|
{ id: 'usage', label: '用量统计', description: '对话累计数据' },
|
||||||
{ id: 'behavior', label: '模型行为', description: '工具提示与界面表现' },
|
{ id: 'behavior', label: '模型行为', description: '工具提示与界面表现' },
|
||||||
{ id: 'skills', label: 'Skills', description: '可用技能开关' },
|
{ id: 'skills', label: 'Skills', description: '可用技能开关' },
|
||||||
{ id: 'image', label: '图片压缩', description: '发送图片的尺寸策略' },
|
{ id: 'image', label: '图片压缩', description: '发送图片的尺寸策略' },
|
||||||
@ -714,7 +765,16 @@ const baseTabs = [
|
|||||||
{ id: 'experiments', label: '实验功能', description: 'Liquid Glass' }
|
{ id: 'experiments', label: '实验功能', description: 'Liquid Glass' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type PersonalTab = 'preferences' | 'model' | 'behavior' | 'skills' | 'image' | 'theme' | 'experiments' | 'admin-monitor';
|
type PersonalTab =
|
||||||
|
| 'preferences'
|
||||||
|
| 'model'
|
||||||
|
| 'usage'
|
||||||
|
| 'behavior'
|
||||||
|
| 'skills'
|
||||||
|
| 'image'
|
||||||
|
| 'theme'
|
||||||
|
| 'experiments'
|
||||||
|
| 'admin-monitor';
|
||||||
|
|
||||||
const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin');
|
const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin');
|
||||||
|
|
||||||
@ -767,6 +827,71 @@ const imageCompressionOptions = [
|
|||||||
{ id: '540p', label: '540p', desc: '最长边不超过 540p 等比缩放' }
|
{ id: '540p', label: '540p', desc: '最长边不超过 540p 等比缩放' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const usageSummary = ref({
|
||||||
|
total_input_tokens: 0,
|
||||||
|
total_output_tokens: 0,
|
||||||
|
total_conversations: 0,
|
||||||
|
total_user_messages: 0,
|
||||||
|
total_tools: 0
|
||||||
|
});
|
||||||
|
const usageLoading = ref(false);
|
||||||
|
const usageError = ref('');
|
||||||
|
const usageUpdatedAt = ref<string | null>(null);
|
||||||
|
|
||||||
|
const usageUpdatedText = computed(() => {
|
||||||
|
if (!usageUpdatedAt.value) {
|
||||||
|
return '尚未刷新';
|
||||||
|
}
|
||||||
|
const date = new Date(usageUpdatedAt.value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '时间未知';
|
||||||
|
}
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchUsageSummary = async () => {
|
||||||
|
if (usageLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
usageLoading.value = true;
|
||||||
|
usageError.value = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/conversations/statistics');
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok || !payload.success) {
|
||||||
|
throw new Error(payload.error || payload.message || '获取用量统计失败');
|
||||||
|
}
|
||||||
|
const data = payload.data || {};
|
||||||
|
const tokenStats = data.token_statistics || {};
|
||||||
|
usageSummary.value = {
|
||||||
|
total_input_tokens: Number(tokenStats.total_input_tokens || 0),
|
||||||
|
total_output_tokens: Number(tokenStats.total_output_tokens || 0),
|
||||||
|
total_conversations: Number(data.total_conversations || 0),
|
||||||
|
total_user_messages: Number(data.total_user_messages || 0),
|
||||||
|
total_tools: Number(data.total_tools || 0)
|
||||||
|
};
|
||||||
|
usageUpdatedAt.value = new Date().toISOString();
|
||||||
|
} catch (error: any) {
|
||||||
|
usageError.value = error?.message || '获取用量统计失败';
|
||||||
|
} finally {
|
||||||
|
usageLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [activeTab.value, visible.value],
|
||||||
|
([tab, isVisible]) => {
|
||||||
|
if (isVisible && tab === 'usage') {
|
||||||
|
fetchUsageSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const setActiveTab = (tab: PersonalTab) => {
|
const setActiveTab = (tab: PersonalTab) => {
|
||||||
activeTab.value = tab;
|
activeTab.value = tab;
|
||||||
};
|
};
|
||||||
@ -912,9 +1037,6 @@ const openApiAdmin = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ===== 主题切换 =====
|
// ===== 主题切换 =====
|
||||||
import { useTheme } from '@/utils/theme';
|
|
||||||
import type { ThemeKey } from '@/utils/theme';
|
|
||||||
|
|
||||||
const { setTheme, loadTheme } = useTheme();
|
const { setTheme, loadTheme } = useTheme();
|
||||||
const themeOptions: Array<{ id: ThemeKey; label: string; desc: string; swatches: string[] }> = [
|
const themeOptions: Array<{ id: ThemeKey; label: string; desc: string; swatches: string[] }> = [
|
||||||
{
|
{
|
||||||
@ -946,6 +1068,146 @@ const applyThemeOption = (theme: ThemeKey) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.usage-summary-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 12px 0 24px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-header h3 {
|
||||||
|
margin: 4px 0 6px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--claude-accent-strong);
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-desc {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-note {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--claude-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-refresh {
|
||||||
|
border: 1px solid var(--claude-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: var(--claude-panel);
|
||||||
|
color: var(--claude-text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
box-shadow: 0 6px 14px rgba(61, 57, 41, 0.12);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-refresh:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-refresh:not(:disabled):hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 14px 28px rgba(61, 57, 41, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-grid--tokens {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-grid--counts {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-item {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--claude-border);
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-item .label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-item .value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--claude-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-item .value--success {
|
||||||
|
color: var(--claude-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-item .value--warning {
|
||||||
|
color: var(--claude-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-item .value--accent {
|
||||||
|
color: var(--claude-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-error {
|
||||||
|
color: var(--claude-warning);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-monitor-page {
|
.admin-monitor-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -1083,6 +1345,16 @@ const applyThemeOption = (theme: ThemeKey) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.usage-summary-refresh {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-summary-grid--tokens,
|
||||||
|
.usage-summary-grid--counts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-monitor-panel {
|
.admin-monitor-panel {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -545,8 +545,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.behavior-section {
|
.behavior-section {
|
||||||
padding: 16px;
|
padding: 0;
|
||||||
border-radius: 12px;
|
border-radius: 0;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -802,10 +802,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
background: var(--theme-surface-muted);
|
background: transparent;
|
||||||
border-radius: 20px;
|
border-radius: 0;
|
||||||
padding: 24px;
|
padding: 0;
|
||||||
border: 1px solid var(--theme-control-border);
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.behavior-field {
|
.behavior-field {
|
||||||
@ -1291,11 +1291,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
background: var(--theme-surface-soft);
|
background: transparent;
|
||||||
border-radius: 20px;
|
border-radius: 0;
|
||||||
border: 1px solid var(--theme-control-border);
|
border: none;
|
||||||
padding: 22px 24px 26px;
|
padding: 0;
|
||||||
box-shadow: var(--theme-shadow-mid);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-section {
|
.theme-section {
|
||||||
@ -3318,4 +3318,3 @@ body[data-theme='dark'] {
|
|||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1011,17 +1011,24 @@ class ConversationManager:
|
|||||||
total_input_tokens = 0
|
total_input_tokens = 0
|
||||||
total_output_tokens = 0
|
total_output_tokens = 0
|
||||||
token_stats_count = 0
|
token_stats_count = 0
|
||||||
|
total_user_messages = 0
|
||||||
|
|
||||||
for conv_id in index.keys():
|
for conv_id in index.keys():
|
||||||
token_stats = self.get_token_statistics(conv_id)
|
conversation_data = self.load_conversation(conv_id)
|
||||||
if token_stats:
|
if not conversation_data:
|
||||||
|
continue
|
||||||
|
validated = self._validate_token_statistics(conversation_data)
|
||||||
|
token_stats = validated.get("token_statistics", {}) or {}
|
||||||
total_input_tokens += token_stats.get("total_input_tokens", 0)
|
total_input_tokens += token_stats.get("total_input_tokens", 0)
|
||||||
total_output_tokens += token_stats.get("total_output_tokens", 0)
|
total_output_tokens += token_stats.get("total_output_tokens", 0)
|
||||||
token_stats_count += 1
|
token_stats_count += 1
|
||||||
|
messages = conversation_data.get("messages") or []
|
||||||
|
total_user_messages += sum(1 for msg in messages if msg.get("role") == "user")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_conversations": total_conversations,
|
"total_conversations": total_conversations,
|
||||||
"total_messages": total_messages,
|
"total_messages": total_messages,
|
||||||
|
"total_user_messages": total_user_messages,
|
||||||
"total_tools": total_tools,
|
"total_tools": total_tools,
|
||||||
"status_distribution": status_count,
|
"status_distribution": status_count,
|
||||||
"thinking_mode_distribution": thinking_mode_count,
|
"thinking_mode_distribution": thinking_mode_count,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user