- 说「我要请假」这类只有意图的短句时,直接弹出表单不再过模型:
后端 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:
+148
-9
@@ -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<Record<string, string>>({});
|
||||
/** 提交后的摘要(用于只读回显) */
|
||||
const summary = ref<{ label: string; text: string }[]>([]);
|
||||
|
||||
/* ==================== 下拉选项:动态代取 + 联动 ==================== */
|
||||
|
||||
/** 动态取回的选项(按字段名);取到后覆盖字段自带的兜底选项 */
|
||||
const dynOptions = reactive<Record<string, { value: string; label: string }[]>>({});
|
||||
/** 动态取选项的状态:idle(依赖未填/未开始) · loading · ok · fail */
|
||||
const optState = reactive<Record<string, "idle" | "loading" | "ok" | "fail">>({});
|
||||
/** 依赖字段值指纹:只有依赖真的变了才重新拉取,避免每次输入都打接口 */
|
||||
const depSig = reactive<Record<string, string>>({});
|
||||
|
||||
/** 当前该显示的选项:动态取回的优先,否则用后端给的(可能是内置兜底) */
|
||||
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() {
|
||||
|
||||
<div class="field__control">
|
||||
<Select
|
||||
v-if="f.widget === 'select'"
|
||||
v-if="widgetOf(f) === 'select'"
|
||||
v-model="values[f.name]"
|
||||
:placeholder="f.example || '请选择'"
|
||||
:disabled="disabled"
|
||||
:placeholder="!depsReady(f) ? '请先选择上级项' : (f.example || '请选择')"
|
||||
:disabled="disabled || !depsReady(f) || optState[f.name] === 'loading'"
|
||||
:loading="optState[f.name] === 'loading'"
|
||||
clearable
|
||||
>
|
||||
<Option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</Option>
|
||||
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
|
||||
</Select>
|
||||
|
||||
<DatePicker
|
||||
v-else-if="f.widget === 'datetime'"
|
||||
v-else-if="widgetOf(f) === 'datetime'"
|
||||
v-model="values[f.name]"
|
||||
type="datetime"
|
||||
format="yyyy-MM-dd HH:mm:ss"
|
||||
@@ -236,7 +374,7 @@ function reset() {
|
||||
/>
|
||||
|
||||
<DatePicker
|
||||
v-else-if="f.widget === 'date'"
|
||||
v-else-if="widgetOf(f) === 'date'"
|
||||
v-model="values[f.name]"
|
||||
type="date"
|
||||
format="yyyy-MM-dd"
|
||||
@@ -246,7 +384,7 @@ function reset() {
|
||||
/>
|
||||
|
||||
<InputNumber
|
||||
v-else-if="f.widget === 'number'"
|
||||
v-else-if="widgetOf(f) === 'number'"
|
||||
v-model="values[f.name]"
|
||||
:precision="f.type === 'integer' ? 0 : undefined"
|
||||
:placeholder="f.example || f.description || '请输入数字'"
|
||||
@@ -255,7 +393,7 @@ function reset() {
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-else-if="f.widget === 'textarea'"
|
||||
v-else-if="widgetOf(f) === 'textarea'"
|
||||
v-model="values[f.name]"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
@@ -271,6 +409,7 @@ function reset() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="optHint(f)" class="field__tip">{{ optHint(f) }}</p>
|
||||
<p v-if="dictNote(f)" class="field__tip">{{ dictNote(f) }}</p>
|
||||
<p v-if="dictWarn(f)" class="field__warn">{{ dictWarn(f) }}</p>
|
||||
<p v-if="f.description && f.example" class="field__tip">{{ f.description }}</p>
|
||||
|
||||
@@ -56,6 +56,20 @@ export interface FormField {
|
||||
* 为 true 时说明「选项能用,但可能不含学校自定义的项」,前端给一句轻提示即可,不阻塞填写。
|
||||
*/
|
||||
optionsFallback?: boolean;
|
||||
/**
|
||||
* 「浏览器代取选项」的调用描述:业务模块的字典接口(班级、学生名单…)只认浏览器 CAS 会话,
|
||||
* 服务端代取必然 302,所以由前端带**用户自己的登录态**去取原始字典,再渲染成下拉。
|
||||
*
|
||||
* <p>{@link pathAndQuery} 里可能含 {@code {{字段名}}} 占位符(见 {@link dependsOn}),
|
||||
* 依赖字段有值后才能替换并发起请求。
|
||||
*/
|
||||
optionCall?: BrowserToolCall;
|
||||
/** 本字段选项依赖的先序字段名(如「学生」依赖「班级」);依赖未填时清空并提示先选 */
|
||||
dependsOn?: string[];
|
||||
/** 从字典返回对象里取哪个字段作为选项「值」(如 classId / stuId) */
|
||||
valueKey?: string;
|
||||
/** 从字典返回对象里取哪个字段作为选项「显示名」(如 className / stuName) */
|
||||
labelKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+7
-103
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模块响应是否为「未登录」信号。
|
||||
*
|
||||
* <p>平台 CAS 过滤器对 AJAX 不真的 302,而是返回 <b>HTTP 200 + `{"code":302,urlToRedirectTo}`</b>
|
||||
* (带 `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<string, number>();
|
||||
const WARM_RETRY_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 模块会话预热:用一次<b>真实导航</b>把业务模块的浏览器 CAS 会话建起来。
|
||||
*
|
||||
* <p>为什么必须这么做:模块接口只认<b>模块自己容器里的</b> CAS 会话(`_const_cas_assertion_`)。
|
||||
* 同源部署只保证 Cookie 送得到,送不到「还没建出来的会话」。而模块 SPA 建会话靠的是
|
||||
* `window.location.href = urlToRedirectTo` 整页跳 SSO 签票回跳 —— fetch 永远拿不到这一步
|
||||
* (平台对 AJAX 只回 JSON `code=302`,不真跳转),于是每次查询都必然 302。
|
||||
*
|
||||
* <p>做法:同源隐藏 iframe 导航到刚失败的那个接口地址(<b>不带</b> `yschapirequest`,
|
||||
* 平台即回真 302 → SSO 见已有 TGT 直接签票回跳 → 模块校验票据后落会话),随后重试原请求。
|
||||
* 整个过程用户无感;只在首次查询某模块时发生一次。
|
||||
*
|
||||
* @param pathAndQuery 刚失败的接口相对路径(`/campusnotice/api/...`),首段即模块上下文
|
||||
* @param onStart 确认要预热时回调(用于更新界面文案)
|
||||
* @returns 是否完成了这轮预热尝试(false = 60s 内已试过,或拿不到模块段)
|
||||
*/
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器代执行(方案 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<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 };
|
||||
}
|
||||
}
|
||||
const execBrowserCall = callModuleApi;
|
||||
|
||||
/** 执行一批代调用并回传续跑;续跑响应若再挂起(链式查询)则递归处理 */
|
||||
async function runBrowserTool(placeholder: ChatItem, pending: any) {
|
||||
|
||||
Reference in New Issue
Block a user