Эх сурвалжийг харах

[Mod 0000]AI助手代码规范化:接口按域拆分、重复实现与类型收敛,修复竞态与错误提示丢失等缺陷

wangkeyi 2 долоо хоног өмнө
parent
commit
6de78411ed
37 өөрчлөгдсөн 992 нэмэгдсэн , 845 устгасан
  1. 161 0
      src/views/ventAI/manageAssistent/api/chat.ts
  2. 0 453
      src/views/ventAI/manageAssistent/api/client.ts
  3. 72 0
      src/views/ventAI/manageAssistent/api/errors.ts
  4. 11 0
      src/views/ventAI/manageAssistent/api/index.ts
  5. 18 0
      src/views/ventAI/manageAssistent/api/model.ts
  6. 48 0
      src/views/ventAI/manageAssistent/api/report.ts
  7. 38 0
      src/views/ventAI/manageAssistent/api/schedule.ts
  8. 66 0
      src/views/ventAI/manageAssistent/api/session.ts
  9. 34 0
      src/views/ventAI/manageAssistent/api/skill.ts
  10. 61 0
      src/views/ventAI/manageAssistent/api/subagent.ts
  11. 29 0
      src/views/ventAI/manageAssistent/api/usage.ts
  12. 44 24
      src/views/ventAI/manageAssistent/components/AiAssistantModal.vue
  13. 11 4
      src/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue
  14. 12 61
      src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue
  15. 3 1
      src/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue
  16. 1 1
      src/views/ventAI/manageAssistent/components/chat/Input/UsageRing.vue
  17. 1 5
      src/views/ventAI/manageAssistent/components/chat/TodoListBar.vue
  18. 4 13
      src/views/ventAI/manageAssistent/components/modals/CreateScheduleModal.vue
  19. 12 36
      src/views/ventAI/manageAssistent/components/modals/CreateSubAgentModal.vue
  20. 2 11
      src/views/ventAI/manageAssistent/components/modals/SkillModal.vue
  21. 25 10
      src/views/ventAI/manageAssistent/components/panels/ScheduleDetailPanel.vue
  22. 17 21
      src/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue
  23. 8 12
      src/views/ventAI/manageAssistent/components/panels/SkillPanel.vue
  24. 7 22
      src/views/ventAI/manageAssistent/components/panels/SubAgentPanel.vue
  25. 0 4
      src/views/ventAI/manageAssistent/components/panels/TaskListPanel.vue
  26. 32 58
      src/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue
  27. 4 6
      src/views/ventAI/manageAssistent/hooks/useChatApproval.ts
  28. 25 2
      src/views/ventAI/manageAssistent/hooks/useChatQuota.ts
  29. 9 1
      src/views/ventAI/manageAssistent/hooks/useChatScroll.ts
  30. 59 66
      src/views/ventAI/manageAssistent/hooks/useChatSend.ts
  31. 14 8
      src/views/ventAI/manageAssistent/hooks/useChatStream.ts
  32. 1 1
      src/views/ventAI/manageAssistent/hooks/useChatTasks.ts
  33. 15 12
      src/views/ventAI/manageAssistent/hooks/useMentionPopups.ts
  34. 47 0
      src/views/ventAI/manageAssistent/lib/clipboard.ts
  35. 1 0
      src/views/ventAI/manageAssistent/lib/index.ts
  36. 32 13
      src/views/ventAI/manageAssistent/lib/markdown.ts
  37. 68 0
      src/views/ventAI/manageAssistent/types/domain.ts

+ 161 - 0
src/views/ventAI/manageAssistent/api/chat.ts

@@ -0,0 +1,161 @@
+// api/chat.ts:对话相关的三个 SSE 流式接口(统一对话 / PDF 审查 / 审批恢复)。
+// 三个入口共用 consumeSse 与 throwStreamHttpError,只是请求体构造方式不同。
+import { getToken } from '/@/utils/auth/index';
+import { consumeSse } from './sse';
+import { throwStreamHttpError } from './errors';
+import type { SseEvent } from '/@/views/ventAI/manageAssistent/types/index';
+
+enum Api {
+  unified = '/ventAI/api/chat',
+  resume = '/ventAI/api/chat/resume',
+}
+
+/**
+ * 统一对话接口(SSE流式响应)
+ * @param params.message - 用户输入的文本,必填
+ * @param params.session_id - 会话唯一标识ID,不传参时服务端自动生成全新会话ID
+ * @param params.file - 上传的PDF文件,可选
+ * @param onChunk - 流式数据回调函数
+ * @returns Promise<{ session_id: string; parse_errors: number }>
+ *          parse_errors 大于 0 表示这条流里有事件被丢弃(正文可能不完整)
+ */
+export const unifiedStream = async (
+  params: { message: string; session_id?: string; file?: File; mode?: string; signal?: AbortSignal },
+  onChunk: (data: SseEvent) => void
+): Promise<{ session_id: string; parse_errors: number }> => {
+  try {
+    const formData = new FormData();
+    formData.append('message', params.message);
+    if (params.session_id) {
+      formData.append('session_id', params.session_id);
+    }
+    if (params.file) {
+      formData.append('file', params.file);
+    }
+    if (params.mode) {
+      formData.append('mode', params.mode);
+    }
+
+    const response = await fetch(Api.unified, {
+      method: 'POST',
+      body: formData,
+      headers: { 'X-Access-Token': getToken() },
+      signal: params.signal,
+    });
+
+    if (!response.ok) {
+      await throwStreamHttpError(response);
+    }
+
+    const { sessionId, parseErrors } = await consumeSse<SseEvent>(response, onChunk);
+
+    // parseErrors > 0 表示这条流里有事件被丢弃,正文可能不完整
+    return { session_id: sessionId, parse_errors: parseErrors };
+  } catch (error) {
+    if ((error as Error)?.name !== 'AbortError') {
+      console.error('统一对话流式请求失败:', error);
+    }
+    throw error;
+  }
+};
+
+/**
+ * PDF审查接口(SSE流式响应)
+ * @param params.file - 配风计划PDF文件,必填
+ * @param params.session_id - 会话唯一标识ID,可选
+ * @param params.message - 附加消息/审查指令,可选
+ * @param onChunk - 流式数据回调函数
+ * @returns Promise<{ session_id: string; parse_errors: number }>
+ *          parse_errors 大于 0 表示这条流里有事件被丢弃(正文可能不完整)
+ */
+export const reviewPdfStream = async (
+  params: { file: File; session_id?: string; message?: string; mode?: string; signal?: AbortSignal },
+  onChunk: (data: SseEvent) => void
+): Promise<{ session_id: string; parse_errors: number }> => {
+  try {
+    const formData = new FormData();
+    formData.append('file', params.file);
+    if (params.session_id) {
+      formData.append('session_id', params.session_id);
+    }
+    if (params.message) {
+      formData.append('message', params.message);
+    }
+    if (params.mode) {
+      formData.append('mode', params.mode);
+    }
+
+    const response = await fetch(Api.unified, {
+      method: 'POST',
+      body: formData,
+      headers: { 'X-Access-Token': getToken() },
+      signal: params.signal,
+    });
+
+    if (!response.ok) {
+      await throwStreamHttpError(response);
+    }
+
+    const { sessionId, parseErrors } = await consumeSse<SseEvent>(response, onChunk);
+
+    // parseErrors > 0 表示这条流里有事件被丢弃,正文可能不完整
+    return { session_id: sessionId, parse_errors: parseErrors };
+  } catch (error) {
+    if ((error as Error)?.name !== 'AbortError') {
+      console.error('PDF审查流式请求失败:', error);
+    }
+    throw error;
+  }
+};
+
+/**
+ * 恢复会话接口(SSE流式响应)
+ * @param params.session_id - 会话ID,必填
+ * @param params.thread_id - LangGraph线程ID,可选
+ * @param params.status - ask_user响应状态:"answered"(默认,消费answers)/ "cancelled"(取消)/ "error"(出错)
+ * @param params.answers - ask_user答案列表,与中断事件ask_user.questions一一对应(status=answered时必填)
+ * @param onChunk - 流式数据回调函数
+ * @returns Promise<{ session_id: string; parse_errors: number }>
+ *          parse_errors 大于 0 表示这条流里有事件被丢弃(正文可能不完整)
+ */
+export const chatResumeStream = async (
+  params: { session_id: string; thread_id?: string; status?: string; answers?: string[]; signal?: AbortSignal },
+  onChunk: (data: SseEvent) => void
+): Promise<{ session_id: string; parse_errors: number }> => {
+  try {
+    const body: Record<string, any> = {
+      session_id: params.session_id,
+      status: params.status || 'answered',
+    };
+    if (params.thread_id) {
+      body.thread_id = params.thread_id;
+    }
+    if (params.answers) {
+      body.answers = params.answers;
+    }
+
+    const response = await fetch(Api.resume, {
+      method: 'POST',
+      body: JSON.stringify(body),
+      headers: {
+        'Content-Type': 'application/json',
+        'X-Access-Token': getToken(),
+      },
+      signal: params.signal,
+    });
+
+    if (!response.ok) {
+      await throwStreamHttpError(response);
+    }
+
+    const { sessionId, parseErrors } = await consumeSse<SseEvent>(response, onChunk);
+
+    // parseErrors > 0 表示这条流里有事件被丢弃,正文可能不完整
+    return { session_id: sessionId, parse_errors: parseErrors };
+  } catch (error) {
+    if ((error as Error)?.name !== 'AbortError') {
+      console.error('恢复会话流式请求失败:', error);
+    }
+    throw error;
+  }
+};

+ 0 - 453
src/views/ventAI/manageAssistent/api/client.ts

