feat(welcome): 首屏按使用画像做「常用优先」,老用户回来先看见自己常办的事
build / build (push) Successful in 1m53s

原来首屏对每个人都长一个样:网格顺序写死,指标卡全是「能力 91 项 / 可提交 39 项」
这类覆盖数字。用过一百次请假的人,请假入口照样排在第 4 位。

新增 src/utils/usage.ts(本机使用画像)
- 用 localStorage 记「模块 / 能力的调用次数 + 最近时间」与「写操作成功件数」。
  后端 GET /api/agent/tools 只下发"你能用什么",不提供"你常用什么",
  所以第一步先落在本机:零后端改动、零数据出网;换设备重新累积。
- 归属按 userId + roleId 隔离(一个账号多重身份能力范围不同,混在一起会串推荐);
  目录未回来时退回平台写入的 currentRole,取不到才退化匿名。
- 模块按「轮」计数(一轮查 3 个接口只 +1,符合用户心里"我问过几次"),
  能力按「调用次数」计数;done 只统计 write && success —— 查询再多次也不算成果。
- rankFrequent / summarize 有意吃 state 而不是吃 scope:调用方手里那份快照带版本依赖,
  记录或清除后能立刻重算,避免"点了清除,页面上常用区块还在"的幽灵状态。

首屏(QuickActions.vue)改成双形态
- 无使用记录 → 完全保持原有引导形态(自我介绍 + 覆盖面指标 + 各模块一句成品请求)。
- 有使用记录 → 个性化问候「欢迎回来,X」,正文直接列出「你最近常办:…」;
  指标卡换成「本周已办成 / 常用模块 / 可操作能力」;
  新增「常用」区块(用过最多的模块,点一下就发);
  快速开始网格按频次重排,用过 2 次以上的卡片描边并打「常用 N 次」角标。
- 能力清单弹层叠加使用次数(模块级「你用过 N 次」+ 能力级次数),
  底部提供「清除本机使用记录」(两段式确认),并说明数据不出本机。

顺带把「常用」延伸到导航与顶栏
- AppSidebar:模块按频次置顶 + 常用项左缘一道色条;排序只在进页面时算一次,
  不在会话内实时跳动(否则"我刚点的那行跑哪去了"会打断操作)。
- App.vue:顶栏胶囊补「已帮你办成 N 件」——原来只回答"系统有多大",
  现在同时回答"它为我做过多少事"。

埋点(Chat.vue)
- 挂在每轮响应的 finally,只认回包里的 toolCalls(含浏览器代执行续跑后的最终结果),
  不认"用户点了哪张卡":用户完全可以自己在输入框里打出「我要请假」,
  只有后端的执行轨迹才是事实;失败时 traces 为空,自动不记。

铁律不变:所有入口仍由 /api/agent/tools 目录驱动,前端不自行判权。
「常用」只做排序不做增删;无权使用的能力即使局部画像里有记录,也不进清单。

