Jelajahi Sumber

[Mod 0000] AI助手接口层收敛:三个流式函数合一 + 响应类型化,并按实测校正接口契约

- 新增 api/streamPost.ts:令牌携带、非 2xx 结构化解码、SSE 事件消费、中断静默统一为唯一实现;
  api/chat.ts 三个流式入口只保留各自的请求体构造(161 → 115 行),请求体字段与顺序保持原语义
- dataPicker/api.ts 复用 streamPost,去掉第三份重复的 fetch + 错误解码实现,M1/M2 审计留档原样保留

- 36 处接口响应全部标注类型:15 个查询接口按真实响应声明类型,21 个命令类接口显式声明 unknown,
  非流式响应不再有 any 流动
- 消费端删除已死的多外壳探测 7 处(sessions / data / 裸数组三选一),历史消息归一化统一到 HistoryMessage,
  useMentionPopups 去掉本地重复的 SkillItem
- 按实际响应校正契约:getScheduleDetail 返回 { task } 而非裸对象;ScheduleTask 补齐后端实际返回的
  7 个字段;ScheduleRun 补齐 task_id / finished_at / input

验证:vue-tsc 与 ESLint 通过(仅剩全仓既有的 CRLF 噪音);真实后端实测覆盖对话流式、附件发送、
  审批恢复、点选解读、重新生成、编辑重发、定时任务面板与执行记录,console 无报错
wangkeyi 1 Minggu lalu
induk
melakukan
4762ae67a5

+ 16 - 36
src/views/ventAI/dataPicker/api.ts

@@ -1,9 +1,6 @@
-import { consumeSse } from '/@/views/ventAI/manageAssistent/api/sse';
-// [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3):复用对话框模块既有的结构化错误解析器,避免两套实现各自演化
-import { throwStreamHttpError } from '/@/views/ventAI/manageAssistent/api/errors';
+// dataPicker/api.ts:点选解读(巷道 / 设备)的 SSE 流式接口。
+import { streamPost } from '/@/views/ventAI/manageAssistent/api/streamPost';
 import type { SSERawEvent } from './types';
-// [审计-第四轮 M1] 重放自我方 fix/base-agent-token(M1/M2/M3):取基座登录态的令牌(与对话链路 manageAssistent/api/chat.ts 同源)
-import { getToken } from '/@/utils/auth';
 
 enum Api {
   clickTun = '/ventAI/api/interpret/click/tun',
@@ -36,41 +33,24 @@ export async function fetchDeviceInterpretation(
   return ssePost(Api.clickDevice, params, onEvent);
 }
 
-/** SSE 流式 POST 请求的通用实现 */
+/**
+ * SSE 流式 POST:请求体、令牌、错误解码、事件消费与中断处理统一交给 api/streamPost。
+ *
+ * [审计-第四轮 M1/M2] 重放自我方 fix/base-agent-token(M1/M2/M3)。本链路原先自造过 fetch + 错误处理,
+ * 收敛后语义由 streamPost 承接,在此留档,避免后续被当成「多余代码」误改:
+ * 1. 令牌是「可选」语义:getToken() 为空时仍照常发请求(TfAgents 侧有 click-anonymous 兜底)。
+ *    禁止加「未登录就拦截/跳登录」的判断(我方 D5 决策)——那会把未登录场景直接变成不可用。
+ *    补令牌的原因:原先是基座里唯一一条匿名调 TfAgents 的路径,消耗只能记到兜底账号,无法按登录人记账。
+ * 2. 非 2xx 复用 throwStreamHttpError 解析响应体 detail,抛出带 status/retryAfter/limitTokens/spentTokens
+ *    的 StreamError(文案由 useDataPicker 的 formatClickError() 渲染);原实现只抛「HTTP错误: 429」,
+ *    把后端给出的原因(额度耗尽等)整个丢掉。
+ * 3. 该接口只把 done / error 当终止事件(与统一对话不同,不含 interrupt)。
+ */
 async function ssePost(
   url: string,
   body: Record<string, string>,
   onEvent: (event: SSERawEvent) => void
 ): Promise<{ thread_id: string; session_id: string }> {
-  const response = await fetch(url, {
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
-      // [审计-第四轮 M1] 重放自我方 fix/base-agent-token(M1/M2/M3)
-      // 原行为:点选解读不带令牌,是基座里唯一一条「匿名」调 TfAgents 的路径,
-      //         消耗只能记到兜底账号 click-anonymous,无法按登录人记账。
-      // 现行为:补上与对话链路同名的请求头(TfAgents 侧读小写 x-access-token,HTTP 头不区分大小写)。
-      // 为什么:恢复按登录人记账归因。
-      // ⚠️ 令牌是「可选」语义:getToken() 为空时仍照常发请求(TfAgents 侧有 click-anonymous 兜底),
-      //    禁止加「未登录就拦截/跳登录」的判断(我方 D5 决策)——那会把未登录场景直接变成不可用。
-      'X-Access-Token': getToken(),
-    },
-    body: JSON.stringify(body),
-  });
-
-  if (!response.ok) {
-    // [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3)
-    // 原行为:throw new Error(`HTTP错误: ${response.status}`) —— 用户只看到「HTTP错误: 429」。
-    // 现行为:复用 manageAssistent/api/errors.ts 的 throwStreamHttpError,它会解析响应体的
-    //         detail(字符串或 { code, message, retry_after, limit_tokens, spent_tokens }),
-    //         抛出携带 status/retryAfter/limitTokens/spentTokens 的 StreamError;
-    //         最终文案由 useDataPicker.ts 的 formatClickError() 统一渲染。
-    // 为什么:429/413/404 等错误需要可读文案,且与对话链路共用一套解析器不重复造轮子。
-    await throwStreamHttpError(response);
-  }
-
-  // 该接口只把 done / error 当终止事件(与统一对话不同,不含 interrupt)
-  const { sessionId, threadId } = await consumeSse<SSERawEvent>(response, onEvent, { terminateOn: ['done', 'error'] });
-
+  const { sessionId, threadId } = await streamPost<SSERawEvent>(url, { body, label: '点选解读流式请求', terminateOn: ['done', 'error'] }, onEvent);
   return { thread_id: threadId, session_id: sessionId };
 }

