Explorar o código

[Merge] 合并 origin/master(+5 提交)到 fix/audit-r4-20260929

上游新增:API 按域拆分重构(6de78411)、dxf 支持(5f41a3be)、
文件预览修复(64cfbb1a)、导航条逻辑与警告修复(e7e89214)、来源卡片默认折叠(83eb0a1e)。

冲突解决(3 处):
- api/client.ts 被上游删除:接受删除;M3 的 MeInfo/getMe 迁至新建 api/user.ts
  (按 barrel 约定导出);M2 的 throwStreamHttpError 导出在新结构 api/errors.ts 天然满足
- AiAssistantModal.vue:import 归一到 barrel 路径并保留 getMe
- useChatScroll.ts:R4-8 与上游同源修复撞车,取上游实现

适配:dataPicker 两文件 import 由 api/client 改为 api/errors(注释旧路径同步更正)。
复验:ESLint 0 error(关闭 prettier CRLF 环境噪声)/scoped tsc ventAI 0 error/
vite build exit 0 且产物含 M1/M2/M3/R4-4/R4-5 标记。
wangkeyi hai 2 semanas
pai
achega
e774cd5c9a
Modificáronse 47 ficheiros con 1691 adicións e 906 borrados
  1. 3 3
      src/views/ventAI/dataPicker/api.ts
  2. 2 2
      src/views/ventAI/dataPicker/useDataPicker.ts
  3. 161 0
      src/views/ventAI/manageAssistent/api/chat.ts
  4. 0 485
      src/views/ventAI/manageAssistent/api/client.ts
  5. 72 0
      src/views/ventAI/manageAssistent/api/errors.ts
  6. 12 0
      src/views/ventAI/manageAssistent/api/index.ts
  7. 18 0
      src/views/ventAI/manageAssistent/api/model.ts
  8. 60 0
      src/views/ventAI/manageAssistent/api/report.ts
  9. 38 0
      src/views/ventAI/manageAssistent/api/schedule.ts
  10. 66 0
      src/views/ventAI/manageAssistent/api/session.ts
  11. 34 0
      src/views/ventAI/manageAssistent/api/skill.ts
  12. 61 0
      src/views/ventAI/manageAssistent/api/subagent.ts
  13. 29 0
      src/views/ventAI/manageAssistent/api/usage.ts
  14. 20 0
      src/views/ventAI/manageAssistent/api/user.ts
  15. 44 25
      src/views/ventAI/manageAssistent/components/AiAssistantModal.vue
  16. 11 4
      src/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue
  17. 13 62
      src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue
  18. 22 2
      src/views/ventAI/manageAssistent/components/chat/ConversationNav.vue
  19. 3 1
      src/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue
  20. 1 1
      src/views/ventAI/manageAssistent/components/chat/Input/UsageRing.vue
  21. 1 5
      src/views/ventAI/manageAssistent/components/chat/TodoListBar.vue
  22. 4 13
      src/views/ventAI/manageAssistent/components/modals/CreateScheduleModal.vue
  23. 12 36
      src/views/ventAI/manageAssistent/components/modals/CreateSubAgentModal.vue
  24. 2 11
      src/views/ventAI/manageAssistent/components/modals/SkillModal.vue
  25. 25 10
      src/views/ventAI/manageAssistent/components/panels/ScheduleDetailPanel.vue
  26. 17 21
      src/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue
  27. 9 13
      src/views/ventAI/manageAssistent/components/panels/SkillPanel.vue
  28. 7 22
      src/views/ventAI/manageAssistent/components/panels/SubAgentPanel.vue
  29. 0 4
      src/views/ventAI/manageAssistent/components/panels/TaskListPanel.vue
  30. 319 0
      src/views/ventAI/manageAssistent/components/resource/CadPreview.vue
  31. 47 58
      src/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue
  32. 4 6
      src/views/ventAI/manageAssistent/hooks/useChatApproval.ts
  33. 15 5
      src/views/ventAI/manageAssistent/hooks/useChatFiles.ts
  34. 25 2
      src/views/ventAI/manageAssistent/hooks/useChatQuota.ts
  35. 3 7
      src/views/ventAI/manageAssistent/hooks/useChatScroll.ts
  36. 59 66
      src/views/ventAI/manageAssistent/hooks/useChatSend.ts
  37. 20 11
      src/views/ventAI/manageAssistent/hooks/useChatStream.ts
  38. 3 2
      src/views/ventAI/manageAssistent/hooks/useChatTasks.ts
  39. 15 12
      src/views/ventAI/manageAssistent/hooks/useMentionPopups.ts
  40. 47 0
      src/views/ventAI/manageAssistent/lib/clipboard.ts
  41. 270 0
      src/views/ventAI/manageAssistent/lib/dxf.ts
  42. 8 4
      src/views/ventAI/manageAssistent/lib/exportDoc.ts
  43. 5 0
      src/views/ventAI/manageAssistent/lib/fileIcon.ts
  44. 2 0
      src/views/ventAI/manageAssistent/lib/filePreview.ts
  45. 2 0
      src/views/ventAI/manageAssistent/lib/index.ts
  46. 32 13
      src/views/ventAI/manageAssistent/lib/markdown.ts
  47. 68 0
      src/views/ventAI/manageAssistent/types/domain.ts

+ 3 - 3
src/views/ventAI/dataPicker/api.ts

@@ -1,8 +1,8 @@
 import { consumeSse } from '/@/views/ventAI/manageAssistent/api/sse';
 // [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3):复用对话框模块既有的结构化错误解析器,避免两套实现各自演化
-import { throwStreamHttpError } from '/@/views/ventAI/manageAssistent/api/client';
+import { throwStreamHttpError } from '/@/views/ventAI/manageAssistent/api/errors';
 import type { SSERawEvent } from './types';
