Sfoglia il codice sorgente

[Feat 0000]助手增加今日费用弹框

wangkeyi 1 settimana fa
parent
commit
081079a4a1

+ 75 - 3
src/views/ventAI/manageAssistent/api.ts

@@ -3,6 +3,7 @@ import { getToken } from '/@/utils/auth';
 import type { SseEvent } from './components/chatModal/types';
 
 enum Api {
+  usageToday = '/ventAI/api/usage/today',
   unified = '/ventAI/api/chat',
   getHistoryList = '/ventAI/api/sessions',
   getDetail = '/ventAI/api/chat/history/',
@@ -17,6 +18,77 @@ enum Api {
   editWord = '/ventAI/api/report/edit-word',
 }
 
+export interface UsageByModel {
+  model: string;
+  cache_hit_tokens: number;
+  cache_miss_tokens: number;
+  output_tokens: number;
+  cost_fen: number;
+  request_count: number;
+}
+
+export interface TodayUsage {
+  date: string;
+  limit_fen: number;
+  spent_fen: number;
+  remaining_fen: number;
+  request_count: number;
+  by_model: UsageByModel[];
+}
+
+interface ApiErrorDetail {
+  code?: string;
+  message?: string;
+  retry_after?: number;
+  limit_fen?: number;
+  spent_fen?: number;
+}
+
+/**
+ * 流式请求被服务端以普通 JSON 拒绝时抛出的错误(429 额度耗尽 / 500 计费异常等)
+ */
+export class StreamError extends Error {
+  status: number;
+  code?: string;
+  retryAfter?: number;
+  limitFen?: number;
+  spentFen?: number;
+
+  constructor(status: number, message: string, detail?: ApiErrorDetail) {
+    super(message);
+    this.name = 'StreamError';
+    this.status = status;
+    this.code = detail?.code;
+    this.retryAfter = detail?.retry_after;
+    this.limitFen = detail?.limit_fen;
+    this.spentFen = detail?.spent_fen;
+  }
+}
+
+const throwStreamHttpError = async (response: Response): Promise<never> => {
+  let detail: ApiErrorDetail | undefined;
+  let message = `HTTP错误: ${response.status}`;
+  try {
+    const body = await response.json();
+    if (body?.detail) {
+      if (typeof body.detail === 'string') {
+        message = body.detail;
+      } else {
+        detail = body.detail as ApiErrorDetail;
+        message = detail.message || message;
+      }
+    }
+  } catch {
+    // 响应体不是 JSON 时保留默认错误信息
+  }
+  throw new StreamError(response.status, message, detail);
+};
+
+/**
+ * 查询今日用量与额度
+ */
+export const getTodayUsage = () => defHttp.get({ url: Api.usageToday }, { isTransformResponse: false });
+
 /**
  * 历史会话接口
  */
@@ -137,7 +209,7 @@ export const unifiedStream = async (
     });
 
     if (!response.ok) {
-      throw new Error(`HTTP错误: ${response.status}`);
+      await throwStreamHttpError(response);
     }
 
     const reader = response.body!.getReader();
@@ -224,7 +296,7 @@ export const reviewPdfStream = async (
     });
 
     if (!response.ok) {
-      throw new Error(`HTTP错误: ${response.status}`);
+      await throwStreamHttpError(response);
     }
 
     const reader = response.body!.getReader();
@@ -314,7 +386,7 @@ export const chatResumeStream = async (
     });
 
     if (!response.ok) {
-      throw new Error(`HTTP错误: ${response.status}`);
+      await throwStreamHttpError(response);
     }
 
     const reader = response.body!.getReader();

+ 32 - 4
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -83,6 +83,10 @@
             :contextMax="contextMax"
             :contextPercent="contextPercent"
             :contextBreakdown="contextBreakdown"
+            :usageLimit="usageToday?.limit_fen ?? 1000"
+            :usageSpent="usageToday?.spent_fen ?? 0"
+            :usageRemaining="usageToday?.remaining_fen ?? 0"
+            :usageByModel="usageToday?.by_model ?? []"
             :initialThinkLevel="thinkLevel"
             :initialEditMode="editMode"
             :taskList="taskList"
@@ -180,6 +184,8 @@
     getModelMsg,
     getSessionMode,
     putSessionMode,
+    getTodayUsage,
+    StreamError,
   } from '../api';
   import { message, Modal } from 'ant-design-vue';
   import TaskListPanel from './chatModal/TaskListPanel.vue';