+ 68 - 114
src/views/ventAI/manageAssistent/api/chat.ts

@@ -1,8 +1,8 @@
 // api/chat.ts:对话相关的三个 SSE 流式接口(统一对话 / PDF 审查 / 审批恢复)。
-// 三个入口共用 consumeSse 与 throwStreamHttpError,只是请求体构造方式不同。
-import { getToken } from '/@/utils/auth/index';
-import { consumeSse } from './sse';
-import { throwStreamHttpError } from './errors';
+//
+// 三个入口的差别只有「请求体怎么构造」和「日志前缀」,其余(带令牌、非 2xx 解码、消费事件流、
+// 中断处理、返回值归一)完全一致,统一走 streamPost,避免三份各自演化。
+import { streamPost } from './streamPost';
 import type { SseEvent } from '/@/views/ventAI/manageAssistent/types/index';
 
 enum Api {
@@ -10,53 +10,47 @@ enum Api {
   resume = '/ventAI/api/chat/resume',
 }
 
+/** 一次流式对话的结束信息 */
+export interface ChatStreamResult {
+  /** 终止事件携带的会话 id,未携带时为空串 */
+  session_id: string;
+  /** 解析失败被丢弃的 data 行数;大于 0 表示正文可能不完整 */
+  parse_errors: number;
+}
+
 /**
  * 统一对话接口(SSE流式响应)
  * @param params.message - 用户输入的文本,必填
  * @param params.session_id - 会话唯一标识ID,不传参时服务端自动生成全新会话ID
  * @param params.file - 上传的PDF文件,可选
+ * @param params.mode - 对话权限模式,可选
  * @param onChunk - 流式数据回调函数
- * @returns Promise<{ session_id: string; parse_errors: number }>
- *          parse_errors 大于 0 表示这条流里有事件被丢弃(正文可能不完整)
+ * @returns 会话 id 与丢弃事件计数(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;
+): Promise<ChatStreamResult> => {
+  const formData = new FormData();
+  // message 一律提交(允许空串):纯附件发送时 message 为空是既有契约,
+  // 不能因为「空值就跳过 append」把请求体变成另一种形状
+  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 { sessionId, parseErrors } = await streamPost<SseEvent>(
+    Api.unified,
+    { body: formData, label: '统一对话流式请求', signal: params.signal },
+    onChunk
+  );
+  return { session_id: sessionId, parse_errors: parseErrors };
 };
 
 /**
@@ -64,48 +58,32 @@ export const unifiedStream = async (
  * @param params.file - 配风计划PDF文件,必填
  * @param params.session_id - 会话唯一标识ID,可选
  * @param params.message - 附加消息/审查指令,可选
+ * @param params.mode - 对话权限模式,可选
  * @param onChunk - 流式数据回调函数
- * @returns Promise<{ session_id: string; parse_errors: number }>
- *          parse_errors 大于 0 表示这条流里有事件被丢弃(正文可能不完整)
+ * @returns 会话 id 与丢弃事件计数(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;
+): Promise<ChatStreamResult> => {
+  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 { sessionId, parseErrors } = await streamPost<SseEvent>(
+    Api.unified,
+    { body: formData, label: 'PDF审查流式请求', signal: params.signal },
+    onChunk
+  );
+  return { session_id: sessionId, parse_errors: parseErrors };
 };
 
 /**
@@ -115,47 +93,23 @@ export const reviewPdfStream = async (
  * @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 表示这条流里有事件被丢弃(正文可能不完整)
+ * @returns 会话 id 与丢弃事件计数(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;
+): Promise<ChatStreamResult> => {
+  const body: Record<string, unknown> = {
+    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 { sessionId, parseErrors } = await streamPost<SseEvent>(Api.resume, { body, label: '恢复会话流式请求', signal: params.signal }, onChunk);
+  return { session_id: sessionId, parse_errors: parseErrors };
 };

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

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

+ 10 - 2
src/views/ventAI/manageAssistent/api/model.ts

@@ -6,13 +6,21 @@ enum Api {
   thinking = '/ventAI/api/model/thinking',
 }
 
+/** 模型信息(GET /model 实测形状):当前模型、思考级别取值与可选级别 */
+export interface ModelInfoResponse {
+  current: string;
+  thinking_level: string;
+  thinking_levels: string[];
+}
+
 /**
  * 查询模型信息
  */
-export const getModelMsg = () => defHttp.get({ url: Api.model }, { isTransformResponse: false });
+export const getModelMsg = (): Promise<ModelInfoResponse> => defHttp.get<ModelInfoResponse>({ url: Api.model }, { isTransformResponse: false });
 
 /**
  * 切换模型思考深度
  * @param level - 思考级别 off/high/highest
  */
-export const switchThinking = (level: string) => defHttp.post({ url: Api.thinking, data: { level } }, { isTransformResponse: false });
+export const switchThinking = (level: string): Promise<unknown> =>
+  defHttp.post<unknown>({ url: Api.thinking, data: { level } }, { isTransformResponse: false });

+ 6 - 6
src/views/ventAI/manageAssistent/api/report.ts

@@ -33,16 +33,16 @@ export const buildReportDownloadUrl = (urlOrFilename: string) => {
  * @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 });