-// [审计-第四轮 M1] 重放自我方 fix/base-agent-token(M1/M2/M3):取基座登录态的令牌(与对话链路 manageAssistent/api/client.ts 同源)
+// [审计-第四轮 M1] 重放自我方 fix/base-agent-token(M1/M2/M3):取基座登录态的令牌(与对话链路 manageAssistent/api/chat.ts 同源)
 import { getToken } from '/@/utils/auth';
 
 enum Api {
@@ -61,7 +61,7 @@ async function ssePost(
   if (!response.ok) {
     // [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3)
     // 原行为:throw new Error(`HTTP错误: ${response.status}`) —— 用户只看到「HTTP错误: 429」。
-    // 现行为:复用 manageAssistent/api/client.ts 的 throwStreamHttpError,它会解析响应体的
+    // 现行为:复用 manageAssistent/api/errors.ts 的 throwStreamHttpError,它会解析响应体的
     //         detail(字符串或 { code, message, retry_after, limit_tokens, spent_tokens }),
     //         抛出携带 status/retryAfter/limitTokens/spentTokens 的 StreamError;
     //         最终文案由 useDataPicker.ts 的 formatClickError() 统一渲染。

+ 2 - 2
src/views/ventAI/dataPicker/useDataPicker.ts

@@ -2,7 +2,7 @@ import { ref, h, createApp, Teleport, type Directive, type App } from 'vue';
 import Antd from 'ant-design-vue';
 import { fetchTunnelInterpretation, fetchDeviceInterpretation } from './api';
 // [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3):formatClickError 需要识别 StreamError 的 status
-import { StreamError } from '/@/views/ventAI/manageAssistent/api/client';
+import { StreamError } from '/@/views/ventAI/manageAssistent/api/errors';
 import type { TodoItem, ToolCallRecord, SSERawEvent } from './types';
 import DataPickerModal from './components/DataPickerModal.vue';
 import aiCloseIcon from '@/assets/images/ventAI/dataPicker/AI-close.svg';
@@ -349,7 +349,7 @@ export function useDataPicker(
    *   HTTP 413 → { "detail": { "code": "...", "message": "…" } }
    *   HTTP 404 → { "detail": { "code": "...", "message": "…" } }
    * 原行为:只有一行 `error.value = e?.message || '请求失败'`,429/413/404 只能显示 `HTTP错误: xxx`。
-   * 现行为:复用 manageAssistent/api/client.ts 的 throwStreamHttpError(不新写一套解析器)
+   * 现行为:复用 manageAssistent/api/errors.ts 的 throwStreamHttpError(不新写一套解析器)
    *         + 本函数的兜底文案,得到可读提示。
    * 为什么:429 带 retryAfter 换算分钟、413/404 服务端无文案时用本地兜底。
    * 保守策略:只对「确认是 StreamError」的情况改写文案;普通 Error 一律走 fallback,

+ 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 - 485
src/views/ventAI/manageAssistent/api/client.ts

@@ -1,485 +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',
-  // [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):当前登录用户权限信息(含 is_admin)
-  me = '/ventAI/api/me',
-  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;
-  }
-}
-
-// [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3):由「模块内私有 const」改为「导出」。
-// 原行为:仅本模块三条对话流内部使用,未导出。
-// 现行为:加 export,供点选链路(dataPicker/api.ts)复用同一套结构化错误解析。
-// 为什么:避免在点选侧再复制一份 detail 解析逻辑(两份实现会各自漂移)。函数体本身零改动。
-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);
-};
-
-/**
- * 查询今日用量与额度
- */
-export const getTodayUsage = () => defHttp.get({ url: Api.usageToday }, { isTransformResponse: false });
-
-/**
- * [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3)
- * 当前登录用户的权限信息。TfAgents 把「技能上传/启停、子智能体增删改、思考级别切换」等
- * 全局变更端点收归管理员后,基座据此隐藏入口(用户已定案「方案 B:由后端返回 is_admin,前端隐藏」)。
- *
- * 后端契约(TfAgents 侧):
- *   GET /api/me(需认证,带 X-Access-Token)
- *   → 200 { "username": "...", "is_admin": true|false, "admin_configured": true|false }
- *   → 401 未登录 / 令牌无效
- * 说明:admin_configured 表示服务端管理员白名单是否已配置;接口不返回白名单内容。
- *
- * 两点实现说明:
- * 1) 这里不手写请求头:走 defHttp 时,axios 请求拦截器(src/utils/http/axios/index.ts)
- *    会自动把令牌塞进 Authorization 与 X-Access-Token,与其它接口保持一致。
- * 2) errorMessageMode: 'none' 是刻意加的:该接口在 TfAgents 尚未上线时返回 404,
- *    若用默认的错误提示模式,每次打开 AI 弹框都会弹一个红色报错(误判为故障)。
- *    这里静默失败,由调用方(AiAssistantModal.fetchMe)按「非管理员」保守降级。
- */
-export interface MeInfo {
-  username: string;
-  is_admin: boolean;
-  admin_configured: boolean;
-}
-
-export const getMe = () => defHttp.get<MeInfo>({ url: Api.me }, { isTransformResponse: false, errorMessageMode: 'none' });
-
-/**
- * 历史会话接口
- */
-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;
+};

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

@@ -0,0 +1,12 @@
+// api barrel:对外保持单一入口,调用方按 `.../api` 引入即可,不必关心内部按域拆分。
+// 各域相互独立:新增接口请放进对应域文件,不要再堆成一个大文件。
+export * from './errors';
+export * from './chat';
+export * from './session';
+export * from './user';
+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 });

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

@@ -0,0 +1,60 @@
+// 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',
+}
+
+/**
+ * 把后端返回的报告地址归一成走 /ventAI 代理的下载地址。
+ *
+ * 后端实际返回过两种形式:`http://<host>/api/download/report/<file>`(绝对地址)与
+ * `/api/download/report/<file>`(相对路径)。后者缺 /ventAI 前缀,直接交给浏览器会打到前端自己的源上
+ * (dev 是 Vite,没有 /api 路由)从而 404。这里统一重建成 /ventAI/api/download/report/<file>,
+ * 与其余接口一致走代理,dev 与生产都不依赖后端返回的 Host。
+ *
+ * 注意地址里的文件名是已百分号编码的,必须先解码再编码,否则会双重编码导致后端找不到文件。
+ */
+export const buildReportDownloadUrl = (urlOrFilename: string) => {
+  const raw = urlOrFilename.split('/').pop() || urlOrFilename;
+  let filename = raw;
+  try {
+    filename = decodeURIComponent(raw);
+  } catch {
+    // 含非法百分号序列时按原样处理
+  }
+  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 });

+ 20 - 0
src/views/ventAI/manageAssistent/api/user.ts

@@ -0,0 +1,20 @@
+// api/user.ts:当前登录用户的身份与权限。
+import { defHttp } from '/@/utils/http/axios/index';
+
+enum Api {
+  me = '/ventAI/api/me',
+}
+
+export interface MeInfo {
+  username: string;
+  is_admin: boolean;
+  admin_configured: boolean;
+}
+
+/**
+ * 查询当前登录用户权限(TfAgents GET /api/me)。
+ * 令牌由 axios 请求拦截器统一带上,这里不手写请求头。
+ * errorMessageMode: 'none':接口未上线时返回 404,用默认模式会让每次打开 AI 弹框都弹一个红错;
+ * 这里静默失败,由调用方按「非管理员」保守降级(且失败不缓存,每次打开重新拉取,管理员名单可能变更)。
+ */
+export const getMe = () => defHttp.get<MeInfo>({ url: Api.me }, { isTransformResponse: false, errorMessageMode: 'none' });

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

@@ -170,7 +170,7 @@
   import { ref, nextTick, watch, computed, onBeforeUnmount } from 'vue';
   import dayjs from 'dayjs';
   // [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):引入 getMe 用于按 is_admin 隐藏管理入口
-  import { getDetail, deleteSession, deleteMessage, StreamError, getMe } from '/@/views/ventAI/manageAssistent/api/client';
+  import { getDetail, deleteSession, deleteMessage, StreamError, getMe } 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';
@@ -182,7 +182,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';
@@ -293,7 +293,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(),
@@ -302,7 +302,6 @@
       currentTodos.value = [];
     },
     setCurrentSessionId: (id) => setCurrentSessionId(id),
-    streamErrorMessage: StreamError,
   });
 
   // —— 发送/流式管线已下沉到 useChatSend(handleSendMessage 作为输入框编排留在组件里) ——