@@ -1,453 +0,0 @@
-import { defHttp } from '/@/utils/http/axios/index';
-import { getToken } from '/@/utils/auth/index';
-import { consumeSse } from './sse';
-import type { SseEvent } from '/@/views/ventAI/manageAssistent/types/index';
-
-enum Api {
-  usageToday = '/ventAI/api/usage/today',
-  unified = '/ventAI/api/chat',
-  getHistoryList = '/ventAI/api/sessions',
-  getDetail = '/ventAI/api/chat/history/',
-  deleteSession = '/ventAI/api/sessions/',
-  deleteMessage = '/ventAI/api/messages/',
-  chatResume = '/ventAI/api/chat/resume',
-  getModelMsg = '/ventAI/api/model',
-  switchThinking = '/ventAI/api/model/thinking',
-  getSkills = '/ventAI/api/skills',
-  schedules = '/ventAI/api/schedules',
-  downloadReport = '/ventAI/api/download/report/',
-  saveReport = '/ventAI/api/report/save/',
-  editWord = '/ventAI/api/report/edit-word',
-}
-
-export interface UsageByModel {
-  model: string;
-  cache_hit_tokens: number;
-  cache_miss_tokens: number;
-  output_tokens: number;
-  tokens: number;
-  request_count: number;
-}
-
-export interface TodayUsage {
-  date: string;
-  limit_tokens: number;
-  spent_tokens: number;
-  remaining_tokens: number;
-  request_count: number;
-  by_model: UsageByModel[];
-}
-
-interface ApiErrorDetail {
-  code?: string;
-  message?: string;
-  retry_after?: number;
-  limit_tokens?: number;
-  spent_tokens?: number;
-}
-
-/**
- * 流式请求被服务端以普通 JSON 拒绝时抛出的错误(429 额度耗尽 / 500 计费异常等)
- */
-export class StreamError extends Error {
-  status: number;
-  code?: string;
-  retryAfter?: number;
-  limitTokens?: number;
-  spentTokens?: 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.limitTokens = detail?.limit_tokens;
-    this.spentTokens = detail?.spent_tokens;
-  }
-}
-
-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 });
-
-/**
- * 历史会话接口
- */
-export const getHistoryList = (params) => defHttp.get({ url: Api.getHistoryList, params }, { joinParamsToUrl: true, isTransformResponse: false });
-
-/**
- * 获取历史会话详情
- * @param session_id - 会话ID
- */
-export const getDetail = (session_id: string) => defHttp.get({ url: Api.getDetail + session_id }, { isTransformResponse: false });
-
-/**
- * 删除会话
- * @param session_id - 会话ID
- */
-export const deleteSession = (session_id: string) => defHttp.delete({ url: Api.deleteSession + session_id }, { isTransformResponse: false });
-
-/**
- * 删除单条消息(按消息主键 id)
- * 删除后服务端会清空该会话的内存线程,下轮对话从数据库重建最近 40 条历史
- * @param message_id - 消息主键 id(来自历史详情 messages[].id)
- */
-export const deleteMessage = (message_id: number) => defHttp.delete({ url: Api.deleteMessage + message_id }, { isTransformResponse: false });
-
-/**
- * 置顶会话
- * @param session_id - 会话ID
- */
-export const pinSession = (session_id: string) => defHttp.put({ url: `/ventAI/api/sessions/${session_id}/pin` }, { isTransformResponse: false });
-
-/**
- * 编辑会话名称
- * @param session_id - 会话ID
- * @param title - 新标题
- */
-export const editSessionTitle = (session_id: string, title: string) =>
-  defHttp.put({ url: `/ventAI/api/sessions/${session_id}/title`, data: { title } }, { isTransformResponse: false });
-
-/**
- * 获取对话权限模式
- * @param session_id - 会话ID
- */
-export const getSessionMode = (session_id: string) => defHttp.get({ url: `/ventAI/api/sessions/${session_id}/mode` }, { isTransformResponse: false });
-
-/**
- * 设置对话权限模式
- * @param session_id - 会话ID
- */
-export const putSessionMode = (session_id: string, mode: string) =>
-  defHttp.put({ url: `/ventAI/api/sessions/${session_id}/mode`, data: { mode } }, { isTransformResponse: false });
-/**
- * 上下文用量查询
- * @param session_id - 会话ID
- */
-export const getContext = (session_id: string) => defHttp.get({ url: `/ventAI/api/sessions/${session_id}/context` }, { isTransformResponse: false });
-
-/**
- * 查询模型信息
- */
-export const getModelMsg = () => defHttp.get({ url: Api.getModelMsg }, { isTransformResponse: false });
-
-/**
- * 切换模型思考深度
- * @param level - 思考级别 off/high/highest
- */
-export const switchThinking = (level: string) => defHttp.post({ url: Api.switchThinking, data: { level } }, { isTransformResponse: false });
-
-/**
- * 查询模型技能
- */
-export const getSkills = () => defHttp.get({ url: Api.getSkills }, { isTransformResponse: false });
-
-/**
- * 查询模型技能详情
- */
-export const getSkillDetails = (name: string) => defHttp.get({ url: Api.getSkills + '/' + name }, { isTransformResponse: false });
-
-/**
- * 启用/禁用模型技能
- */
-export const toggleSkill = (name: string) => defHttp.post({ url: Api.getSkills + '/' + name + '/toggle' }, { isTransformResponse: false });
-
-/**
- * 上传新技能
- * @param file - zip附件
- */
-export const uploadSkill = (file: File) => {
-  const formData = new FormData();
-  formData.append('file', file);
-  return defHttp.post(
-    { url: Api.getSkills + '/upload', data: formData, headers: { 'Content-Type': 'multipart/form-data' } },
-    { isTransformResponse: false }
-  );
-};
-/**
- * 统一对话接口(SSE流式响应)
- * @param params.message - 用户输入的文本,必填
- * @param params.session_id - 会话唯一标识ID,不传参时服务端自动生成全新会话ID
- * @param params.file - 上传的PDF文件,可选
- * @param onChunk - 流式数据回调函数
- * @returns Promise<{ session_id: string; parse_errors: number }>
- *          parse_errors 大于 0 表示这条流里有事件被丢弃(正文可能不完整)
- */
-export const unifiedStream = async (
-  params: { message: string; session_id?: string; file?: File; mode?: string; signal?: AbortSignal },
-  onChunk: (data: SseEvent) => void
-): Promise<{ session_id: string; parse_errors: number }> => {
-  try {
-    const formData = new FormData();
-    formData.append('message', params.message);
-    if (params.session_id) {
-      formData.append('session_id', params.session_id);
-    }
-    if (params.file) {
-      formData.append('file', params.file);
-    }
-    if (params.mode) {
-      formData.append('mode', params.mode);
-    }
-
-    const response = await fetch(Api.unified, {
-      method: 'POST',
-      body: formData,
-      headers: { 'X-Access-Token': getToken() },
-      signal: params.signal,
-    });
-
-    if (!response.ok) {
-      await throwStreamHttpError(response);
-    }
-
-    const { sessionId, parseErrors } = await consumeSse<SseEvent>(response, onChunk);
-
-    // parseErrors > 0 表示这条流里有事件被丢弃,正文可能不完整
-    return { session_id: sessionId, parse_errors: parseErrors };
-  } catch (error) {
-    if ((error as Error)?.name !== 'AbortError') {
-      console.error('统一对话流式请求失败:', error);
-    }
-    throw error;
-  }
-};
-
-/**
- * PDF审查接口(SSE流式响应)
- * @param params.file - 配风计划PDF文件,必填
- * @param params.session_id - 会话唯一标识ID,可选
- * @param params.message - 附加消息/审查指令,可选
- * @param onChunk - 流式数据回调函数
- * @returns Promise<{ session_id: string; parse_errors: number }>
- *          parse_errors 大于 0 表示这条流里有事件被丢弃(正文可能不完整)
- */
-export const reviewPdfStream = async (
-  params: { file: File; session_id?: string; message?: string; mode?: string; signal?: AbortSignal },
-  onChunk: (data: SseEvent) => void
-): Promise<{ session_id: string; parse_errors: number }> => {
-  try {
-    const formData = new FormData();
-    formData.append('file', params.file);
-    if (params.session_id) {
-      formData.append('session_id', params.session_id);
-    }
-    if (params.message) {
-      formData.append('message', params.message);
-    }
-    if (params.mode) {
-      formData.append('mode', params.mode);
-    }
-
-    const response = await fetch(Api.unified, {
-      method: 'POST',
-      body: formData,
-      headers: { 'X-Access-Token': getToken() },
-      signal: params.signal,
-    });
-
-    if (!response.ok) {
-      await throwStreamHttpError(response);
-    }
-
-    const { sessionId, parseErrors } = await consumeSse<SseEvent>(response, onChunk);
-
-    // parseErrors > 0 表示这条流里有事件被丢弃,正文可能不完整
-    return { session_id: sessionId, parse_errors: parseErrors };
-  } catch (error) {
-    if ((error as Error)?.name !== 'AbortError') {
-      console.error('PDF审查流式请求失败:', error);
-    }
-    throw error;
-  }
-};
-
-/**
- * 恢复会话接口(SSE流式响应)
- * @param params.session_id - 会话ID,必填
- * @param params.thread_id - LangGraph线程ID,可选
- * @param params.status - ask_user响应状态:"answered"(默认,消费answers)/ "cancelled"(取消)/ "error"(出错)
- * @param params.answers - ask_user答案列表,与中断事件ask_user.questions一一对应(status=answered时必填)
- * @param onChunk - 流式数据回调函数
- * @returns Promise<{ session_id: string; parse_errors: number }>
- *          parse_errors 大于 0 表示这条流里有事件被丢弃(正文可能不完整)
- */
-export const chatResumeStream = async (
-  params: { session_id: string; thread_id?: string; status?: string; answers?: string[]; signal?: AbortSignal },
-  onChunk: (data: SseEvent) => void
-): Promise<{ session_id: string; parse_errors: number }> => {
-  try {
-    const body: Record<string, any> = {
-      session_id: params.session_id,
-      status: params.status || 'answered',
-    };
-    if (params.thread_id) {
-      body.thread_id = params.thread_id;
-    }
-    if (params.answers) {
-      body.answers = params.answers;
-    }
-
-    const response = await fetch(Api.chatResume, {
-      method: 'POST',
-      body: JSON.stringify(body),
-      headers: {
-        'Content-Type': 'application/json',
-        'X-Access-Token': getToken(),
-      },
-      signal: params.signal,
-    });
-
-    if (!response.ok) {
-      await throwStreamHttpError(response);
-    }
-
-    const { sessionId, parseErrors } = await consumeSse<SseEvent>(response, onChunk);
-
-    // parseErrors > 0 表示这条流里有事件被丢弃,正文可能不完整
-    return { session_id: sessionId, parse_errors: parseErrors };
-  } catch (error) {
-    if ((error as Error)?.name !== 'AbortError') {
-      console.error('恢复会话流式请求失败:', error);
-    }
-    throw error;
-  }
-};
-
-/**
- * 定时任务接口
- */
-export const createSchedule = (data: {
-  name: string;
-  schedule?: { freq: string; hour?: number; minute?: number; day_of_week?: number; day_of_month?: number };
-  cron_expr?: string;
-  prompt: string;
-  notify_webhook?: string;
-}) => defHttp.post({ url: Api.schedules, data }, { isTransformResponse: false });
-
-export const getScheduleList = (include_disabled = true) =>
-  defHttp.get({ url: Api.schedules, params: { include_disabled } }, { joinParamsToUrl: true, isTransformResponse: false });
-
-export const getScheduleDetail = (task_id: number) => defHttp.get({ url: `${Api.schedules}/${task_id}` }, { isTransformResponse: false });
-
-export const updateSchedule = (
-  task_id: number,
-  data: { name?: string; schedule?: Record<string, any>; cron_expr?: string; prompt?: string; notify_webhook?: string }
-) => defHttp.put({ url: `${Api.schedules}/${task_id}`, data }, { isTransformResponse: false });
-
-export const pauseSchedule = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/pause` }, { isTransformResponse: false });
-
-export const resumeSchedule = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/resume` }, { isTransformResponse: false });
-
-export const deleteSchedule = (id: number) => defHttp.delete({ url: `${Api.schedules}/${id}` }, { isTransformResponse: false });
-
-export const getScheduleRuns = (task_id: number, limit = 50) =>
-  defHttp.get({ url: `${Api.schedules}/${task_id}/runs`, params: { limit } }, { joinParamsToUrl: true, isTransformResponse: false });
-
-export const runScheduleNow = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/run` }, { isTransformResponse: false });
-
-/**
- * 获取子智能体列表
- */
-export const getSubAgents = () => defHttp.get({ url: '/ventAI/api/subagents' }, { isTransformResponse: false });
-
-/**
- * 获取工具注册表与模型列表(表单用)
- */
-export const getSubAgentTools = () => defHttp.get({ url: '/ventAI/api/subagents/tools' }, { isTransformResponse: false });
-
-/**
- * 创建子智能体
- */
-export const createSubAgent = (data: {
-  name: string;
-  description: string;
-  system_prompt: string;
-  tools?: string[];
-  model?: string;
-  enabled?: boolean;
-}) => defHttp.post({ url: '/ventAI/api/subagents', data }, { isTransformResponse: false });
-
-/**
- * 获取子智能体详情
- */
-export const getSubAgentDetail = (name: string) => defHttp.get({ url: `/ventAI/api/subagents/${name}` }, { isTransformResponse: false });
-
-/**
- * 更新子智能体
- */
-export const updateSubAgent = (
-  name: string,
-  data: { description?: string; system_prompt?: string; tools?: string[]; model?: string; enabled?: boolean }
-) => defHttp.put({ url: `/ventAI/api/subagents/${name}`, data }, { isTransformResponse: false });
-
-/**
- * 删除子智能体
- */
-export const deleteSubAgent = (name: string) => defHttp.delete({ url: `/ventAI/api/subagents/${name}` }, { isTransformResponse: false });
-
-/**
- * 启用/禁用子智能体
- */
-export const toggleSubAgent = (name: string) => defHttp.post({ url: `/ventAI/api/subagents/${name}/toggle` }, { isTransformResponse: false });
-
-/**
- * 报告下载
- * 从 URL 或文件名中提取纯文件名,构建 /api/download/report/{filename} 地址
- */
-export const buildReportDownloadUrl = (urlOrFilename: string) => {
-  const filename = urlOrFilename.split('/').pop() || urlOrFilename;
-  return `${Api.downloadReport}${encodeURIComponent(filename)}`;
-};
-
-/**
- * 覆盖保存报告(文本)
- * @param filename - 文件名,如 report_20260810_150000.md
- * @param content - 文本内容
- */
-export const saveReportText = (filename: string, content: string) =>
-  defHttp.put({ url: `${Api.saveReport}${encodeURIComponent(filename)}`, data: { content } }, { isTransformResponse: false });
-
-/**
- * 覆盖保存报告(二进制,如 xlsx)
- * @param filename - 文件名
- * @param data - ArrayBuffer / Blob / Uint8Array
- */
-export const saveReportBinary = (filename: string, data: ArrayBuffer | Blob | Uint8Array) =>
-  defHttp.put(
-    {
-      url: `${Api.saveReport}${encodeURIComponent(filename)}`,
-      data,
-      headers: { 'Content-Type': 'application/octet-stream' },
-    },
-    { isTransformResponse: false }
-  );
-
-/**
- * 对话式修改 Word 报告
- * @param filename - docx 文件名
- * @param instruction - 修改要求(自然语言)
- */
-export const editWordReport = (filename: string, instruction: string) =>
-  defHttp.post({ url: Api.editWord, data: { filename, instruction } }, { isTransformResponse: false });

+ 72 - 0
src/views/ventAI/manageAssistent/api/errors.ts

@@ -0,0 +1,72 @@
+// api/errors.ts:后端错误体的统一解码。
+// 流式请求(fetch)与非流式请求(defHttp)共用同一套 detail 规则,避免两处各自解析漂移。
+
+/** 后端错误体 { detail: ... } 里 detail 为对象时的形状 */
+export interface ApiErrorDetail {
+  code?: string;
+  message?: string;
+  retry_after?: number;
+  limit_tokens?: number;
+  spent_tokens?: number;
+}
+
+/**
+ * 流式请求被服务端以普通 JSON 拒绝时抛出的错误(429 额度耗尽 / 500 计费异常等)
+ */
+export class StreamError extends Error {
+  status: number;
+  code?: string;
+  retryAfter?: number;
+  limitTokens?: number;
+  spentTokens?: 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.limitTokens = detail?.limit_tokens;
+    this.spentTokens = detail?.spent_tokens;
+  }
+}
+
+/** 流式响应非 2xx:解析后端 detail 后抛出 StreamError */
+export 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);
+};
+
+/**
+ * 取出后端返回的错误说明,供非流式接口的调用方展示。
+ *
+ * 后端(FastAPI)的错误体形状是 { detail: string | { code, message, ... } },
+ * 因此读 err.response.data.message 永远取不到值,界面只会显示 axios 的
+ * "Request failed with status code 403",把后端真正有用的说明(例如"管理员未配置")整个丢掉。
+ * 这里复用与 throwStreamHttpError 相同的 detail 解码规则。
+ */
+export const extractApiErrorMessage = (err: unknown, fallback: string): string => {
+  const data = (err as { response?: { data?: { detail?: string | ApiErrorDetail } } })?.response?.data;
+  const detail = data?.detail;
+  if (typeof detail === 'string' && detail) return detail;
+  if (detail && typeof detail === 'object' && typeof detail.message === 'string' && detail.message) {
+    return detail.message;
+  }
+  // 非 detail 形状时退回 Error.message(网络错误、超时等仍能给出原因),最后用调用方兜底文案
+  const msg = (err as { message?: string })?.message;
+  return msg || fallback;
+};

+ 11 - 0
src/views/ventAI/manageAssistent/api/index.ts

@@ -0,0 +1,11 @@
+// api barrel:对外保持单一入口,调用方按 `.../api` 引入即可,不必关心内部按域拆分。
+// 各域相互独立:新增接口请放进对应域文件,不要再堆成一个大文件。
+export * from './errors';
+export * from './chat';
+export * from './session';
+export * from './usage';
+export * from './model';
+export * from './skill';
+export * from './schedule';
+export * from './subagent';
+export * from './report';

+ 18 - 0
src/views/ventAI/manageAssistent/api/model.ts

@@ -0,0 +1,18 @@
+// api/model.ts:模型信息与思考级别。
+import { defHttp } from '/@/utils/http/axios/index';
+
+enum Api {
+  model = '/ventAI/api/model',
+  thinking = '/ventAI/api/model/thinking',
+}
+
+/**
+ * 查询模型信息
+ */
+export const getModelMsg = () => defHttp.get({ url: Api.model }, { isTransformResponse: false });
+
+/**
+ * 切换模型思考深度
+ * @param level - 思考级别 off/high/highest
+ */
+export const switchThinking = (level: string) => defHttp.post({ url: Api.thinking, data: { level } }, { isTransformResponse: false });

+ 48 - 0
src/views/ventAI/manageAssistent/api/report.ts

@@ -0,0 +1,48 @@
+// api/report.ts:报告文件(下载地址构造 / 覆盖保存 / 对话式改 Word)。
+import { defHttp } from '/@/utils/http/axios/index';
+
+enum Api {
+  downloadReport = '/ventAI/api/download/report/',
+  saveReport = '/ventAI/api/report/save/',
+  editWord = '/ventAI/api/report/edit-word',
+}
+
+/**
+ * 报告下载
+ * 从 URL 或文件名中提取纯文件名,构建 /api/download/report/{filename} 地址
+ */
+export const buildReportDownloadUrl = (urlOrFilename: string) => {
+  const filename = urlOrFilename.split('/').pop() || urlOrFilename;
+  return `${Api.downloadReport}${encodeURIComponent(filename)}`;
+};
+
+/**
+ * 覆盖保存报告(文本)
+ * @param filename - 文件名,如 report_20260810_150000.md
+ * @param content - 文本内容
+ */
+export const saveReportText = (filename: string, content: string) =>
+  defHttp.put({ url: `${Api.saveReport}${encodeURIComponent(filename)}`, data: { content } }, { isTransformResponse: false });
+
+/**
+ * 覆盖保存报告(二进制,如 xlsx)
+ * @param filename - 文件名
+ * @param data - ArrayBuffer / Blob / Uint8Array
+ */
+export const saveReportBinary = (filename: string, data: ArrayBuffer | Blob | Uint8Array) =>
+  defHttp.put(
+    {
+      url: `${Api.saveReport}${encodeURIComponent(filename)}`,
+      data,
+      headers: { 'Content-Type': 'application/octet-stream' },
+    },
+    { isTransformResponse: false }
+  );
+
+/**
+ * 对话式修改 Word 报告
+ * @param filename - docx 文件名
+ * @param instruction - 修改要求(自然语言)
+ */
+export const editWordReport = (filename: string, instruction: string) =>
+  defHttp.post({ url: Api.editWord, data: { filename, instruction } }, { isTransformResponse: false });

+ 38 - 0
src/views/ventAI/manageAssistent/api/schedule.ts

@@ -0,0 +1,38 @@
+// api/schedule.ts:定时任务(增删改查 / 启停 / 立即执行 / 执行记录)。
+import { defHttp } from '/@/utils/http/axios/index';
+
+enum Api {
+  schedules = '/ventAI/api/schedules',
+}
+
+/**
+ * 定时任务接口
+ */
+export const createSchedule = (data: {
+  name: string;
+  schedule?: { freq: string; hour?: number; minute?: number; day_of_week?: number; day_of_month?: number };
+  cron_expr?: string;
+  prompt: string;
+  notify_webhook?: string;
+}) => defHttp.post({ url: Api.schedules, data }, { isTransformResponse: false });
+
+export const getScheduleList = (include_disabled = true) =>
+  defHttp.get({ url: Api.schedules, params: { include_disabled } }, { joinParamsToUrl: true, isTransformResponse: false });
+
+export const getScheduleDetail = (task_id: number) => defHttp.get({ url: `${Api.schedules}/${task_id}` }, { isTransformResponse: false });
+
+export const updateSchedule = (
+  task_id: number,
+  data: { name?: string; schedule?: Record<string, any>; cron_expr?: string; prompt?: string; notify_webhook?: string }
+) => defHttp.put({ url: `${Api.schedules}/${task_id}`, data }, { isTransformResponse: false });
+
+export const pauseSchedule = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/pause` }, { isTransformResponse: false });
+
+export const resumeSchedule = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/resume` }, { isTransformResponse: false });
+
+export const deleteSchedule = (id: number) => defHttp.delete({ url: `${Api.schedules}/${id}` }, { isTransformResponse: false });
+
+export const getScheduleRuns = (task_id: number, limit = 50) =>
+  defHttp.get({ url: `${Api.schedules}/${task_id}/runs`, params: { limit } }, { joinParamsToUrl: true, isTransformResponse: false });
+
+export const runScheduleNow = (task_id: number) => defHttp.post({ url: `${Api.schedules}/${task_id}/run` }, { isTransformResponse: false });

+ 66 - 0
src/views/ventAI/manageAssistent/api/session.ts

@@ -0,0 +1,66 @@
+// api/session.ts:会话(历史列表 / 详情 / 标题 / 置顶 / 权限模式 / 上下文用量)与消息删除。
+import { defHttp } from '/@/utils/http/axios/index';
+
+// 统一用枚举寻址,避免同一域里模板串与枚举两套写法混用
+enum Api {
+  sessions = '/ventAI/api/sessions',
+  chatHistory = '/ventAI/api/chat/history/',
+  messages = '/ventAI/api/messages/',
+}
+
+/**
+ * 历史会话接口
+ */
+export const getHistoryList = (params) => defHttp.get({ url: Api.sessions, params }, { joinParamsToUrl: true, isTransformResponse: false });
+
+/**
+ * 获取历史会话详情
+ * @param session_id - 会话ID
+ */
+export const getDetail = (session_id: string) => defHttp.get({ url: Api.chatHistory + session_id }, { isTransformResponse: false });
+
+/**
+ * 删除会话
+ * @param session_id - 会话ID
+ */
+export const deleteSession = (session_id: string) => defHttp.delete({ url: `${Api.sessions}/${session_id}` }, { isTransformResponse: false });
+
+/**
+ * 删除单条消息(按消息主键 id)
+ * 删除后服务端会清空该会话的内存线程,下轮对话从数据库重建最近 40 条历史
+ * @param message_id - 消息主键 id(来自历史详情 messages[].id)
+ */
+export const deleteMessage = (message_id: number) => defHttp.delete({ url: Api.messages + message_id }, { isTransformResponse: false });
+
+/**
+ * 置顶会话
+ * @param session_id - 会话ID
+ */
+export const pinSession = (session_id: string) => defHttp.put({ url: `${Api.sessions}/${session_id}/pin` }, { isTransformResponse: false });
+
+/**
+ * 编辑会话名称
+ * @param session_id - 会话ID
+ * @param title - 新标题
+ */
+export const editSessionTitle = (session_id: string, title: string) =>
+  defHttp.put({ url: `${Api.sessions}/${session_id}/title`, data: { title } }, { isTransformResponse: false });
+
+/**
+ * 获取对话权限模式
+ * @param session_id - 会话ID
+ */
+export const getSessionMode = (session_id: string) => defHttp.get({ url: `${Api.sessions}/${session_id}/mode` }, { isTransformResponse: false });
+
+/**
+ * 设置对话权限模式
+ * @param session_id - 会话ID
+ */
+export const putSessionMode = (session_id: string, mode: string) =>
+  defHttp.put({ url: `${Api.sessions}/${session_id}/mode`, data: { mode } }, { isTransformResponse: false });
+
+/**
+ * 上下文用量查询
+ * @param session_id - 会话ID
+ */
+export const getContext = (session_id: string) => defHttp.get({ url: `${Api.sessions}/${session_id}/context` }, { isTransformResponse: false });

+ 34 - 0
src/views/ventAI/manageAssistent/api/skill.ts

@@ -0,0 +1,34 @@
+// api/skill.ts:模型技能(列表 / 详情 / 启停 / 上传)。
+import { defHttp } from '/@/utils/http/axios/index';
+
+enum Api {
+  skills = '/ventAI/api/skills',
+}
+
+/**
+ * 查询模型技能
+ */
+export const getSkills = () => defHttp.get({ url: Api.skills }, { isTransformResponse: false });
+
+/**
+ * 查询模型技能详情
+ */
+export const getSkillDetails = (name: string) => defHttp.get({ url: `${Api.skills}/${name}` }, { isTransformResponse: false });
+
+/**
+ * 启用/禁用模型技能
+ */
+export const toggleSkill = (name: string) => defHttp.post({ url: `${Api.skills}/${name}/toggle` }, { isTransformResponse: false });
+
+/**
+ * 上传新技能
+ * @param file - zip附件
+ */
+export const uploadSkill = (file: File) => {
+  const formData = new FormData();
+  formData.append('file', file);
+  return defHttp.post(
+    { url: `${Api.skills}/upload`, data: formData, headers: { 'Content-Type': 'multipart/form-data' } },
+    { isTransformResponse: false }
+  );
+};

+ 61 - 0
src/views/ventAI/manageAssistent/api/subagent.ts

@@ -0,0 +1,61 @@
+// api/subagent.ts:子智能体(列表 / 工具注册表 / 增删改查 / 启停)。
+import { defHttp } from '/@/utils/http/axios/index';
+import type { SubAgent, SubAgentToolGroup } from '/@/views/ventAI/manageAssistent/types/index';
+
+enum Api {
+  subagents = '/ventAI/api/subagents',
+}
+
+/**
+ * 获取子智能体列表。
+ * 契约已对真实响应核实:{ subagents: SubAgent[] };tool_infos 项形状统一为 { name, display_name, exists }。
+ * 响应外壳在此拆掉,调用方直接拿到数组,不再各自猜 subagents / data / 裸数组。
+ */
+export const getSubAgents = async (): Promise<SubAgent[]> => {
+  const res = await defHttp.get({ url: Api.subagents }, { isTransformResponse: false });
+  return res?.subagents ?? [];
+};
+
+/**
+ * 获取子智能体表单可选用的工具注册表。
+ * 契约已对真实响应核实:{ groups: SubAgentToolGroup[] };响应中不存在 models 字段。
+ */
+export const getSubAgentTools = async (): Promise<SubAgentToolGroup[]> => {
+  const res = await defHttp.get({ url: `${Api.subagents}/tools` }, { isTransformResponse: false });
+  return res?.groups ?? [];
+};
+
+/**
+ * 创建子智能体
+ */
+export const createSubAgent = (data: {
+  name: string;
+  description: string;
+  system_prompt: string;
+  tools?: string[];
+  model?: string;
+  enabled?: boolean;
+}) => defHttp.post({ url: Api.subagents, data }, { isTransformResponse: false });
+
+/**
+ * 获取子智能体详情(当前无调用方,保留以镜像后端接口)
+ */
+export const getSubAgentDetail = (name: string) => defHttp.get({ url: `${Api.subagents}/${name}` }, { isTransformResponse: false });
+
+/**
+ * 更新子智能体
+ */
+export const updateSubAgent = (
+  name: string,
+  data: { description?: string; system_prompt?: string; tools?: string[]; model?: string; enabled?: boolean }
+) => defHttp.put({ url: `${Api.subagents}/${name}`, data }, { isTransformResponse: false });
+
+/**
+ * 删除子智能体
+ */
+export const deleteSubAgent = (name: string) => defHttp.delete({ url: `${Api.subagents}/${name}` }, { isTransformResponse: false });
+
+/**
+ * 启用/禁用子智能体
+ */
+export const toggleSubAgent = (name: string) => defHttp.post({ url: `${Api.subagents}/${name}/toggle` }, { isTransformResponse: false });

+ 29 - 0
src/views/ventAI/manageAssistent/api/usage.ts

@@ -0,0 +1,29 @@
+// api/usage.ts:今日用量与额度。
+import { defHttp } from '/@/utils/http/axios/index';
+
+enum Api {
+  usageToday = '/ventAI/api/usage/today',
+}
+
+export interface UsageByModel {
+  model: string;
+  cache_hit_tokens: number;
+  cache_miss_tokens: number;
+  output_tokens: number;
+  tokens: number;
+  request_count: number;
+}
+
+export interface TodayUsage {
+  date: string;
+  limit_tokens: number;
+  spent_tokens: number;
+  remaining_tokens: number;
+  request_count: number;
+  by_model: UsageByModel[];
+}
+
+/**
+ * 查询今日用量与额度
+ */
+export const getTodayUsage = () => defHttp.get({ url: Api.usageToday }, { isTransformResponse: false });

+ 44 - 24
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -161,7 +161,7 @@
 <script setup lang="ts">
   import { ref, nextTick, watch, computed, onBeforeUnmount } from 'vue';
   import dayjs from 'dayjs';
-  import { getDetail, deleteSession, deleteMessage, StreamError } from '/@/views/ventAI/manageAssistent/api/client';
+  import { getDetail, deleteSession, deleteMessage, StreamError } from '/@/views/ventAI/manageAssistent/api';
   import { message, Modal } from 'ant-design-vue';
   import TaskListPanel from '/@/views/ventAI/manageAssistent/components/panels/TaskListPanel.vue';
   import ChatHeader from '/@/views/ventAI/manageAssistent/components/chat/ChatHeader.vue';
@@ -173,7 +173,7 @@
   import SubAgentPanel from '/@/views/ventAI/manageAssistent/components/panels/SubAgentPanel.vue';
   import SchedulePanel from '/@/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue';
   import RightPanelTabs from '/@/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue';
-  import { getScheduleList } from '/@/views/ventAI/manageAssistent/api/client';
+  import { getScheduleList } from '/@/views/ventAI/manageAssistent/api';
   import type { Message, Task } from '/@/views/ventAI/manageAssistent/types/index';
   import { buildCapsuleFiles, buildCapsuleLinks, buildCapsuleAgents } from '/@/views/ventAI/manageAssistent/lib/capsule';
   import { useChatStream } from '../hooks/useChatStream';
@@ -284,7 +284,7 @@
     getCurrentTaskId: () => currentTaskId.value,
     switchTask: (id) => switchTask(id),
     setTaskAbortController: (id, controller) => setTaskAbortController(id, controller),
-    handleStreamEvent: (data, aiMsgIndex, taskId) => chatStream.handleEvent(data, aiMsgIndex, taskId),
+    handleStreamEvent: (data, aiMsg, taskId) => chatStream.handleEvent(data, aiMsg, taskId),
     scrollToBottom: (force) => scrollToBottom(force),
     scrollToBottomThrottled: (force) => scrollToBottomThrottled(force),
     refreshUsage: () => fetchTodayUsage(),
@@ -314,17 +314,13 @@
       currentTaskId.value = taskId;
     },
     getMessages: () => messages.value,
-    setMessages: (next) => {
-      messages.value = next;
-    },
     setTaskList: (next) => {
       taskList.value = next;
     },
-    getCurrentSessionId: () => getCurrentSessionId(),
     getEditMode: () => editMode.value,
     getPendingApproval: () => currentPendingApproval.value,
     getChatMessages: () => chatMessagesRef.value,
-    handleStreamEvent: (data, aiMsgIndex, taskId) => chatStream.handleEvent(data, aiMsgIndex, taskId),
+    handleStreamEvent: (data, aiMsg, taskId) => chatStream.handleEvent(data, aiMsg, taskId),
     scrollToBottomThrottled: (force) => scrollToBottomThrottled(force),
     resetTodos: () => {
       showTodoBar.value = false;
@@ -360,6 +356,9 @@
   // 删除单条消息:先删数据库行,后端再清空该会话内存线程,下轮从数据库重建上下文
   const handleDeleteMessage = (msg: Message) => {
     if (!msg.id) return;
+    // 确认框与删除请求都是异步的,用户可能在这期间切到别的会话:记住这条消息所属的任务,
+    // 写回它而不是「当前任务」,否则被删的消息会一直留在原任务里直到刷新。
+    const ownerTask = currentTask.value;
     Modal.confirm({
       title: '提示',
       content: '是否删除该条消息?',
@@ -370,8 +369,9 @@
         try {
           await deleteMessage(msg.id!);
           message.success('消息已删除');
-          // 单一数据源:写回当前任务的数组即可
-          messages.value = messages.value.filter((m) => m !== msg);
+          if (ownerTask) {
+            ownerTask.messages = ownerTask.messages.filter((m) => m !== msg);
+          }
         } catch {
           message.error('删除失败');
         }
@@ -387,8 +387,10 @@
     try {
       const res = await getScheduleList();
       scheduleListForDetail.value = res?.tasks || res?.data || (Array.isArray(res) ? res : []);
-    } catch {
+    } catch (e) {
+      console.error('获取定时任务列表失败:', e);
       scheduleListForDetail.value = [];
+      message.error('获取定时任务列表失败,定时任务详情可能不是最新');
     }
   };
 
@@ -484,6 +486,7 @@
     fetchModelMsg,
     fetchSessionMode,
     fetchContext,
+    invalidateQuotaFetch,
     handleThinkLevelChange,
     handleEditModeChange,
   } = useChatQuota(() => getCurrentSessionId());
@@ -529,7 +532,9 @@
           }
           historyLoadedTasks.add(taskId);
         } catch (error) {
+          // 拉不到历史时界面会是空白会话,容易被误认为「对话丢了」,必须给出提示
           console.error('加载历史会话失败:', error);
+          message.error('加载历史会话失败,请稍后重试');
         }
       }
     }
@@ -538,6 +543,8 @@
       fetchContext(newTask.sessionId);
       fetchSessionMode(newTask.sessionId);
     } else {
+      // 空白会话不拉取额度:作废在途请求,否则上一条会话的响应会盖掉下面刚重置的默认值
+      invalidateQuotaFetch();
       contextUsed.value = 0;
       contextPercent.value = 0;
       contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
@@ -580,6 +587,7 @@
       attachedFiles: [],
     };
 
+    invalidateQuotaFetch();
     contextUsed.value = 0;
     contextPercent.value = 0;
     contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
@@ -662,6 +670,10 @@
     selectedSessions.value = [];
     selectedSkill.value = null;
 
+    // 记住本次发送属于哪个任务:请求期间用户可能切到别的会话,失败时的报错必须落回发起它的任务,
+    // 否则报错气泡会塞进正在看的那条会话,而发起任务的占位消息没人清理(永远停在「正在输入」)。
+    const originTask = currentTask.value;
+
     try {
       await nextTick();
       chatMessagesRef.value?.forceScrollToBottom();
@@ -678,19 +690,25 @@
       message.error(errText);
       if (isStreamError) fetchTodayUsage();
 
-      const lastMsg = messages.value[messages.value.length - 1];
-      if (lastMsg && lastMsg.type === 'ai' && lastMsg.isLoading) {
-        messages.value.pop();
+      // 归位到发起本次请求的任务。任务可能在请求期间被删除,那时只保留上面的提示。
+      if (originTask) {
+        const msgs = originTask.messages;
+        const lastMsg = msgs[msgs.length - 1];
+        if (lastMsg && lastMsg.type === 'ai' && lastMsg.isLoading) {
+          msgs.pop();
+        }
+        msgs.push({
+          type: 'ai',
+          content: isStreamError ? error.message : '抱歉,请求失败,请稍后重试。',
+          time: dayjs().format('HH:mm'),
+        });
       }
-
-      const errorMsg: Message = {
-        type: 'ai',
-        content: isStreamError ? error.message : '抱歉,请求失败,请稍后重试。',
-        time: dayjs().format('HH:mm'),
-      };
-      messages.value.push(errorMsg);
     } finally {
-      await scrollToBottom(true);
+      // 自动滚到底只对「用户仍停在这条任务上」有意义,否则会把正在看的别的会话拽到底部。
+      // 按对象身份比较:任务在拿到真实 session_id 后会被改名(id 会变),用 id 比较会误判。
+      if (currentTask.value === originTask) {
+        await scrollToBottom(true);
+      }
     }
   };
 
@@ -712,8 +730,8 @@
     markPendingApproval: (taskId, sessionId, threadId) => {
       setTaskPendingApproval(taskId, sessionId, threadId);
     },
-    initAskUserAnswers: (aiMsgIndex, questions) => {
-      chatMessagesRef.value?.initAskUserAnswers(aiMsgIndex, questions);
+    initAskUserAnswers: (aiMsg, questions) => {
+      chatMessagesRef.value?.initAskUserAnswers(aiMsg, questions);
     },
   });
 
@@ -773,6 +791,7 @@
             historyLoadedTasks.add(targetTask.id);
           } catch (error) {
             console.error('加载历史会话失败:', error);
+            message.error('加载历史会话失败,请稍后重试');
           }
         }
 
@@ -780,6 +799,7 @@
           fetchContext(targetTask.sessionId);
           fetchSessionMode(targetTask.sessionId);
         } else {
+          invalidateQuotaFetch();
           contextUsed.value = 0;
           contextPercent.value = 0;
           contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };

+ 11 - 4
src/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue

@@ -40,7 +40,7 @@
       placeholder="请输入你的问题"
       :auto-size="{ minRows: 3, maxRows: 6 }"
       @keydown="handleSessionKeydown"
-      @keydown.enter.prevent="handleEnterKey"
+      @keydown.enter="handleEnterKey"
       @keydown.backspace="handleBackspace"
       @compositionstart="handleCompositionStart"
       @compositionend="handleCompositionEnd"
@@ -100,6 +100,7 @@
         :list="filteredSkillList"
         :active-index="activeSkillIndex"
         :selected-name="selectedSkill ? selectedSkill.name : ''"
+        :load-failed="skillListLoadFailed"
         @select="handleSelectSkill"
         @close="closeSkillPopup"
       />
@@ -119,7 +120,7 @@
   import SessionPopup from '/@/views/ventAI/manageAssistent/components/chat/Input/SessionPopup.vue';
   import SkillPopup from '/@/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue';
   import { useMentionPopups } from '/@/views/ventAI/manageAssistent/hooks/useMentionPopups';
-  import type { UsageByModel } from '/@/views/ventAI/manageAssistent/api/client';
+  import type { UsageByModel } from '/@/views/ventAI/manageAssistent/api';
   import type { AttachedFile, Task } from '/@/views/ventAI/manageAssistent/types/index';
 
   const props = withDefaults(
@@ -204,6 +205,7 @@
     selectedSkill,
     filteredSessionList,
     filteredSkillList,
+    skillListLoadFailed,
     handleSessionKeydown,
     handleSelectSession,
     handleSelectSkill,
@@ -251,11 +253,16 @@
   };
 
   const handleEnterKey = (e: KeyboardEvent) => {
-    // 输入法组合态下的回车只用于上屏候选词,不能触发发送
+    // 输入法组合态下的回车只用于上屏候选词:既不发送,也不阻止默认行为(否则会打断候选上屏)
     if (composing || e.isComposing) return;
+    // 弹层里被消费的回车由 useMentionPopups 自己 preventDefault,这里不再插手
     if (showSessionPopup.value || showSkillPopup.value) return;
+    // Shift+Enter 留给多行输入:不阻止默认行为,让 textarea 正常插入换行
+    if (e.shiftKey) return;
+    // 其余情况沿用「回车不插入换行」:不能发送(内容为空 / 生成中)时也保持这一行为
+    e.preventDefault();
     if (consumeJustSelected()) return;
-    if (!e.shiftKey && props.modelValue.trim() && !props.loading) {
+    if (props.modelValue.trim() && !props.loading) {
       emit('send');
     }
   };

+ 12 - 61
src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue

@@ -163,19 +163,15 @@
     getFileIconVar,
     getCssUrl,
     getMessageKey,
+    getTableIcons,
+    copyToClipboard,
+    flashCopySuccess,
   } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AgentGroup } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AgentDetailData, AskUserQuestion, Message, AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
   export type { AgentDetailData };
-  // import katex from 'katex';
-  // import 'katex/dist/katex.min.css';
-
-  const tableIcons = {
-    copy: getCssUrl('--img-chat-copy-icon'),
-    download: getCssUrl('--img-chat-download-icon-btn'),
-    preview: getCssUrl('--img-chat-preview-icon'),
-    copySuccess: getCssUrl('--img-chat-copy-success-icon'),
-  };
+
+  const tableIcons = getTableIcons();
 
   const props = defineProps<{
     messages: Message[];
@@ -363,18 +359,16 @@
   };
 
   // 点击文件卡片:按类型拉取内容并在右侧辅助面板打开预览标签
-  // ask_user 答案状态(按消息索引存储)
-  // 审批卡实例按消息对象索引:SSE 侧拿到消息下标后据此路由到对应卡片(卡片自身也会在 data 变化时重建答案)
+  // ask_user 答案状态(按消息对象索引)
+  // 审批卡实例按消息对象索引:SSE 侧直接以消息对象路由到对应卡片(卡片自身也会在 data 变化时重建答案)
   const askCardRefs = new Map<Message, InstanceType<typeof AskUserCard>>();
   const setAskCardRef = (msg: Message, el: unknown) => {
     if (el) askCardRefs.set(msg, el as InstanceType<typeof AskUserCard>);
     else askCardRefs.delete(msg);
   };
 
-  const initAskUserAnswers = (index: number, questions: AskUserQuestion[]) => {
-    const msg = props.messages[index];
-    const card = msg ? askCardRefs.get(msg) : undefined;
-    card?.init(questions);
+  const initAskUserAnswers = (msg: Message, questions: AskUserQuestion[]) => {
+    askCardRefs.get(msg)?.init(questions);
   };
 
   const messagesRef = ref<HTMLElement>();
@@ -397,38 +391,7 @@
 
     switch (action) {
       case 'copy': {
-        const markdown = tableHtmlToMarkdown(tableHtml);
-        const doCopy = async () => {
-          try {
-            await navigator.clipboard.writeText(markdown);
-          } catch {
-            const ta = document.createElement('textarea');
-            ta.value = markdown;
-            ta.style.position = 'fixed';
-            ta.style.opacity = '0';
-            document.body.appendChild(ta);
-            ta.select();
-            document.execCommand('copy');
-            document.body.removeChild(ta);
-          }
-        };
-        doCopy().then(() => {
-          const btn = target;
-          const img = btn.querySelector('img');
-          const successIcon = btn.dataset.iconSuccess;
-          const originalIcon = btn.dataset.icon;
-          if (img && successIcon) {
-            img.src = successIcon;
-            btn.title = '复制成功';
-            const reset = () => {
-              img.src = originalIcon!;
-              btn.title = '复制Markdown';
-              wrapper.removeEventListener('mouseleave', reset);
-            };
-            const wrapper = btn.closest('.markdown-table-wrapper')!;
-            wrapper.addEventListener('mouseleave', reset);
-          }
-        });
+        copyToClipboard(tableHtmlToMarkdown(tableHtml)).then(() => flashCopySuccess(target));
         break;
       }
       case 'csv': {
@@ -509,20 +472,8 @@
 
   const copyMessageContent = async (content: string, messageKey: string) => {
     if (!content) return;
-    try {
-      await navigator.clipboard.writeText(content);
-      copiedMessageKey.value = messageKey;
-    } catch {
-      const textarea = document.createElement('textarea');
-      textarea.value = content;
-      textarea.style.position = 'fixed';
-      textarea.style.opacity = '0';
-      document.body.appendChild(textarea);
-      textarea.select();
-      document.execCommand('copy');
-      document.body.removeChild(textarea);
-      copiedMessageKey.value = messageKey;
-    }
+    await copyToClipboard(content);
+    copiedMessageKey.value = messageKey;
   };
 
   // 仅滚动消息列表容器:scrollIntoView 会联动滚动 #adaptive-container 等祖先,导致整页上移、底部露出空白

+ 3 - 1
src/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue

@@ -22,7 +22,7 @@
         <div class="skill-item-icon"></div>
         <span class="skill-item-name">{{ skill.display_name }}</span>
       </div>
-      <div v-if="list.length === 0" class="skill-empty">暂无匹配技能</div>
+      <div v-if="list.length === 0" class="skill-empty">{{ loadFailed ? '技能列表加载失败,请稍后重试' : '暂无匹配技能' }}</div>
     </div>
     <div class="skill-popup-footer">
       <span>点击选择技能(仅可选1个)</span>
@@ -43,6 +43,8 @@
     list: SkillOption[];
     activeIndex: number;
     selectedName: string;
+    /** 技能列表是否加载失败:失败时空态文案要与「确实没有技能」区分开 */
+    loadFailed?: boolean;
   }>();
 
   const emit = defineEmits<{ (e: 'select', skill: SkillOption): void; (e: 'close'): void }>();

+ 1 - 1
src/views/ventAI/manageAssistent/components/chat/Input/UsageRing.vue

@@ -57,7 +57,7 @@
 <script setup lang="ts">
   import { computed, ref } from 'vue';
   import { onClickOutside } from '@vueuse/core';
-  import type { UsageByModel } from '/@/views/ventAI/manageAssistent/api/client';
+  import type { UsageByModel } from '/@/views/ventAI/manageAssistent/api';
   import { formatTokens } from '/@/views/ventAI/manageAssistent/lib/index';
 
   /** 今日用量环 + 明细弹层。额度数值来自父组件,弹层开关是本组件内部状态。 */

+ 1 - 5
src/views/ventAI/manageAssistent/components/chat/TodoListBar.vue

@@ -20,6 +20,7 @@
 
 <script setup lang="ts">
   import { ref, computed, onMounted } from 'vue';
+  import { getCssUrl } from '/@/views/ventAI/manageAssistent/lib/index';
 
   const props = defineProps<{
     todos: Array<{ content: string; status: string }>;
@@ -29,11 +30,6 @@
 
   const completedCount = computed(() => props.todos.filter((t) => t.status === 'completed').length);
 
-  const getCssUrl = (varName: string) => {
-    const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
-    return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
-  };
-
   const arrowIcon = ref('');
   onMounted(() => {
     arrowIcon.value = getCssUrl('--img-chat-arrow-icon');

+ 4 - 13
src/views/ventAI/manageAssistent/components/modals/CreateScheduleModal.vue

@@ -103,19 +103,11 @@
 
 <script setup lang="ts">
   import { ref, reactive, computed, watch } from 'vue';
-  import { createSchedule, updateSchedule } from '/@/views/ventAI/manageAssistent/api/client';
+  import { createSchedule, updateSchedule, extractApiErrorMessage } from '/@/views/ventAI/manageAssistent/api';
   import { message } from 'ant-design-vue';
+  import type { ScheduleFormData } from '/@/views/ventAI/manageAssistent/types/index';
 
-  interface ScheduleData {
-    id: number;
-    name: string;
-    cron_expr: string;
-    prompt: string;
-    notify_webhook?: string;
-    schedule?: { freq: string; hour?: number; minute?: number; day_of_week?: number; day_of_month?: number; human?: string };
-  }
-
-  const props = defineProps<{ visible: boolean; editData?: ScheduleData | null }>();
+  const props = defineProps<{ visible: boolean; editData?: ScheduleFormData | null }>();
   const emit = defineEmits<{
     (e: 'close'): void;
     (e: 'created'): void;
@@ -283,8 +275,7 @@
       }
       emit('close');
     } catch (err: any) {
-      const msg = err?.response?.data?.message || err?.message || (isEdit.value ? '更新失败,请重试' : '创建失败,请重试');
-      message.error(msg);
+      message.error(extractApiErrorMessage(err, isEdit.value ? '更新失败,请重试' : '创建失败,请重试'));
     } finally {
       submitting.value = false;
     }

+ 12 - 36
src/views/ventAI/manageAssistent/components/modals/CreateSubAgentModal.vue

@@ -76,7 +76,9 @@
         </div>
       </div>
 
-      <!-- <div class="form-item">
+      <!-- 模型下拉已停用:工具注册表接口不返回 models 字段(已核实),恢复前需先确定模型列表来源。
+           form.model 仍从 editData 回填并原样回传,不编辑即可保持原值。
+      <div class="form-item">
         <label class="form-label">模型</label>
         <a-select v-model:value="form.model" class="form-select" placeholder="留空则跟随对话助手当前模型" allow-clear>
           <a-select-option v-for="m in modelList" :key="m" :value="m">{{ m }}</a-select-option>
@@ -102,32 +104,11 @@
 
 <script setup lang="ts">
   import { ref, reactive, computed, watch } from 'vue';
-  import { createSubAgent, updateSubAgent, getSubAgentTools } from '/@/views/ventAI/manageAssistent/api/client';
+  import { createSubAgent, updateSubAgent, getSubAgentTools, extractApiErrorMessage } from '/@/views/ventAI/manageAssistent/api';
   import { message } from 'ant-design-vue';
+  import type { SubAgentFormData, SubAgentToolGroup } from '/@/views/ventAI/manageAssistent/types/index';
 
-  interface ToolInfo {
-    name: string;
-    display_name: string;
-    description?: string;
-  }
-
-  interface ToolGroup {
-    key: string;
-    display: string;
-    tools: ToolInfo[];
-  }
-
-  interface SubAgentData {
-    name: string;
-    description: string;
-    system_prompt: string;
-    tools: string[];
-    tool_infos?: ToolInfo[];
-    model: string;
-    enabled: boolean;
-  }
-
-  const props = defineProps<{ visible: boolean; editData?: SubAgentData | null }>();
+  const props = defineProps<{ visible: boolean; editData?: SubAgentFormData | null }>();
   const emit = defineEmits<{
     (e: 'close'): void;
     (e: 'created'): void;
@@ -136,8 +117,7 @@
 
   const submitting = ref(false);
   const toolsLoading = ref(false);
-  const toolGroups = ref<ToolGroup[]>([]);
-  const modelList = ref<string[]>([]);
+  const toolGroups = ref<SubAgentToolGroup[]>([]);
   const activeGroupKey = ref('');
   const toolSearch = ref('');
 
@@ -166,15 +146,12 @@
   const fetchToolsAndModels = async () => {
     toolsLoading.value = true;
     try {
-      const res = await getSubAgentTools();
-      toolGroups.value = res?.groups || [];
-      modelList.value = res?.models || [];
+      toolGroups.value = await getSubAgentTools();
       if (toolGroups.value.length > 0 && !activeGroupKey.value) {
         activeGroupKey.value = toolGroups.value[0].key;
       }
     } catch {
       toolGroups.value = [];
-      modelList.value = [];
     } finally {
       toolsLoading.value = false;
     }
@@ -202,7 +179,7 @@
     }
   );
 
-  const getGroupSelectedCount = (group: ToolGroup) => {
+  const getGroupSelectedCount = (group: SubAgentToolGroup) => {
     return group.tools.filter((t) => form.tools.includes(t.name)).length;
   };
 
@@ -215,7 +192,7 @@
     }
   };
 
-  const selectAllInGroup = (group: ToolGroup) => {
+  const selectAllInGroup = (group: SubAgentToolGroup) => {
     for (const tool of group.tools) {
       if (!form.tools.includes(tool.name)) {
         form.tools.push(tool.name);
@@ -223,7 +200,7 @@
     }
   };
 
-  const clearGroupTools = (group: ToolGroup) => {
+  const clearGroupTools = (group: SubAgentToolGroup) => {
     const names = new Set(group.tools.map((t) => t.name));
     form.tools = form.tools.filter((t) => !names.has(t));
   };
@@ -264,8 +241,7 @@
       }
       emit('close');
     } catch (err: any) {
-      const msg = err?.response?.data?.message || err?.message || (isEdit.value ? '更新失败,请重试' : '创建失败,请重试');
-      message.error(msg);
+      message.error(extractApiErrorMessage(err, isEdit.value ? '更新失败,请重试' : '创建失败,请重试'));
     } finally {
       submitting.value = false;
     }

+ 2 - 11
src/views/ventAI/manageAssistent/components/modals/SkillModal.vue

@@ -50,19 +50,10 @@
 
 <script setup lang="ts">
   import { ref, reactive, computed, watch } from 'vue';
-  import { getSkills, toggleSkill, getSkillDetails } from '/@/views/ventAI/manageAssistent/api/client';
+  import { getSkills, toggleSkill, getSkillDetails } from '/@/views/ventAI/manageAssistent/api';
   import { renderMarkdown } from '/@/views/ventAI/manageAssistent/lib/index';
   import { message } from 'ant-design-vue';
-
-  interface Skill {
-    name: string;
-    display_name: string;
-    path: string;
-    description: string;
-    enabled: boolean;
-    agents: string[];
-    has_skill_md: boolean;
-  }
+  import type { Skill } from '/@/views/ventAI/manageAssistent/types/index';
 
   const props = defineProps<{ visible: boolean }>();
   const emit = defineEmits<{ (e: 'close'): void }>();

+ 25 - 10
src/views/ventAI/manageAssistent/components/panels/ScheduleDetailPanel.vue

@@ -89,7 +89,7 @@
   import { ref, watch } from 'vue';
   import { onClickOutside } from '@vueuse/core';
   import { message, Modal } from 'ant-design-vue';
-  import { getScheduleRuns, pauseSchedule, resumeSchedule, deleteSchedule } from '/@/views/ventAI/manageAssistent/api/client';
+  import { getScheduleRuns, pauseSchedule, resumeSchedule, deleteSchedule } from '/@/views/ventAI/manageAssistent/api';
   import CreateScheduleModal from '/@/views/ventAI/manageAssistent/components/modals/CreateScheduleModal.vue';
   import type { ScheduleTask } from '/@/views/ventAI/manageAssistent/types/index';
 
@@ -119,22 +119,25 @@
   });
 
   const handleToggle = async () => {
-    if (!props.task) return;
+    // 锚定点击时的任务:请求期间可能切到别的标签,await 之后再读 props.task 会翻转另一个任务的开关
+    const task = props.task;
+    if (!task) return;
     try {
-      if (props.task.enabled) {
-        await pauseSchedule(props.task.id);
-        message.success(`已暂停任务「${props.task.name}」`);
+      if (task.enabled) {
+        await pauseSchedule(task.id);
+        message.success(`已暂停任务「${task.name}」`);
       } else {
-        await resumeSchedule(props.task.id);
-        message.success(`已恢复任务「${props.task.name}」`);
+        await resumeSchedule(task.id);
+        message.success(`已恢复任务「${task.name}」`);
       }
-      props.task.enabled = !props.task.enabled; // eslint-disable-line vue/no-mutating-props
+      task.enabled = !task.enabled; // eslint-disable-line vue/no-mutating-props
       emit('updated');
     } catch {
       message.error('操作失败');
     }
   };
 
+  // 同 SchedulePanel:「立即执行」刻意只打开反馈会话,不调用 runScheduleNow(产品选择,勿改)
   const handleRunNow = async () => {
     if (!props.task || !props.task.enabled) return;
     if (props.task.feedback_session_id) {
@@ -187,11 +190,18 @@
   const historyList = ref<any[]>([]);
   const historyLoading = ref(false);
   const historyStats = ref<{ total: number; success: number; failed: number } | null>(null);
+  // 序号:只采纳最后一次发出的请求的响应(见 fetchHistory 注释)
+  let historySeq = 0;
 
+  // 组件实例会在多个标签/任务之间复用(RightPanelTabs 渲染本组件时没加 :key),切走后旧请求可能后到,
+  // 把上一条任务的执行记录与统计写到当前面板上——现象是标题已是新任务、记录却还是旧任务的。
+  // 用序号只采纳「最后一次发出的请求」的响应,与 useChatQuota 里同类竞态的处理方式一致。
   const fetchHistory = async (taskId: number) => {
+    const seq = ++historySeq;
     historyLoading.value = true;
     try {
       const res = await getScheduleRuns(taskId);
+      if (seq !== historySeq) return;
       const runs = res?.runs || (Array.isArray(res) ? res : []);
       historyList.value = runs;
       historyStats.value = {
@@ -199,11 +209,16 @@
         success: runs.filter((r: any) => r.status === 'success').length,
         failed: runs.filter((r: any) => r.status === 'failed').length,
       };
-    } catch {
+    } catch (e) {
+      if (seq !== historySeq) return;
+      // 失败不能只清空:用户会把「后端挂了」看成「没有执行记录」
+      console.error('获取执行记录失败:', e);
       historyList.value = [];
       historyStats.value = null;
+      message.error('获取执行记录失败,请稍后重试');
     } finally {
-      historyLoading.value = false;
+      // 只有仍是最新请求时才收尾 loading,否则会把后发请求的 loading 提前关掉
+      if (seq === historySeq) historyLoading.value = false;
     }
   };
 

+ 17 - 21
src/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue

@@ -111,25 +111,14 @@
 <script setup lang="ts">
   import { ref, reactive, computed, onMounted } from 'vue';
   import { PlusOutlined } from '@ant-design/icons-vue';
-  import { getScheduleList, deleteSchedule, pauseSchedule, resumeSchedule, getScheduleRuns } from '/@/views/ventAI/manageAssistent/api/client';
+  import { getScheduleList, deleteSchedule, pauseSchedule, resumeSchedule, getScheduleRuns } from '/@/views/ventAI/manageAssistent/api';
   import { message, Modal } from 'ant-design-vue';
   import CreateScheduleModal from '/@/views/ventAI/manageAssistent/components/modals/CreateScheduleModal.vue';
+  import type { ScheduleTask } from '/@/views/ventAI/manageAssistent/types/index';
 
   const emit = defineEmits<{ (e: 'close'): void; (e: 'open-session', sessionId: string, prompt: string): void; (e: 'data-changed'): void }>();
 
-  interface ScheduleItem {
-    id: number;
-    name: string;
-    cron_expr: string;
-    cron_human: string;
-    prompt: string;
-    enabled: boolean;
-    next_run_at: string | null;
-    feedback_session_id?: string;
-    schedule?: { freq: string; human?: string };
-  }
-
-  const scheduleList = ref<ScheduleItem[]>([]);
+  const scheduleList = ref<ScheduleTask[]>([]);
   const loading = ref(false);
   const searchKeyword = ref('');
   const filterStatus = ref<'all' | 'enabled' | 'disabled'>('all');
@@ -143,7 +132,7 @@
     });
   });
   const showCreateModal = ref(false);
-  const editData = ref<ScheduleItem | null>(null);
+  const editData = ref<ScheduleTask | null>(null);
   const expandedIds = ref(new Set<number>());
   const historyMap = reactive<Record<number, any[]>>({});
   const historyLoadingMap = reactive<Record<number, boolean>>({});
@@ -154,8 +143,11 @@
     try {
       const res = await getScheduleList();
       scheduleList.value = res?.tasks || res?.data || (Array.isArray(res) ? res : []);
-    } catch {
+    } catch (e) {
+      // 失败不能只留空列表:用户会把「后端挂了」看成「一条定时任务都没有」
+      console.error('获取定时任务列表失败:', e);
       scheduleList.value = [];
+      message.error('获取定时任务列表失败,请稍后重试');
     } finally {
       loading.value = false;
     }
@@ -171,12 +163,12 @@
     showCreateModal.value = true;
   };
 
-  const handleEdit = (item: ScheduleItem) => {
+  const handleEdit = (item: ScheduleTask) => {
     editData.value = item;
     showCreateModal.value = true;
   };
 
-  const handleToggle = async (item: ScheduleItem) => {
+  const handleToggle = async (item: ScheduleTask) => {
     try {
       if (item.enabled) {
         await pauseSchedule(item.id);
@@ -192,7 +184,7 @@
     }
   };
 
-  const handleDelete = (item: ScheduleItem) => {
+  const handleDelete = (item: ScheduleTask) => {
     Modal.confirm({
       title: '提示',
       content: `是否删除定时任务「${item.name}」?`,
@@ -211,7 +203,9 @@
     });
   };
 
-  const handleRunNow = async (item: ScheduleItem) => {
+  // 「立即执行」的当前设计:打开该任务的反馈会话,不调用后端的立即执行接口
+  // (api/schedule.ts 里的 runScheduleNow 保留但未使用)。这是产品上的刻意选择,不要改成调 runScheduleNow。
+  const handleRunNow = async (item: ScheduleTask) => {
     if (!item.enabled) return;
     if (item.feedback_session_id) {
       emit('open-session', item.feedback_session_id, item.prompt);
@@ -237,8 +231,10 @@
           success: runs.filter((r: any) => r.status === 'success').length,
           failed: runs.filter((r: any) => r.status === 'failed').length,
         };
-      } catch {
+      } catch (e) {
+        console.error('获取执行记录失败:', e);
         historyMap[taskId] = [];
+        message.error('获取执行记录失败,请稍后重试');
       } finally {
         historyLoadingMap[taskId] = false;
       }

+ 8 - 12
src/views/ventAI/manageAssistent/components/panels/SkillPanel.vue

@@ -57,19 +57,10 @@
 
 <script setup lang="ts">
   import { ref, reactive, computed } from 'vue';
-  import { getSkills, toggleSkill, getSkillDetails } from '/@/views/ventAI/manageAssistent/api/client';
+  import { getSkills, toggleSkill, getSkillDetails } from '/@/views/ventAI/manageAssistent/api';
   import { renderMarkdown } from '/@/views/ventAI/manageAssistent/lib/index';
   import { message } from 'ant-design-vue';
-
-  interface Skill {
-    name: string;
-    display_name: string;
-    path: string;
-    description: string;
-    enabled: boolean;
-    agents: string[];
-    has_skill_md: boolean;
-  }
+  import type { Skill } from '/@/views/ventAI/manageAssistent/types/index';
 
   const emit = defineEmits<{ (e: 'close'): void }>();
 
@@ -94,8 +85,11 @@
     try {
       const res = await getSkills();
       skills.value = res?.skills || [];
-    } catch {
+    } catch (e) {
+      // 失败不能只留空列表:用户会把「后端挂了」看成「一个技能都没有」
+      console.error('获取技能列表失败:', e);
       skills.value = [];
+      message.error('获取技能列表失败,请稍后重试');
     } finally {
       loading.value = false;
     }
@@ -111,6 +105,8 @@
     }
   };
 
+  // 技能详情展开是刻意停用的(产品决定):列表入口保留,点击不响应。
+  // 恢复时删掉下面这行 return 即可——后续逻辑与模板(skill-detail 块)都是完整的。
   const handleSkillClick = async (skill: Skill) => {
     return;
     if (skillDetailMap[skill.name]) {

+ 7 - 22
src/views/ventAI/manageAssistent/components/panels/SubAgentPanel.vue

@@ -86,27 +86,10 @@
 <script setup lang="ts">
   import { ref, computed } from 'vue';
   import { PlusOutlined } from '@ant-design/icons-vue';
-  import { getSubAgents, deleteSubAgent, toggleSubAgent } from '/@/views/ventAI/manageAssistent/api/client';
+  import { getSubAgents, deleteSubAgent, toggleSubAgent } from '/@/views/ventAI/manageAssistent/api';
   import { message, Modal } from 'ant-design-vue';
   import CreateSubAgentModal from '/@/views/ventAI/manageAssistent/components/modals/CreateSubAgentModal.vue';
-
-  interface ToolInfo {
-    name: string;
-    display_name: string;
-    exists: boolean;
-  }
-
-  interface SubAgent {
-    name: string;
-    description: string;
-    system_prompt: string;
-    tools: string[];
-    tool_infos: ToolInfo[];
-    model: string;
-    enabled: boolean;
-    created_at: string;
-    updated_at: string;
-  }
+  import type { SubAgent } from '/@/views/ventAI/manageAssistent/types/index';
 
   const emit = defineEmits<{ (e: 'close'): void }>();
 
@@ -130,10 +113,12 @@
   const fetchSubAgents = async () => {
     loading.value = true;
     try {
-      const res = await getSubAgents();
-      agents.value = res?.subagents || res?.data || [];
-    } catch {
+      agents.value = await getSubAgents();
+    } catch (e) {
+      // 失败不能只留空列表:用户会把「后端挂了」看成「一个子智能体都没有」
+      console.error('获取子智能体列表失败:', e);
       agents.value = [];
+      message.error('获取子智能体列表失败,请稍后重试');
     } finally {
       loading.value = false;
     }

+ 0 - 4
src/views/ventAI/manageAssistent/components/panels/TaskListPanel.vue

@@ -74,7 +74,6 @@
 
   type OptionType = 'add' | 'search' | 'skill' | 'schedules' | 'sub-agents';
 
-  const activeOption = ref<OptionType>('add');
   const searchKeyword = ref('');
 
   const pinnedTaskList = computed(() => {
@@ -127,9 +126,6 @@
       emit('toggle-sub-agents');
     } else if (type === 'schedules') {
       emit('toggle-schedules');
-    } else {
-      activeOption.value = type;
-      searchKeyword.value = '';
     }
   };
 </script>

+ 32 - 58
src/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue

@@ -41,10 +41,10 @@
           <div class="no-preview-hint">请将文件转换为 .docx 格式后重新上传</div>
         </div>
         <div v-else-if="isWordFile(file.name)" ref="wordPreviewRef" class="word-preview">
-          <div v-if="file.arrayBuffer" ref="docxScaleBoxRef" class="docx-scale-box">
+          <div v-if="file.arrayBuffer && !wordPreviewFailed" ref="docxScaleBoxRef" class="docx-scale-box">
             <VueOfficeDocx :src="file.arrayBuffer" @rendered="handleRendered" @error="handleError" />
           </div>
-          <div v-else class="no-preview-text">无法加载Word文档</div>
+          <div v-else class="no-preview-text">{{ wordPreviewFailed ? 'Word 文档渲染失败(文件可能已损坏)' : '无法加载Word文档' }}</div>
         </div>
         <div v-else-if="isExcelFile(file.name)" class="excel-preview">
           <div v-if="excelSheets.length > 1" class="excel-tabs">
@@ -67,7 +67,7 @@
               </tbody>
             </table>
           </div>
-          <div v-else class="no-preview-text">Excel文件内容为空</div>
+          <div v-else class="no-preview-text">{{ excelParseFailed ? 'Excel 解析失败(文件可能已损坏或格式不受支持)' : 'Excel文件内容为空' }}</div>
         </div>
         <div v-else-if="isCsvFile(file.name)" class="csv-preview">
           <table v-if="csvRows.length > 0" class="csv-table">
@@ -103,7 +103,7 @@
 <script setup lang="ts">
   import { ref, computed, watch, onUnmounted, defineAsyncComponent } from 'vue';
   import { message } from 'ant-design-vue';
-  import { saveReportText } from '/@/views/ventAI/manageAssistent/api/client';
+  import { saveReportText } from '/@/views/ventAI/manageAssistent/api';
   import {
     isImageFile,
     isPdfFile,
@@ -117,6 +117,10 @@
     getFileIconVar,
     renderMarkdown,
     wrapMarkdownCodeBlocks,
+    getCssUrl,
+    getTableIcons,
+    copyToClipboard,
+    flashCopySuccess,
   } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
 
@@ -124,17 +128,7 @@
   const VueOfficeDocx = defineAsyncComponent(() => import('@vue-office/docx/lib/v3/vue-office-docx.mjs'));
   // [perf-base-v1] 卡2c: xlsx 改为 parseExcel 内 await import 按需加载(见下)
 
-  const getCssUrl = (varName: string) => {
-    const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
-    return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
-  };
-
-  const tableIcons = {
-    copy: getCssUrl('--img-chat-copy-icon'),
-    download: getCssUrl('--img-chat-download-icon-btn'),
-    preview: getCssUrl('--img-chat-preview-icon'),
-    copySuccess: getCssUrl('--img-chat-copy-success-icon'),
-  };
+  const tableIcons = getTableIcons();
 
   const props = withDefaults(
     defineProps<{
@@ -182,29 +176,35 @@
     }
   };
 
-  // 文件内容更新后(保存成功),自动退出编辑模式
-  watch(
-    () => props.file.content,
-    () => {
-      if (isEditing.value) {
-        isEditing.value = false;
-        editContent.value = '';
-        saving.value = false;
-      }
+  // 退出编辑态的两种情形:
+  // 1) 保存成功后文件内容被替换(同一文件对象上的 content 变化);
+  // 2) 切换到另一个文件标签页——RightPanelTabs 渲染本组件时没有加 :key,两个文件标签复用同一个
+  //    实例,如果丢掉这一条,isEditing/editContent(上一个文件的草稿)会留下来,此时 saveEdit 用的是
+  //    新的 saveFilename + 旧的 editContent,会把 A 的内容写进 B 的文件名,属于静默覆盖文件。
+  watch([() => props.file, () => props.file.content], () => {
+    if (isEditing.value) {
+      isEditing.value = false;
+      editContent.value = '';
+      saving.value = false;
     }
-  );
+  });
 
   // Excel 解析
   const excelSheets = ref<{ name: string; data: string[][] }[]>([]);
   const activeSheet = ref(0);
   const excelLoading = ref(false);
 
+  // 失败态:没有它的话「解析/渲染失败」会被显示成「内容为空」,把人误导到文件本身
+  const excelParseFailed = ref(false);
+  const wordPreviewFailed = ref(false);
+
   const currentSheetData = computed(() => {
     return excelSheets.value[activeSheet.value]?.data || [];
   });
 
   const parseExcel = async (file: AttachedFile) => {
     excelLoading.value = true;
+    excelParseFailed.value = false;
     try {
       let buffer = file.arrayBuffer;
       if (!buffer && file.originalFile) {
@@ -223,6 +223,7 @@
     } catch (err) {
       console.error('Excel解析失败:', err);
       excelSheets.value = [];
+      excelParseFailed.value = true;
     } finally {
       excelLoading.value = false;
     }
@@ -231,6 +232,9 @@
   watch(
     () => props.file,
     (file) => {
+      // 换文件时先清掉上一个文件的失败态,否则错误提示会跟着新文件一起显示
+      excelParseFailed.value = false;
+      wordPreviewFailed.value = false;
       if (isExcelFile(file.name)) {
         parseExcel(file);
       }
@@ -250,38 +254,6 @@
 
   // Markdown 代码块工具栏操作(复制/下载/预览)
 
-  const copyToClipboard = async (text: string) => {
-    try {
-      await navigator.clipboard.writeText(text);
-    } catch {
-      const ta = document.createElement('textarea');
-      ta.value = text;
-      ta.style.position = 'fixed';
-      ta.style.opacity = '0';
-      document.body.appendChild(ta);
-      ta.select();
-      document.execCommand('copy');
-      document.body.removeChild(ta);
-    }
-  };
-
-  const showCopySuccess = (btn: HTMLElement) => {
-    const img = btn.querySelector('img');
-    const successIcon = btn.dataset.iconSuccess;
-    const originalIcon = btn.dataset.icon;
-    if (img && successIcon) {
-      img.src = successIcon;
-      btn.title = '复制成功';
-      const wrapper = btn.closest('.markdown-code-wrapper')!;
-      const reset = () => {
-        img.src = originalIcon!;
-        btn.title = '复制内容';
-        wrapper.removeEventListener('mouseleave', reset);
-      };
-      wrapper.addEventListener('mouseleave', reset);
-    }
-  };
-
   const handleTableAction = (e: Event) => {
     const target = (e.target as HTMLElement).closest('.table-action-btn') as HTMLElement;
     if (!target) return;
@@ -294,7 +266,7 @@
 
     switch (action) {
       case 'copy-md': {
-        copyToClipboard(content).then(() => showCopySuccess(target));
+        copyToClipboard(content).then(() => flashCopySuccess(target));
         break;
       }
       case 'download-md': {
@@ -388,6 +360,8 @@
 
   const handleError = (err: any) => {
     console.error('文件预览失败:', err);
+    // 之前只打日志:渲染失败的 docx 会留下一片空白面板,看不出是坏了还是本来就没内容
+    wordPreviewFailed.value = true;
   };
 </script>
 

+ 4 - 6
src/views/ventAI/manageAssistent/hooks/useChatApproval.ts

@@ -1,7 +1,7 @@
 import { computed, ref } from 'vue';
 import { message } from 'ant-design-vue';
-import { chatResumeStream, StreamError } from '/@/views/ventAI/manageAssistent/api/client';
-import type { SseEvent, Task } from '/@/views/ventAI/manageAssistent/types/index';
+import { chatResumeStream, StreamError } from '/@/views/ventAI/manageAssistent/api';
+import type { Message, SseEvent, Task } from '/@/views/ventAI/manageAssistent/types/index';
 
 /**
  * 待审批(ask_user 中断)流程:按任务登记审批信息、提交答案续跑、取消提问。
@@ -14,7 +14,7 @@ export interface ChatApprovalDeps {
   getCurrentTaskId: () => string;
   switchTask: (taskId: string) => void | Promise<void>;
   setTaskAbortController: (taskId: string, controller: AbortController | null) => void;
-  handleStreamEvent: (data: SseEvent, aiMsgIndex: number, taskId: string) => void;
+  handleStreamEvent: (data: SseEvent, aiMsg: Message, taskId: string) => void;
   scrollToBottom: (force?: boolean) => Promise<void>;
   scrollToBottomThrottled: (force?: boolean) => void;
   refreshUsage: () => void;
@@ -116,8 +116,6 @@ export const useChatApproval = (deps: ChatApprovalDeps) => {
     lastMsg.isLoading = true;
     lastMsg.generateStartTime = Date.now();
 
-    const aiMsgIndex = sourceArr.length - 1;
-
     task.isStreaming = true;
     resetTodos();
 
@@ -134,7 +132,7 @@ export const useChatApproval = (deps: ChatApprovalDeps) => {
           signal: controller.signal,
         },
         (data: SseEvent) => {
-          handleStreamEvent(data, aiMsgIndex, task.id);
+          handleStreamEvent(data, lastMsg, task.id);
           if (getCurrentTaskId() === task.id) scrollToBottomThrottled();
         }
       );

+ 25 - 2
src/views/ventAI/manageAssistent/hooks/useChatQuota.ts

@@ -1,7 +1,7 @@
 import { ref } from 'vue';
 import { message } from 'ant-design-vue';
-import { getContext, getModelMsg, getSessionMode, getTodayUsage, putSessionMode, switchThinking } from '/@/views/ventAI/manageAssistent/api/client';
-import type { TodayUsage } from '/@/views/ventAI/manageAssistent/api/client';
+import { getContext, getModelMsg, getSessionMode, getTodayUsage, putSessionMode, switchThinking } from '/@/views/ventAI/manageAssistent/api';
+import type { TodayUsage } from '/@/views/ventAI/manageAssistent/api';
 
 /**
  * 额度与上下文用量、会话权限模式、模型思考级别。
@@ -28,6 +28,20 @@ export const useChatQuota = (getCurrentSessionId: () => string) => {
   const editMode = ref('full');
   const contextBreakdown = ref({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 });
 
+  // —— 过期响应防护 ——
+  // 切会话时都会重新拉取,旧请求可能后到:不加闸的话额度环/上下文/权限模式会显示上一条会话的值,
+  // 其中 editMode 还会作为下一次请求的 mode 发出去,属于功能性错误。
+  // 每个来源各用一个序号,只采纳「最后一次发出的请求」的响应——刻意不比对 sessionId:
+  // 流结束时 task 的 sessionId 迁移尚未完成,按会话比对会误杀那条正常的刷新。
+  // invalidateQuotaFetch 用于「已经不需要这些响应」的场景:切到尚无 session_id 的空白会话时
+  // 根本不会发新请求,必须手动作废在途响应,否则旧值会盖掉刚重置的默认值。
+  let modeSeq = 0;
+  let contextSeq = 0;
+  const invalidateQuotaFetch = () => {
+    modeSeq++;
+    contextSeq++;
+  };
+
   const fetchModelMsg = async () => {
     try {
       const res = await getModelMsg();
@@ -40,24 +54,30 @@ export const useChatQuota = (getCurrentSessionId: () => string) => {
   };
 
   const fetchSessionMode = async (sessionId: string) => {
+    const seq = ++modeSeq;
     try {
       const res = await getSessionMode(sessionId);
+      if (seq !== modeSeq) return;
       if (res?.mode) {
         editMode.value = res.mode;
       }
     } catch (e) {
+      if (seq !== modeSeq) return;
       console.error('获取会话模式失败:', e);
     }
   };
 
   const fetchContext = async (sessionId: string) => {
+    const seq = ++contextSeq;
     try {
       const res = await getContext(sessionId);
+      if (seq !== contextSeq) return;
       contextUsed.value = res?.current_usage ?? 0;
       contextMax.value = res?.total_limit ?? 0;
       contextPercent.value = res?.usage_pct ?? 0;
       contextBreakdown.value = res?.breakdown ?? { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
     } catch (e) {
+      if (seq !== contextSeq) return;
       console.error('获取上下文容量失败:', e);
       contextUsed.value = 0;
       contextMax.value = 0;
@@ -75,6 +95,8 @@ export const useChatQuota = (getCurrentSessionId: () => string) => {
   };
 
   const handleEditModeChange = async (modeKey: string) => {
+    // 用户刚改了模式:作废在途的旧查询,否则旧响应到达时会把界面(和下一次请求的 mode)改回去
+    modeSeq++;
     editMode.value = modeKey;
     const sessionId = getCurrentSessionId();
     if (!sessionId) return;
@@ -97,6 +119,7 @@ export const useChatQuota = (getCurrentSessionId: () => string) => {
     fetchModelMsg,
     fetchSessionMode,
     fetchContext,
+    invalidateQuotaFetch,
     handleThinkLevelChange,
     handleEditModeChange,
   };

+ 9 - 1
src/views/ventAI/manageAssistent/hooks/useChatScroll.ts

@@ -1,4 +1,4 @@
-import { nextTick } from 'vue';
+import { nextTick, onBeforeUnmount } from 'vue';
 import type ChatMessages from '/@/views/ventAI/manageAssistent/components/chat/ChatMessages.vue';
 
 /**
@@ -30,5 +30,13 @@ export const useChatScroll = (getChatMessages: () => InstanceType<typeof ChatMes
     }
   };
 
+  // 弹框是 destroyOnClose,卸载时可能正压着一帧待执行的滚动:显式取消,避免在已销毁的组件上跑 nextTick
+  onBeforeUnmount(() => {
+    if (scrollRafId) {
+      cancelAnimationFrame(scrollRafId);
+      scrollRafId = null;
+    }
+  });
+
   return { scrollToBottom, scrollToBottomThrottled };
 };

+ 59 - 66
src/views/ventAI/manageAssistent/hooks/useChatSend.ts

@@ -1,9 +1,9 @@
 import { computed, nextTick } from 'vue';
 import dayjs from 'dayjs';
 import { message } from 'ant-design-vue';
-import { unifiedStream, reviewPdfStream, getDetail, deleteMessage } from '/@/views/ventAI/manageAssistent/api/client';
+import { unifiedStream, reviewPdfStream, getDetail, deleteMessage } from '/@/views/ventAI/manageAssistent/api';
 import type { AttachedFile, Message, SseEvent, Task } from '/@/views/ventAI/manageAssistent/types/index';
-import { normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
+import { getCssUrl, normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
 
 /**
  * 发送与流式管线:推送一条空 AI 消息 → 建 AbortController → 走 SSE(纯文本 / PDF 审查)→ 处理
@@ -17,13 +17,11 @@ export interface ChatSendDeps {
   getCurrentTaskId: () => string;
   setCurrentTaskId: (taskId: string) => void;
   getMessages: () => Message[];
-  setMessages: (messages: Message[]) => void;
   setTaskList: (tasks: Task[]) => void;
-  getCurrentSessionId: () => string;
   getEditMode: () => string;
   getPendingApproval: () => unknown;
   getChatMessages: () => { forceScrollToBottom?: () => void } | undefined;
-  handleStreamEvent: (data: SseEvent, aiMsgIndex: number, taskId: string) => void;
+  handleStreamEvent: (data: SseEvent, aiMsg: Message, taskId: string) => void;
   scrollToBottomThrottled: (force?: boolean) => void;
   resetTodos: () => void;
   migrateTaskApproval: (fromTaskId: string, toTaskId: string) => void;
@@ -37,9 +35,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
     getCurrentTaskId,
     setCurrentTaskId,
     getMessages,
-    setMessages,
     setTaskList,
-    getCurrentSessionId,
     getEditMode,
     getPendingApproval,
     getChatMessages,
@@ -84,21 +80,18 @@ export const useChatSend = (deps: ChatSendDeps) => {
     taskAbortControllers.clear();
   };
 
-  const getSkillRefIconUrl = () => {
-    const val = getComputedStyle(document.documentElement).getPropertyValue('--img-chat-options-skill-icon').trim();
-    return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
-  };
+  const getSkillRefIconUrl = () => getCssUrl('--img-chat-options-skill-icon');
 
-  const getSessionRefIconUrl = () => {
-    const val = getComputedStyle(document.documentElement).getPropertyValue('--img-chat-popup-session-icon').trim();
-    return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
-  };
+  const getSessionRefIconUrl = () => getCssUrl('--img-chat-popup-session-icon');
 
-  const streamAiAnswer = async (userInput: string, file?: AttachedFile) => {
-    const originTaskId = getCurrentTaskId();
+  // targetTaskId:回答写进哪个任务。默认当前任务;重新生成与编辑重发是在若干次 await 之后才调到这里,
+  // 必须显式传入发起任务,否则请求期间切走后新回答会落到别的会话上。
+  const streamAiAnswer = async (userInput: string, file?: AttachedFile, targetTaskId?: string) => {
+    const originTaskId = targetTaskId || getCurrentTaskId();
+    const originTask = getTaskList().find((t) => t.id === originTaskId);
+    if (!originTask) return;
     const now = dayjs();
-    const aiMsgIndex = getMessages().length;
-    const aiMsg: Message = {
+    const newMsg: Message = {
       type: 'ai',
       content: '',
       time: now.format('HH:mm'),
@@ -107,18 +100,21 @@ export const useChatSend = (deps: ChatSendDeps) => {
       thinkingSteps: [],
       generateStartTime: Date.now(),
     };
-    getMessages().push(aiMsg);
+    originTask.messages.push(newMsg);
+    // 从数组里读回这一条:任务状态是 reactive 的,push 进去的原始对象由代理包裹,
+    // 之后必须通过代理读写才会触发视图更新——直接改上面的 newMsg,界面会一直停在“正在输入”。
+    const aiMsg = originTask.messages[originTask.messages.length - 1];
 
-    resetTodos();
+    // 进度条属于「正在看的任务」,不要为别的任务清空它
+    if (getCurrentTaskId() === originTaskId) resetTodos();
 
-    const originTask = getTaskList().find((t) => t.id === originTaskId);
-    if (originTask) originTask.isStreaming = true;
+    originTask.isStreaming = true;
 
     const controller = new AbortController();
     setTaskAbortController(originTaskId, controller);
 
     const onChunk = (data: SseEvent) => {
-      handleStreamEvent(data, aiMsgIndex, originTaskId);
+      handleStreamEvent(data, aiMsg, originTaskId);
       if (getCurrentTaskId() === originTaskId) scrollToBottomThrottled();
     };
 
@@ -128,7 +124,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
         ? await reviewPdfStream(
             {
               file: originalFile,
-              session_id: getCurrentSessionId() || undefined,
+              session_id: originTask.sessionId || undefined,
               message: userInput || undefined,
               mode: getEditMode(),
               signal: controller.signal,
@@ -138,7 +134,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
         : await unifiedStream(
             {
               message: userInput,
-              session_id: getCurrentSessionId() || undefined,
+              session_id: originTask.sessionId || undefined,
               mode: getEditMode(),
               signal: controller.signal,
             },
@@ -176,17 +172,12 @@ export const useChatSend = (deps: ChatSendDeps) => {
     } catch (error) {
       // 用户主动停止生成
       if ((error as Error)?.name === 'AbortError') {
-        const originTask = getTaskList().find((t) => t.id === originTaskId);
-        // 单一数据源:任务自己的数组就是视图数组,直接取即可
-        const arr = originTask?.messages;
-        const aiMsg = arr?.[aiMsgIndex];
-        if (aiMsg) {
-          aiMsg.isLoading = false;
-          // 后端未返回耗时,冻结前端伪计时
-          aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
-          if (!aiMsg.content) {
-            aiMsg.content = '已停止生成。';
-          }
+        // 目标消息直接用发起流式时创建的那个对象:数组可能已被删除消息重排/变短,按下标取会指错
+        aiMsg.isLoading = false;
+        // 后端未返回耗时,冻结前端伪计时
+        aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
+        if (!aiMsg.content) {
+          aiMsg.content = '已停止生成。';
         }
         return;
       }
@@ -254,10 +245,16 @@ export const useChatSend = (deps: ChatSendDeps) => {
       .trim();
 
   // 补齐消息的数据库主键 id(刚流式生成的消息前端没有 id,但后端已持久化)。
-  // 用 getDetail 返回的列表按尾部对齐回填,避免编辑重发/重新生成删除时后端残留重复记录。
-  const hydrateMessageIds = async () => {
-    const sessionId = getCurrentSessionId();
-    if (!sessionId) return;
+  //
+  // sessionId 与 msgs 都由调用方在流程开始时锚定后传入:本函数内部有 await,如果这里再去读
+  // 「当前会话 / 当前任务」,请求期间用户切走就会把别的会话的消息当成目标,回填出的 id 会让
+  // 后续的删除打到另一条会话上。
+  //
+  // 两边条数必须严格一致才回填。早期实现用「长度差」做尾部对齐,一旦本地存在仅前端的消息
+  // (例如发送失败的报错气泡,后端没有对应行),长度差为负,之后每条未回填的消息都会拿到
+  // 前一条的 id —— 删除按钮据此调 DELETE /messages/{id},删掉的是用户根本没想删的那一行。
+  const hydrateMessageIds = async (sessionId: string, msgs: Message[]) => {
+    if (!sessionId || !msgs.length) return;
     try {
       const res = await getDetail(sessionId);
       const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
@@ -265,23 +262,19 @@ export const useChatSend = (deps: ChatSendDeps) => {
       // 用 transformHistoryToMessages 归一化,避免 model_thinking 等记录导致条数与前端不对齐
       const history = transformHistoryToMessages(raw);
       const ids = history.map((m) => m.id).filter((id): id is number => id != null);
-      if (!ids.length) return;
-      const msgs = getMessages();
-      const offset = ids.length - msgs.length;
+      if (ids.length !== msgs.length) return;
       for (let i = 0; i < msgs.length; i++) {
-        const dIdx = offset + i;
-        if (dIdx >= 0 && dIdx < ids.length && msgs[i].id == null) {
-          msgs[i].id = ids[dIdx];
-        }
+        if (msgs[i].id == null) msgs[i].id = ids[i];
       }
     } catch (e) {
       console.error('补齐消息 id 失败:', e);
     }
   };
 
-  // 重新生成后,删除后端因重发而产生的重复用户消息(保留第一次,形成「一次提问、多次回答」)
-  const removeDuplicateRegenerateUserMessage = async (expectedContent: string) => {
-    const sessionId = getCurrentSessionId();
+  // 重新生成后,删除后端因重发而产生的重复用户消息(保留第一次,形成「一次提问、多次回答」)。
+  // sessionId 同样由调用方锚定传入:若这里读「当前会话」,重新生成期间切走就会去别的会话里
+  // 按内容匹配并删除 user 记录,可能删掉一条与本次操作无关的正常消息。
+  const removeDuplicateRegenerateUserMessage = async (sessionId: string, expectedContent: string) => {
     if (!sessionId) return;
     try {
       const res = await getDetail(sessionId);
@@ -310,7 +303,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
       return;
     }
 
-    const msgs = getMessages();
+    const msgs = task.messages;
     const lastAiIdx = msgs.length - 1;
     if (lastAiIdx < 0 || msgs[lastAiIdx].type !== 'ai') return;
 
@@ -328,12 +321,13 @@ export const useChatSend = (deps: ChatSendDeps) => {
     const userInput = userMsg.rawInput || stripInjectedRefs(userMsg.content);
     const attachedFile = userMsg.attachedFile;
 
-    // 不删除旧 AI 消息、不动用户提问,直接重发同一条用户输入重新生成
-    await streamAiAnswer(userInput, attachedFile);
+    // 不删除旧 AI 消息、不动用户提问,直接重发同一条用户输入重新生成。
+    // 以下三步都在 await 之后,一律显式锚定发起任务:用「当前任务/当前会话」会在切走后作用于别的会话
+    await streamAiAnswer(userInput, attachedFile, task.id);
     // 删除重发产生的重复用户消息,形成「一次提问、多次回答」
-    await removeDuplicateRegenerateUserMessage(userInput);
+    await removeDuplicateRegenerateUserMessage(task.sessionId, userInput);
     // 回填新生成消息的 id,使折叠组内各条消息都能正常显示删除按钮
-    await hydrateMessageIds();
+    await hydrateMessageIds(task.sessionId, task.messages);
   };
 
   // 编辑用户消息并重发(截断该消息及其后所有消息)
@@ -345,7 +339,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
       return;
     }
 
-    const msgs = getMessages();
+    const msgs = task.messages;
     if (index < 0 || index >= msgs.length || msgs[index].type !== 'user') return;
     const trimmed = (newContent || '').trim();
     if (!trimmed) {
@@ -355,11 +349,10 @@ export const useChatSend = (deps: ChatSendDeps) => {
 
     const attachedFile = msgs[index].attachedFile;
 
-    await hydrateMessageIds();
+    // 全程锚定发起任务:这几步都在 await 之后,用「当前任务」会在切走后把别的会话的记录删掉
+    await hydrateMessageIds(task.sessionId, msgs);
     // 从后往前删除后端记录(有 id 的才调接口)
-    const toDelete = getMessages()
-      .slice(index)
-      .filter((m) => m.id != null);
+    const toDelete = msgs.slice(index).filter((m) => m.id != null);
     for (const m of toDelete) {
       try {
         await deleteMessage(m.id!);
@@ -367,8 +360,8 @@ export const useChatSend = (deps: ChatSendDeps) => {
         console.error('删除消息失败:', e);
       }
     }
-    // 本地截断到编辑位置
-    setMessages(getMessages().slice(0, index));
+    // 本地截断到编辑位置(原地截断,始终操作同一个任务的消息数组)
+    msgs.splice(index);
     const now = dayjs();
     const userMsg: Message = {
       type: 'user',
@@ -378,10 +371,10 @@ export const useChatSend = (deps: ChatSendDeps) => {
       createdAt: now.toISOString(),
       attachedFile,
     };
-    getMessages().push(userMsg);
+    msgs.push(userMsg);
     await nextTick();
-    getChatMessages()?.forceScrollToBottom?.();
-    await streamAiAnswer(trimmed, attachedFile);
+    if (getCurrentTaskId() === task.id) getChatMessages()?.forceScrollToBottom?.();
+    await streamAiAnswer(trimmed, attachedFile, task.id);
   };
   return {
     loading,

+ 14 - 8
src/views/ventAI/manageAssistent/hooks/useChatStream.ts

@@ -1,5 +1,5 @@
-import { nextTick } from 'vue';
-import type { AskUserQuestion, Task, ThinkingStep, SseEvent } from '/@/views/ventAI/manageAssistent/types/index';
+import { nextTick, toRaw } from 'vue';
+import type { AskUserQuestion, Message, Task, ThinkingStep, SseEvent } from '/@/views/ventAI/manageAssistent/types/index';
 import { normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
 
 // 工具名 → 中文智能体名(agent 分组标题用)。仅本模块使用,随事件映射一起内聚在此
@@ -58,23 +58,29 @@ export interface ChatStreamHooks {
   /** interrupt 事件:登记待审批 */
   markPendingApproval: (taskId: string, sessionId: string, threadId: string) => void;
   /** interrupt 事件:初始化 ask_user 答案状态 */
-  initAskUserAnswers: (aiMsgIndex: number, questions: AskUserQuestion[]) => void;
+  initAskUserAnswers: (aiMsg: Message, questions: AskUserQuestion[]) => void;
 }
 
 /**
  * 统一对话流(SSE)事件处理器。
  *
- * 数据源约定:aiMsg 固定从「任务自己的 messages 数组」读取——视图层的 messages 就是当前任务的
- * messages(同一个数组),因此流式增量原地落在任务对象上,不需要任何「视图数组 ↔ 任务数组」同步。
+ * 数据源约定:目标消息由调用方以「消息对象」传入,而不是数组下标 —— 消息数组会因删除消息被
+ * filter 成新数组、长度也会变;下标一旦漂移,aiMsg 就会指向别的消息或指空,流式期间删掉靠前的
+ * 消息会让后续所有事件被丢弃,回答卡在半截且 isLoading 不再复位、计时也停住。
+ *
+ * 这里再按对象身份从任务数组里找回那一条,拿到的是数组内的响应式代理:调用方传进来的可能是
+ * 未经代理的原始对象,直接改原始对象不会触发 Vue 更新(界面会一直停在“正在输入”)。
+ * 消息对象在整个生命周期内引用稳定,数组重排也仍指向同一条,因此按对象寻址是安全的。
  * 切到别的任务时,增量仍然写回发起流式的那个任务,回到该任务时内容完整。
  */
 export const useChatStream = (hooks: ChatStreamHooks) => {
-  const handleEvent = (data: SseEvent, aiMsgIndex: number, taskId: string) => {
+  const handleEvent = (data: SseEvent, targetMsg: Message, taskId: string) => {
     const streamingTask = hooks.findTask(taskId);
     if (!streamingTask) return;
 
     const isViewingTask = hooks.isCurrentTask(taskId);
-    const aiMsg = streamingTask.messages[aiMsgIndex];
+    const rawTarget = toRaw(targetMsg);
+    const aiMsg = streamingTask.messages.find((m) => toRaw(m) === rawTarget);
     if (!aiMsg) return;
 
     const normalizedContent = normalizeLineBreaks(data.content || '');
@@ -320,7 +326,7 @@ export const useChatStream = (hooks: ChatStreamHooks) => {
         if (data.ask_user?.questions) {
           const questions = data.ask_user.questions;
           nextTick(() => {
-            hooks.initAskUserAnswers(aiMsgIndex, questions);
+            hooks.initAskUserAnswers(aiMsg, questions);
           });
         }
         break;

+ 1 - 1
src/views/ventAI/manageAssistent/hooks/useChatTasks.ts

@@ -1,7 +1,7 @@
 import { computed, ref } from 'vue';
 import dayjs from 'dayjs';
 import { message } from 'ant-design-vue';
-import { editSessionTitle, getHistoryList, pinSession } from '/@/views/ventAI/manageAssistent/api/client';
+import { editSessionTitle, getHistoryList, pinSession } from '/@/views/ventAI/manageAssistent/api';
 import type { Message, Task } from '/@/views/ventAI/manageAssistent/types/index';
 import { normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
 

+ 15 - 12
src/views/ventAI/manageAssistent/hooks/useMentionPopups.ts

@@ -1,5 +1,5 @@
 import { computed, nextTick, onMounted, ref, watch } from 'vue';
-import { getSkills } from '/@/views/ventAI/manageAssistent/api/client';
+import { getSkills } from '/@/views/ventAI/manageAssistent/api';
 import type { Task } from '/@/views/ventAI/manageAssistent/types/index';
 
 /**
@@ -62,6 +62,8 @@ export const useMentionPopups = (deps: MentionPopupDeps) => {
   const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
   const skillPopupManual = ref(false);
   const skillList = ref<SkillItem[]>([]);
+  // 技能列表拉取失败:$ 弹层的空态据此区分「加载失败」与「确实没有技能」,避免失败被显示成空数据
+  const skillListLoadFailed = ref(false);
   let skillJustSelected = false;
 
   const fetchSkillList = async () => {
@@ -81,8 +83,13 @@ export const useMentionPopups = (deps: MentionPopupDeps) => {
         const res = await getSkills();
         skillList.value = res?.skills || [];
         skillListCache = skillList.value;
-      } catch {
+        skillListLoadFailed.value = false;
+      } catch (e) {
+        // 这里刻意不弹提示:组件一挂载就预取,失败会打扰与技能无关的操作。
+        // 改为记住失败状态,由 $ 弹层的空态如实说明。
+        console.error('获取技能列表失败:', e);
         skillList.value = [];
+        skillListLoadFailed.value = true;
       } finally {
         skillListFetchPromise = null;
       }
@@ -94,14 +101,6 @@ export const useMentionPopups = (deps: MentionPopupDeps) => {
     fetchSkillList();
   });
 
-  const isSkillSelected = (name: string) => {
-    return selectedSkill.value?.name === name;
-  };
-
-  const isSessionSelected = (sessionId: string) => {
-    return selectedSessions.value.some((s) => s.id === sessionId);
-  };
-
   const sessionSearchText = computed(() => {
     const val = getModelValue() || '';
     const hashIndex = val.lastIndexOf('#');
@@ -267,8 +266,13 @@ export const useMentionPopups = (deps: MentionPopupDeps) => {
     (newVal, oldVal) => {
       if (isRemovingTag) return;
       if (oldVal && !newVal) {
+        // 输入框被清空时,引用一并作废。必须同步回父组件:真正写进请求的是父组件那份
+        // (发送时按它拼 #session:/$skill: 前缀),只清本地 ref 会让 chip 消失但引用照旧发出,
+        // 用户会看到一条自己以为已删除、且无法再移除的引用。
         selectedSessions.value = [];
+        setSelectedSessions([]);
         selectedSkill.value = null;
+        setSelectedSkill(null);
       }
     }
   );
@@ -358,8 +362,7 @@ export const useMentionPopups = (deps: MentionPopupDeps) => {
     selectedSkill,
     filteredSessionList,
     filteredSkillList,
-    isSessionSelected,
-    isSkillSelected,
+    skillListLoadFailed,
     handleSessionKeydown,
     handleSelectSession,
     handleSelectSkill,

+ 47 - 0
src/views/ventAI/manageAssistent/lib/clipboard.ts

@@ -0,0 +1,47 @@
+// lib/clipboard.ts:剪贴板写入与「复制成功」回显。
+// 表格工具栏、markdown 代码块工具栏、消息复制原先各写一份 navigator.clipboard + execCommand
+// 降级分支,这里收敛为唯一实现。
+
+/**
+ * 写入剪贴板。
+ * navigator.clipboard 只在安全上下文(https / localhost)可用,其余情况降级到 execCommand。
+ */
+export const copyToClipboard = async (text: string): Promise<void> => {
+  try {
+    await navigator.clipboard.writeText(text);
+  } catch {
+    const textarea = document.createElement('textarea');
+    textarea.value = text;
+    textarea.style.position = 'fixed';
+    textarea.style.opacity = '0';
+    document.body.appendChild(textarea);
+    textarea.select();
+    document.execCommand('copy');
+    document.body.removeChild(textarea);
+  }
+};
+
+/**
+ * 复制成功后把按钮图标临时换成成功态,鼠标离开所在工具栏时还原。
+ *
+ * 原图标与原提示文案都取自按钮自身(data-icon / title,由 markdown.ts 注入),
+ * 因此表格工具栏与代码块工具栏共用一份实现,调用方不需要各自传文案。
+ */
+export const flashCopySuccess = (btn: HTMLElement): void => {
+  const img = btn.querySelector('img');
+  const successIcon = btn.dataset.iconSuccess;
+  const originalIcon = btn.dataset.icon;
+  if (!img || !successIcon) return;
+
+  const originalTitle = btn.title;
+  const wrapper = btn.closest('.markdown-table-wrapper, .markdown-code-wrapper');
+  img.src = successIcon;
+  btn.title = '复制成功';
+
+  const reset = () => {
+    img.src = originalIcon || img.src;
+    btn.title = originalTitle;
+    wrapper?.removeEventListener('mouseleave', reset);
+  };
+  wrapper?.addEventListener('mouseleave', reset);
+};

+ 1 - 0
src/views/ventAI/manageAssistent/lib/index.ts

@@ -6,3 +6,4 @@ export * from './fileIcon';
 export * from './theme';
 export * from './filePreview';
 export * from './messageKey';
+export * from './clipboard';

+ 32 - 13
src/views/ventAI/manageAssistent/lib/markdown.ts

@@ -3,6 +3,34 @@ import { marked, Renderer, walkTokens } from 'marked';
 import type { Tokens } from 'marked';
 import katex from 'katex';
 import { sanitizeHtml } from '/@/utils/markdownSafe';
+import { getCssUrl } from './theme';
+
+/** markdown 工具栏(表格 / markdown 代码块)图标:与 wrapTables / wrapMarkdownCodeBlocks 注入的按钮一一对应 */
+export interface MarkdownToolbarIcons {
+  copy: string;
+  download: string;
+  preview: string;
+  copySuccess: string;
+}
+
+// 图标对应的 CSS 变量名:调用方不再各自硬编码,换图标只需改这一处
+const TOOLBAR_ICON_VARS = {
+  copy: '--img-chat-copy-icon',
+  download: '--img-chat-download-icon-btn',
+  preview: '--img-chat-preview-icon',
+  copySuccess: '--img-chat-copy-success-icon',
+} as const;
+
+/**
+ * 工具栏图标的唯一来源。
+ * 经 getCssUrl 读取(内部按变量名缓存、主题类名变化时失效),调用方在 setup 里取一次即可。
+ */
+export const getTableIcons = (): MarkdownToolbarIcons => ({
+  copy: getCssUrl(TOOLBAR_ICON_VARS.copy),
+  download: getCssUrl(TOOLBAR_ICON_VARS.download),
+  preview: getCssUrl(TOOLBAR_ICON_VARS.preview),
+  copySuccess: getCssUrl(TOOLBAR_ICON_VARS.copySuccess),
+});
 
 const mdRenderer = new Renderer();
 
@@ -48,11 +76,7 @@ const processFootnotes = (html: string, maxId?: number): string => {
   });
 };
 
-export const renderMarkdown = (
-  text: string,
-  icons?: { copy: string; download: string; preview: string; copySuccess: string },
-  maxCitationId?: number
-): string => {
+export const renderMarkdown = (text: string, icons?: MarkdownToolbarIcons, maxCitationId?: number): string => {
   if (!text) return '';
   const processed = text.replace(/<br\s*\/?>/gi, '  \n');
   let html = marked.parse(processed, { renderer: mdRenderer }) as string;
@@ -79,12 +103,7 @@ const MD_CACHE_MAX = 200;
 
 const mdCache = new Map<string, string>();
 
-export const renderMarkdownCached = (
-  text: string,
-  icons?: { copy: string; download: string; preview: string; copySuccess: string },
-  maxCitationId?: number,
-  cacheable = true
-): string => {
+export const renderMarkdownCached = (text: string, icons?: MarkdownToolbarIcons, maxCitationId?: number, cacheable = true): string => {
   if (!cacheable || !text) return renderMarkdown(text, icons, maxCitationId);
 
   // \u0001 作分隔符:不会出现在正文与图标地址里,避免不同入参拼出同一个 key
@@ -125,7 +144,7 @@ export const extractMarkdownLinks = (text: string): Array<{ title: string; href:
   return links;
 };
 
-export const wrapTables = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => {
+export const wrapTables = (html: string, icons: MarkdownToolbarIcons): string => {
   return html.replace(/<table>([\s\S]*?)<\/table>/g, (match) => {
     const encodedTable = encodeURIComponent(match);
     return `<div class="markdown-table-wrapper">