+export const saveReportText = (filename: string, content: string): Promise<unknown> =>
+  defHttp.put<unknown>({ 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(
+export const saveReportBinary = (filename: string, data: ArrayBuffer | Blob | Uint8Array): Promise<unknown> =>
+  defHttp.put<unknown>(
     {
       url: `${Api.saveReport}${encodeURIComponent(filename)}`,
       data,
@@ -56,5 +56,5 @@ export const saveReportBinary = (filename: string, data: ArrayBuffer | Blob | Ui
  * @param filename - docx 文件名
  * @param instruction - 修改要求(自然语言)
  */
-export const editWordReport = (filename: string, instruction: string) =>
-  defHttp.post({ url: Api.editWord, data: { filename, instruction } }, { isTransformResponse: false });
+export const editWordReport = (filename: string, instruction: string): Promise<unknown> =>
+  defHttp.post<unknown>({ url: Api.editWord, data: { filename, instruction } }, { isTransformResponse: false });

+ 58 - 12
src/views/ventAI/manageAssistent/api/schedule.ts

@@ -1,10 +1,51 @@
 // api/schedule.ts:定时任务(增删改查 / 启停 / 立即执行 / 执行记录)。
+//
+// 响应形状均已对真实响应核实(2026-10):
+//   GET /schedules            → { tasks, total }
+//   GET /schedules/{id}       → { task }
+//   GET /schedules/{id}/runs  → { runs, task_id, total },runs[] 元素为
+//                               { id, task_id, status, started_at, finished_at, duration_ms, input, output, error }
+// 命令类接口(增改删 / 启停 / 立即执行)的响应体没有调用方读取,声明为 unknown。
 import { defHttp } from '/@/utils/http/axios/index';
+import type { ScheduleTask } from '/@/views/ventAI/manageAssistent/types/index';
 
 enum Api {
   schedules = '/ventAI/api/schedules',
 }
 
+export interface ScheduleListResponse {
+  tasks: ScheduleTask[];
+  total: number;
+}
+
+/**
+ * 一次执行记录(GET /schedules/{id}/runs 响应的 runs[] 元素)。
+ * 仍在执行中的记录只会填到 started_at,后续字段按可选处理(面板渲染也一律先判空)。
+ */
+export interface ScheduleRun {
+  id: number;
+  task_id: number;
+  /** running / success / failed / skipped(见 SchedulePanel 的 runStatusLabel) */
+  status: string;
+  started_at: string;
+  finished_at?: string;
+  duration_ms?: number;
+  input?: string;
+  output?: string;
+  error?: string;
+}
+
+export interface ScheduleRunsResponse {
+  runs: ScheduleRun[];
+  task_id: number;
+  total: number;
+}
+
+/** 单条定时任务详情(GET /schedules/{id}):外层是 { task } 而不是裸对象,已对真实响应核实 */
+export interface ScheduleDetailResponse {
+  task: ScheduleTask;
+}
+
 /**
  * 定时任务接口
  */
@@ -14,25 +55,30 @@ export const createSchedule = (data: {
   cron_expr?: string;
   prompt: string;
   notify_webhook?: string;
-}) => defHttp.post({ url: Api.schedules, data }, { isTransformResponse: false });
+}): Promise<unknown> => defHttp.post<unknown>({ 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 getScheduleList = (include_disabled = true): Promise<ScheduleListResponse> =>
+  defHttp.get<ScheduleListResponse>({ 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 getScheduleDetail = (task_id: number): Promise<ScheduleDetailResponse> =>
+  defHttp.get<ScheduleDetailResponse>({ 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 });
+  data: { name?: string; schedule?: Record<string, unknown>; cron_expr?: string; prompt?: string; notify_webhook?: string }
+): Promise<unknown> => defHttp.put<unknown>({ 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 pauseSchedule = (task_id: number): Promise<unknown> =>
+  defHttp.post<unknown>({ 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 resumeSchedule = (task_id: number): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.schedules}/${task_id}/resume` }, { isTransformResponse: false });
 
-export const deleteSchedule = (id: number) => defHttp.delete({ url: `${Api.schedules}/${id}` }, { isTransformResponse: false });
+export const deleteSchedule = (id: number): Promise<unknown> =>
+  defHttp.delete<unknown>({ 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 getScheduleRuns = (task_id: number, limit = 50): Promise<ScheduleRunsResponse> =>
+  defHttp.get<ScheduleRunsResponse>({ 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 runScheduleNow = (task_id: number): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.schedules}/${task_id}/run` }, { isTransformResponse: false });

+ 89 - 11
src/views/ventAI/manageAssistent/api/session.ts

@@ -1,5 +1,9 @@
 // api/session.ts:会话(历史列表 / 详情 / 标题 / 置顶 / 权限模式 / 上下文用量)与消息删除。
+//
+// 响应形状均已对真实响应采样核实(2026-10);命令类接口(删除/置顶/改标题/改模式)的响应体没有
+// 调用方读取,故如实声明为 unknown,不用 any 也不假造一个没人验证过的外壳。
 import { defHttp } from '/@/utils/http/axios/index';
+import type { CitationItem, CitationsData } from '/@/views/ventAI/manageAssistent/types/index';
 
 // 统一用枚举寻址,避免同一域里模板串与枚举两套写法混用
 enum Api {
@@ -8,59 +12,133 @@ enum Api {
   messages = '/ventAI/api/messages/',
 }
 
+/** 会话列表项(GET /sessions 响应的 sessions[] 元素) */
+export interface SessionListItem {
+  session_id: string;
+  title: string;
+  user_name: string;
+  sort_order: number;
+  created_at: string;
+  updated_at: string;
+  message_count: number;
+}
+
+export interface SessionListResponse {
+  sessions: SessionListItem[];
+  total: number;
+}
+
+/**
+ * 历史消息(GET /chat/history/{id} 响应的 messages[] 元素)。
+ *
+ * role 为 'user' / 'assistant'。字段形状已对真实响应采样核实,另外保留两个「旧格式兼容」字段:
+ * type 与 tool_calls,当前后端不再返回,但历史数据里存在,归一化逻辑(transformHistoryToMessages)
+ * 仍然要吃它们,故如实声明为可选,而不是让消费端各自 as any。
+ */
+export interface HistoryMessage {
+  id: number;
+  session_id: string;
+  role: string;
+  content: string;
+  created_at: string;
+  /** 旧格式标记:'user_message' / 'model_thinking' */
+  type?: string;
+  /** 历史接口返回扁平数组,SSE 事件返回 { items, model_only },两种都要容 */
+  citations: CitationItem[] | CitationsData | null;
+  report_url: string | null;
+  report_filename: string | null;
+  /** 后端会返回但前端未消费,如实声明为 unknown 而不是 any */
+  tool_calls: unknown;
+}
+
+export interface SessionHistoryResponse {
+  session_id: string;
+  total: number;
+  messages: HistoryMessage[];
+}
+
+/** 会话权限模式(GET /sessions/{id}/mode) */
+export interface SessionModeResponse {
+  session_id: string;
+  mode: string;
+}
+
+/** 上下文容量(GET /sessions/{id}/context) */
+export interface SessionContextResponse {
+  session_id: string;
+  current_usage: number;
+  total_limit: number;
+  usage_pct: number;
+  updated_at: string;
+  breakdown: {
+    messages: number;
+    mcp: number;
+    skills: number;
+    system_prompt: number;
+    other: number;
+  };
+}
+
 /**
  * 历史会话接口
  */
-export const getHistoryList = (params) => defHttp.get({ url: Api.sessions, params }, { joinParamsToUrl: true, isTransformResponse: false });
+export const getHistoryList = (params: Record<string, unknown>): Promise<SessionListResponse> =>
+  defHttp.get<SessionListResponse>({ 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 });
+export const getDetail = (session_id: string): Promise<SessionHistoryResponse> =>
+  defHttp.get<SessionHistoryResponse>({ 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 });
+export const deleteSession = (session_id: string): Promise<unknown> =>
+  defHttp.delete<unknown>({ 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 });
+export const deleteMessage = (message_id: number): Promise<unknown> =>
+  defHttp.delete<unknown>({ 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 });
+export const pinSession = (session_id: string): Promise<unknown> =>
+  defHttp.put<unknown>({ 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 });
+export const editSessionTitle = (session_id: string, title: string): Promise<unknown> =>
+  defHttp.put<unknown>({ 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 });
+export const getSessionMode = (session_id: string): Promise<SessionModeResponse> =>
+  defHttp.get<SessionModeResponse>({ 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 });
+export const putSessionMode = (session_id: string, mode: string): Promise<unknown> =>
+  defHttp.put<unknown>({ 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 });
+export const getContext = (session_id: string): Promise<SessionContextResponse> =>
+  defHttp.get<SessionContextResponse>({ url: `${Api.sessions}/${session_id}/context` }, { isTransformResponse: false });

+ 20 - 5
src/views/ventAI/manageAssistent/api/skill.ts

@@ -1,33 +1,48 @@
 // api/skill.ts:模型技能(列表 / 详情 / 启停 / 上传)。
+//
+// 列表项形状与 types/domain.ts 的 Skill 一致,直接复用该类型,不再各写一份。
 import { defHttp } from '/@/utils/http/axios/index';
+import type { Skill } from '/@/views/ventAI/manageAssistent/types/index';
 
 enum Api {
   skills = '/ventAI/api/skills',
 }
 
+export interface SkillListResponse {
+  skills: Skill[];
+}
+
+/** 技能详情(GET /skills/{name}):SKILL.md 正文 */
+export interface SkillDetailResponse {
+  name: string;
+  content: string;
+}
+
 /**
  * 查询模型技能
  */
-export const getSkills = () => defHttp.get({ url: Api.skills }, { isTransformResponse: false });
+export const getSkills = (): Promise<SkillListResponse> => defHttp.get<SkillListResponse>({ url: Api.skills }, { isTransformResponse: false });
 
 /**
  * 查询模型技能详情
  */
-export const getSkillDetails = (name: string) => defHttp.get({ url: `${Api.skills}/${name}` }, { isTransformResponse: false });
+export const getSkillDetails = (name: string): Promise<SkillDetailResponse> =>
+  defHttp.get<SkillDetailResponse>({ url: `${Api.skills}/${name}` }, { isTransformResponse: false });
 
 /**
  * 启用/禁用模型技能
  */
-export const toggleSkill = (name: string) => defHttp.post({ url: `${Api.skills}/${name}/toggle` }, { isTransformResponse: false });
+export const toggleSkill = (name: string): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.skills}/${name}/toggle` }, { isTransformResponse: false });
 
 /**
  * 上传新技能
  * @param file - zip附件
  */
-export const uploadSkill = (file: File) => {
+export const uploadSkill = (file: File): Promise<unknown> => {
   const formData = new FormData();
   formData.append('file', file);
-  return defHttp.post(
+  return defHttp.post<unknown>(
     { url: `${Api.skills}/upload`, data: formData, headers: { 'Content-Type': 'multipart/form-data' } },
     { isTransformResponse: false }
   );

+ 70 - 0
src/views/ventAI/manageAssistent/api/streamPost.ts

@@ -0,0 +1,70 @@
+// api/streamPost.ts:SSE 流式请求的发起与收尾。
+//
+// 与 sse.ts 的分工:sse.ts 只负责「怎么把字节流解析成事件」,本文件负责「怎么发起这次请求」——
+// 带令牌、非 2xx 解码成 StreamError、消费事件流、把用户主动中断当成正常结果、把结果归一成
+// { sessionId, threadId, parseErrors }。对话链路与点选解读链路共用这一份,避免各自演化。
+import { getToken } from '/@/utils/auth/index';
+import { consumeSse } from './sse';
+import type { SseConsumeResult, SseEventLike } from './sse';
+import { throwStreamHttpError } from './errors';
+
+export interface StreamPostOptions {
+  /**
+   * 请求体。FormData 走 multipart(不手写 Content-Type:boundary 必须由浏览器生成),
+   * 其余对象按 JSON 提交。
+   */
+  body: FormData | Record<string, unknown>;
+  /** 出错日志前缀,如「统一对话流式请求」 */
+  label: string;
+  signal?: AbortSignal;
+  /** 终止事件类型,默认 done / error / interrupt(见 sse.ts) */
+  terminateOn?: readonly string[];
+}
+
+/**
+ * 发起一次 SSE 流式 POST 并消费到终止事件。
+ * 用户主动中断(AbortError)不打日志:那是正常操作,不是异常。
+ *
+ * @param url - 接口地址(相对路径,由 dev 代理或同源网关转发)
+ * @param options - 请求体 / 日志前缀 / 中断信号 / 终止事件类型
+ * @param onChunk - 每个事件回调
+ */
+export const streamPost = async <E extends SseEventLike>(
+  url: string,
+  options: StreamPostOptions,
+  onChunk: (data: E) => void
+): Promise<SseConsumeResult> => {
+  let payload: BodyInit;
+  let contentType: Record<string, string> = {};
+  if (options.body instanceof FormData) {
+    payload = options.body;
+  } else {
+    payload = JSON.stringify(options.body);
+    contentType = { 'Content-Type': 'application/json' };
+  }
+
+  try {
+    const response = await fetch(url, {
+      method: 'POST',
+      body: payload,
+      headers: {
+        // 令牌是「可选」语义:getToken() 为空时仍照常发请求(后端有兜底账号)。
+        // 不要改成「未登录就拦截」——那会把未登录场景直接变成不可用。
+        'X-Access-Token': getToken(),
+        ...contentType,
+      },
+      signal: options.signal,
+    });
+
+    if (!response.ok) {
+      await throwStreamHttpError(response);
+    }
+
+    return await consumeSse<E>(response, onChunk, { terminateOn: options.terminateOn });
+  } catch (error) {
+    if ((error as Error)?.name !== 'AbortError') {
+      console.error(`${options.label}失败:`, error);
+    }
+    throw error;
+  }
+};

+ 20 - 9
src/views/ventAI/manageAssistent/api/subagent.ts

@@ -6,22 +6,30 @@ enum Api {
   subagents = '/ventAI/api/subagents',
 }
 
+/** 子智能体列表响应(GET /subagents):tool_infos 项形状统一为 { name, display_name, exists } */
+export interface SubAgentListResponse {
+  subagents: SubAgent[];
+}
+
+/** 工具注册表响应(GET /subagents/tools):响应中不存在 models 字段 */
+export interface SubAgentToolsResponse {
+  groups: SubAgentToolGroup[];
+}
+
 /**
  * 获取子智能体列表。
- * 契约已对真实响应核实:{ 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 });
+  const res = await defHttp.get<SubAgentListResponse>({ 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 });
+  const res = await defHttp.get<SubAgentToolsResponse>({ url: `${Api.subagents}/tools` }, { isTransformResponse: false });
   return res?.groups ?? [];
 };
 
@@ -35,12 +43,13 @@ export const createSubAgent = (data: {
   tools?: string[];
   model?: string;
   enabled?: boolean;
-}) => defHttp.post({ url: Api.subagents, data }, { isTransformResponse: false });
+}): Promise<unknown> => defHttp.post<unknown>({ url: Api.subagents, data }, { isTransformResponse: false });
 
 /**
  * 获取子智能体详情(当前无调用方,保留以镜像后端接口)
  */
-export const getSubAgentDetail = (name: string) => defHttp.get({ url: `${Api.subagents}/${name}` }, { isTransformResponse: false });
+export const getSubAgentDetail = (name: string): Promise<SubAgent> =>
+  defHttp.get<SubAgent>({ url: `${Api.subagents}/${name}` }, { isTransformResponse: false });
 
 /**
  * 更新子智能体
@@ -48,14 +57,16 @@ export const getSubAgentDetail = (name: string) => defHttp.get({ url: `${Api.sub
 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 });
+): Promise<unknown> => defHttp.put<unknown>({ url: `${Api.subagents}/${name}`, data }, { isTransformResponse: false });
 
 /**
  * 删除子智能体
  */
-export const deleteSubAgent = (name: string) => defHttp.delete({ url: `${Api.subagents}/${name}` }, { isTransformResponse: false });
+export const deleteSubAgent = (name: string): Promise<unknown> =>
+  defHttp.delete<unknown>({ url: `${Api.subagents}/${name}` }, { isTransformResponse: false });
 
 /**
  * 启用/禁用子智能体
  */
-export const toggleSubAgent = (name: string) => defHttp.post({ url: `${Api.subagents}/${name}/toggle` }, { isTransformResponse: false });
+export const toggleSubAgent = (name: string): Promise<unknown> =>
+  defHttp.post<unknown>({ url: `${Api.subagents}/${name}/toggle` }, { isTransformResponse: false });

+ 1 - 1
src/views/ventAI/manageAssistent/api/usage.ts

@@ -26,4 +26,4 @@ export interface TodayUsage {
 /**
  * 查询今日用量与额度
  */
-export const getTodayUsage = () => defHttp.get({ url: Api.usageToday }, { isTransformResponse: false });
+export const getTodayUsage = (): Promise<TodayUsage> => defHttp.get<TodayUsage>({ url: Api.usageToday }, { isTransformResponse: false });

+ 10 - 7
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -195,7 +195,7 @@
   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';
-  import type { Message, Task } from '/@/views/ventAI/manageAssistent/types/index';
+  import type { Message, ScheduleTask, Task } from '/@/views/ventAI/manageAssistent/types/index';
   import { buildCapsuleFiles, buildCapsuleLinks, buildCapsuleAgents } from '/@/views/ventAI/manageAssistent/lib/capsule';
   import { useChatStream } from '../hooks/useChatStream';
   import { useChatTasks } from '../hooks/useChatTasks';
@@ -403,12 +403,13 @@
   const chatInputRef = ref<InstanceType<typeof ChatInputArea>>();
   const chatMessagesRef = ref<InstanceType<typeof ChatMessages>>();
 
-  const scheduleListForDetail = ref<any[]>([]);
+  const scheduleListForDetail = ref<ScheduleTask[]>([]);
 
   const fetchScheduleListForDetail = async () => {
     try {
       const res = await getScheduleList();
-      scheduleListForDetail.value = res?.tasks || res?.data || (Array.isArray(res) ? res : []);
+      // getScheduleList 已按真实契约标注 { tasks, total }
+      scheduleListForDetail.value = res?.tasks ?? [];
     } catch (e) {
       console.error('获取定时任务列表失败:', e);
       scheduleListForDetail.value = [];
@@ -483,7 +484,7 @@
     const task = currentTask.value;
     const tab = task?.rightPanels?.tabs.find((t) => t.type === 'schedule');
     if (tab?.type === 'schedule') {
-      const fresh = scheduleListForDetail.value.find((s: any) => s.id === tab.task.id);
+      const fresh = scheduleListForDetail.value.find((s) => s.id === tab.task.id);
       if (fresh) tab.task = fresh;
     }
     schedulePanelRef.value?.fetchScheduleList?.();
@@ -569,7 +570,7 @@
       if (!newTask.isStreaming) {
         try {
           const res = await getDetail(newTask.sessionId);
-          const messages = res?.messages || (Array.isArray(res) ? res : res?.data);
+          const messages = res?.messages;
           if (Array.isArray(messages)) {
             newTask.messages = transformHistoryToMessages(messages);
           }
@@ -597,7 +598,9 @@
     syncTodoBarFromMessages();
 
     // 切换任务时检测是否为定时任务会话:自动打开/关闭定时任务详情标签
-    const scheduleItem = newTask.sessionId ? scheduleListForDetail.value.find((s: any) => s.feedback_session_id === newTask.sessionId) : undefined;
+    const scheduleItem = newTask.sessionId
+      ? scheduleListForDetail.value.find((s) => s.feedback_session_id === newTask.sessionId)
+      : undefined;
     const panelState = getPanelState(newTask);
     const existingScheduleTab = panelState.tabs.find((t) => t.type === 'schedule');
     if (scheduleItem) {
@@ -851,7 +854,7 @@
         if (targetTask.sessionId && !historyLoadedTasks.has(targetTask.id)) {
           try {
             const res = await getDetail(targetTask.sessionId);
-            const messages = res?.messages || (Array.isArray(res) ? res : res?.data);
+            const messages = res?.messages;
             if (Array.isArray(messages)) {
               targetTask.messages = transformHistoryToMessages(messages);
             }

+ 5 - 4
src/views/ventAI/manageAssistent/components/panels/ScheduleDetailPanel.vue

@@ -92,6 +92,7 @@
   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';
+  import type { ScheduleRun } from '/@/views/ventAI/manageAssistent/api';
 
   const props = defineProps<{ task: ScheduleTask | null; hideHeader?: boolean }>();
   const emit = defineEmits<{
@@ -187,7 +188,7 @@
     emit('updated');
   };
 
-  const historyList = ref<any[]>([]);
+  const historyList = ref<ScheduleRun[]>([]);
   const historyLoading = ref(false);
   const historyStats = ref<{ total: number; success: number; failed: number } | null>(null);
   // 序号:只采纳最后一次发出的请求的响应(见 fetchHistory 注释)
@@ -202,12 +203,12 @@
     try {
       const res = await getScheduleRuns(taskId);
       if (seq !== historySeq) return;
-      const runs = res?.runs || (Array.isArray(res) ? res : []);
+      const runs = res?.runs ?? [];
       historyList.value = runs;
       historyStats.value = {
         total: res?.total ?? runs.length,
-        success: runs.filter((r: any) => r.status === 'success').length,
-        failed: runs.filter((r: any) => r.status === 'failed').length,
+        success: runs.filter((r) => r.status === 'success').length,
+        failed: runs.filter((r) => r.status === 'failed').length,
       };
     } catch (e) {
       if (seq !== historySeq) return;

+ 7 - 5
src/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue

@@ -115,6 +115,7 @@
   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';
+  import type { ScheduleRun } from '/@/views/ventAI/manageAssistent/api';
 
   const emit = defineEmits<{ (e: 'close'): void; (e: 'open-session', sessionId: string, prompt: string): void; (e: 'data-changed'): void }>();
 
@@ -134,7 +135,7 @@
   const showCreateModal = ref(false);
   const editData = ref<ScheduleTask | null>(null);
   const expandedIds = ref(new Set<number>());
-  const historyMap = reactive<Record<number, any[]>>({});
+  const historyMap = reactive<Record<number, ScheduleRun[]>>({});
   const historyLoadingMap = reactive<Record<number, boolean>>({});
   const historyStatsMap = reactive<Record<number, { total: number; success: number; failed: number }>>({});
 
@@ -142,7 +143,8 @@
     loading.value = true;
     try {
       const res = await getScheduleList();
-      scheduleList.value = res?.tasks || res?.data || (Array.isArray(res) ? res : []);
+      // getScheduleList 已按真实契约标注 { tasks, total }
+      scheduleList.value = res?.tasks ?? [];
     } catch (e) {
       // 失败不能只留空列表:用户会把「后端挂了」看成「一条定时任务都没有」
       console.error('获取定时任务列表失败:', e);
@@ -224,12 +226,12 @@
       historyLoadingMap[taskId] = true;
       try {
         const res = await getScheduleRuns(taskId);
-        const runs = res?.runs || (Array.isArray(res) ? res : []);
+        const runs = res?.runs ?? [];
         historyMap[taskId] = runs;
         historyStatsMap[taskId] = {
           total: res?.total ?? runs.length,
-          success: runs.filter((r: any) => r.status === 'success').length,
-          failed: runs.filter((r: any) => r.status === 'failed').length,
+          success: runs.filter((r) => r.status === 'success').length,
+          failed: runs.filter((r) => r.status === 'failed').length,
         };
       } catch (e) {
         console.error('获取执行记录失败:', e);

+ 1 - 6
src/views/ventAI/manageAssistent/hooks/useChatFiles.ts

@@ -52,12 +52,7 @@ export const useChatFiles = (getChatInput: () => InstanceType<typeof ChatInputAr
       // 读取方式只判断一次:onload 与 readAs* 两处各写一遍条件容易走偏。
       // .dxf 必须在此显式分流到文本:否则会落到 dataURL 分支,附件里只有 preview、拿不到 content,
       // DXF 预览面板只能显示空白。
-      const readMode =
-        isTextFile(name) || isCsvFile(name) || isDxfFile(name)
-          ? 'text'
-          : isWordFile(name) || isExcelFile(name)
-            ? 'binary'
-            : 'dataurl';
+      const readMode = isTextFile(name) || isCsvFile(name) || isDxfFile(name) ? 'text' : isWordFile(name) || isExcelFile(name) ? 'binary' : 'dataurl';
 
       reader.onload = (e) => {
         activeFileReader.value = null;

+ 7 - 5
src/views/ventAI/manageAssistent/hooks/useChatSend.ts

@@ -2,6 +2,7 @@ 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';
+import type { HistoryMessage } from '/@/views/ventAI/manageAssistent/api';
 import type { AttachedFile, Message, SseEvent, Task } from '/@/views/ventAI/manageAssistent/types/index';
 import { getCssUrl, getUserInputText, normalizeLineBreaks, stripInjectedRefs } from '/@/views/ventAI/manageAssistent/lib/index';
 
@@ -26,7 +27,7 @@ export interface ChatSendDeps {
   resetTodos: () => void;
   migrateTaskApproval: (fromTaskId: string, toTaskId: string) => void;
   historyLoadedTasks: Set<string>;
-  transformHistoryToMessages: (data: Array<Record<string, unknown>>) => Message[];
+  transformHistoryToMessages: (data: HistoryMessage[]) => Message[];
 }
 
 export const useChatSend = (deps: ChatSendDeps) => {
@@ -250,7 +251,8 @@ export const useChatSend = (deps: ChatSendDeps) => {
     if (!sessionId || !msgs.length) return;
     try {
       const res = await getDetail(sessionId);
-      const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
+      // getDetail 已按真实契约标注 { messages, ... },不再猜 messages / data / 裸数组三种外壳
+      const raw = res?.messages;
       if (!Array.isArray(raw)) return;
       // 用 transformHistoryToMessages 归一化,避免 model_thinking 等记录导致条数与前端不对齐
       const history = transformHistoryToMessages(raw);
@@ -271,13 +273,13 @@ export const useChatSend = (deps: ChatSendDeps) => {
     if (!sessionId) return;
     try {
       const res = await getDetail(sessionId);
-      const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
+      const raw = res?.messages;
       if (!Array.isArray(raw)) return;
 
       // 保留第一条 user(原始提问),删除其余与重发内容一致的重复 user,避免多次重新生成残留
-      const userMsgs = raw.filter((m: any) => m?.role === 'user' || m?.type === 'user_message');
+      const userMsgs = raw.filter((m) => m?.role === 'user' || m?.type === 'user_message');
       const duplicates = userMsgs.filter(
-        (m: any, idx: number) => idx > 0 && normalizeLineBreaks(m?.content || '').trim() === expectedContent.trim() && m?.id != null
+        (m, idx) => idx > 0 && normalizeLineBreaks(m?.content || '').trim() === expectedContent.trim() && m?.id != null
       );
       for (const dup of duplicates) {
         await deleteMessage(dup.id);

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

@@ -3,6 +3,7 @@ import dayjs from 'dayjs';
 import { message } from 'ant-design-vue';
 import { buildReportDownloadUrl, editSessionTitle, getHistoryList, pinSession } from '/@/views/ventAI/manageAssistent/api';
 import type { Message, Task } from '/@/views/ventAI/manageAssistent/types/index';
+import type { HistoryMessage } from '/@/views/ventAI/manageAssistent/api';
 import { normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
 
 /**
@@ -22,56 +23,56 @@ export const useChatTasks = () => {
   const fetchSessionList = async () => {
     try {
       const res = await getHistoryList({});
-      const sessions = res?.sessions || res?.data || [];
-      if (Array.isArray(sessions)) {
-        const apiTasks: Task[] = sessions.map((item: { session_id: string; title: string; updated_at: string; sort_order?: number }) => ({
-          id: item.session_id,
-          name: item.title || '未命名会话',
-          sessionId: item.session_id,
-          messages: [],
-          attachedFiles: [],
-          updatedAt: item.updated_at,
-          sortOrder: item.sort_order,
-        }));
-
-        // Build lookup maps for O(1) access
-        const existingTaskMap = new Map(taskList.value.map((t) => [t.id, t]));
-        const apiTaskIds = new Set(apiTasks.map((t) => t.id));
-
-        // 流式中的空白任务:后端已为其创建会话,但前端要等流结束才拿到 session_id。
-        // 该会话在接口里是一条全新记录,归并回空白任务而不是单独插入,避免列表出现重复条目
-        let blankStreamingTask: Task | null = taskList.value.find((t) => !t.sessionId && t.isStreaming) || null;
-
-        const mergedTasks: Task[] = [];
-        for (const apiTask of apiTasks) {
-          if (blankStreamingTask && (blankStreamingTask.pendingSessionId === apiTask.id || !knownApiSessionIds.has(apiTask.id))) {
-            blankStreamingTask.pendingSessionId = apiTask.id;
-            blankStreamingTask.name = apiTask.name;
-            blankStreamingTask.updatedAt = apiTask.updatedAt;
-            blankStreamingTask.sortOrder = apiTask.sortOrder;
-            blankStreamingTask = null;
-            continue;
-          }
-
-          const existing = existingTaskMap.get(apiTask.id);
-          if (existing) {
-            // 原地更新以保持对象引用,避免流式回调持有的任务引用失效(isStreaming 等状态丢失)
-            existing.name = apiTask.name;
-            existing.updatedAt = apiTask.updatedAt;
-            existing.sortOrder = apiTask.sortOrder;
-            mergedTasks.push(existing);
-          } else {
-            mergedTasks.push(apiTask);
-          }
+      // getHistoryList 已按真实契约标注 { sessions, total },不再猜 sessions / data / 裸数组三种外壳
+      const sessions = res?.sessions ?? [];
+
+      const apiTasks: Task[] = sessions.map((item) => ({
+        id: item.session_id,
+        name: item.title || '未命名会话',
+        sessionId: item.session_id,
+        messages: [],
+        attachedFiles: [],
+        updatedAt: item.updated_at,
+        sortOrder: item.sort_order,
+      }));
+
+      // Build lookup maps for O(1) access
+      const existingTaskMap = new Map(taskList.value.map((t) => [t.id, t]));
+      const apiTaskIds = new Set(apiTasks.map((t) => t.id));
+
+      // 流式中的空白任务:后端已为其创建会话,但前端要等流结束才拿到 session_id。
+      // 该会话在接口里是一条全新记录,归并回空白任务而不是单独插入,避免列表出现重复条目
+      let blankStreamingTask: Task | null = taskList.value.find((t) => !t.sessionId && t.isStreaming) || null;
+
+      const mergedTasks: Task[] = [];
+      for (const apiTask of apiTasks) {
+        if (blankStreamingTask && (blankStreamingTask.pendingSessionId === apiTask.id || !knownApiSessionIds.has(apiTask.id))) {
+          blankStreamingTask.pendingSessionId = apiTask.id;
+          blankStreamingTask.name = apiTask.name;
+          blankStreamingTask.updatedAt = apiTask.updatedAt;
+          blankStreamingTask.sortOrder = apiTask.sortOrder;
+          blankStreamingTask = null;
+          continue;
         }
 
-        apiTasks.forEach((apiTask) => knownApiSessionIds.add(apiTask.id));
+        const existing = existingTaskMap.get(apiTask.id);
+        if (existing) {
+          // 原地更新以保持对象引用,避免流式回调持有的任务引用失效(isStreaming 等状态丢失)
+          existing.name = apiTask.name;
+          existing.updatedAt = apiTask.updatedAt;
+          existing.sortOrder = apiTask.sortOrder;
+          mergedTasks.push(existing);
+        } else {
+          mergedTasks.push(apiTask);
+        }
+      }
 
-        const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTaskIds.has(t.id));
+      apiTasks.forEach((apiTask) => knownApiSessionIds.add(apiTask.id));
 
-        taskList.value = [...localOnlyTasks, ...mergedTasks];
-        return mergedTasks;
-      }
+      const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTaskIds.has(t.id));
+
+      taskList.value = [...localOnlyTasks, ...mergedTasks];
+      return mergedTasks;
     } catch (error) {
       console.error('获取会话列表失败:', error);
     }
@@ -137,18 +138,11 @@ export const useChatTasks = () => {
     return [];
   };
 
-  const transformHistoryToMessages = (
-    data: Array<{
-      id?: number;
-      role?: string;
-      type?: string;
-      content: string;
-      created_at?: string;
-      report_url?: string;
-      report_filename?: string;
-      citations?: any;
-    }>
-  ): Message[] => {
+  /**
+   * 历史消息 → 前端 Message[]。入参直接用接口声明的 HistoryMessage,
+   * 不再在本文件另立一份结构副本:两套形状一旦分叉,接口字段变化就不会在这里报错。
+   */
+  const transformHistoryToMessages = (data: HistoryMessage[]): Message[] => {
     const messages: Message[] = [];
 
     for (const item of data) {
@@ -165,14 +159,13 @@ export const useChatTasks = () => {
           // 后端返回的报告地址前缀不稳定(绝对地址 / 缺 /ventAI 的相对路径),入前端状态前统一归一
           msg.downloadFiles = [{ url: buildReportDownloadUrl(item.report_url), filename: item.report_filename }];
         }
-        if (item.citations) {
-          // 历史接口返回 citations 为扁平数组,SSE 事件返回 { items, model_only },统一归一化
-          const raw = item.citations;
-          if (Array.isArray(raw)) {
-            msg.citations = { items: raw, model_only: raw.length === 0 };
-          } else {
-            msg.citations = raw;
-          }
+        // 历史接口返回 citations 为扁平数组,SSE 事件返回 { items, model_only },统一归一化
+        const rawCitations = item.citations;
+        if (Array.isArray(rawCitations)) {
+          msg.citations = { items: rawCitations, model_only: rawCitations.length === 0 };
+        } else if (rawCitations) {
+          // null 与 undefined 对调用方等价(均按「没有引用」处理),不赋值以免把 Message 上的可选字段写成 null
+          msg.citations = rawCitations;
         }
         messages.push(msg);
       }

+ 4 - 14
src/views/ventAI/manageAssistent/hooks/useMentionPopups.ts

@@ -1,6 +1,6 @@
 import { computed, nextTick, onMounted, ref, watch } from 'vue';
 import { getSkills } from '/@/views/ventAI/manageAssistent/api';
-import type { Task } from '/@/views/ventAI/manageAssistent/types/index';
+import type { Skill, Task } from '/@/views/ventAI/manageAssistent/types/index';
 
 /**
  * 输入框的会话/技能引用弹层:`#` 触发会话提及、`$` 触发技能提及,含搜索过滤、方向键导航、
@@ -21,18 +21,8 @@ export interface MentionPopupDeps {
   setSelectedSkill: (skill: { name: string; display_name: string } | null) => void;
 }
 
-interface SkillItem {
-  name: string;
-  display_name: string;
-  path: string;
-  description: string;
-  enabled: boolean;
-  agents: string[];
-  has_skill_md: boolean;
-}
-
 // Module-level cache for skill list to avoid redundant API calls across component mounts
-let skillListCache: SkillItem[] | null = null;
+let skillListCache: Skill[] | null = null;
 let skillListFetchPromise: Promise<void> | null = null;
 
 export const useMentionPopups = (deps: MentionPopupDeps) => {
@@ -61,7 +51,7 @@ export const useMentionPopups = (deps: MentionPopupDeps) => {
   const activeSkillIndex = ref(0);
   const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
   const skillPopupManual = ref(false);
-  const skillList = ref<SkillItem[]>([]);
+  const skillList = ref<Skill[]>([]);
   // 技能列表拉取失败:$ 弹层的空态据此区分「加载失败」与「确实没有技能」,避免失败被显示成空数据
   const skillListLoadFailed = ref(false);
   let skillJustSelected = false;
@@ -220,7 +210,7 @@ export const useMentionPopups = (deps: MentionPopupDeps) => {
     nextTick(() => focusTextarea());
   };
 
-  const handleSelectSkill = (skill: SkillItem) => {
+  const handleSelectSkill = (skill: Skill) => {
     selectedSkill.value = { name: skill.name, display_name: skill.display_name };
     removeSkillSearchTag();
     showSkillPopup.value = false;

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

@@ -24,6 +24,11 @@ export interface CitationsData {
   message?: string;
 }
 
+/**
+ * 定时任务列表项(GET /schedules 响应的 tasks[] 元素)。
+ * 字段形状已对真实数据核实;末尾几项是接口会返回、前端当前未使用的字段,
+ * 如实声明它们,避免「类型里没有」被误读成「接口没有」。
+ */
 export interface ScheduleTask {
   id: number;
   name: string;
@@ -34,6 +39,13 @@ export interface ScheduleTask {
   next_run_at: string | null;
   feedback_session_id?: string;
   schedule?: { freq: string; human?: string };
+  session_id?: string;
+  task_type?: string;
+  tz?: string;
+  user_id?: string;
+  notify_webhook?: string;
+  last_run_at?: string;
+  created_at?: string;
 }
 
 /** 定时任务创建/编辑表单的形状:比列表项多 notify_webhook,少 cron_human/enabled/next_run_at */