@@ -324,17 +323,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;
@@ -370,6 +365,9 @@
   // 删除单条消息:先删数据库行,后端再清空该会话内存线程,下轮从数据库重建上下文
   const handleDeleteMessage = (msg: Message) => {
     if (!msg.id) return;
+    // 确认框与删除请求都是异步的,用户可能在这期间切到别的会话:记住这条消息所属的任务,
+    // 写回它而不是「当前任务」,否则被删的消息会一直留在原任务里直到刷新。
+    const ownerTask = currentTask.value;
     Modal.confirm({
       title: '提示',
       content: '是否删除该条消息?',
@@ -380,8 +378,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('删除失败');
         }
@@ -397,8 +396,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('获取定时任务列表失败,定时任务详情可能不是最新');
     }
   };
 
@@ -497,6 +498,7 @@
     fetchModelMsg,
     fetchSessionMode,
     fetchContext,
+    invalidateQuotaFetch,
     handleThinkLevelChange,
     handleEditModeChange,
   } = useChatQuota(() => getCurrentSessionId());
@@ -542,7 +544,9 @@
           }
           historyLoadedTasks.add(taskId);
         } catch (error) {
+          // 拉不到历史时界面会是空白会话,容易被误认为「对话丢了」,必须给出提示
           console.error('加载历史会话失败:', error);
+          message.error('加载历史会话失败,请稍后重试');
         }
       }
     }
@@ -551,6 +555,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 };
@@ -593,6 +599,7 @@
       attachedFiles: [],
     };
 
+    invalidateQuotaFetch();
     contextUsed.value = 0;
     contextPercent.value = 0;
     contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
@@ -675,6 +682,10 @@
     selectedSessions.value = [];
     selectedSkill.value = null;
 
+    // 记住本次发送属于哪个任务:请求期间用户可能切到别的会话,失败时的报错必须落回发起它的任务,
+    // 否则报错气泡会塞进正在看的那条会话,而发起任务的占位消息没人清理(永远停在「正在输入」)。
+    const originTask = currentTask.value;
+
     try {
       await nextTick();
       chatMessagesRef.value?.forceScrollToBottom();
@@ -691,19 +702,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);
+      }
     }
   };
 
@@ -725,8 +742,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);
     },
   });
 
@@ -810,6 +827,7 @@
             historyLoadedTasks.add(targetTask.id);
           } catch (error) {
             console.error('加载历史会话失败:', error);
+            message.error('加载历史会话失败,请稍后重试');
           }
         }
 
@@ -817,6 +835,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"
@@ -108,6 +108,7 @@
         :list="filteredSkillList"
         :active-index="activeSkillIndex"
         :selected-name="selectedSkill ? selectedSkill.name : ''"
+        :load-failed="skillListLoadFailed"
         @select="handleSelectSkill"
         @close="closeSkillPopup"
       />
@@ -127,7 +128,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(
@@ -217,6 +218,7 @@
     selectedSkill,
     filteredSessionList,
     filteredSkillList,
+    skillListLoadFailed,
     handleSessionKeydown,
     handleSelectSession,
     handleSelectSkill,
@@ -264,11 +266,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');
     }
   };

+ 13 - 62
src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue

@@ -52,7 +52,7 @@
               v-if="message.citations"
               :message="message"
               :index="index"
-              :collapsed="!!collapsedCitations[getMessageKey(message)]"
+              :collapsed="collapsedCitations[getMessageKey(message)] ?? true"
               @update:collapsed="collapsedCitations[getMessageKey(message)] = $event"
             />
 
@@ -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 等祖先,导致整页上移、底部露出空白

+ 22 - 2
src/views/ventAI/manageAssistent/components/chat/ConversationNav.vue

@@ -71,6 +71,26 @@
       .replace(/\s+/g, ' ')
       .trim();
 
+  // toPlainText 是纯函数,但 qaPairs 在流式期间每个 token 都会重算:历史消息的正文一字未改,
+  // 却要跟着重跑上面整串正则(长会话下等于「每个 token × 全部历史正文」)。按原文记忆化,命中直接返回。
+  // 流式中的消息由调用方传 cacheable=false 排除——它每个 token 都变,写进缓存只会在 FIFO 淘汰下
+  // 把历史消息的条目一条条挤出去,反而让后续 token 全部落空。
+  const PLAIN_TEXT_CACHE_MAX = 200;
+  const plainTextCache = new Map<string, string>();
+
+  const toPlainTextCached = (md: string, cacheable = true): string => {
+    if (!cacheable) return toPlainText(md);
+    const cached = plainTextCache.get(md);
+    if (cached !== undefined) return cached;
+    const plain = toPlainText(md);
+    if (plainTextCache.size >= PLAIN_TEXT_CACHE_MAX) {
+      const oldestKey = plainTextCache.keys().next().value as string | undefined;
+      if (oldestKey !== undefined) plainTextCache.delete(oldestKey);
+    }
+    plainTextCache.set(md, plain);
+    return plain;
+  };
+
   const truncateText = (text: string, max = 50): string => (text.length > max ? `${text.slice(0, max)}…` : text);
 
   // 一轮问答 = 一条用户消息 + 其后的连续 AI 消息
@@ -80,10 +100,10 @@
     props.messages.forEach((msg, index) => {
       if (msg.type === 'user') {
         if (current) pairs.push(current);
-        current = { startIndex: index, question: truncateText(toPlainText(msg.content)), answer: '' };
+        current = { startIndex: index, question: truncateText(toPlainTextCached(msg.content)), answer: '' };
       } else {
         if (!current) current = { startIndex: index, question: '', answer: '' };
-        const part = msg.isLoading && !msg.content ? '正在生成...' : toPlainText(msg.content);
+        const part = msg.isLoading && !msg.content ? '正在生成...' : toPlainTextCached(msg.content, !msg.isLoading);
         if (part) current.answer = truncateText(current.answer ? `${current.answer} ${part}` : part);
       }
     });

+ 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;
       }

+ 9 - 13
src/views/ventAI/manageAssistent/components/panels/SkillPanel.vue

@@ -56,20 +56,11 @@
 </template>
 
 <script setup lang="ts">
-  import { ref, reactive, computed, watch } from 'vue';
-  import { getSkills, toggleSkill, getSkillDetails } from '/@/views/ventAI/manageAssistent/api/client';
+  import { ref, reactive, computed } from 'vue';
+  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

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

+ 319 - 0
src/views/ventAI/manageAssistent/components/resource/CadPreview.vue

