This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 智能助手对话页。
|
||||
*
|
||||
* 交互要点:
|
||||
* - 用户发一句话 → 后端编排(大模型决策 + 调用业务接口)→ 返回回复 + 工具执行轨迹
|
||||
* - 信息不全时后端下发「补全表单」,这里渲染成可填卡片,用户填完直接提交执行(不再反复追问)
|
||||
* - 等待期间显示阶段提示(理解需求 → 调用接口)
|
||||
* - 助手消息下挂「工具轨迹卡」,写操作后提供「去查看」直达对应模块页面
|
||||
* - 输入框轮播示例,并按关键词提示「怎么说更准确」
|
||||
* - 能力目录由外壳统一拉取后传入,侧栏与首屏共用一份数据
|
||||
*/
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
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 type { AgentToolCatalog, BrowserToolCall, ChatItem, ToolResultItem } from "@/types/agent";
|
||||
|
||||
const props = defineProps<{ user?: any; catalog?: AgentToolCatalog | null }>();
|
||||
|
||||
const items = ref<ChatItem[]>([]);
|
||||
const input = ref("");
|
||||
const sending = ref(false);
|
||||
const stage = ref("");
|
||||
const sessionId = ref<string | undefined>(undefined);
|
||||
const listEl = ref<HTMLElement | null>(null);
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
/** 等待期间的阶段轮播(后端返回前给用户进度感) */
|
||||
let stageTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const STAGES = ["正在理解你的需求…", "正在判断需要调用哪些功能…", "正在调用系统接口…", "正在整理结果…"];
|
||||
|
||||
/**
|
||||
* 输入示例轮播:直接给出"可以怎么说"的成品句,比抽象描述更好用。
|
||||
*
|
||||
* 每条标注它依赖的「模块 + 工具」,用于按当前身份裁剪:
|
||||
* 学生身份下不会轮播「发通知」「专业发展积分」这类教师专属句子 ——
|
||||
* 否则等于给用户一个"照着说必被拒"的示范。
|
||||
* 判据取自能力目录本身(模块是否下发、工具名是否存在),前端无需知道角色规则。
|
||||
*/
|
||||
const EXAMPLES: { text: string; module: string; tool?: string }[] = [
|
||||
{ text: "我要请 10月12日 上午的病假,发烧就医", module: "homeleave", tool: "leave.submit" },
|
||||
{ text: "我的请假记录有几次", module: "homeleave", tool: "leave.myLeaves" },
|
||||
{ text: "发一条关于社团招新的通知", module: "campusnotice", tool: "notice.publish" },
|
||||
{ text: "这周有什么新的校园公告?", module: "campusnotice", tool: "notice.received" },
|
||||
{ text: "提交数学作业", module: "coursehomework", tool: "homework.submit" },
|
||||
{ text: "看看我还有哪些作业没做", module: "coursehomework", tool: "homework.myList" },
|
||||
{ text: "宿舍有什么通知需要我回执吗?", module: "dormnotice", tool: "dorm.myNotice" },
|
||||
{ text: "报名秋季亲子运动会,2 人参加", module: "familyactivity", tool: "activity.signup" },
|
||||
{ text: "帮我找一份语文课件", module: "resourcelibrary", tool: "resource.search" },
|
||||
{ text: "我的专业发展积分是多少?", module: "profession", tool: "profession.myPoints" },
|
||||
];
|
||||
|
||||
/** 当前身份下适合轮播的示例句;能力目录未就绪时先给全量,避免输入框提示空着 */
|
||||
const examples = computed(() => {
|
||||
const mods = props.catalog?.modules;
|
||||
if (!mods) return EXAMPLES.map(e => e.text);
|
||||
return EXAMPLES.filter(e => {
|
||||
const list = mods[e.module] || [];
|
||||
return list.length > 0 && (!e.tool || list.some(t => t.name === e.tool));
|
||||
}).map(e => e.text);
|
||||
});
|
||||
|
||||
const exampleIndex = ref(0);
|
||||
let exampleTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const placeholder = computed(() => {
|
||||
const list = examples.value;
|
||||
if (!list.length) return "说说你想办什么…";
|
||||
return `试试说:${list[exampleIndex.value % list.length]}`;
|
||||
});
|
||||
|
||||
/** 边说边提示:命中关键词时告诉用户"这样说更准" */
|
||||
const TIPS: { key: RegExp; text: string }[] = [
|
||||
{ key: /请假|病假|事假/, text: "说清「类型 + 起止时间 + 事由」一次就能提交,例:10月12日上午病假,发烧就医" },
|
||||
{ key: /公告|通知(发布|发布公告)/, text: "发布公告需要「标题 + 正文 + 发文部门」,例:发一条关于运动会的通知,正文…" },
|
||||
{ key: /作业|作答|提交作/, text: "可以说「我有哪些作业没做」,或「提交数学作业」,我会列出作业让你选" },
|
||||
{ key: /宿舍|回执/, text: "例:宿舍有什么通知需要我回执吗?我会列出通知并给出回执表单" },
|
||||
{ key: /报名|活动/, text: "例:报名秋季亲子运动会,2 人参加,联系人张伟 13800000000" },
|
||||
{ key: /审批|通过|驳回/, text: "例:把 101 号请假审批通过,意见「同意」" },
|
||||
{ key: /资源|课件|教案|资料|试题/, text: "例:帮我找一份语文课件;想留存就说「收藏这份课件」" },
|
||||
{ key: /积分|专业发展|记录|成果/, text: "查积分说「我的专业发展积分是多少」;要留痕说「发布一条听课记录」" },
|
||||
];
|
||||
const activeTip = computed(() =>
|
||||
input.value.trim() ? TIPS.find(t => t.key.test(input.value.trim()))?.text || "" : ""
|
||||
);
|
||||
|
||||
const isEmpty = computed(() => items.value.length === 0);
|
||||
|
||||
onMounted(() => {
|
||||
exampleTimer = setInterval(() => {
|
||||
/* 用当前身份可见的例句数取模:目录到达后例句会变少,索引必须跟着收敛 */
|
||||
exampleIndex.value = (exampleIndex.value + 1) % Math.max(1, examples.value.length);
|
||||
}, 4200);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopStage();
|
||||
if (exampleTimer) clearInterval(exampleTimer);
|
||||
});
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
const el = listEl.value;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
/** 输入框随内容长高(最多 148px,再长就内部滚动) */
|
||||
function autoGrow() {
|
||||
const el = inputEl.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 148)}px`;
|
||||
}
|
||||
watch(input, () => nextTick(autoGrow));
|
||||
|
||||
function startStage() {
|
||||
let i = 0;
|
||||
stage.value = STAGES[0];
|
||||
stageTimer = setInterval(() => {
|
||||
i = Math.min(i + 1, STAGES.length - 1);
|
||||
stage.value = STAGES[i];
|
||||
const last = items.value[items.value.length - 1];
|
||||
if (last && last.role === "assistant") last.stage = stage.value;
|
||||
}, 1600);
|
||||
}
|
||||
|
||||
function stopStage() {
|
||||
if (stageTimer) {
|
||||
clearInterval(stageTimer);
|
||||
stageTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 新建一条等待中的助手消息 */
|
||||
function pushPending(): ChatItem {
|
||||
const placeholder: ChatItem = {
|
||||
id: `a-${Date.now()}`,
|
||||
role: "assistant",
|
||||
text: "",
|
||||
stage: STAGES[0],
|
||||
traces: [],
|
||||
at: Date.now(),
|
||||
};
|
||||
items.value.push(placeholder);
|
||||
sending.value = true;
|
||||
startStage();
|
||||
scrollToBottom();
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
/** 把后端响应灌进占位消息 */
|
||||
function fill(placeholder: ChatItem, resp: any) {
|
||||
sessionId.value = resp?.sessionId || sessionId.value;
|
||||
placeholder.text = resp?.reply || "";
|
||||
placeholder.traces = resp?.toolCalls || [];
|
||||
placeholder.wroteSomething = !!resp?.wroteSomething;
|
||||
placeholder.landingPath = resp?.landingPath;
|
||||
if (resp?.form) {
|
||||
placeholder.form = resp.form;
|
||||
}
|
||||
if (resp?.warning && !resp?.reply) {
|
||||
placeholder.error = resp.warning;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器代执行(方案 A):模块接口只认浏览器 CAS 会话,服务端代调必然 302。
|
||||
* 后端挂起后把接口描述下发到这里,用「用户自己的登录态」调完模块接口再把结果回传续跑。
|
||||
*
|
||||
* 前提:本应用与模块同站部署(同源 Cookie 自动携带)。本地 dev(localhost:8081)
|
||||
* 跨域取不到 base 域会话,fetch 会失败或 404 → 结果回传后由服务端判为失败,
|
||||
* 自动落回「去系统里查看」的深链兜底,UI 不会卡死。
|
||||
*/
|
||||
async function execBrowserCall(call: BrowserToolCall): Promise<ToolResultItem> {
|
||||
const base = { name: call.name, arguments: call.arguments };
|
||||
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 { ...base, status: res.status, body: await res.text() };
|
||||
} catch (e: any) {
|
||||
const reason = e?.name === "AbortError" ? "请求超时(15 秒)" : e?.message || "网络请求失败";
|
||||
return { ...base, error: reason };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行一批代调用并回传续跑;续跑响应若再挂起(链式查询)则递归处理 */
|
||||
async function runBrowserTool(placeholder: ChatItem, pending: any) {
|
||||
// ★ 先把挂起响应里的 sessionId 落地:续跑请求要用它找回同一会话,
|
||||
// 否则服务端会当成没有历史的新会话(模型拿不到上文的 tool_calls)。
|
||||
sessionId.value = pending?.sessionId || sessionId.value;
|
||||
const calls: BrowserToolCall[] = pending?.browserTool?.calls || [];
|
||||
placeholder.text = pending?.reply || pending?.browserTool?.notice || "正在用你的登录态查询业务模块…";
|
||||
const results: ToolResultItem[] = [];
|
||||
for (const call of calls) {
|
||||
placeholder.stage = `正在用你的登录态${call.write ? "提交" : "查询"}「${call.title}」…`;
|
||||
results.push(await execBrowserCall(call));
|
||||
}
|
||||
placeholder.stage = "正在整理结果…";
|
||||
const resp: any = await agentApi.submitToolResults(sessionId.value, results).send();
|
||||
fill(placeholder, resp);
|
||||
if (resp?.browserTool) {
|
||||
await runBrowserTool(placeholder, resp);
|
||||
}
|
||||
}
|
||||
|
||||
async function send(text?: string) {
|
||||
const content = (text ?? input.value).trim();
|
||||
if (!content || sending.value) return;
|
||||
|
||||
input.value = "";
|
||||
items.value.push({ id: `u-${Date.now()}`, role: "user", text: content, at: Date.now() });
|
||||
const placeholder = pushPending();
|
||||
|
||||
try {
|
||||
const resp: any = await agentApi.chat({ sessionId: sessionId.value, message: content }).send();
|
||||
if (resp?.browserTool) {
|
||||
await runBrowserTool(placeholder, resp);
|
||||
} else {
|
||||
fill(placeholder, resp);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.message === "redirect") return;
|
||||
placeholder.error = e?.message || "请求失败,请稍后重试";
|
||||
} finally {
|
||||
stopStage();
|
||||
placeholder.stage = "";
|
||||
sending.value = false;
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
/** 表单提交:直接执行对应工具,不再让模型二次判断 */
|
||||
async function onFormSubmit(payload: { item: ChatItem; form: { tool: string; args: Record<string, any> } }) {
|
||||
if (sending.value) return;
|
||||
const { item, form } = payload;
|
||||
|
||||
item.formSubmitted = true; // 卡片立即转为只读回显
|
||||
const placeholder = pushPending();
|
||||
|
||||
try {
|
||||
const resp: any = await agentApi.submitForm(sessionId.value, form).send();
|
||||
if (resp?.browserTool) {
|
||||
await runBrowserTool(placeholder, resp);
|
||||
} else {
|
||||
fill(placeholder, resp);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.message === "redirect") return;
|
||||
placeholder.error = e?.message || "提交失败,请稍后重试";
|
||||
item.formSubmitted = false; // 失败则允许用户改完再提交
|
||||
} finally {
|
||||
stopStage();
|
||||
placeholder.stage = "";
|
||||
sending.value = false;
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// Enter 发送;Shift+Enter 换行
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}
|
||||
|
||||
async function newSession() {
|
||||
try {
|
||||
await agentApi.clearSession(sessionId.value).send();
|
||||
} catch {
|
||||
/* 忽略:清理失败不影响本地重置 */
|
||||
}
|
||||
sessionId.value = undefined;
|
||||
items.value = [];
|
||||
input.value = "";
|
||||
Message.success("已开启新对话");
|
||||
}
|
||||
|
||||
/** 供侧栏调用:把模块示例句直接发出去 */
|
||||
function pickSample(text: string) {
|
||||
send(text);
|
||||
}
|
||||
|
||||
defineExpose({ newSession, pickSample });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat">
|
||||
<div ref="listEl" class="chat__list agent-scroll">
|
||||
<QuickActions v-if="isEmpty" :catalog="catalog ?? null" @pick="t => send(t)" />
|
||||
<div v-else class="chat__stream">
|
||||
<MessageBubble
|
||||
v-for="(it, i) in items"
|
||||
:key="it.id"
|
||||
:item="it"
|
||||
:pending="sending && i === items.length - 1 && !it.text"
|
||||
@form-submit="onFormSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat__composer">
|
||||
<div class="chat__box" :class="{ 'chat__box--busy': sending }">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="input"
|
||||
class="chat__input"
|
||||
rows="1"
|
||||
:disabled="sending"
|
||||
:placeholder="placeholder"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<div class="chat__actions">
|
||||
<button class="chat__ghost" type="button" :disabled="sending" title="清空当前会话" @click="newSession">
|
||||
新会话
|
||||
</button>
|
||||
<button
|
||||
class="chat__send"
|
||||
type="button"
|
||||
:disabled="!input.trim() || sending"
|
||||
@click="send()"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 12l16-8-6 8 6 8-16-8z" />
|
||||
</svg>
|
||||
{{ sending ? "处理中" : "发送" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="chat__hint" :class="{ 'chat__hint--tip': !!activeTip }">
|
||||
<svg v-if="activeTip" class="chat__hint-icon" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M12 3a6 6 0 00-3.5 10.9V17h7v-3.1A6 6 0 0012 3z" />
|
||||
<path d="M9.5 20.5h5" />
|
||||
</svg>
|
||||
<template v-if="activeTip">{{ activeTip }}</template>
|
||||
<template v-else>Enter 发送 · Shift + Enter 换行 · 写操作请确认信息无误后再提交</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat__list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat__stream {
|
||||
max-width: var(--agent-readw);
|
||||
margin: 0 auto;
|
||||
padding: 26px 28px 8px;
|
||||
}
|
||||
|
||||
/* ------------------------------ 输入区 ------------------------------ */
|
||||
.chat__composer {
|
||||
flex: none;
|
||||
padding: 12px 28px 16px;
|
||||
background: linear-gradient(to top, var(--agent-bg) 58%, rgba(244, 246, 251, 0));
|
||||
}
|
||||
|
||||
.chat__box {
|
||||
max-width: var(--agent-readw);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
padding: 10px 12px 10px 16px;
|
||||
border: 1px solid var(--agent-border-strong);
|
||||
border-radius: 18px;
|
||||
background: var(--agent-surface);
|
||||
box-shadow: 0 2px 4px rgba(15, 23, 42, 0.04), 0 18px 34px -20px rgba(15, 23, 42, 0.32);
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.chat__box:focus-within {
|
||||
border-color: var(--agent-primary);
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1), 0 18px 34px -20px rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
|
||||
.chat__box--busy {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chat__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: var(--agent-text);
|
||||
min-height: 26px;
|
||||
max-height: 148px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.chat__input::placeholder {
|
||||
color: var(--agent-text-mute);
|
||||
}
|
||||
|
||||
.chat__actions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.chat__ghost {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
background: transparent;
|
||||
color: var(--agent-text-sub);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.16s ease, color 0.16s ease;
|
||||
}
|
||||
|
||||
.chat__ghost:hover:not(:disabled) {
|
||||
background: var(--agent-surface-2);
|
||||
color: var(--agent-text);
|
||||
}
|
||||
|
||||
.chat__ghost:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat__send {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(135deg, #2563eb, #4f46e5);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.3px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 20px -10px rgba(37, 99, 235, 0.9);
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
|
||||
.chat__send:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 14px 24px -10px rgba(37, 99, 235, 0.95);
|
||||
}
|
||||
|
||||
.chat__send:disabled {
|
||||
background: #cbd5e1;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat__hint {
|
||||
max-width: var(--agent-readw);
|
||||
margin: 9px auto 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--agent-text-mute);
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
.chat__hint--tip {
|
||||
color: var(--agent-primary-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat__hint-icon {
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user