From abd97adf9a885f23e107d44754300fa87df30108 Mon Sep 17 00:00:00 2001 From: lijing Date: Mon, 12 Oct 2026 17:49:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(leave):=20=E8=A3=B8=E6=84=8F=E5=9B=BE?= =?UTF-8?q?=E7=9B=B4=E8=BE=BE=E8=A1=A8=E5=8D=95=20+=20=E6=8C=89=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E5=88=86=E7=A6=BB=E8=AF=B7=E5=81=87=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=20+=20=E5=89=8D=E7=AB=AF=E4=BB=A3=E5=8F=96=E4=B8=8B=E6=8B=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 说「我要请假」这类只有意图的短句时,直接弹出表单不再过模型: 后端 IntentShortcut 在编排器入口做「去空白标点 + 剥前后缀后正好等于标准说法 且原句 ≤12 字」的窄匹配,命中即跳过 LLM 直接下发表单。 - 按身份分离:学生 → leave.submit(提交申请),教师/管理员 → leave.register (帮学生登记,必填 classId+stuId);RolePolicy 把两者分别归入 STUDENT_ONLY 与 STAFF_ONLY,模型视野里天然只有一个,不可能选错。 - 表单字段由各自必填参数推导;下拉选项改为后端下发 optionCall(含 {{占位符}}), 由前端带用户登录态代取后按 valueKey/labelKey 映射(模块 /api/* 服务端必然 302)。 stuId 依赖 classId,选完班级才解锁学生名单。 - 抽出 utils/module-session.ts(会话预热 / 带登录态调模块 / 字典项解析), Chat.vue 与 FormCard.vue 复用。 - 例句与提示改为「我要请假」「帮学生登记请假」。 --- src/components/FormCard.vue | 157 ++++++++++++++++++++++++++++++-- src/types/agent.ts | 14 +++ src/utils/module-session.ts | 174 ++++++++++++++++++++++++++++++++++++ src/views/Chat.vue | 110 ++--------------------- 4 files changed, 343 insertions(+), 112 deletions(-) create mode 100644 src/utils/module-session.ts diff --git a/src/components/FormCard.vue b/src/components/FormCard.vue index 060bfd6..b53468d 100644 --- a/src/components/FormCard.vue +++ b/src/components/FormCard.vue @@ -4,7 +4,8 @@ * * 用户说「我要请假」但信息不全时,后端会把缺失字段(中文标签 + 格式示例 + 下拉选项) * 下发到这里渲染成表单 —— 比让模型口头追问精确得多: - * - 下拉框:选项由后端从业务字典接口现取(如请假类型、发文部门) + * - 下拉框:选项由后端从业务字典接口现取(如请假类型、班级),或由本组件带**用户登录态**代取 + * - 联动下拉:如「学生」依赖先选好「班级」(后端在字段上标注 dependsOn + 占位符地址) * - 日期时间:用选择器,避免用户手输格式出错 * - 已识别的信息以只读标签回显,用户可核对 * 提交后直接执行对应工具,不再让模型二次猜测。 @@ -14,6 +15,7 @@ */ import { reactive, computed, ref, watch } from "vue"; import { Message } from "view-ui-plus"; +import { callModuleApi, parseOptionItems } from "@/utils/module-session"; import type { FormField, FormRequest } from "@/types/agent"; const props = defineProps<{ @@ -50,6 +52,103 @@ const errors = reactive>({}); /** 提交后的摘要(用于只读回显) */ const summary = ref<{ label: string; text: string }[]>([]); +/* ==================== 下拉选项:动态代取 + 联动 ==================== */ + +/** 动态取回的选项(按字段名);取到后覆盖字段自带的兜底选项 */ +const dynOptions = reactive>({}); +/** 动态取选项的状态:idle(依赖未填/未开始) · loading · ok · fail */ +const optState = reactive>({}); +/** 依赖字段值指纹:只有依赖真的变了才重新拉取,避免每次输入都打接口 */ +const depSig = reactive>({}); + +/** 当前该显示的选项:动态取回的优先,否则用后端给的(可能是内置兜底) */ +function fieldOptions(f: FormField) { + const dyn = dynOptions[f.name]; + if (dyn && dyn.length) return dyn; + return f.options || []; +} + +/** 依赖字段是否都已填好 —— 没填好就不该去拉从属选项,也不该让用户点开一个空下拉 */ +function depsReady(f: FormField) { + return (f.dependsOn || []).every(d => { + const v = values[d]; + return v !== undefined && v !== null && String(v).trim() !== ""; + }); +} + +function depSignature(f: FormField) { + return (f.dependsOn || []).map(d => `${d}=${values[d] ?? ""}`).join("&"); +} + +/** + * 实际控件类型:动态取选项失败、且没有任何可选项时,退化成可手填的输入框 + * (填编号),而不是留一个点不开的空下拉把用户卡死。 + */ +function widgetOf(f: FormField) { + const st = optState[f.name]; + if (f.optionCall && st === "fail" && !fieldOptions(f).length) { + return f.type === "integer" || f.type === "number" ? "number" : "text"; + } + return f.widget; +} + +/** + * 用用户自己的登录态去业务模块取本字段的下拉选项。 + * + * 为什么不走后端:模块的字典接口只认浏览器 CAS 会话,服务端代取必然 `code=302`。 + * 地址里的 `{{字段名}}` 由后端按 dependsOn 生成,这里替换成当前值(必须 URL 编码)。 + */ +async function loadOptions(f: FormField) { + const call = f.optionCall; + if (!call) return; + if (!depsReady(f)) { + delete dynOptions[f.name]; + optState[f.name] = "idle"; + return; + } + optState[f.name] = "loading"; + let pathAndQuery = String(call.pathAndQuery || ""); + for (const d of f.dependsOn || []) { + pathAndQuery = pathAndQuery.split(`{{${d}}}`).join(encodeURIComponent(String(values[d]))); + } + const res = await callModuleApi({ ...call, pathAndQuery }); + const items = res.error ? [] : parseOptionItems(res.body, f.valueKey, f.labelKey); + if (items.length) { + dynOptions[f.name] = items; + optState[f.name] = "ok"; + // 只有一个可选值时直接选上(如教师只教一个班),省掉一次无意义的点击 + const cur = values[f.name]; + if ((cur === undefined || cur === null || String(cur).trim() === "") && items.length === 1) { + values[f.name] = f.type === "integer" || f.type === "number" + ? Number(items[0].value) : items[0].value; + } + } else { + dynOptions[f.name] = []; + optState[f.name] = "fail"; + } +} + +/** 依赖一变就重新拉从属选项;换班后旧的学生选择必须清掉 */ +function syncOptions() { + for (const f of props.form?.fields || []) { + if (!f.optionCall) continue; + const sig = depSignature(f); + if (depSig[f.name] === sig) continue; + const hadSig = depSig[f.name] !== undefined; + depSig[f.name] = sig; + if (hadSig && (f.dependsOn || []).length) { + values[f.name] = undefined; + } + loadOptions(f); + } +} + +function resetOptionState() { + Object.keys(dynOptions).forEach(k => delete dynOptions[k]); + Object.keys(optState).forEach(k => delete optState[k]); + Object.keys(depSig).forEach(k => delete depSig[k]); +} + function toDate(v: any): Date | undefined { if (!v) return undefined; if (v instanceof Date) return v; @@ -66,6 +165,7 @@ function fmt(d: Date, withTime: boolean): string { function initValues() { Object.keys(values).forEach(k => delete values[k]); Object.keys(errors).forEach(k => delete errors[k]); + resetOptionState(); for (const f of props.form?.fields || []) { if (f.widget === "datetime" || f.widget === "date") { values[f.name] = toDate(f.defaultValue) ?? ""; @@ -75,11 +175,22 @@ function initValues() { values[f.name] = f.defaultValue ?? ""; } } + // 初值就位后立刻拉一次动态选项(含只有一层依赖的情况) + syncOptions(); } initValues(); watch(() => props.form, initValues); +/** + * 依赖字段变化 → 重新拉从属选项。 + * 依赖形如「字段名:班级=3」,因此选完班级才会去取学生名单,其它字段的输入不会触发请求。 + */ +watch( + () => (props.form?.fields || []).map(f => `${f.name}:${depSignature(f)}`).join("|"), + () => syncOptions() +); + /** 只读回显:已从对话里识别出的参数(优先用后端翻译好的可读文本) */ const presetList = computed(() => { const f = props.form; @@ -117,6 +228,8 @@ function normalize(f: FormField, v: any) { * 下拉框是可用的,只是可能不包含学校自定义的项 —— 用淡色说明,不阻塞填写。 */ function dictNote(f: FormField): string { + // 有动态代取通道时由 optHint 统一说明,这里不重复 + if (f.optionCall) return ""; return f.optionsFallback ? `「${f.label}」为系统内置标准类型;学校自定义项暂时没取到。` : ""; @@ -127,12 +240,36 @@ function dictNote(f: FormField): string { * 免得用户对着一个空输入框发呆。 */ function dictWarn(f: FormField): string { + if (f.optionCall) return ""; if (f.optionsFallback) return ""; if (!f.optionsFrom) return ""; if (f.options && f.options.length) return ""; return `「${f.label}」的可选项暂时没取到(来源接口未通),可以先直接填对应编号,稍后再试。`; } +function labelOfName(name: string): string { + const f = (props.form?.fields || []).find(x => x.name === name); + return f?.label || NAME_LABEL[name] || name; +} + +/** + * 动态下拉的状态提示:等待上级字段 / 正在读取 / 取不到(可手填编号)。 + * 有了它,用户不会对着一个空下拉框猜「为什么点不开」。 + */ +function optHint(f: FormField): string { + if (!f.optionCall) return ""; + const st = optState[f.name]; + if (st === "loading") return "正在读取可选项…"; + if (!depsReady(f)) { + const deps = (f.dependsOn || []).map(labelOfName).join("、"); + return `请先选择「${deps}」,这里会自动列出可选项`; + } + if (st === "fail") { + return `「${f.label}」的可选项暂时没取到,可以先直接填对应编号,或稍后重试。`; + } + return ""; +} + function onSubmit() { if (props.submitted || props.disabled) return; @@ -216,17 +353,18 @@ function reset() {
+

{{ optHint(f) }}

{{ dictNote(f) }}

{{ dictWarn(f) }}

{{ f.description }}

diff --git a/src/types/agent.ts b/src/types/agent.ts index 260e296..d0be992 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -56,6 +56,20 @@ export interface FormField { * 为 true 时说明「选项能用,但可能不含学校自定义的项」,前端给一句轻提示即可,不阻塞填写。 */ optionsFallback?: boolean; + /** + * 「浏览器代取选项」的调用描述:业务模块的字典接口(班级、学生名单…)只认浏览器 CAS 会话, + * 服务端代取必然 302,所以由前端带**用户自己的登录态**去取原始字典,再渲染成下拉。 + * + *

{@link pathAndQuery} 里可能含 {@code {{字段名}}} 占位符(见 {@link dependsOn}), + * 依赖字段有值后才能替换并发起请求。 + */ + optionCall?: BrowserToolCall; + /** 本字段选项依赖的先序字段名(如「学生」依赖「班级」);依赖未填时清空并提示先选 */ + dependsOn?: string[]; + /** 从字典返回对象里取哪个字段作为选项「值」(如 classId / stuId) */ + valueKey?: string; + /** 从字典返回对象里取哪个字段作为选项「显示名」(如 className / stuName) */ + labelKey?: string; } /** diff --git a/src/utils/module-session.ts b/src/utils/module-session.ts new file mode 100644 index 0000000..dc29a23 --- /dev/null +++ b/src/utils/module-session.ts @@ -0,0 +1,174 @@ +/** + * 业务模块「用户态接口」的浏览器侧调用工具。 + * + * 背景(本工程的核心约束):各业务模块的 `/api/*` 只认**模块自己容器里的 CAS 会话** + * (`_const_cas_assertion_`),服务端代调必然拿到 `{"code":302,urlToRedirectTo}`。 + * 所以凡是"要用用户身份读模块数据",都得由前端带同源 Cookie 去发这个请求。 + * + * 两个使用场景共用本文件: + * 1. 对话里的「浏览器代执行」(模型决定的工具调用,见 `views/Chat.vue`); + * 2. 表单下拉的「代取选项」(班级 / 学生名单等,见 `components/FormCard.vue`)。 + */ +import type { BrowserToolCall, ToolResultItem } from "@/types/agent"; + +/** 每个模块最近一次「会话预热」的时间戳(避免失败后每条请求都白等一轮) */ +const warmTriedAt = new Map(); +const WARM_RETRY_MS = 60_000; + +/** + * 判断模块响应是否为「未登录」信号。 + * + * 平台 CAS 过滤器对 AJAX 不真的 302,而是返回 **HTTP 200 + `{"code":302,urlToRedirectTo}`** + * (带 `yschapirequest: 1` 时的约定)。body 以 `<` 开头是「落到网页」的老问题,另有守卫处理。 + */ +export function isLoginRedirect(status: number, body?: string): boolean { + if (status < 200 || status >= 300 || !body || !body.includes("302")) return false; + try { + const j = JSON.parse(body); + return j != null && String(j.code) === "302" && !!j.urlToRedirectTo; + } catch { + return false; + } +} + +/** + * 模块会话预热:用一次真实导航把业务模块的浏览器 CAS 会话建起来。 + * + * 为什么必须这么做:模块接口只认模块自己容器里的 CAS 会话。同源部署只保证 Cookie 送得到, + * 送不到「还没建出来的会话」。而模块 SPA 建会话靠的是整页跳 SSO 签票回跳 —— fetch 永远拿不到 + * 这一步(平台对 AJAX 只回 JSON `code=302`,不真跳转),于是每次请求都必然 302。 + * + * 做法:同源隐藏 iframe 导航到刚失败的那个接口地址(**不带** `yschapirequest`, + * 平台即回真 302 → SSO 见已有 TGT 直接签票回跳 → 模块校验票据后落会话),随后重试原请求。 + * 整个过程用户无感;只在首次访问某模块时发生一次。 + * + * @param pathAndQuery 刚失败的接口相对路径(`/homeleave/api/...`),首段即模块上下文 + * @returns 是否完成了这轮预热尝试(false = 60s 内已试过,或拿不到模块段) + */ +export function warmModuleSession(pathAndQuery: string, onStart?: () => void, timeoutMs = 6000): Promise { + const ctx = pathAndQuery.split("/").filter(Boolean)[0] || ""; + if (!ctx) return Promise.resolve(false); + const now = Date.now(); + if (now - (warmTriedAt.get(ctx) || 0) < WARM_RETRY_MS) return Promise.resolve(false); + warmTriedAt.set(ctx, now); + onStart?.(); + + const url = `${location.origin}${pathAndQuery}`; + return new Promise(resolve => { + const frame = document.createElement("iframe"); + frame.setAttribute("aria-hidden", "true"); + frame.style.cssText = "position:fixed;left:-9999px;top:0;width:1px;height:1px;border:0;"; + let settled = false; + const done = (ok: boolean) => { + if (settled) return; + settled = true; + window.clearTimeout(cap); + window.setTimeout(() => frame.remove(), 0); + resolve(ok); + }; + const cap = window.setTimeout(() => done(false), timeoutMs); + // 导航链(模块 302 → SSO 签票 → 回跳模块)走完才会触发 load,再留一点沉降时间 + frame.onload = () => window.setTimeout(() => done(true), 700); + frame.src = url; + document.body.appendChild(frame); + }); +} + +/** + * 用用户自己的登录态调用一次模块接口;遇到 `code=302` 会先预热会话再重试一次。 + * + * @param call 接口描述(`pathAndQuery` 相对当前站点根,同站部署 ⇒ 同源) + * @param onWarm 识别出「需要建会话」时的回调(用于更新界面文案) + */ +export async function callModuleApi(call: BrowserToolCall, onWarm?: () => void): Promise { + const base = { name: call.name, arguments: call.arguments }; + const send = async () => { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 15000); + try { + const headers: Record = { yschapirequest: "1" }; + let body: string | undefined; + if (call.method !== "GET") { + headers["Content-Type"] = call.contentType || "application/json;charset=UTF-8"; + body = call.body; + } + const res = await fetch(`${location.origin}${call.pathAndQuery}`, { + method: call.method === "GET" ? "GET" : "POST", + headers, + body, + credentials: "same-origin", + signal: ctrl.signal, + }); + return { status: res.status, body: await res.text() }; + } finally { + clearTimeout(timer); + } + }; + try { + let r = await send(); + if (isLoginRedirect(r.status, r.body)) { + const warmed = await warmModuleSession(call.pathAndQuery, onWarm); + if (warmed) r = await send(); + } + return { ...base, status: r.status, body: r.body }; + } catch (e: any) { + const reason = e?.name === "AbortError" ? "请求超时(15 秒)" : e?.message || "网络请求失败"; + return { ...base, error: reason }; + } +} + +/** + * 把「字典接口原始响应」解析成表单下拉项。 + * + * 解析是**确定性**的:由后端在字段定义里显式给出 {@code valueKey / labelKey} + * (如 `classId/className`),前端只按这两个键取值,不猜结构。 + */ +export function parseOptionItems( + body?: string, + valueKey?: string, + labelKey?: string +): { value: string; label: string }[] { + if (!body) return []; + let payload: any; + try { + payload = JSON.parse(body); + } catch { + return []; + } + if (payload == null) return []; + // 平台统一包装 {code,msg,data}:非 0 即业务失败 + if (typeof payload === "object" && "code" in payload) { + if (Number(payload.code) !== 0) return []; + payload = payload.data; + } + const arr = findArray(payload, 0); + if (!arr) return []; + + const vk = valueKey || "id"; + const lk = labelKey || "name"; + const out: { value: string; label: string }[] = []; + for (const item of arr) { + if (item == null || typeof item !== "object") continue; + const v = (item as any)[vk]; + if (v === undefined || v === null || String(v) === "") continue; + const l = (item as any)[lk]; + out.push({ + value: String(v), + label: l === undefined || l === null || String(l) === "" ? String(v) : String(l), + }); + } + return out; +} + +/** 递归找第一个数组(限深,容忍 data 外面再包一层 list/records/rows) */ +function findArray(node: any, depth: number): any[] | null { + if (node == null || depth > 3) return null; + if (Array.isArray(node)) return node; + if (typeof node === "object") { + for (const k of Object.keys(node)) { + const a = findArray(node[k], depth + 1); + if (a) return a; + } + } + return null; +} diff --git a/src/views/Chat.vue b/src/views/Chat.vue index 37e89cb..e6015ec 100644 --- a/src/views/Chat.vue +++ b/src/views/Chat.vue @@ -15,6 +15,7 @@ import { Message } from "view-ui-plus"; import { agentApi } from "@/api/module/agent"; import QuickActions from "@/components/QuickActions.vue"; import MessageBubble from "@/components/MessageBubble.vue"; +import { callModuleApi } from "@/utils/module-session"; import type { AgentToolCatalog, BrowserToolCall, ChatItem, ToolResultItem } from "@/types/agent"; const props = defineProps<{ user?: any; catalog?: AgentToolCatalog | null }>(); @@ -40,7 +41,9 @@ const STAGES = ["正在理解你的需求…", "正在判断需要调用哪些 * 判据取自能力目录本身(模块是否下发、工具名是否存在),前端无需知道角色规则。 */ const EXAMPLES: { text: string; module: string; tool?: string }[] = [ - { text: "我要请 10月12日 上午的病假,发烧就医", module: "homeleave", tool: "leave.submit" }, + // 学生:说"我要请假"直接弹出请假申请表单;教师:弹出"帮学生登记请假"表单(含选班级/学生) + { text: "我要请假", module: "homeleave", tool: "leave.submit" }, + { text: "帮学生登记请假", module: "homeleave", tool: "leave.register" }, { text: "我的请假记录有几次", module: "homeleave", tool: "leave.myLeaves" }, { text: "发一条关于社团招新的通知", module: "campusnotice", tool: "notice.publish" }, { text: "这周有什么新的校园公告?", module: "campusnotice", tool: "notice.received" }, @@ -71,7 +74,7 @@ const placeholder = computed(() => { /** 边说边提示:命中关键词时告诉用户"这样说更准" */ const TIPS: { key: RegExp; text: string }[] = [ - { key: /请假|病假|事假/, text: "说清「类型 + 起止时间 + 事由」一次就能提交,例:10月12日上午病假,发烧就医" }, + { key: /请假|病假|事假/, text: "直接说「我要请假」就会弹出请假表单;也可以一次说全:10月12日上午病假,发烧就医" }, { key: /公告|通知(发布|发布公告)/, text: "发布公告需要「标题 + 正文 + 发文部门」,例:发一条关于运动会的通知,正文…" }, { key: /作业|作答|提交作/, text: "可以说「我有哪些作业没做」,或「提交数学作业」,我会列出作业让你选" }, { key: /宿舍|回执/, text: "例:宿舍有什么通知需要我回执吗?我会列出通知并给出回执表单" }, @@ -163,71 +166,6 @@ function fill(placeholder: ChatItem, resp: any) { } } -/** - * 判断模块响应是否为「未登录」信号。 - * - *

平台 CAS 过滤器对 AJAX 不真的 302,而是返回 HTTP 200 + `{"code":302,urlToRedirectTo}` - * (带 `yschapirequest: 1` 时的约定)。body 以 `<` 开头是「落到网页」的老问题,另有守卫处理。 - */ -function isLoginRedirect(status: number, body?: string): boolean { - if (status < 200 || status >= 300 || !body || !body.includes("302")) return false; - try { - const j = JSON.parse(body); - return j != null && String(j.code) === "302" && !!j.urlToRedirectTo; - } catch { - return false; - } -} - -/** 每个模块最近一次「会话预热」的时间戳(避免失败后每条查询都白等一轮) */ -const warmTriedAt = new Map(); -const WARM_RETRY_MS = 60_000; - -/** - * 模块会话预热:用一次真实导航把业务模块的浏览器 CAS 会话建起来。 - * - *

为什么必须这么做:模块接口只认模块自己容器里的 CAS 会话(`_const_cas_assertion_`)。 - * 同源部署只保证 Cookie 送得到,送不到「还没建出来的会话」。而模块 SPA 建会话靠的是 - * `window.location.href = urlToRedirectTo` 整页跳 SSO 签票回跳 —— fetch 永远拿不到这一步 - * (平台对 AJAX 只回 JSON `code=302`,不真跳转),于是每次查询都必然 302。 - * - *

做法:同源隐藏 iframe 导航到刚失败的那个接口地址(不带 `yschapirequest`, - * 平台即回真 302 → SSO 见已有 TGT 直接签票回跳 → 模块校验票据后落会话),随后重试原请求。 - * 整个过程用户无感;只在首次查询某模块时发生一次。 - * - * @param pathAndQuery 刚失败的接口相对路径(`/campusnotice/api/...`),首段即模块上下文 - * @param onStart 确认要预热时回调(用于更新界面文案) - * @returns 是否完成了这轮预热尝试(false = 60s 内已试过,或拿不到模块段) - */ -function warmModuleSession(pathAndQuery: string, onStart?: () => void, timeoutMs = 6000): Promise { - const ctx = pathAndQuery.split("/").filter(Boolean)[0] || ""; - if (!ctx) return Promise.resolve(false); - const now = Date.now(); - if (now - (warmTriedAt.get(ctx) || 0) < WARM_RETRY_MS) return Promise.resolve(false); - warmTriedAt.set(ctx, now); - onStart?.(); - - const url = `${location.origin}${pathAndQuery}`; - return new Promise(resolve => { - const frame = document.createElement("iframe"); - frame.setAttribute("aria-hidden", "true"); - frame.style.cssText = "position:fixed;left:-9999px;top:0;width:1px;height:1px;border:0;"; - let settled = false; - const done = (ok: boolean) => { - if (settled) return; - settled = true; - window.clearTimeout(cap); - window.setTimeout(() => frame.remove(), 0); - resolve(ok); - }; - const cap = window.setTimeout(() => done(false), timeoutMs); - // 导航链(模块 302 → SSO 签票 → 回跳模块)走完才会触发 load,再留一点沉降时间 - frame.onload = () => window.setTimeout(() => done(true), 700); - frame.src = url; - document.body.appendChild(frame); - }); -} - /** * 浏览器代执行(方案 A):模块接口只认浏览器 CAS 会话,服务端代调必然 302。 * 后端挂起后把接口描述下发到这里,用「用户自己的登录态」调完模块接口再把结果回传续跑。 @@ -238,43 +176,9 @@ function warmModuleSession(pathAndQuery: string, onStart?: () => void, timeoutMs * * 首次遇到 `code=302`(该浏览器还没建过这个模块的会话)会先预热一次再重试; * 会话建好后同一浏览器后续查询直接命中,无额外开销。 + * 实现细节见 `@/utils/module-session`(表单下拉的「代取选项」共用同一套逻辑)。 */ -async function execBrowserCall(call: BrowserToolCall, onWarm?: () => void): Promise { - const base = { name: call.name, arguments: call.arguments }; - const send = async () => { - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), 15000); - try { - const headers: Record = { yschapirequest: "1" }; - let body: string | undefined; - if (call.method !== "GET") { - headers["Content-Type"] = call.contentType || "application/json;charset=UTF-8"; - body = call.body; - } - const res = await fetch(`${location.origin}${call.pathAndQuery}`, { - method: call.method === "GET" ? "GET" : "POST", - headers, - body, - credentials: "same-origin", - signal: ctrl.signal, - }); - return { status: res.status, body: await res.text() }; - } finally { - clearTimeout(timer); - } - }; - try { - let r = await send(); - if (isLoginRedirect(r.status, r.body)) { - const warmed = await warmModuleSession(call.pathAndQuery, onWarm); - if (warmed) r = await send(); - } - return { ...base, status: r.status, body: r.body }; - } catch (e: any) { - const reason = e?.name === "AbortError" ? "请求超时(15 秒)" : e?.message || "网络请求失败"; - return { ...base, error: reason }; - } -} +const execBrowserCall = callModuleApi; /** 执行一批代调用并回传续跑;续跑响应若再挂起(链式查询)则递归处理 */ async function runBrowserTool(placeholder: ChatItem, pending: any) {