@@ -0,0 +1,319 @@
+<template>
+  <div class="cad-preview">
+    <!-- DXF:本地解析 + canvas 渲染,不依赖任何后端服务 -->
+    <div class="cad-dxf">
+      <div v-if="dxfError" class="cad-hint">{{ dxfError }}</div>
+      <div v-else class="cad-canvas-area">
+        <canvas ref="canvasRef" class="cad-canvas" @wheel.prevent="onWheel" @mousedown="onMouseDown" @dblclick="fitView"></canvas>
+        <div class="cad-toolbar">
+          <span class="cad-stat">{{ dxfStat }}</span>
+          <a-button size="small" type="link" @click="fitView">适应窗口</a-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+  import { parseDxf } from '/@/views/ventAI/manageAssistent/lib/dxf';
+  import type { DxfEntity, DxfParseResult } from '/@/views/ventAI/manageAssistent/lib/dxf';
+  import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
+
+  /**
+   * 右侧面板的 CAD 预览:本地解析 DXF 并在 canvas 上绘制(可滚轮缩放、拖拽平移),不依赖任何后端服务。
+   *
+   * 当前只处理 .dxf——面板仅在 isDxfFile 命中时才渲染本组件。
+   * .dwg(专有二进制,需服务端转换)与 .mxweb(依赖 mxcad 及其配套服务)暂不预览,
+   * 由 fetchPreviewFile 返回 null 走"回退为下载"的老路径。
+   */
+  const props = defineProps<{ file: AttachedFile }>();
+
+  // —— DXF ——
+  const canvasRef = ref<HTMLCanvasElement>();
+  const dxfError = ref('');
+  const parsed = ref<DxfParseResult | null>(null);
+  const view = ref({ scale: 1, tx: 0, ty: 0 });
+  const dxfStat = computed(() => {
+    const r = parsed.value;
+    if (!r) return '';
+    return r.skipped > 0 ? `${r.entities.length} 个对象(跳过 ${r.skipped} 个不支持的对象)` : `${r.entities.length} 个对象`;
+  });
+
+  const loadDxf = () => {
+    parsed.value = null;
+    dxfError.value = '';
+    const buf = props.file.arrayBuffer;
+    const text = props.file.content ?? (buf ? new TextDecoder().decode(buf) : '');
+    if (!text.trim()) {
+      dxfError.value = '未取到 DXF 内容,无法预览';
+      return;
+    }
+    const result = parseDxf(text);
+    if (!result.entities.length) {
+      dxfError.value = result.skipped ? `未解析到可显示的图形(跳过 ${result.skipped} 个不支持的实体)` : 'DXF 中没有可显示的实体';
+      return;
+    }
+    parsed.value = result;
+    fitView();
+  };
+
+  /** 按图形范围缩放到画布(留 10% 边距) */
+  const fitView = () => {
+    const canvas = canvasRef.value;
+    if (!canvas || !parsed.value) return;
+    const w = canvas.clientWidth || 1;
+    const h = canvas.clientHeight || 1;
+    const b = parsed.value.bounds;
+    if (!b) {
+      view.value = { scale: 1, tx: w / 2, ty: h / 2 };
+      draw();
+      return;
+    }
+    const spanX = Math.max(b.maxX - b.minX, 1e-6);
+    const spanY = Math.max(b.maxY - b.minY, 1e-6);
+    const scale = Math.min(w / spanX, h / spanY) * 0.9;
+    const cx = (b.minX + b.maxX) / 2;
+    const cy = (b.minY + b.maxY) / 2;
+    // 纵向取加号:绘制时 y 轴翻转(screenY = ty - y * scale)
+    view.value = { scale, tx: w / 2 - cx * scale, ty: h / 2 + cy * scale };
+    draw();
+  };
+
+  const pathEntity = (ctx: CanvasRenderingContext2D, e: DxfEntity) => {
+    ctx.beginPath();
+    switch (e.kind) {
+      case 'line':
+        ctx.moveTo(e.a.x, e.a.y);
+        ctx.lineTo(e.b.x, e.b.y);
+        break;
+      case 'polyline': {
+        if (!e.points.length) return;
+        ctx.moveTo(e.points[0].x, e.points[0].y);
+        for (let i = 1; i < e.points.length; i++) ctx.lineTo(e.points[i].x, e.points[i].y);
+        if (e.closed) ctx.closePath();
+        break;
+      }
+      case 'circle':
+        ctx.arc(e.center.x, e.center.y, Math.max(e.radius, 0), 0, Math.PI * 2);
+        break;
+      case 'arc':
+        // 绘制变换含 y 翻转,而 arc 的参数 t 恰好等于 DXF 角度(cos/sin 直接对应图纸坐标),
+        // 所以取 false(t 递增)才是 DXF 意义上的逆时针短弧;取 true 会画成 270° 的补弧(实测如此)。
+        ctx.arc(e.center.x, e.center.y, Math.max(e.radius, 0), (e.startDeg * Math.PI) / 180, (e.endDeg * Math.PI) / 180, false);
+        break;
+      case 'ellipse':
+        ctx.ellipse(
+          e.center.x,
+          e.center.y,
+          Math.max(e.rx, 0),
+          Math.max(e.ry, 0),
+          (e.rotationDeg * Math.PI) / 180,
+          (e.startDeg * Math.PI) / 180,
+          (e.endDeg * Math.PI) / 180,
+          false
+        );
+        break;
+      case 'point': {
+        // 单点必须画成小十字,否则 stroke 什么都看不到
+        const r = 1.5 / (view.value.scale || 1);
+        ctx.moveTo(e.at.x - r, e.at.y);
+        ctx.lineTo(e.at.x + r, e.at.y);
+        ctx.moveTo(e.at.x, e.at.y - r);
+        ctx.lineTo(e.at.x, e.at.y + r);
+        break;
+      }
+      default:
+        break;
+    }
+  };
+
+  const drawTextEntity = (ctx: CanvasRenderingContext2D, e: Extract<DxfEntity, { kind: 'text' }>) => {
+    const { scale, tx, ty } = view.value;
+    const px = e.height * scale;
+    // 太小看不清、太大(图纸被缩得极小或字号本就夸张)就不画,避免糊满整屏
+    if (!Number.isFinite(px) || px < 5 || px > 120) return;
+    ctx.save();
+    ctx.translate(tx + e.at.x * scale, ty - e.at.y * scale);
+    if (e.rotationDeg) ctx.rotate((-e.rotationDeg * Math.PI) / 180);
+    ctx.font = `${px}px sans-serif`;
+    ctx.fillStyle = '#9fb3c8';
+    ctx.fillText(e.text, 0, 0);
+    ctx.restore();
+  };
+
+  const draw = () => {
+    const canvas = canvasRef.value;
+    const data = parsed.value;
+    if (!canvas || !data) return;
+    const dpr = window.devicePixelRatio || 1;
+    const w = canvas.clientWidth || 1;
+    const h = canvas.clientHeight || 1;
+    canvas.width = Math.round(w * dpr);
+    canvas.height = Math.round(h * dpr);
+    const ctx = canvas.getContext('2d');
+    if (!ctx) return;
+    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+    ctx.clearRect(0, 0, w, h);
+
+    const { scale, tx, ty } = view.value;
+    ctx.save();
+    ctx.translate(tx, ty);
+    // DXF 的 y 轴向上,canvas 向下 → 纵向取负;线宽除以 scale 保持屏幕上恒为 1px
+    ctx.scale(scale, -scale);
+    ctx.strokeStyle = '#c9d6e4';
+    ctx.lineWidth = 1 / scale;
+    for (const e of data.entities) {
+      if (e.kind === 'text') continue;
+      pathEntity(ctx, e);
+      ctx.stroke();
+    }
+    ctx.restore();
+
+    // 文字在翻转后的坐标系里会被镜像,单独按屏幕坐标绘制
+    for (const e of data.entities) {
+      if (e.kind === 'text') drawTextEntity(ctx, e);
+    }
+  };
+
+  // 平移/缩放
+  let dragging = false;
+  let lastX = 0;
+  let lastY = 0;
+
+  const onMouseMove = (ev: MouseEvent) => {
+    if (!dragging) return;
+    const v = view.value;
+    view.value = { ...v, tx: v.tx + (ev.clientX - lastX), ty: v.ty + (ev.clientY - lastY) };
+    lastX = ev.clientX;
+    lastY = ev.clientY;
+    draw();
+  };
+
+  const onMouseUp = () => {
+    dragging = false;
+    window.removeEventListener('mousemove', onMouseMove);
+    window.removeEventListener('mouseup', onMouseUp);
+  };
+
+  const onMouseDown = (ev: MouseEvent) => {
+    dragging = true;
+    lastX = ev.clientX;
+    lastY = ev.clientY;
+    window.addEventListener('mousemove', onMouseMove);
+    window.addEventListener('mouseup', onMouseUp);
+  };
+
+  const onWheel = (ev: WheelEvent) => {
+    const canvas = canvasRef.value;
+    if (!canvas) return;
+    const rect = canvas.getBoundingClientRect();
+    const px = ev.clientX - rect.left;
+    const py = ev.clientY - rect.top;
+    const v = view.value;
+    const scale = Math.min(Math.max(v.scale * (ev.deltaY < 0 ? 1.15 : 1 / 1.15), 1e-6), 1e6);
+    // 以光标为不动点:tx' = px - (px - tx) * k
+    const k = scale / v.scale;
+    view.value = { scale, tx: px - (px - v.tx) * k, ty: py - (py - v.ty) * k };
+    draw();
+  };
+
+  let resizeObserver: ResizeObserver | null = null;
+
+  // 每次换文件后都要把观察者绑到「当前这块」画布:DXF 分支的画布是随文件类型重新创建的,
+  // 继续观察被替换掉的旧画布,尺寸变化就不会再触发重绘。
+  const setupResizeObserver = () => {
+    resizeObserver?.disconnect();
+    resizeObserver = null;
+    const el = canvasRef.value;
+    if (!el) return;
+    resizeObserver = new ResizeObserver(() => {
+      if (parsed.value) fitView();
+    });
+    resizeObserver.observe(el);
+  };
+
+  // 面板实例会在多个文件标签之间复用(RightPanelTabs 没加 :key),换文件必须重新初始化。
+  // 必须先等一次 nextTick:面板实例在多个文件标签间复用,此刻模板还停在上一个文件的分支上
+  //(例如文本预览块),画布尚未挂载,canvasRef 为空会让 fitView 直接 return,绘制被静默跳过——
+  // 表现就是面板一片空白,得手动点一次「适应窗口」才出图。
+  const init = async () => {
+    onMouseUp();
+    parsed.value = null;
+    dxfError.value = '';
+    await nextTick();
+    loadDxf();
+    setupResizeObserver();
+  };
+
+  onMounted(() => {
+    void init();
+  });
+
+  watch(
+    () => props.file,
+    () => {
+      void init();
+    }
+  );
+
+  onBeforeUnmount(() => {
+    onMouseUp();
+    resizeObserver?.disconnect();
+    resizeObserver = null;
+  });
+</script>
+
+<style scoped lang="less">
+  .cad-preview {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    min-height: 0;
+  }
+
+  .cad-dxf {
+    display: flex;
+    flex-direction: column;
+    flex: 1;
+    min-height: 0;
+  }
+
+  .cad-canvas-area {
+    position: relative;
+    flex: 1;
+    min-height: 320px;
+  }
+
+  .cad-canvas {
+    display: block;
+    width: 100%;
+    height: 100%;
+    cursor: grab;
+    background: rgb(13 21 32 / 55%);
+
+    &:active {
+      cursor: grabbing;
+    }
+  }
+
+  .cad-toolbar {
+    position: absolute;
+    right: 8px;
+    bottom: 8px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .cad-stat {
+    font-size: 12px;
+    color: #8b949e;
+  }
+
+  .cad-hint {
+    padding: 12px 16px;
+    font-size: 13px;
+    color: #8b949e;
+  }
+</style>

+ 47 - 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">
@@ -87,6 +87,9 @@
         <div v-else-if="isMdFile(file.name)" class="markdown-preview">
           <div class="markdown-body" v-html="renderedMarkdown" @click="handleTableAction"></div>
         </div>
+        <div v-else-if="isDxfFile(file.name)" class="cad-preview-wrapper">
+          <CadPreview :file="file" />
+        </div>
         <div v-else-if="isTextFile(file.name)" class="text-preview">
           <pre>{{ file.content || '文件内容加载中...' }}</pre>
         </div>
@@ -103,7 +106,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,
@@ -113,28 +116,24 @@
     isCsvFile,
     isMdFile,
     isTextFile,
+    isDxfFile,
     getFileExtension,
     getFileIconVar,
     renderMarkdown,
     wrapMarkdownCodeBlocks,
+    getCssUrl,
+    getTableIcons,
+    copyToClipboard,
+    flashCopySuccess,
   } from '/@/views/ventAI/manageAssistent/lib/index';
   import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