@@ -196,6 +202,7 @@
   import SchedulePanel from './chatModal/SchedulePanel.vue';
   import ScheduleDetailPanel from './chatModal/ScheduleDetailPanel.vue';
   import { getScheduleList } from '../api';
+  import type { TodayUsage } from '../api';
   import type { AttachedFile, Message, Task, SseEvent } from './chatModal/types';
   import { isTextFile, isVideoFile, isAudioFile, isWordFile, isExcelFile, isCsvFile } from './chatModal/utils';
 
@@ -315,6 +322,19 @@
 
   const messages = ref<Message[]>([]);
 
+  const usageToday = ref<TodayUsage | null>(null);
+
+  const fetchTodayUsage = async () => {
+    try {
+      const res = await getTodayUsage();
+      if (res && typeof res === 'object') {
+        usageToday.value = res;
+      }
+    } catch (e) {
+      console.error('获取今日用量失败:', e);
+    }
+  };
+
   const contextUsed = ref(0);
   const contextMax = ref(1000000);
   const contextPercent = ref(0);
@@ -773,11 +793,13 @@
         }
       } else {
         console.error('提交答案失败:', error);
-        message.error('提交失败,请重试');
+        const isStreamError = error instanceof StreamError;
+        message.error(isStreamError ? error.message : '提交失败,请重试');
+        if (isStreamError) fetchTodayUsage();
         lastMsg.isLoading = false;
         lastMsg.durationMs = (lastMsg.baseDurationMs || 0) + (lastMsg.generateStartTime ? Date.now() - lastMsg.generateStartTime : 0);
         if (!lastMsg.content) {
-          lastMsg.content = '抱歉,提交请求失败,请稍后重试。';
+          lastMsg.content = isStreamError ? error.message : '抱歉,提交请求失败,请稍后重试。';
         }
       }
     } finally {
@@ -1173,7 +1195,10 @@
       }
     } catch (error) {
       console.error('发送消息失败:', error);
-      message.error('发送失败,请重试');
+      const isStreamError = error instanceof StreamError;
+      const errText = isStreamError ? error.message : '发送失败,请重试';
+      message.error(errText);
+      if (isStreamError) fetchTodayUsage();
 
       const lastMsg = messages.value[messages.value.length - 1];
       if (lastMsg && lastMsg.type === 'ai' && lastMsg.isLoading) {
@@ -1182,7 +1207,7 @@
 
       const errorMsg: Message = {
         type: 'ai',
-        content: '抱歉,请求失败,请稍后重试。',
+        content: isStreamError ? error.message : '抱歉,请求失败,请稍后重试。',
         time: dayjs().format('HH:mm'),
       };
       messages.value.push(errorMsg);
@@ -1476,6 +1501,8 @@
           aiMsg.sessionId = data.session_id;
           fetchContext(data.session_id);
         }
+        // 对话结束后刷新今日用量
+        fetchTodayUsage();
         aiMsg.durationMs = data.duration_ms
           ? (aiMsg.baseDurationMs || 0) + data.duration_ms
           : (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
@@ -1802,6 +1829,7 @@
         await fetchSessionList();
         fetchModelMsg();
         fetchScheduleListForDetail();
+        fetchTodayUsage();
 
         // 去除重复的空白任务,只保留一个
         taskList.value = taskList.value.filter((task, index) => {

+ 279 - 0
src/views/ventAI/manageAssistent/components/chatModal/ChatInputArea.vue

@@ -155,6 +155,63 @@
             </div>
           </div>
         </div>
+        <div class="action-divider"></div>
+        <div class="btn-usage-wrapper" ref="usagePopupRef" @mouseenter="showUsagePopup = true" @mouseleave="showUsagePopup = false">
+          <a-tooltip>
+            <div class="btn-usage" @click="toggleUsagePopup">
+              <svg class="usage-ring" viewBox="0 0 32 32">
+                <circle class="usage-ring-bg" cx="16" cy="16" r="13" />
+                <circle
+                  class="usage-ring-fill"
+                  cx="16"
+                  cy="16"
+                  r="13"
+                  :stroke-dasharray="usageCircumference"
+                  :stroke-dashoffset="usageOffset"
+                  :style="{ stroke: usageColor }"
+                />
+              </svg>
+              <span class="usage-symbol">¥</span>
+            </div>
+          </a-tooltip>
+          <div v-if="showUsagePopup" class="usage-popup" @click.stop>
+            <div class="usage-popup-title">今日用量</div>
+            <div class="usage-popup-info">
+              <span class="usage-spent">¥{{ usageSpentYuan }}</span>
+              <span class="usage-sep">/</span>
+              <span class="usage-limit">¥{{ usageLimitYuan }}</span>
+              <span class="usage-pct">({{ usagePercent }}%)</span>
+            </div>
+            <div class="usage-popup-bar-track">
+              <div class="usage-popup-bar-fill" :style="{ width: usagePercent + '%', background: usageColor }"></div>
+            </div>
+            <div class="usage-remaining">剩余额度 ¥{{ usageRemainingYuan }}</div>
+            <div v-if="usageByModel.length > 0" class="usage-breakdown">
+              <div v-for="item in usageByModel" :key="item.model" class="usage-model-item">
+                <!-- <div class="usage-model-header">
+                  <span class="usage-model-name">{{ item.model }}</span>
+                  <span class="usage-model-cost">¥{{ fenToYuan(item.cost_fen) }}</span>
+                </div> -->
+                <div class="usage-model-row">
+                  <span class="usage-model-label">输入命中</span>
+                  <span class="usage-model-value">{{ formatTokens(item.cache_hit_tokens) }}</span>
+                </div>
+                <div class="usage-model-row">
+                  <span class="usage-model-label">输入未命中</span>
+                  <span class="usage-model-value">{{ formatTokens(item.cache_miss_tokens) }}</span>
+                </div>
+                <div class="usage-model-row">
+                  <span class="usage-model-label">输出</span>
+                  <span class="usage-model-value">{{ formatTokens(item.output_tokens) }}</span>
+                </div>
+                <div class="usage-model-row">
+                  <span class="usage-model-label">请求次数</span>
+                  <span class="usage-model-value">{{ item.request_count }} 次</span>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
         <!-- <div class="action-divider"></div>
         <div class="btn-model-wrapper" ref="modelPopupRef">
           <a-tooltip title="选择模型">
@@ -279,6 +336,7 @@
   import { CloseOutlined } from '@ant-design/icons-vue';
   import { formatFileSize } from './utils';
   import { getSkills } from '../../api';
+  import type { UsageByModel } from '../../api';
   import type { AttachedFile, Task } from './types';
 
   const props = withDefaults(
@@ -291,6 +349,10 @@
       contextMax?: number;
       contextPercent?: number;
       contextBreakdown?: { messages: number; mcp: number; skills: number; system_prompt: number; other: number };
+      usageLimit?: number;
+      usageSpent?: number;
+      usageRemaining?: number;
+      usageByModel?: UsageByModel[];
       initialThinkLevel?: string;
       initialEditMode?: string;
       taskList?: Task[];
@@ -302,6 +364,10 @@
       contextPercent: 0,
       streaming: false,
       contextBreakdown: () => ({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 }),
+      usageLimit: 1000,
+      usageSpent: 0,
+      usageRemaining: 0,
+      usageByModel: () => [],
       initialThinkLevel: 'off',
       initialEditMode: 'full',
       taskList: () => [],
@@ -374,6 +440,38 @@
     showContextPopup.value = false;
   });
 
+  // 今日用量
+  const usagePopupRef = ref<HTMLElement>();
+  const showUsagePopup = ref(false);
+
+  const usagePercent = computed(() => {
+    if (!props.usageLimit) return 0;
+    return Math.min(100, Math.round((props.usageSpent / props.usageLimit) * 100));
+  });
+
+  const usageCircumference = computed(() => 2 * Math.PI * 13);
+  const usageOffset = computed(() => usageCircumference.value * (1 - usagePercent.value / 100));
+
+  const usageColor = computed(() => {
+    if (usagePercent.value >= 90) return '#ff4d4f';
+    if (usagePercent.value >= 70) return '#faad14';
+    return '#52c41a';
+  });
+
+  const fenToYuan = (fen: number) => (fen / 100).toFixed(2);
+
+  const usageSpentYuan = computed(() => fenToYuan(props.usageSpent));
+  const usageLimitYuan = computed(() => fenToYuan(props.usageLimit));
+  const usageRemainingYuan = computed(() => fenToYuan(Math.max(0, props.usageRemaining ?? props.usageLimit - props.usageSpent)));
+
+  const toggleUsagePopup = () => {
+    showUsagePopup.value = !showUsagePopup.value;
+  };
+
+  onClickOutside(usagePopupRef, () => {
+    showUsagePopup.value = false;
+  });
+
   // 模型选择
   const modelPopupRef = ref<HTMLElement>();
   const showModelPopup = ref(false);
@@ -1309,6 +1407,187 @@
             }
           }
         }
