- 说「我要请假」这类只有意图的短句时,直接弹出表单不再过模型:
后端 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 复用。
- 例句与提示改为「我要请假」「帮学生登记请假」。
This commit is contained in:
@@ -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<string, number>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块会话预热:用一次<b>真实导航</b>把业务模块的浏览器 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<boolean> {
|
||||
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<boolean>(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<ToolResultItem> {
|
||||
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<string, string> = { 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;
|
||||
}
|
||||
Reference in New Issue
Block a user