+  import CadPreview from '/@/views/ventAI/manageAssistent/components/resource/CadPreview.vue';
 
   // [perf-base-v1] 卡2c: VueOfficeDocx 改异步加载,仅打开 docx 预览时才拉取 office-preview chunk
   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 +181,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 +228,7 @@
     } catch (err) {
       console.error('Excel解析失败:', err);
       excelSheets.value = [];
+      excelParseFailed.value = true;
     } finally {
       excelLoading.value = false;
     }
@@ -231,6 +237,9 @@
   watch(
     () => props.file,
     (file) => {
+      // 换文件时先清掉上一个文件的失败态,否则错误提示会跟着新文件一起显示
+      excelParseFailed.value = false;
+      wordPreviewFailed.value = false;
       if (isExcelFile(file.name)) {
         parseExcel(file);
       }
@@ -250,38 +259,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 +271,7 @@
 
     switch (action) {
       case 'copy-md': {
-        copyToClipboard(content).then(() => showCopySuccess(target));
+        copyToClipboard(content).then(() => flashCopySuccess(target));
         break;
       }
       case 'download-md': {
@@ -388,6 +365,8 @@
 
   const handleError = (err: any) => {
     console.error('文件预览失败:', err);
+    // 之前只打日志:渲染失败的 docx 会留下一片空白面板,看不出是坏了还是本来就没内容
+    wordPreviewFailed.value = true;
   };
 </script>
 
@@ -904,6 +883,16 @@
       }
     }
 
+    // 与 excel 分支同样撑满内容区(抵消外层 16px padding),让画布拿到真实高度
+    .cad-preview-wrapper {
+      display: flex;
+      flex-direction: column;
+      width: calc(100% + 32px);
+      height: calc(100% + 32px);
+      margin: -16px;
+      overflow: hidden;
+    }
+
     .edit-mode {
       display: flex;
       flex-direction: column;

+ 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();
         }
       );

+ 15 - 5
src/views/ventAI/manageAssistent/hooks/useChatFiles.ts

@@ -2,7 +2,7 @@ import { ref } from 'vue';
 import dayjs from 'dayjs';
 import { message } from 'ant-design-vue';
 import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
-import { isAudioFile, isCsvFile, isExcelFile, isTextFile, isVideoFile, isWordFile } from '/@/views/ventAI/manageAssistent/lib/index';
+import { isAudioFile, isCsvFile, isDxfFile, isExcelFile, isTextFile, isVideoFile, isWordFile } from '/@/views/ventAI/manageAssistent/lib/index';
 import type ChatInputArea from '/@/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue';
 
 /** 输入区附件:选择文件 → 按类型读取(文本/二进制/预览)→ 待发送状态 */
@@ -44,20 +44,30 @@ export const useChatFiles = (getChatInput: () => InstanceType<typeof ChatInputAr
       const reader = new FileReader();
       activeFileReader.value = reader;
 
+      // 读取方式只判断一次:onload 与 readAs* 两处各写一遍条件容易走偏。
+      // .dxf 必须在此显式分流到文本:否则会落到 dataURL 分支,附件里只有 preview、拿不到 content,
+      // DXF 预览面板只能显示空白。
+      const readMode =
+        isTextFile(file.name) || isCsvFile(file.name) || isDxfFile(file.name)
+          ? 'text'
+          : isWordFile(file.name) || isExcelFile(file.name)
+            ? 'binary'
+            : 'dataurl';
+
       reader.onload = (e) => {
         activeFileReader.value = null;
-        if (isTextFile(file.name) || isCsvFile(file.name)) {
+        if (readMode === 'text') {
           newFile.content = e.target?.result as string;
-        } else if (isWordFile(file.name) || isExcelFile(file.name)) {
+        } else if (readMode === 'binary') {
           newFile.arrayBuffer = e.target?.result as ArrayBuffer;
         } else {
           newFile.preview = e.target?.result as string;
         }
       };
 
-      if (isTextFile(file.name) || isCsvFile(file.name)) {
+      if (readMode === 'text') {
         reader.readAsText(file);
-      } else if (isWordFile(file.name) || isExcelFile(file.name)) {
+      } else if (readMode === 'binary') {
         reader.readAsArrayBuffer(file);
       } else {
         reader.readAsDataURL(file);

+ 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,
   };

+ 3 - 7
src/views/ventAI/manageAssistent/hooks/useChatScroll.ts

@@ -30,14 +30,10 @@ export const useChatScroll = (getChatMessages: () => InstanceType<typeof ChatMes
     }
   };
 
-  // [审计-第四轮 R4-8] 卸载时取消挂起的 rAF。
-  // 原行为:scrollRafId 仅在重入时 cancel 自身,组件卸载后不 cancel,残留回调会在卸载后
-  //         再执行一次(内部全 optional chaining,无崩溃、无累积,但无意义)。
-  // 现行为:onBeforeUnmount 里若有挂起 rAF 则 cancel 并复位。
-  // 为什么:定时器/动画帧挂摘对称,消除卸载后的空转回调。
-  // 注:本 composable 仅在组件 setup 内调用(AiAssistantModal),onBeforeUnmount 注册到该组件上。
+  // 弹框是 destroyOnClose,卸载时可能正压着一帧待执行的滚动:显式取消,避免在已销毁的组件上跑 nextTick
+  // [审计-第四轮 R4-8] 与审计分支同源修复:卸载时取消挂起 rAF(上游重构已自带,取上游实现)
   onBeforeUnmount(() => {
-    if (scrollRafId !== null) {
+    if (scrollRafId) {
       cancelAnimationFrame(scrollRafId);
       scrollRafId = null;
     }

+ 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,

+ 20 - 11
src/views/ventAI/manageAssistent/hooks/useChatStream.ts

@@ -1,6 +1,7 @@
-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';
+import { buildReportDownloadUrl } from '/@/views/ventAI/manageAssistent/api';
 
 // 工具名 → 中文智能体名(agent 分组标题用)。仅本模块使用,随事件映射一起内聚在此
 const AGENT_NAME_MAP: Record<string, string> = {
@@ -58,23 +59,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 || '');
@@ -200,7 +207,8 @@ export const useChatStream = (hooks: ChatStreamHooks) => {
         });
         if (data.download_url) {
           if (!aiMsg.downloadFiles) aiMsg.downloadFiles = [];
-          aiMsg.downloadFiles.push({ url: data.download_url, filename: data.filename || '文件' });
+          // 与历史加载一致:后端返回的报告地址前缀不稳定,入状态前统一归一(见 buildReportDownloadUrl)
+          aiMsg.downloadFiles.push({ url: buildReportDownloadUrl(data.download_url), filename: data.filename || '文件' });
         }
         break;
       }
@@ -270,8 +278,9 @@ export const useChatStream = (hooks: ChatStreamHooks) => {
 
       case 'word_download':
         if (normalizedContent) {
-          aiMsg.wordDownloadUrl = normalizedContent;
-          streamingTask.wordUrl = normalizedContent;
+          const reportUrl = buildReportDownloadUrl(normalizedContent);
+          aiMsg.wordDownloadUrl = reportUrl;
+          streamingTask.wordUrl = reportUrl;
         }
         break;
 
@@ -320,7 +329,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;

+ 3 - 2
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 { buildReportDownloadUrl, 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';
 
@@ -162,7 +162,8 @@ export const useChatTasks = () => {
       } else if (item.role === 'assistant') {
         const msg: Message = { id: item.id, type: 'ai', content: normalizedContent, time, createdAt };
         if (item.report_url && item.report_filename) {
-          msg.downloadFiles = [{ url: item.report_url, filename: item.report_filename }];
+          // 后端返回的报告地址前缀不稳定(绝对地址 / 缺 /ventAI 的相对路径),入前端状态前统一归一
+          msg.downloadFiles = [{ url: buildReportDownloadUrl(item.report_url), filename: item.report_filename }];
         }
         if (item.citations) {
           // 历史接口返回 citations 为扁平数组,SSE 事件返回 { items, model_only },统一归一化

+ 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);
+};

+ 270 - 0
src/views/ventAI/manageAssistent/lib/dxf.ts

@@ -0,0 +1,270 @@
+// lib/dxf.ts:极简 DXF 解析(只取"画二维预览"需要的实体)。
+//
+// 为什么不引三方库:three 的 examples 里已无 DXFLoader,而 dxf-parser / three-dxf 需要新增依赖;
+// 这里只覆盖常见几何与文字实体(line/polyline/circle/arc/ellipse/text/point),
+// 不支持的实体按个数统计返回(skipped),由界面如实告知——避免"看着像空图"的误导。
+//
+// DXF 是"组码 + 值"交替两行一组的纯文本格式,且 y 轴向上(与 canvas 相反,渲染时翻转)。
+
+export interface DxfPoint {
+  x: number;
+  y: number;
+}
+
+export type DxfEntity =
+  | { kind: 'line'; a: DxfPoint; b: DxfPoint }
+  | { kind: 'polyline'; points: DxfPoint[]; closed: boolean }
+  | { kind: 'circle'; center: DxfPoint; radius: number }
+  | { kind: 'arc'; center: DxfPoint; radius: number; startDeg: number; endDeg: number }
+  | { kind: 'ellipse'; center: DxfPoint; rx: number; ry: number; rotationDeg: number; startDeg: number; endDeg: number }
+  | { kind: 'text'; at: DxfPoint; height: number; text: string; rotationDeg: number }
+  | { kind: 'point'; at: DxfPoint };
+
+export interface DxfBounds {
+  minX: number;
+  minY: number;
+  maxX: number;
+  maxY: number;
+}
+
+export interface DxfParseResult {
+  entities: DxfEntity[];
+  /** 未支持的实体个数(含 INSERT/SPLINE/HATCH/DIMENSION 等) */
+  skipped: number;
+  bounds: DxfBounds | null;
+}
+
+/** 一个实体在解析过程中的原始组码序列(保序,LWPOLYLINE 的顶点要靠顺序成对还原) */
+interface RawEntity {
+  type: string;
+  codes: Array<[number, string]>;
+}
+
+const num = (raw: RawEntity, code: number, fallback = 0): number => {
+  const hit = raw.codes.find((c) => c[0] === code);
+  const v = hit ? Number.parseFloat(hit[1]) : Number.NaN;
+  return Number.isFinite(v) ? v : fallback;
+};
+
+/** 位标志(code 70):第 1 位表示闭合 */
+const isClosed = (raw: RawEntity): boolean => (Math.trunc(num(raw, 70, 0)) & 1) === 1;
+
+const DEG = 180 / Math.PI;
+
+/** 把 LWPOLYLINE 里交替出现的 10/20 组码还原成顶点序列 */
+const polylinePoints = (raw: RawEntity): Array<{ x: number; y: number }> => {
+  const points: Array<{ x: number; y: number }> = [];
+  let pendingX: number | null = null;
+  for (const [code, value] of raw.codes) {
+    if (code === 10) {
+      pendingX = Number.parseFloat(value);
+    } else if (code === 20 && pendingX !== null) {
+      const y = Number.parseFloat(value);
+      if (Number.isFinite(pendingX) && Number.isFinite(y)) points.push({ x: pendingX, y });
+      pendingX = null;
+    }
+  }
+  return points;
+};
+
+/** 按"组码 值"两行一组切分;容错:空行/非数字组码行只跳过该行,不错位后续内容 */
+const toPairs = (dxfText: string): Array<[number, string]> => {
+  const lines = dxfText.split(/\r\n|\r|\n/);
+  const pairs: Array<[number, string]> = [];
+  let pendingCode: number | null = null;
+  for (const line of lines) {
+    if (pendingCode === null) {
+      const code = Number.parseInt(line.trim(), 10);
+      // 组码行必须是整数;空行等异常行忽略,继续等下一个组码
+      if (Number.isFinite(code)) pendingCode = code;
+      continue;
+    }
+    pairs.push([pendingCode, line]);
+    pendingCode = null;
+  }
+  return pairs;
+};
+
+/** 把原始实体转成可渲染实体;不支持的返回 null(由调用方计入 skipped) */
+const toEntity = (raw: RawEntity, verticesOfPolyline: Array<{ x: number; y: number }> | null): DxfEntity | null => {
+  switch (raw.type) {
+    case 'LINE':
+      return { kind: 'line', a: { x: num(raw, 10), y: num(raw, 20) }, b: { x: num(raw, 11), y: num(raw, 21) } };
+    case 'LWPOLYLINE': {
+      const points = polylinePoints(raw);
+      return points.length >= 2 ? { kind: 'polyline', points, closed: isClosed(raw) } : null;
+    }
+    case 'POLYLINE': {
+      // 顶点在后续的 VERTEX 实体里,由调用方收集后传入
+      const points = verticesOfPolyline ?? [];
+      return points.length >= 2 ? { kind: 'polyline', points, closed: isClosed(raw) } : null;
+    }
+    case 'CIRCLE':
+      return { kind: 'circle', center: { x: num(raw, 10), y: num(raw, 20) }, radius: num(raw, 40) };
+    case 'ARC':
+      return {
+        kind: 'arc',
+        center: { x: num(raw, 10), y: num(raw, 20) },
+        radius: num(raw, 40),
+        startDeg: num(raw, 50),
+        endDeg: num(raw, 51),
+      };
+    case 'ELLIPSE': {
+      // 11/21 是"相对圆心的长轴端点",40 是短长轴比,41/42 是起止参数(弧度,非角度)
+      const mx = num(raw, 11);
+      const my = num(raw, 21);
+      const rx = Math.hypot(mx, my);
+      const ratio = num(raw, 40, 1) || 1;
+      return {
+        kind: 'ellipse',
+        center: { x: num(raw, 10), y: num(raw, 20) },
+        rx,
+        ry: rx * Math.abs(ratio),
+        rotationDeg: Math.atan2(my, mx) * DEG,
+        startDeg: num(raw, 41, 0) * DEG,
+        endDeg: num(raw, 42, Math.PI * 2) * DEG,
+      };
+    }
+    case 'TEXT':
+    case 'MTEXT': {
+      // MTEXT 长文本会拆成 1/3 两个组码,这里拼接
+      const body = raw.codes
+        .filter((c) => c[0] === 1 || c[0] === 3)
+        .map((c) => c[1])
+        .join('');
+      return { kind: 'text', at: { x: num(raw, 10), y: num(raw, 20) }, height: num(raw, 40), text: body, rotationDeg: num(raw, 50) };
+    }
+    case 'POINT':
+      return { kind: 'point', at: { x: num(raw, 10), y: num(raw, 20) } };
+    case 'SOLID':
+    case '3DFACE': {
+      // 四个角点,按闭合折线画(预览足够)
+      const corners = [
+        { x: num(raw, 10), y: num(raw, 20) },
+        { x: num(raw, 11), y: num(raw, 21) },
+        { x: num(raw, 12), y: num(raw, 22) },
+        { x: num(raw, 13), y: num(raw, 23) },
+      ];
+      return { kind: 'polyline', points: corners, closed: true };
+    }
+    default:
+      return null;
+  }
+};
+
+const growBounds = (b: DxfBounds, x: number, y: number): void => {
+  if (!Number.isFinite(x) || !Number.isFinite(y)) return;
+  if (x < b.minX) b.minX = x;
+  if (y < b.minY) b.minY = y;
+  if (x > b.maxX) b.maxX = x;
+  if (y > b.maxY) b.maxY = y;
+};
+
+const entityBounds = (e: DxfEntity, b: DxfBounds): void => {
+  switch (e.kind) {
+    case 'line':
+      growBounds(b, e.a.x, e.a.y);
+      growBounds(b, e.b.x, e.b.y);
+      break;
+    case 'polyline':
+      e.points.forEach((p) => growBounds(b, p.x, p.y));
+      break;
+    case 'circle':
+    case 'arc':
+      growBounds(b, e.center.x - e.radius, e.center.y - e.radius);
+      growBounds(b, e.center.x + e.radius, e.center.y + e.radius);
+      break;
+    case 'ellipse': {
+      const r = Math.max(e.rx, e.ry);
+      growBounds(b, e.center.x - r, e.center.y - r);
+      growBounds(b, e.center.x + r, e.center.y + r);
+      break;
+    }
+    case 'text':
+      growBounds(b, e.at.x, e.at.y);
+      // 文字高度也算进范围,避免只有标注的图被"缩放到看不见"
+      growBounds(b, e.at.x + e.text.length * e.height * 0.6, e.at.y + e.height);
+      break;
+    case 'point':
+      growBounds(b, e.at.x, e.at.y);
+      break;
+  }
+};
+
+/**
+ * 解析 DXF 文本。只读 ENTITIES 段;解析失败/无实体时返回空列表,不抛异常。
+ */
+export const parseDxf = (dxfText: string): DxfParseResult => {
+  const entities: DxfEntity[] = [];
+  let skipped = 0;
+  const pairs = toPairs(dxfText);
+
+  let inEntities = false;
+  let raw: RawEntity | null = null;
+  // POLYLINE 的顶点来自后续 VERTEX 实体,需要跨实体累积
+  let polylineVertices: Array<{ x: number; y: number }> | null = null;
+
+  const flush = () => {
+    if (!raw) return;
+    const built = toEntity(raw, raw.type === 'POLYLINE' ? polylineVertices : null);
+    if (built) entities.push(built);
+    else skipped++;
+    raw = null;
+    polylineVertices = null;
+  };
+
+  for (let i = 0; i < pairs.length; i++) {
+    const [code, value] = pairs[i];
+    const token = value.trim();
+
+    if (code === 0) {
+      // 实体/段的边界
+      if (raw?.type === 'POLYLINE' && token === 'VERTEX') {
+        // 顶点属于父 POLYLINE:这里只收集它的组码,不 flush 父实体
+        const vertex: RawEntity = { type: 'VERTEX', codes: [] };
+        const vCodes: Array<[number, string]> = [];
+        let j = i + 1;
+        while (j < pairs.length && pairs[j][0] !== 0) {
+          vCodes.push(pairs[j]);
+          j++;
+        }
+        vertex.codes = vCodes;
+        const vx = num(vertex, 10);
+        const vy = num(vertex, 20);
+        if (Number.isFinite(vx) && Number.isFinite(vy)) polylineVertices!.push({ x: vx, y: vy });
+        i = j - 1;
+        continue;
+      }
+
+      flush();
+
+      if (token === 'SECTION') {
+        // 下一组是段名
+        const name = pairs[i + 1]?.[1]?.trim();
+        inEntities = name === 'ENTITIES';
+        if (pairs[i + 1]) i++;
+        continue;
+      }
+      if (token === 'ENDSEC') {
+        inEntities = false;
+        continue;
+      }
+      if (!inEntities) continue; // BLOCKS/HEADER 等段内的实体不参与预览
+      raw = { type: token, codes: [] };
+      if (token === 'POLYLINE') polylineVertices = [];
+      continue;
+    }
+
+    if (raw && inEntities) raw.codes.push([code, value]);
+  }
+  flush();
+
+  let bounds: DxfBounds | null = null;
+  if (entities.length) {
+    bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
+    entities.forEach((e) => entityBounds(e, bounds!));
+    if (!Number.isFinite(bounds.minX) || bounds.minX > bounds.maxX) bounds = null;
+  }
+
+  return { entities, skipped, bounds };
+};

+ 8 - 4
src/views/ventAI/manageAssistent/lib/exportDoc.ts

@@ -51,9 +51,13 @@ export const markdownToDocxBlob = async (markdown: string, title?: string): Prom
   const { Document, Packer, Paragraph, TextRun, HeadingLevel, Table, TableRow, TableCell, WidthType, AlignmentType, UnderlineType } =
     (await import('docx')) as any;
 
+  // 上面解构出的是 any 值,不能直接当类型名使用(TS2749),这里给出等价的类型别名
+  type DocxRun = any;
+  type DocxBlock = any;
+
   // 行内 token 递归渲染为 TextRun 序列(加粗/斜体/删除线/行内代码/链接)
-  const renderInline = (tokens: any[], style: { bold?: boolean; italics?: boolean; strike?: boolean } = {}): TextRun[] => {
-    const runs: TextRun[] = [];
+  const renderInline = (tokens: any[], style: { bold?: boolean; italics?: boolean; strike?: boolean } = {}): DocxRun[] => {
+    const runs: DocxRun[] = [];
     for (const t of tokens || []) {
       switch (t.type) {
         case 'text':
@@ -93,7 +97,7 @@ export const markdownToDocxBlob = async (markdown: string, title?: string): Prom
     return runs.length ? runs : [new TextRun({ text: '' })];
   };
 
-  const children: (Paragraph | Table)[] = [];
+  const children: DocxBlock[] = [];
 
   if (title) {
     children.push(new Paragraph({ text: title, heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER }));
@@ -134,7 +138,7 @@ export const markdownToDocxBlob = async (markdown: string, title?: string): Prom
       }
       case 'code': {
         const lines = (t.text || '').split('\n');
-        const runs: TextRun[] = [];
+        const runs: DocxRun[] = [];
         lines.forEach((line: string, i: number) => {
           runs.push(new TextRun({ text: line, font: { name: 'Consolas' } }));
           if (i < lines.length - 1) runs.push(new TextRun({ break: 1 }));

+ 5 - 0
src/views/ventAI/manageAssistent/lib/fileIcon.ts

@@ -17,6 +17,11 @@ export const isExcelFile = (filename: string): boolean => /\.(xls|xlsx)$/i.test(
 
 export const isCsvFile = (filename: string): boolean => /\.csv$/i.test(filename);
 
+// —— CAD ——
+// 当前只支持 .dxf(明文交换格式,可本地解析渲染);
+// .dwg(专有二进制,需服务端转换)与 .mxweb(依赖 mxcad 及其配套服务)暂不预览,由 fetchPreviewFile 返回 null 回退为下载。
+export const isDxfFile = (filename: string): boolean => /\.dxf$/i.test(filename);
+
 export const isTextFile = (filename: string): boolean =>
   /\.(txt|md|json|csv|xml|yaml|yml|log|ini|toml|cfg|conf|env|gitignore|dockerignore|editorconfig|prettierrc|eslintrc|babelrc|properties|sh|bat|ps1|cmd|bash|zsh|fish|sql|graphql|proto|js|ts|jsx|tsx|vue|css|scss|sass|less|html|htm|svelte|py|java|kt|kts|c|cpp|h|hpp|cc|cxx|cs|go|rs|rb|php|swift|m|mm|r|R|lua|pl|pm|hs|ex|exs|erl|clj|scala|dart|zig|nim|v|sol|tf|hcl|gradle|cmake|makefile|mk)$/i.test(
     filename

+ 2 - 0
src/views/ventAI/manageAssistent/lib/filePreview.ts

@@ -15,6 +15,8 @@ export const fetchPreviewFile = async (url: string, filename: string): Promise<A
     yaml: 'text/yaml',
     yml: 'text/yaml',
     csv: 'text/csv',
+    // .dxf 是明文,按文本取回后由预览面板本地解析渲染
+    dxf: 'text/plain',
   };
   const id = `report-${url}`;
   const uploadTime = new Date().toISOString();

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

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

+ 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
@@ -145,7 +164,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">
@@ -172,7 +191,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;
+}