+        .btn-usage-wrapper {
+          position: relative;
+          display: flex;
+          align-items: center;
+        }
+        .btn-usage {
+          width: 30px;
+          height: 30px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          cursor: pointer;
+          position: relative;
+          border-radius: 6px;
+          transition: background 0.2s;
+
+          &:hover {
+            background: rgba(10, 132, 255, 0.1);
+          }
+
+          .usage-ring {
+            width: 24px;
+            height: 24px;
+            transform: rotate(-90deg);
+
+            .usage-ring-bg {
+              fill: none;
+              stroke: rgba(255, 255, 255, 0.1);
+              stroke-width: 3;
+            }
+
+            .usage-ring-fill {
+              fill: none;
+              stroke-width: 3;
+              stroke-linecap: round;
+              transition: stroke-dashoffset 0.3s ease;
+            }
+          }
+
+          .usage-symbol {
+            position: absolute;
+            font-size: 9px;
+            color: #e0e6ed;
+            font-weight: 600;
+            line-height: 1;
+          }
+        }
+        .usage-popup {
+          position: absolute;
+          bottom: 42px;
+          left: 50%;
+          transform: translateX(-50%);
+          background: #0a2a3f;
+          border: 1px solid #1a4a6f;
+          border-radius: 8px;
+          min-width: 200px;
+          padding: 14px 16px;
+
+          &::before {
+            content: '';
+            position: absolute;
+            bottom: -12px;
+            left: 0;
+            right: 0;
+            height: 12px;
+          }
+          z-index: 1000;
+          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+          .usage-popup-title {
+            font-size: 13px;
+            color: #8b949e;
+            margin-bottom: 8px;
+          }
+
+          .usage-popup-info {
+            display: flex;
+            align-items: baseline;
+            gap: 2px;
+            margin-bottom: 10px;
+
+            .usage-spent {
+              font-size: 18px;
+              font-weight: 600;
+              color: #e0e6ed;
+            }
+
+            .usage-sep {
+              font-size: 14px;
+              color: #8b949e;
+            }
+
+            .usage-limit {
+              font-size: 14px;
+              color: #8b949e;
+            }
+
+            .usage-pct {
+              font-size: 13px;
+              color: #8b949e;
+            }
+          }
+
+          .usage-popup-bar-track {
+            width: 100%;
+            height: 6px;
+            background: rgba(255, 255, 255, 0.08);
+            border-radius: 3px;
+            overflow: hidden;
+
+            .usage-popup-bar-fill {
+              height: 100%;
+              border-radius: 3px;
+              transition: width 0.3s ease;
+            }
+          }
+
+          .usage-remaining {
+            margin-top: 6px;
+            font-size: 14px;
+            color: #8b949e;
+          }
+
+          .usage-breakdown {
+            margin-top: 10px;
+            padding-top: 10px;
+            border-top: 1px solid rgba(255, 255, 255, 0.08);
+
+            .usage-model-item {
+              padding: 6px 0;
+              border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
+
+              &:last-child {
+                border-bottom: none;
+                padding-bottom: 0;
+              }
+
+              .usage-model-header {
+                display: flex;
+                justify-content: space-between;
+                align-items: center;
+                gap: 12px;
+                margin-bottom: 4px;
+
+                .usage-model-name {
+                  font-size: 14px;
+                  font-weight: 600;
+                  color: #e0e6ed;
+                  overflow: hidden;
+                  text-overflow: ellipsis;
+                  white-space: nowrap;
+                }
+
+                .usage-model-cost {
+                  font-size: 14px;
+                  font-weight: 600;
+                  color: #3a8fd4;
+                  white-space: nowrap;
+                  flex-shrink: 0;
+                }
+              }
+
+              .usage-model-row {
+                display: flex;
+                justify-content: space-between;
+                align-items: center;
+                padding: 2px 0;
+
+                .usage-model-label {
+                  font-size: 12px;
+                  color: #8b949e;
+                }
+
+                .usage-model-value {
+                  font-size: 12px;
+                  color: #e0e6ed;
+                }
+              }
+            }
+          }
+        }
         .btn-model-wrapper {
           position: relative;
           display: flex;