diff --git a/src/views/Chat.vue b/src/views/Chat.vue
index fd92150..287ee10 100644
--- a/src/views/Chat.vue
+++ b/src/views/Chat.vue
@@ -165,6 +165,71 @@ 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。
* 后端挂起后把接口描述下发到这里,用「用户自己的登录态」调完模块接口再把结果回传续跑。
@@ -172,31 +237,44 @@ function fill(placeholder: ChatItem, resp: any) {
* 前提:本应用与模块同站部署(同源 Cookie 自动携带)。本地 dev(localhost:8081)
* 跨域取不到 base 域会话,fetch 会失败或 404 → 结果回传后由服务端判为失败,
* 自动落回「去系统里查看」的深链兜底,UI 不会卡死。
+ *
+ * 首次遇到 `code=302`(该浏览器还没建过这个模块的会话)会先预热一次再重试;
+ * 会话建好后同一浏览器后续查询直接命中,无额外开销。
*/
-async function execBrowserCall(call: BrowserToolCall): Promise {
+async function execBrowserCall(call: BrowserToolCall, onWarm?: () => void): Promise {
const base = { name: call.name, arguments: call.arguments };
- 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 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);
}
- const res = await fetch(`${location.origin}${call.pathAndQuery}`, {
- method: call.method === "GET" ? "GET" : "POST",
- headers,
- body,
- credentials: "same-origin",
- signal: ctrl.signal,
- });
- return { ...base, status: res.status, body: await res.text() };
+ };
+ 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 };
- } finally {
- clearTimeout(timer);
}
}
@@ -210,7 +288,10 @@ async function runBrowserTool(placeholder: ChatItem, pending: any) {
const results: ToolResultItem[] = [];
for (const call of calls) {
placeholder.stage = `正在用你的登录态${call.write ? "提交" : "查询"}「${call.title}」…`;
- results.push(await execBrowserCall(call));
+ results.push(await execBrowserCall(call, () => {
+ const ctx = call.pathAndQuery.split("/").filter(Boolean)[0] || "该模块";
+ placeholder.stage = `「${ctx}」模块还没建立登录会话,正在为你自动登录…`;
+ }));
}
placeholder.stage = "正在整理结果…";
const resp: any = await agentApi.submitToolResults(sessionId.value, results).send();