@@ -152,7 +171,7 @@ const decodeHtmlEntities = (escaped: string): string => {
 };
 // 给渲染后 HTML 中的 ```markdown / ```md 代码块加上操作工具栏(复制/下载/预览)
 
-export const wrapMarkdownCodeBlocks = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => {
+export const wrapMarkdownCodeBlocks = (html: string, icons: MarkdownToolbarIcons): string => {
   return html.replace(/<pre><code class="[^"]*language-(?:markdown|md)[^"]*">([\s\S]*?)<\/code><\/pre>/g, (match, escaped: string) => {
     const encoded = encodeURIComponent(decodeHtmlEntities(escaped));
     return `<div class="markdown-code-wrapper">

+ 68 - 0
src/views/ventAI/manageAssistent/types/domain.ts

@@ -35,3 +35,71 @@ export interface ScheduleTask {
   feedback_session_id?: string;
   schedule?: { freq: string; human?: string };
 }
+
+/** 定时任务创建/编辑表单的形状:比列表项多 notify_webhook,少 cron_human/enabled/next_run_at */
+export interface ScheduleFormData {
+  id: number;
+  name: string;
+  cron_expr: string;
+  prompt: string;
+  notify_webhook?: string;
+  schedule?: { freq: string; hour?: number; minute?: number; day_of_week?: number; day_of_month?: number; human?: string };
+}
+
+/** 模型技能(技能面板与技能弹窗共用同一份列表项形状) */
+export interface Skill {
+  name: string;
+  display_name: string;
+  path: string;
+  description: string;
+  enabled: boolean;
+  agents: string[];
+  has_skill_md: boolean;
+}
+
+/** 子智能体已注册工具的简要信息(列表接口 tool_infos 项,带 exists 标记) */
+export interface SubAgentToolInfo {
+  name: string;
+  display_name: string;
+  exists: boolean;
+}
+
+/** 可选工具的选项(工具注册表接口返回,带说明,供表单勾选) */
+export interface SubAgentToolOption {
+  name: string;
+  display_name: string;
+  description?: string;
+}
+
+/** 可选工具分组(表单里按类别展示) */
+export interface SubAgentToolGroup {
+  key: string;
+  display: string;
+  tools: SubAgentToolOption[];
+}
+
+/** 子智能体创建/编辑表单的形状 */
+export interface SubAgentFormData {
+  name: string;
+  description: string;
+  system_prompt: string;
+  tools: string[];
+  tool_infos?: SubAgentToolInfo[];
+  model: string;
+  enabled: boolean;
+}
+
+/**
+ * 子智能体列表项(GET /ventAI/api/subagents 响应的 subagents[] 元素)。
+ *
+ * keywords 与 thinking_level 是后端真实返回、但表单不管理的字段,字段形状已对 13 条真实数据逐一核实:
+ * keywords 是逗号分隔的字符串(不是数组),thinking_level 是字符串(如 "follow")。
+ * 如实声明它们,避免"类型里没有"被误读成"接口没有"。
+ */
+export interface SubAgent extends SubAgentFormData {
+  tool_infos: SubAgentToolInfo[];
+  keywords: string;
+  thinking_level: string;
+  created_at: string;
+  updated_at: string;
+}