验证:真机 46/46(.scratch/verify-usage-personalization.mjs,覆盖首次访问形态、
埋点计数口径、写操作才计件、常用前置与角标、侧栏顺序、清除记录、多账号隔离);
请假登记回归 17/17。
This commit is contained in:
lijing
2026-10-13 17:11:43 +08:00
parent 275a7c83af
commit e00f0db729
5 changed files with 820 additions and 63 deletions
+24 -1
View File
@@ -7,10 +7,11 @@
* 再往下传,避免各组件各拉一次。
* - 会话状态留在对话页内部,侧栏的「新建对话」通过组件 ref 调用它的方法触发。
*/
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, onUnmounted, ref } from "vue";
import { Message } from "view-ui-plus";
import { agentApi, baseApi } from "@/api/module/agent";
import AppSidebar from "@/components/AppSidebar.vue";
import { onUsageChange, usageScope, usageSummary } from "@/utils/usage";
import type { AgentToolCatalog, UserInfoVo } from "@/types/agent";
const user = ref<UserInfoVo | null>(null);
@@ -30,6 +31,21 @@ const writeCount = computed(() => {
return n;
});
/**
* 累计「已帮你办成」件数(本机使用画像,只有写操作成功才累加)。
*
* 顶栏原本只回答"这个系统有多大"(能力数 / 可提交数),
* 补上这一格之后它同时回答"它为我做过多少事" —— 老用户最关心的那个数字。
* 画像变更用订阅器感知,因此用户刚提交完一张表单,这里立刻 +1。
*/
const usageVersion = ref(0);
let offUsage: (() => void) | null = null;
const doneCount = computed(() => {
usageVersion.value; // 显式依赖:订阅器触发时重算
return usageSummary(usageScope(catalog.value)).doneTotal;
});
/**
* 拉取当前登录用户(平台 CAS 会话)。
*
@@ -63,8 +79,11 @@ async function loadCatalog() {
onMounted(() => {
loadUser();
loadCatalog();
offUsage = onUsageChange(() => usageVersion.value++);
});
onUnmounted(() => offUsage?.());
function onNewSession() {
chatRef.value?.newSession?.();
}
@@ -89,6 +108,10 @@ function onNewSession() {
可用能力 <strong class="agent-num">{{ toolCount }}</strong> 项
<i class="agent-capsule__sep" />
可直接提交 <strong class="agent-num">{{ writeCount }}</strong> 项
<template v-if="doneCount">
<i class="agent-capsule__sep" />
已帮你办成 <strong class="agent-num">{{ doneCount }}</strong> 件
</template>
</span>
</div>
</header>
+33 -5
View File
@@ -4,7 +4,8 @@
*
* 承担三件事,让主区可以专心做对话:
* 1. 品牌与身份 —— 一眼知道这是什么、当前是谁在用
* 2. 能力导航 —— 7 个业务模块全部列出,点一下就等于发出一句完整请求
* 2. 能力导航 —— 当前身份可用的模块全部列出(**常用的排在前面并带一道色条**),
* 点一下就等于发出一句完整请求
* 3. 使用提示 —— 常驻说明"信息不全也没关系",降低首次使用门槛
*/
import { computed, ref } from "vue";
@@ -13,6 +14,7 @@ import { baseApi } from "@/api/module/agent";
import conf from "@/assets/config/host.config";
import { MODULE_LABEL, moduleMeta, orderedModuleKeys } from "@/assets/config/modules";
import { beginLogoutGuard } from "@/utils/logout-guard";
import { rankModuleKeys, usageScope, usageSnapshot } from "@/utils/usage";
import type { AgentToolCatalog, UserInfoVo } from "@/types/agent";
const props = defineProps<{ catalog: AgentToolCatalog | null; user?: UserInfoVo | null }>();
@@ -74,16 +76,26 @@ async function logout() {
const active = ref<string>("");
/**
* 侧栏只列出「当前身份真正可用」的模块。
* 侧栏只列出「当前身份真正可用」的模块,并**按使用频次把常用项置顶**。
*
* 能力目录由后端按「角色 + 已授权模块」过滤后下发,学生 / 教师 / 管理员拿到的集合本就不同,
* 因此这里直接以目录为准,而不是任何前端写死的名单 —— 既不会出现"点了就被拒"的假入口,
* 也不会漏掉后端新上线、前端还没配元数据的模块。
*
* ★ 排序只在**目录到达时算一次**(`catalog` 变化才重算),不订阅画像变更:
* 侧栏是导航而非展示位,如果用户点完一个模块它就当场跳到第一位,
* "我刚点的那一行跑哪去了"会打断操作。下次进页面时它自然已经在前面。
*/
const modules = computed(() => {
const mods = props.catalog?.modules || {};
return orderedModuleKeys(mods).map(key => {
const keyList = orderedModuleKeys(mods);
const scope = usageScope(props.catalog);
const keys = rankModuleKeys(scope, keyList);
const heat = usageSnapshot(scope).modules;
return keys.map(key => {
const meta = moduleMeta(key);
const used = heat[key]?.count ?? 0;
return {
key,
icon: meta.icon,
@@ -91,6 +103,9 @@ const modules = computed(() => {
sample: meta.sample,
label: MODULE_LABEL[key] || key,
count: mods[key].length,
/** 用过 2 次以上算常用:左侧给一道色条,方便扫视时定位 */
hot: used >= 2,
used,
};
});
});
@@ -156,8 +171,12 @@ function pick(m: { key: string; sample: string }) {
v-for="m in modules"
:key="m.key"
class="sb__item"
:class="[`tone-${m.tone}`, { 'sb__item--on': active === m.key }]"
:title="`对「${m.label}」说:${m.sample}`"
:class="[`tone-${m.tone}`, { 'sb__item--on': active === m.key, 'sb__item--hot': m.hot }]"
:title="
m.hot
? `你常办这个(用过 ${m.used} 次)|对「${m.label}」说:${m.sample}`
: `对「${m.label}」说:${m.sample}`
"
@click="pick(m)"
>
<span class="sb__item-icon">{{ m.icon }}</span>
@@ -383,6 +402,15 @@ function pick(m: { key: string; sample: string }) {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
/* 常用模块:左缘一道亮色条(深色侧栏上比文字标记更安静,也不会挤掉模块名) */
.sb__item--hot {
box-shadow: inset 2px 0 0 var(--tone-light, #93c5fd);
}
.sb__item--hot.sb__item--on {
box-shadow: inset 2px 0 0 var(--tone-light, #93c5fd), inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
.sb__item-icon {
flex: none;
width: 25px;
+403 -56
View File
@@ -1,21 +1,37 @@
<script setup lang="ts">
/**
* 首屏引导区。
* 首屏引导区(空状态)。
*
* 排版分三层,视线由上到下自然下落:
* 1. Hero 横幅 —— 说明"我能帮你做什么",建立信任
* 2. 指标卡 —— 用数字证明覆盖面(能力数 / 可提交数 / 模块数)
* 3. 快速开始 —— 每个**当前身份可用的**业务模块给一句成品请求,点一下就等于说出来
* 末尾一格是"查看全部能力",既补齐网格,也让全部能力可查。
* 同一块地方要服务两种完全不同的人,因此这里做**双形态**:
*
* 关键:模块集合与数量都来自能力目录(后端已按角色过滤),不是写死的 7 个 / 54 项。
* 学生登录后不会出现「教师专业发展」这类入口 —— 否则点下去必被服务端拒绝。
* ① 第一次来(本机没有使用记录)—— 引导形态,回答"你能干什么"
* Hero 自我介绍 → 覆盖面指标(能力数 / 可提交数 / 模块数)→ 每个模块一句成品请求
*
* ② 用过之后再回来 —— 个性化形态,回答"我接着办什么"
* Hero 个性化问候 + "你最近常办" → 个人战绩指标(本周办成 / 常用模块 / 可操作能力)
* → 「常用」区块(用过最多的几个模块直接可点)→ 快速开始网格(**常用模块自己浮到前面**)
*
* 切换到 ① / ② 只取决于 `utils/usage` 里有没有记录,用户不需要任何设置。
*
* 关键:模块集合与数量都来自能力目录(后端已按角色过滤),不是写死的 6 个 / 54 项。
* 「常用优先」只做**排序**,不做增删 —— 学生登录后不会因为"排在后面"而丢失入口,
* 也不会出现「教师专业发展」这类前端自己造出来的、点下去必被拒的假入口。
*/
import { computed, ref } from "vue";
import { computed, onMounted, onUnmounted, ref } from "vue";
import { Message } from "view-ui-plus";
import { MODULE_LABEL, MODULE_ORDER, moduleMeta, orderedModuleKeys } from "@/assets/config/modules";
import {
clearUsage,
onUsageChange,
rankFrequent,
rankModuleKeys,
summarize,
usageScope,
usageSnapshot,
} from "@/utils/usage";
import type { AgentToolCatalog } from "@/types/agent";
const props = defineProps<{ catalog: AgentToolCatalog | null }>();
const props = defineProps<{ catalog: AgentToolCatalog | null; user?: any | null }>();
const emit = defineEmits<{ (e: "pick", text: string): void }>();
/**
@@ -34,15 +50,78 @@ const PRESET_TEXT: Record<string, { title: string; desc: string; text: string }>
profession: { title: "我的积分", desc: "查看专业发展积分", text: "我的专业发展积分是多少?" },
};
/** 打过多少次才算「常用」(1 次可能是随手点的,不急着打标) */
const HOT_MIN = 2;
/** 「常用」区块最多铺几张卡 */
const FREQ_MAX = 4;
/* ---------------------------------- 使用画像 ---------------------------------- */
/**
* 快捷卡条目:跟随能力目录生成。
* 画像读取。
*
* `version` 是个纯计数器:`utils/usage` 的订阅器在记录变更后 +1,
* 于是下面所有依赖 `usage` 的 computed 会重算 —— 这样首屏不必知道画像何时被写,
* 也不需要把 Vue 的响应式引进那个纯工具模块。
*/
const scope = computed(() => usageScope(props.catalog));
const version = ref(0);
let offUsage: (() => void) | null = null;
onMounted(() => {
offUsage = onUsageChange(() => version.value++);
});
onUnmounted(() => offUsage?.());
const usage = computed(() => {
version.value; // 显式依赖:订阅器触发时重算
return usageSnapshot(scope.value);
});
/** 当前身份可见的模块(能力目录过滤后),并已按使用频次重排 */
const moduleKeys = computed(() => {
const mods = props.catalog?.modules;
const keys = mods ? orderedModuleKeys(mods) : MODULE_ORDER;
return rankModuleKeys(scope.value, keys);
});
/** 用过的模块(频次倒序)—— 决定首屏是否进入个性化形态 */
const frequent = computed(() =>
rankFrequent(usage.value, moduleKeys.value).map(m => {
const meta = moduleMeta(m.key);
return {
...m,
icon: meta.icon,
tone: meta.tone,
label: MODULE_LABEL[m.key] || m.key,
text: PRESET_TEXT[m.key]?.text || meta.sample,
};
})
);
const hasHistory = computed(() => frequent.value.length > 0);
const greeting = computed(() => {
const name = String(props.user?.name || "").trim();
return name ? `欢迎回来,${name} 👋` : "欢迎回来 👋";
});
const heroIntro = computed(() => {
const top = frequent.value.slice(0, 3).map(m => m.label);
if (!top.length) return "";
return `你最近常办:${top.join("、")}。接着办就直接说,也可以从下面挑一个。`;
});
/* ---------------------------------- 快捷卡 ---------------------------------- */
/**
* 快捷卡条目:跟随能力目录生成,**顺序跟随使用频次**。
*
* 目录未就绪时(首次加载中 / 拉取失败)先按全量模块占位,避免首屏"快速开始"整块空着;
* 目录到达后立即收缩为当前身份真正可用的模块。
*/
const tiles = computed(() => {
const keys = props.catalog?.modules ? orderedModuleKeys(props.catalog.modules) : MODULE_ORDER;
return keys
const tiles = computed(() =>
moduleKeys.value
.map(key => {
const meta = moduleMeta(key);
const preset = PRESET_TEXT[key];
@@ -53,10 +132,11 @@ const tiles = computed(() => {
title: preset?.title || MODULE_LABEL[key] || key,
desc: preset?.desc || "点一下直接发起",
text: preset?.text || meta.sample,
count: usage.value.modules[key]?.count ?? 0,
};
})
.filter(t => t.text);
});
.filter(t => t.text)
);
const writeCount = computed(() => {
const mods = props.catalog?.modules || {};
@@ -71,41 +151,112 @@ const moduleGroups = computed(() =>
label: MODULE_LABEL[key] || key,
meta: moduleMeta(key),
tools: props.catalog!.modules[key],
/** 该模块被用过多少次(0 表示还没用过,不显示徽标) */
used: usage.value.modules[key]?.count ?? 0,
}))
);
/** 指标卡:数字来自后端能力目录,未就绪时先显示占位 */
const stats = computed(() => [
{
key: "tools",
value: props.catalog?.total ?? 0,
unit: "项",
label: "可操作能力",
hint: "覆盖全部业务模块",
tone: "blue",
icon: "layers",
},
{
key: "write",
value: writeCount.value,
unit: "项",
label: "可直接提交",
hint: "请假、报名、发公告…",
tone: "violet",
icon: "bolt",
},
{
key: "mods",
value: moduleGroups.value.length,
unit: "个",
label: "已接入模块",
hint: "全部来自现有系统",
tone: "teal",
icon: "cube",
},
]);
/** 单个能力被用过多少次(能力清单弹层用) */
function toolUse(name: string): number {
return usage.value.tools[name]?.count ?? 0;
}
/**
* 指标卡:有使用历史时换成"与我有关"的三个数字。
*
* 覆盖面的数字(54 项 / 30 项)第一次看有意义,看到第十次就是噪音 ——
* 常来的用户更想知道"我这周用你办了几件事、我常用的是哪几个模块"。
* 因此只在**没有任何历史**时才显示覆盖率。
*/
const stats = computed(() => {
const summary = summarize(usage.value);
if (summary.hasHistory) {
return [
{
key: "done",
value: summary.doneWeek,
unit: "件",
label: "本周已办成",
hint: `累计 ${summary.doneTotal} 件 · 写操作成功才计数`,
tone: "blue",
icon: "check",
},
{
key: "freq",
value: frequent.value.length,
unit: "个",
label: "常用模块",
hint: "已按你的使用频次置顶",
tone: "violet",
icon: "star",
},
{
key: "tools",
value: props.catalog?.total ?? 0,
unit: "项",
label: "可操作能力",
hint: "覆盖全部业务模块",
tone: "teal",
icon: "layers",
},
];
}
return [
{
key: "tools",
value: props.catalog?.total ?? 0,
unit: "项",
label: "可操作能力",
hint: "覆盖全部业务模块",
tone: "blue",
icon: "layers",
},
{
key: "write",
value: writeCount.value,
unit: "项",
label: "可直接提交",
hint: "请假、报名、发公告…",
tone: "violet",
icon: "bolt",
},
{
key: "mods",
value: moduleGroups.value.length,
unit: "个",
label: "已接入模块",
hint: "全部来自现有系统",
tone: "teal",
icon: "cube",
},
];
});
/* ---------------------------------- 弹层与清除 ---------------------------------- */
const allOpen = ref(false);
/** 清除记录的两段式确认(避免误点把画像清空) */
const clearConfirm = ref(false);
let clearTimer: ReturnType<typeof setTimeout> | null = null;
function clearUsageRecord() {
if (!clearConfirm.value) {
clearConfirm.value = true;
clearTimer = setTimeout(() => (clearConfirm.value = false), 4000);
return;
}
if (clearTimer) clearTimeout(clearTimer);
clearConfirm.value = false;
clearUsage(scope.value);
Message.success("已清除本机使用记录");
allOpen.value = false;
}
onUnmounted(() => {
if (clearTimer) clearTimeout(clearTimer);
});
function pick(text: string) {
emit("pick", text);
@@ -114,21 +265,25 @@ function pick(text: string) {
<template>
<div class="welcome">
<!-- ① Hero -->
<!-- ① Hero(有历史时换成个性化问候 + 常用回顾) -->
<section class="hero">
<span class="hero__glow hero__glow--a" />
<span class="hero__glow hero__glow--b" />
<div class="hero__inner">
<span class="hero__tag">AI Agent · 直连业务系统</span>
<h2>你好,我是校园智能助手 👋</h2>
<p>
<h2>{{ hasHistory ? greeting : "你好,我是校园智能助手 👋" }}</h2>
<p v-if="hasHistory">
{{ heroIntro }}<br />
信息说不全也没关系,我会给你一张表,填完就提交。
</p>
<p v-else>
我不只会聊天 —— 说出需求,我会直接调用系统接口帮你办完。<br />
信息说不全也没关系,我会给你一张表,填完就提交。
</p>
</div>
</section>
<!-- ② 指标卡(上浮压住横幅下沿) -->
<!-- ② 指标卡(上浮压住横幅下沿):有历史时是"我的战绩",无历史时是"覆盖面" -->
<section class="stats">
<div v-for="s in stats" :key="s.key" class="stat" :class="`tone-${s.tone}`">
<span class="stat__icon">
@@ -141,6 +296,13 @@ function pick(text: string) {
<template v-else-if="s.icon === 'bolt'">
<path d="M13 2.5L5.5 13H11l-1 8.5L17.5 11H12l1-8.5z" />
</template>
<template v-else-if="s.icon === 'check'">
<circle cx="12" cy="12" r="9" />
<path d="M7.8 12.3l2.8 2.8 5.6-6" />
</template>
<template v-else-if="s.icon === 'star'">
<path d="M12 3.2l2.7 5.6 6.1.9-4.4 4.3 1 6.1-5.4-2.9-5.4 2.9 1-6.1L3.2 9.7l6.1-.9L12 3.2z" />
</template>
<template v-else>
<path d="M12 2.5l8.5 4.7v9.6L12 21.5l-8.5-4.7V7.2L12 2.5z" />
<path d="M12 12l8.5-4.8M12 12v9.5M12 12L3.5 7.2" />
@@ -158,7 +320,30 @@ function pick(text: string) {
</div>
</section>
<!-- ③ 快速开始 -->
<!-- ③ 常用(仅当本机有使用记录):把所有"你反复在办的事"摆到最前面 -->
<section v-if="frequent.length" class="block">
<div class="agent-section">
<span class="agent-section__title">常用</span>
<span class="agent-section__hint">按你的使用频次排序,点一下就直接发起</span>
</div>
<div class="freq">
<button
v-for="m in frequent.slice(0, FREQ_MAX)"
:key="m.key"
class="freq__item"
:class="`tone-${m.tone}`"
:title="`对「${m.label}」说:${m.text}`"
@click="pick(m.text)"
>
<span class="freq__icon">{{ m.icon }}</span>
<span class="freq__label">{{ m.label }}</span>
<span class="freq__count agent-num">用过 {{ m.count }} 次</span>
</button>
</div>
</section>
<!-- ④ 快速开始(常用模块自动前置,并在卡片角标标出) -->
<section class="block">
<div class="agent-section">
<span class="agent-section__title">快速开始</span>
@@ -166,7 +351,14 @@ function pick(text: string) {
</div>
<div class="grid">
<button v-for="t in tiles" :key="t.module" class="tile" :class="`tone-${t.tone}`" @click="pick(t.text)">
<button
v-for="t in tiles"
:key="t.module"
class="tile"
:class="[`tone-${t.tone}`, { 'tile--hot': t.count >= HOT_MIN }]"
@click="pick(t.text)"
>
<span v-if="t.count >= HOT_MIN" class="tile__badge agent-num">常用 {{ t.count }} 次</span>
<span class="tile__icon">{{ t.icon }}</span>
<span class="tile__title">{{ t.title }}</span>
<span class="tile__desc">{{ t.desc }}</span>
@@ -178,7 +370,7 @@ function pick(text: string) {
</span>
</button>
<!-- 第 8 格:补齐网格 + 让 54 项能力可查 -->
<!-- 末格:补齐网格 + 让全部能力可查 -->
<button class="tile tile--more" :disabled="!catalog" @click="allOpen = true">
<span class="tile__icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
@@ -201,7 +393,7 @@ function pick(text: string) {
</div>
</section>
<!-- 能力全清单 -->
<!-- ⑤ 能力全清单(叠加使用次数,让用户一眼看出哪些是自己的高频项) -->
<Modal v-model="allOpen" title="已接入的全部能力" :footer-hide="true" width="780" class="caps-modal">
<div class="caps-body agent-scroll">
<p class="caps-modal__lead">
@@ -212,14 +404,25 @@ function pick(text: string) {
<span class="caps-group__icon" :class="`tone-${g.meta.tone}`">{{ g.meta.icon }}</span>
<strong>{{ g.label }}</strong>
<em class="agent-num">{{ g.tools.length }} 项</em>
<span v-if="g.used" class="caps-group__used agent-num">你用过 {{ g.used }} 次</span>
</div>
<div class="caps-group__list">
<span v-for="t in g.tools" :key="t.name" class="caps-tool" :class="{ 'caps-tool--write': t.write }">
<i class="caps-tool__dot" />
{{ t.title }}
<em v-if="toolUse(t.name)" class="caps-tool__count agent-num">{{ toolUse(t.name) }}</em>
</span>
</div>
</div>
<div class="caps-foot">
<p class="caps-foot__note">
「常用」与使用次数只统计在你这台设备的这个浏览器里,不会上传到服务端。
</p>
<button class="caps-foot__clear" :class="{ 'caps-foot__clear--on': clearConfirm }" type="button" @click="clearUsageRecord">
{{ clearConfirm ? "再点一次确认清除" : "清除本机使用记录" }}
</button>
</div>
</div>
</Modal>
</div>
@@ -366,11 +569,71 @@ function pick(text: string) {
white-space: nowrap;
}
/* ------------------------------ 快速开始 ------------------------------ */
/* ------------------------------ 常用 ------------------------------ */
.block {
margin-top: 30px;
}
.freq {
display: flex;
flex-wrap: wrap;
gap: 11px;
}
.freq__item {
flex: 1 1 186px;
display: flex;
align-items: center;
gap: 9px;
padding: 10px 13px;
border-radius: var(--agent-radius);
background: var(--agent-surface);
border: 1px solid var(--agent-border);
box-shadow: var(--agent-shadow-xs);
font-family: inherit;
text-align: left;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.freq__item:hover {
transform: translateY(-2px);
border-color: var(--tone);
box-shadow: var(--agent-shadow-sm);
}
.freq__icon {
flex: none;
width: 28px;
height: 28px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12.5px;
font-weight: 600;
color: var(--tone);
background: var(--tone-soft);
}
.freq__label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 550;
color: var(--agent-text);
}
.freq__count {
flex: none;
font-size: 11.5px;
color: var(--agent-text-mute);
}
/* ------------------------------ 快速开始 ------------------------------ */
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -378,6 +641,7 @@ function pick(text: string) {
}
.tile {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
@@ -400,6 +664,25 @@ function pick(text: string) {
border-color: var(--tone);
}
/* 常用模块:用模块自己的色调描一圈边,扫视时第一眼就能看见 */
.tile--hot {
border-color: var(--tone);
box-shadow: var(--agent-shadow-sm);
}
.tile__badge {
position: absolute;
top: 11px;
right: 11px;
padding: 2px 8px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.2px;
color: var(--tone);
background: var(--tone-soft);
}
.tile__icon {
width: 38px;
height: 38px;
@@ -442,7 +725,7 @@ function pick(text: string) {
transform: translateX(2px);
}
/* 第 8 格:更多能力(虚线,弱化) */
/* 末格:更多能力(虚线,弱化) */
.tile--more {
border-style: dashed;
border-color: var(--agent-border-strong);
@@ -497,6 +780,17 @@ function pick(text: string) {
color: var(--agent-text-mute);
}
/* 「你用过 N 次」:告诉用户这个模块里哪些是她真正在用的 */
.caps-group__used {
margin-left: auto;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
color: var(--agent-primary-dark);
background: var(--agent-primary-soft);
box-shadow: inset 0 0 0 1px var(--agent-primary-line);
}
.caps-group__icon {
width: 24px;
height: 24px;
@@ -535,6 +829,16 @@ function pick(text: string) {
background: var(--agent-text-mute);
}
.caps-tool__count {
font-style: normal;
margin-left: 1px;
padding: 0 5px;
border-radius: 999px;
font-size: 10.5px;
color: var(--agent-text-mute);
background: rgba(15, 23, 42, 0.05);
}
.caps-tool--write {
color: var(--agent-text);
background: var(--agent-primary-soft);
@@ -544,4 +848,47 @@ function pick(text: string) {
.caps-tool--write .caps-tool__dot {
background: var(--agent-primary);
}
/* 弹层页脚:说明数据留在本机 + 一键清除 */
.caps-foot {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--agent-border);
display: flex;
align-items: center;
gap: 14px;
}
.caps-foot__note {
flex: 1 1 auto;
margin: 0;
font-size: 11.5px;
line-height: 1.6;
color: var(--agent-text-mute);
}
.caps-foot__clear {
flex: none;
height: 30px;
padding: 0 13px;
border-radius: 9px;
border: 1px solid var(--agent-border-strong);
background: var(--agent-surface);
color: var(--agent-text-sub);
font-family: inherit;
font-size: 12px;
cursor: pointer;
transition: all 0.16s ease;
}
.caps-foot__clear:hover {
border-color: var(--agent-text-mute);
color: var(--agent-text);
}
.caps-foot__clear--on {
color: var(--agent-danger);
border-color: var(--agent-danger);
background: var(--agent-danger-soft);
}
</style>
+337
View File
@@ -0,0 +1,337 @@
/**
* 本机使用画像 —— 首屏「常用优先」的数据底座。
*
* <p>解决的问题:能力目录 `GET /api/agent/tools` 只回答「**你能用什么**」,
* 它不回答「**你常用什么**」。后端也没有为用户行为单独建表。
* 于是每个用户打开助手看到的都是同一套固定顺序 —— 用了十次请假的人,
* 请假入口还排在第 4 位。这里把「谁、常用哪几个模块和能力」在本机记下来,
* 交给首屏与侧栏做排序,让高频入口自己浮到前面。
*
* <p>为什么放 localStorage 而不是服务端:
* <ul>
* <li>零后端改动、零接口往返 —— 首屏不必再等一个「使用记录」接口;</li>
* <li>用户行为数据留在本机浏览器,不出网,也不进任何日志;</li>
* <li>代价是换设备 / 换浏览器 / 清缓存后重新累积,对「常用优先」这个体感目标可以接受。</li>
* </ul>
* 如需跨设备,后续再把这层换成后端接口即可 —— 调用方只依赖本文件导出的函数,不关心存储位置。
*
* <p>★ 隔离维度:`userId + roleId`。一个账号常有多重身份(老师 / 校领导 / 家长),
* 平台只按**当前身份**下发能力目录,所以 A 身份的常用项不该推给 B 身份。
* 目录尚未回来时退回平台外壳写入的 `localStorage.currentRole`(与 AppSidebar 同源),
* 两者都取不到才退化为匿名画像 —— 宁可少记,也不要记串。
*/
import type { AgentToolCatalog, ToolCallTrace } from "@/types/agent";
/** localStorage 键前缀。**结构变更时改这里的版本号**,旧数据会被自动判为作废,而不是解析出错 */
const STORAGE_PREFIX = "yunhai.agent.usage.v1";
/** 画像结构版本(与 STORAGE_PREFIX 的版本号保持一致) */
const STATE_VERSION = 1;
/** 单个模块 / 能力的累计记录 */
interface Entry {
/** 累计使用次数 */
count: number;
/** 最近一次使用时间(毫秒时间戳) */
lastAt: number;
}
/** 能力级记录:多存归属模块与是否为写操作,便于后续做推荐时过滤 */
interface ToolEntry extends Entry {
/** 所属模块标识(homeleave / campusnotice …) */
module?: string;
/** 能力中文名,冗余保存以便目录变化后仍可读 */
title?: string;
/** 是否为写操作(会改动业务数据) */
write?: boolean;
}
/** 完整画像 */
export interface UsageState {
version: number;
/** 模块标识 → 使用记录 */
modules: Record<string, Entry>;
/** 工具名 → 使用记录 */
tools: Record<string, ToolEntry>;
/** 「办成的事」计数:**只有写操作且执行成功**才累加,查询不算成果 */
done: {
/** 累计(跨周不重置) */
total: number;
/** 当前自然周(周一起算)的起点时间戳 */
weekStart: number;
/** 当前自然周内办成的件数 */
weekCount: number;
};
}
/** 画像的归属范围:一个「账号 + 当前身份」一份 */
export interface UsageScope {
/** localStorage 键名 */
key: string;
userId: string;
roleId: string;
}
/** 「常用模块」条目(在画像基础上补上调用方给的原始顺序) */
export interface FrequentModule {
key: string;
count: number;
lastAt: number;
}
/** 首屏 / 顶栏要的个人化概况 */
export interface UsageSummary {
/** 是否已有可用的使用历史(决定首屏走个性化还是首次引导) */
hasHistory: boolean;
/** 累计办成件数 */
doneTotal: number;
/** 本周办成件数(跨周时自动归零) */
doneWeek: number;
/** 用过的模块数 */
modules: number;
/** 用过的能力数 */
tools: number;
}
function emptyState(): UsageState {
return {
version: STATE_VERSION,
modules: {},
tools: {},
done: { total: 0, weekStart: 0, weekCount: 0 },
};
}
/**
* 计算「本周」起点(周一 00:00)。
*
* <p>用自然周而非滚动 7 天:用户说「本周办了几件」时,心里想的是这周一到现在,
* 而不是「过去 168 小时」。
*/
function weekStartOf(ts: number): number {
const d = new Date(ts);
d.setHours(0, 0, 0, 0);
const mondayOffset = (d.getDay() + 6) % 7; // getDay(): 周日=0 → 周一=0
d.setDate(d.getDate() - mondayOffset);
return d.getTime();
}
/**
* 解析出当前画像的归属范围。
*
* @param catalog 能力目录(首选来源:`identity.userId` / `identity.roleId` 与后端判权同源)
*/
export function usageScope(catalog?: AgentToolCatalog | null): UsageScope {
const id = catalog?.identity;
let userId = id?.userId ? String(id.userId) : "";
let roleId = id?.roleId ? String(id.roleId) : "";
if (!userId) {
// 目录还没回来:退回平台外壳写入的 currentRole(AppSidebar 的 Utils.getCurrentRoleInfo 同源)
try {
const raw = localStorage.getItem("currentRole");
if (raw) {
const parsed = JSON.parse(decodeURI(raw));
userId = parsed?.userId ? String(parsed.userId) : "";
roleId = roleId || (parsed?.roleId ? String(parsed.roleId) : "");
}
} catch {
/* 平台字段格式变化:不阻塞,退化为匿名画像 */
}
}
if (!userId) userId = "anon";
return { key: `${STORAGE_PREFIX}:${userId}:${roleId || "-"}`, userId, roleId };
}
/** 读取画像(每次返回新对象,调用方随便改,不会污染存储) */
export function usageSnapshot(scope: UsageScope): UsageState {
try {
const raw = localStorage.getItem(scope.key);
if (!raw) return emptyState();
const parsed = JSON.parse(raw) as Partial<UsageState>;
if (!parsed || parsed.version !== STATE_VERSION) return emptyState();
const base = emptyState();
return {
version: STATE_VERSION,
modules: parsed.modules || base.modules,
tools: parsed.tools || base.tools,
done: { ...base.done, ...(parsed.done || {}) },
};
} catch {
// 存储被禁用(隐私模式)或内容损坏:当成「没有历史」,不影响任何功能
return emptyState();
}
}
function persist(scope: UsageScope, state: UsageState) {
try {
localStorage.setItem(scope.key, JSON.stringify(state));
} catch {
/* 配额满 / 被禁用:降级为「不记忆」,设置项以外的功能全部照常 */
}
notify();
}
function bump<T extends Entry>(map: Record<string, T>, key: string, at: number): T {
const entry = map[key] || (map[key] = { count: 0, lastAt: 0 } as T);
entry.count += 1;
entry.lastAt = at;
return entry;
}
/**
* 记录一轮工具执行 —— 画像的**权威来源**。
*
* <p>为什么不记「用户点了哪张卡」而是要记真实的 `toolCalls`:
* 用户完全可以自己在输入框里把「我要请假」打出来,此时前端并不知道他要调什么;
* 只有后端回包里带上 `name` / `module` / `write` / `success` 的执行轨迹才是事实。
* 因此埋点挂在**响应回来之后**,而不是点击那一刻。
*
* @param traces 后端下发的工具执行轨迹(含浏览器代执行续跑后的最终结果)
*/
export function noteTraces(scope: UsageScope, traces?: ToolCallTrace[] | null) {
if (!traces || !traces.length) return;
const state = usageSnapshot(scope);
const now = Date.now();
let doneCount = 0;
/** 本轮碰到的模块:**一次请求算一次**,不按工具条数重复累加 */
const touchedModules = new Set<string>();
traces.forEach(tr => {
if (!tr?.name) return;
// 模块按「轮」计数:一轮里查了 3 个接口也只在画像里 +1。
// 否则「学生请假 · 用过 2 次」会出现在用户其实只问过一次的场景里,
// 而用户心里的"用过几次"就是"我问过几次"。
if (tr.module) touchedModules.add(tr.module);
// 能力按「调用次数」计数:同一个工具一轮里被调两次就是两次,用于后续做能力级推荐
const tool = bump(state.tools, tr.name, now) as ToolEntry;
if (tr.title) tool.title = tr.title;
if (tr.module) tool.module = tr.module;
tool.write = !!tr.write;
// 只有「写操作 + 执行成功」才算办成一件事。
// 查询类能力哪怕一天调一百次,也不该让用户看到「已为你办成 100 件事」。
if (tr.write && tr.success) doneCount++;
});
touchedModules.forEach(m => bump(state.modules, m, now));
if (doneCount > 0) {
const weekStart = weekStartOf(now);
if (state.done.weekStart !== weekStart) {
state.done.weekStart = weekStart;
state.done.weekCount = 0;
}
state.done.total += doneCount;
state.done.weekCount += doneCount;
}
persist(scope, state);
}
/**
* 按使用频次给模块键排序(常用优先)。
*
* <p>排序规则:**次数多的在前 → 次数相同则最近用过的在前 → 都没用过就保持传入顺序**。
* 只重排、不增删 —— 传入的一定原样返回,可见性依旧完全由能力目录决定,
* 所以这里不会制造出任何「点了必被拒」的越权入口。
*
* @param keys 原始顺序的模块键(通常来自 `orderedModuleKeys(catalog.modules)`)
*/
export function rankModuleKeys(scope: UsageScope, keys: string[]): string[] {
if (keys.length < 2) return [...keys];
const state = usageSnapshot(scope);
const baseIndex = new Map(keys.map((k, i) => [k, i]));
return [...keys].sort((a, b) => {
const ea = state.modules[a];
const eb = state.modules[b];
const ca = ea?.count ?? 0;
const cb = eb?.count ?? 0;
if (ca !== cb) return cb - ca;
const la = ea?.lastAt ?? 0;
const lb = eb?.lastAt ?? 0;
if (la !== lb) return lb - la;
return (baseIndex.get(a) ?? 0) - (baseIndex.get(b) ?? 0);
});
}
/**
* 取出「用过的模块」,按频次倒序。
*
* <p>★ 有意做成「吃 state 而不是吃 scope」:调用方(Vue 组件)手里那份 `state`
* 通常来自一个带版本依赖的 computed,这样**画像一变(记录 / 清除)就能立刻重算**。
* 若在这里自己去读 localStorage,就绕过了调用方的响应式依赖,
* 会出现「点了清除,页面上「常用」区块还在」这类幽灵状态。
*
* @param keys 候选模块键(原顺序,通常来自能力目录)
* @param minCount 最少用过几次才算「常用」;默认 1(用过就进榜,给用户即时反馈)
*/
export function rankFrequent(state: UsageState, keys: string[], minCount = 1): FrequentModule[] {
return keys
.map(key => ({ key, count: state.modules[key]?.count ?? 0, lastAt: state.modules[key]?.lastAt ?? 0 }))
.filter(m => m.count >= minCount)
.sort((a, b) => b.count - a.count || b.lastAt - a.lastAt);
}
/**
* 个人化概况。
*
* <p>同样有意「吃 state」:见 {@link rankFrequent} 的说明。
*
* @param now 计算「本周」的基准时刻;默认取当前时间(测试需要固定时间时可显式传入)
*/
export function summarize(state: UsageState, now = Date.now()): UsageSummary {
const weekStart = weekStartOf(now);
return {
hasHistory: Object.keys(state.modules).length > 0 || state.done.total > 0,
doneTotal: state.done.total,
// 存储里的周起点不是本周 ⇒ 本周还没办过事(历史数据不清,只是本格归零)
doneWeek: state.done.weekStart === weekStart ? state.done.weekCount : 0,
modules: Object.keys(state.modules).length,
tools: Object.keys(state.tools).length,
};
}
/** 便捷版:自己去读一次存储。不适合需要响应式重算的场景,请用 {@link summarize} */
export function usageSummary(scope: UsageScope): UsageSummary {
return summarize(usageSnapshot(scope));
}
/** 清空当前范围的画像(用户主动清除,不可恢复) */
export function clearUsage(scope: UsageScope) {
try {
localStorage.removeItem(scope.key);
} catch {
/* 存储不可用:本来也没记下什么 */
}
notify();
}
/* -------------------------------------------------------------------------- */
/* 变更通知:让已经渲染的区块(首屏 / 侧栏 / 顶栏)能跟着更新,无需引入 Vue 依赖 */
/* -------------------------------------------------------------------------- */
const listeners = new Set<() => void>();
/** 订阅画像变更;返回退订函数(组件 onUnmounted 时务必调用) */
export function onUsageChange(cb: () => void): () => void {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
function notify() {
listeners.forEach(cb => {
try {
cb();
} catch {
/* 单个订阅者出错不影响其它订阅者 */
}
});
}
+23 -1
View File
@@ -9,6 +9,7 @@
* - 助手消息下挂「工具轨迹卡」,写操作后提供「去查看」直达对应模块页面
* - 输入框轮播示例,并按关键词提示「怎么说更准确」
* - 能力目录由外壳统一拉取后传入,侧栏与首屏共用一份数据
* - 每轮执行完把工具轨迹记进本机使用画像(首屏「常用优先」的数据来源)
*/
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { Message } from "view-ui-plus";
@@ -17,6 +18,7 @@ import { isTimeoutMessage } from "@/utils/alova/alova-core";
import QuickActions from "@/components/QuickActions.vue";
import MessageBubble from "@/components/MessageBubble.vue";
import { callModuleApi } from "@/utils/module-session";
import { noteTraces, usageScope } from "@/utils/usage";
import type { AgentToolCatalog, BrowserToolCall, ChatItem, ToolResultItem } from "@/types/agent";
const props = defineProps<{ user?: any; catalog?: AgentToolCatalog | null }>();
@@ -211,6 +213,24 @@ function fill(placeholder: ChatItem, resp: any) {
*/
const execBrowserCall = callModuleApi;
/**
* 使用画像的归属范围(账号 + 当前身份,见 `@/utils/usage`)。
* 目录未回来时它会退回平台写入的 currentRole,因此这里不必等目录就绪。
*/
const usageScopeRef = computed(() => usageScope(props.catalog));
/**
* 把本轮真实执行轨迹记进本机使用画像 —— 首屏「常用优先」的数据来源。
*
* <p>★ 埋点挂在**响应回来之后**、且只认 `toolCalls`,而不是"用户点了哪张卡":
* 用户完全可能自己在输入框里打出「我要请假」,此时前端并不知道他要调什么工具;
* 只有后端回包里的 `name / module / write / success` 才是事实。
* 请求失败时 `traces` 为空,这里自动不记 —— 点了但没办成的事不该进"常用"。
*/
function recordUsage(item: ChatItem) {
noteTraces(usageScopeRef.value, item.traces);
}
/** 执行一批代调用并回传续跑;续跑响应若再挂起(链式查询)则递归处理 */
async function runBrowserTool(placeholder: ChatItem, pending: any) {
// ★ 先把挂起响应里的 sessionId 落地:续跑请求要用它找回同一会话,
@@ -258,6 +278,7 @@ async function send(text?: string) {
stopStage();
placeholder.stage = "";
sending.value = false;
recordUsage(placeholder); // 仅重试成功后才会走到这里并带上 traces
scrollToBottom();
}
}
@@ -285,6 +306,7 @@ async function onFormSubmit(payload: { item: ChatItem; form: { tool: string; arg
stopStage();
placeholder.stage = "";
sending.value = false;
recordUsage(placeholder); // 表单提交同样是写操作,办成了要计入「本周已办成」
scrollToBottom();
}
}
@@ -320,7 +342,7 @@ defineExpose({ newSession, pickSample });
<template>
<div class="chat">
<div ref="listEl" class="chat__list agent-scroll">
<QuickActions v-if="isEmpty" :catalog="catalog ?? null" @pick="t => send(t)" />
<QuickActions v-if="isEmpty" :catalog="catalog ?? null" :user="user ?? null" @pick="t => send(t)" />
<div v-else class="chat__stream">
<MessageBubble
v-for="(it, i) in items"