| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712 |
- <template>
- <div class="ai-page ai-plugin">
- <a-tabs v-model:active-key="activeTab" class="ai-plugin__tabs" size="small" destroy-inactive-tab-pane>
- <template #rightExtra>
- <div class="ai-topbar__actions">
- <span class="ai-topbar__meta ai-plugin__summary">{{ summary }}</span>
- <a-button size="small" :loading="loading" @click="reload">
- <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
- 刷新
- </a-button>
- </div>
- </template>
- <!-- ── ① 工具(只读,数据来自 agent_tool 表) ─────────────── -->
- <a-tab-pane key="tool">
- <template #tab>
- <span>工具</span>
- <span class="ai-plugin__tab-count">{{ tools.length }}</span>
- </template>
- <div class="ai-plugin__ops">
- <a-select
- v-model:value="groupFilter"
- class="ai-plugin__select"
- size="small"
- :options="groupOptions"
- placeholder="全部分类"
- />
- <a-input
- v-model:value="toolKeyword"
- class="ai-plugin__search"
- size="small"
- allow-clear
- placeholder="搜索工具名,如 stat_call_night"
- >
- <template #prefix><Icon icon="mdi:magnify" :size="14" /></template>
- </a-input>
- <span class="ai-plugin__mono ai-plugin__filter-count">
- {{
- filteredTools.length === tools.length
- ? `共 ${tools.length} 个`
- : `匹配 ${filteredTools.length} / ${tools.length} 个`
- }}
- </span>
- </div>
- <a-spin :spinning="loading">
- <div v-if="filteredTools.length" class="ai-plugin__grid">
- <article
- v-for="tool in filteredTools"
- :key="tool.toolName"
- class="ai-plugin__card is-clickable"
- :title="tool.toolName"
- @click="openTool(tool)"
- >
- <div class="ai-plugin__card-head">
- <span class="ai-plugin__mono ai-plugin__tool-name ai-plugin__ellipsis">{{ tool.toolName }}</span>
- <span v-if="tool.readOnly" class="ai-plugin__chip">只读</span>
- </div>
- <div class="ai-plugin__card-meta">
- <span class="ai-plugin__chip">{{ tool.groupLabel || tool.groupCode || '未分组' }}</span>
- <span v-if="tool.defaultActive" class="ai-plugin__chip is-success">默认装备</span>
- </div>
- <p class="ai-plugin__card-desc">{{ tool.description || '(无说明)' }}</p>
- </article>
- </div>
- <div v-else class="ai-plugin__empty">
- {{
- tools.length ? '没有匹配的工具' : '还没有同步到工具清单(服务端启动时会自动同步,需先建 agent_tool 表)'
- }}
- </div>
- </a-spin>
- </a-tab-pane>
- <!-- ── ② 技能 ──────────────────────────────────────────── -->
- <a-tab-pane key="skill">
- <template #tab>
- <span>技能</span>
- <span class="ai-plugin__tab-count">{{ skills.length }}</span>
- </template>
- <div class="ai-plugin__ops">
- <a-button size="small" type="primary" :disabled="installing" :loading="installing" @click="pickZip">
- <template #icon><Icon icon="mdi:upload" :size="14" /></template>
- 上传 zip 安装
- </a-button>
- <input ref="fileRef" class="ai-plugin__file" type="file" accept=".zip" @change="onZipPicked" />
- </div>
- <a-spin :spinning="loading">
- <div v-if="skills.length" class="ai-plugin__grid">
- <article v-for="skill in skills" :key="skill.path || skill.name" class="ai-plugin__card">
- <div class="ai-plugin__card-head">
- <div class="ai-plugin__name">
- <span class="ai-plugin__ellipsis" :title="skill.name">{{ skill.name }}</span>
- <span class="ai-plugin__chip" :class="sourceMeta(skill).cls">{{ sourceMeta(skill).label }}</span>
- </div>
- <a-switch
- v-if="skill.managed"
- :checked="skill.enabled !== false"
- size="small"
- @change="(checked) => onToggle(skill, checked === true)"
- />
- <span v-else class="ai-plugin__status is-off">只读</span>
- </div>
- <p class="ai-plugin__card-desc">{{ skill.description || '(SKILL.md 里没有 description)' }}</p>
- <div class="ai-plugin__sub ai-plugin__ellipsis" :title="skill.path">{{ skill.path }}</div>
- <div class="ai-plugin__card-ops">
- <a-button type="link" size="small" :loading="skillLoading === skill.name" @click="openSkill(skill)">
- 查看 SKILL.md
- </a-button>
- <a-button v-if="skill.managed" type="link" size="small" danger @click="confirmRemove(skill)">
- 卸载
- </a-button>
- </div>
- </article>
- </div>
- <div v-else class="ai-plugin__empty">
- 还没有技能。上传一个包含 <b>SKILL.md</b> 的 zip 即可安装(包里只有一层技能目录时最省事)。
- </div>
- </a-spin>
- </a-tab-pane>
- </a-tabs>
- <!-- 弹框:工具详情(说明 + 所属工具组说明) -->
- <a-modal
- v-model:open="toolOpen"
- :title="selectedTool?.toolName || '工具详情'"
- :width="760"
- :footer="null"
- destroy-on-close
- >
- <div v-if="selectedTool" class="ai-plugin__detail">
- <div class="ai-plugin__detail-tags">
- <span class="ai-plugin__chip">{{ selectedTool.groupLabel || selectedTool.groupCode || '未分组' }}</span>
- <span v-if="selectedTool.defaultActive" class="ai-plugin__chip is-success">默认装备</span>
- <span v-if="selectedTool.readOnly" class="ai-plugin__chip">只读</span>
- </div>
- <div class="ai-plugin__detail-block">
- <div class="ai-plugin__detail-label">说明</div>
- <p class="ai-plugin__detail-text">{{ selectedTool.description || '(无说明)' }}</p>
- </div>
- <div v-if="selectedTool.groupDescription" class="ai-plugin__detail-block">
- <div class="ai-plugin__detail-label">所属工具组说明(写给模型的激活时机)</div>
- <p class="ai-plugin__detail-text">{{ selectedTool.groupDescription }}</p>
- </div>
- </div>
- </a-modal>
- <!-- 抽屉:SKILL.md -->
- <a-drawer
- v-model:open="skillOpen"
- :title="skillDetail?.name || 'SKILL.md'"
- :width="720"
- placement="right"
- destroy-on-close
- >
- <div v-if="skillDetail" class="ai-plugin__preview">
- <div class="ai-plugin__preview-meta">
- <span class="ai-plugin__chip" :class="sourceMeta(skillDetail).cls">{{ sourceMeta(skillDetail).label }}</span>
- <span class="ai-plugin__mono ai-plugin__ellipsis" :title="skillDetail.path">{{ skillDetail.path }}</span>
- </div>
- <div v-if="skillDetail.files?.length" class="ai-plugin__preview-files">
- <span class="ai-plugin__mono">附属文件:</span>
- <span v-for="file in skillDetail.files" :key="file.path" class="ai-plugin__chip" :title="`${file.size} 字节`">
- {{ file.path }}
- </span>
- </div>
- <pre class="ai-plugin__pre">{{ skillDetail.content }}</pre>
- </div>
- </a-drawer>
- </div>
- </template>
- <script lang="ts" setup name="AiPluginIndex">
- /**
- * 插件与技能(服务端 agent 版)
- *
- * 两个页签全部来自服务端 `module/agent`:
- * ① 工具 —— 服务端启动时反射扫描 `@Tool` 后落库的 `agent_tool` 表(只读)。
- * 平铺展示全部工具,支持按分类筛选 + 工具名搜索,点行弹框看详情与参数。
- * ② 技能 —— 服务端本机用户级技能目录(`.agentscope/users/{用户}/skills`)的 SKILL.md 包,
- * 支持上传 zip 安装 / 卸载 / 启停
- *
- * 旧版这里走的是桌面端 Codex 的 IPC(codexApi),Codex 已废弃,故整页重写为服务端驱动;
- * 也因此浏览器环境同样可用(不再需要「仅桌面客户端可用」的降级提示)。
- * 原「MCP 接入」页签已按需求移除(服务端 MCP Server 能力本身保留,仅不再在页面展示)。
- */
- import { computed, onMounted, ref } from 'vue';
- import { Button, Drawer, Input, Modal, Select, Spin, Switch, Tabs } from 'ant-design-vue';
- import Icon from '@/core/components/Icon/src/Icon.vue';
- import { useMessage } from '@/core/hooks/web/useMessage';
- import { useUserStore } from '@/core/store/modules/case';
- import {
- deleteSkill,
- installSkillZip,
- listSkills,
- listTools,
- readSkill,
- toggleSkill,
- } from '../../api/capabilityApi';
- import type { AgentTool, Skill, SkillDetail } from '../../api/types';
- const ATabs = Tabs;
- const ATabPane = Tabs.TabPane;
- const AButton = Button;
- const ADrawer = Drawer;
- const AInput = Input;
- const AModal = Modal;
- const ASelect = Select;
- const ASpin = Spin;
- const ASwitch = Switch;
- const { showMessage } = useMessage();
- const userStore = useUserStore();
- type PluginTab = 'tool' | 'skill';
- const activeTab = ref<PluginTab>('tool');
- const loading = ref(false);
- const installing = ref(false);
- /** agent_tool 表的扁平行(只读) */
- const tools = ref<AgentTool[]>([]);
- const skills = ref<Skill[]>([]);
- const toolOpen = ref(false);
- const selectedTool = ref<AgentTool | null>(null);
- const toolKeyword = ref('');
- /** 分类筛选(空 = 全部分类) */
- const groupFilter = ref('');
- const skillOpen = ref(false);
- const skillDetail = ref<SkillDetail | null>(null);
- const skillLoading = ref('');
- const fileRef = ref<HTMLInputElement>();
- /** 当前案件ID:只用于附带展示「该案件 agent 配置的只读技能仓库」,技能本身与案件无关 */
- const caseId = computed(() => (userStore.getCaseInfo?.id ? String(userStore.getCaseInfo.id) : null));
- const summary = computed(() => `${tools.value.length} 个工具 · ${skills.value.length} 个技能`);
- /**
- * 分类下拉:按表里的 groupSort 排(业务组按注册顺序,基础工具最后),选项文案用组中文名。
- * 页面不再把工具按组折叠展示,分类只用于筛选。
- */
- const groupOptions = computed(() => {
- const byCode = new Map<string, { code: string; label: string; sort: number; count: number }>();
- for (const tool of tools.value) {
- const code = tool.groupCode || 'base';
- const hit = byCode.get(code);
- if (hit) {
- hit.count++;
- } else {
- byCode.set(code, {
- code,
- label: tool.groupLabel || code,
- sort: tool.groupSort ?? Number.MAX_SAFE_INTEGER,
- count: 1,
- });
- }
- }
- const options = [...byCode.values()].sort((a, b) => a.sort - b.sort || a.code.localeCompare(b.code));
- return [
- { value: '', label: `全部分类(${tools.value.length})` },
- ...options.map((g) => ({ value: g.code, label: `${g.label}(${g.count})` })),
- ];
- });
- /** 列表筛选:分类 + 工具名关键字(工具名搜索,与需求一致) */
- const filteredTools = computed(() => {
- const keyword = toolKeyword.value.trim().toLowerCase();
- return tools.value.filter((tool) => {
- if (groupFilter.value && (tool.groupCode || 'base') !== groupFilter.value) return false;
- if (!keyword) return true;
- return tool.toolName.toLowerCase().includes(keyword);
- });
- });
- onMounted(reload);
- async function reload() {
- loading.value = true;
- try {
- const [toolList, skillList] = await Promise.all([
- listTools().catch(() => [] as AgentTool[]),
- listSkills(caseId.value).catch(() => [] as Skill[]),
- ]);
- tools.value = toolList || [];
- skills.value = skillList || [];
- } finally {
- loading.value = false;
- }
- }
- /* ---------------- 工具 ---------------- */
- function openTool(tool: AgentTool) {
- selectedTool.value = tool;
- toolOpen.value = true;
- }
- /** 全限定类名 → 短名(抽屉里展示「类#方法」,太长会把行挤爆) */
- function shortClass(className?: string) {
- if (!className) return '';
- const idx = className.lastIndexOf('.');
- return idx >= 0 ? className.slice(idx + 1) : className;
- }
- /* ---------------- 技能 ---------------- */
- function sourceMeta(skill: Skill) {
- return skill.source === 'repo'
- ? { label: '配置仓库', cls: '' }
- : { label: skill.managed ? '用户技能' : '技能', cls: '' };
- }
- function pickZip() {
- fileRef.value?.click();
- }
- async function onZipPicked(event: Event) {
- const input = event.target as HTMLInputElement;
- const file = input.files?.[0];
- // 允许连续上传同一个文件:先清空,再交给后续流程
- input.value = '';
- if (!file) return;
- if (!file.name.toLowerCase().endsWith('.zip')) {
- showMessage('请选择 .zip 技能包', 'warning');
- return;
- }
- installing.value = true;
- try {
- const skill = await installSkillZip(file, false);
- showMessage(`技能「${skill?.name || file.name}」已安装`, 'success');
- await reload();
- } catch {
- // 失败原因(同名已存在 / 没有 SKILL.md / 包内路径非法…)由 defHttp 统一弹出后端 message
- } finally {
- installing.value = false;
- }
- }
- async function onToggle(skill: Skill, enabled: boolean) {
- try {
- await toggleSkill(skill.name, enabled);
- skill.enabled = enabled;
- showMessage(enabled ? `已启用「${skill.name}」` : `已停用「${skill.name}」,下一轮对话生效`, 'success');
- } catch {
- // 失败不改本地状态:开关会因 :checked 绑定而回弹
- }
- }
- function confirmRemove(skill: Skill) {
- Modal.confirm({
- title: '卸载技能',
- content: `确认删除技能「${skill.name}」?该技能目录会被删除,且不可恢复。`,
- okText: '卸载',
- okType: 'primary',
- okButtonProps: { danger: true },
- cancelText: '取消',
- onOk: async () => {
- await deleteSkill(skill.name);
- showMessage('已卸载', 'success');
- await reload();
- },
- });
- }
- async function openSkill(skill: Skill) {
- skillLoading.value = skill.name;
- try {
- skillDetail.value = await readSkill(skill.name, caseId.value);
- skillOpen.value = true;
- } catch {
- // 读取失败原因由 defHttp 弹出
- } finally {
- skillLoading.value = '';
- }
- }
- </script>
- <style lang="less" scoped>
- .ai-plugin {
- /* Tabs 撑起整页骨架:页签栏即顶部条,内容区自己滚动 */
- &__tabs {
- flex: 1 1 auto;
- min-height: 0;
- :deep(.ant-tabs-nav) {
- flex: 0 0 auto;
- margin: 0;
- padding: 0 var(--ai-sp-2);
- background: var(--ai-bg);
- border-bottom: 1px solid var(--ai-border);
- /* antd 自带的发丝线与顶部条边框重叠 */
- &::before {
- display: none;
- }
- }
- /* 靠页签内边距撑出顶部条高度(--ai-header-h),下划线才能压在分隔线上 */
- :deep(.ant-tabs-tab) {
- padding: 15px 0;
- font-size: 13.5px;
- }
- :deep(.ant-tabs-extra-content) {
- margin-left: var(--ai-sp-3);
- }
- /* 内容区内边距取分析页那一档(8px),左右与页签栏文字对齐 */
- :deep(.ant-tabs-content-holder) {
- flex: 1 1 auto;
- min-height: 0;
- padding: var(--ai-sp-2);
- overflow-y: auto;
- overscroll-behavior: contain;
- }
- }
- /* 摘要与页签同在一行,过长时限宽省略,避免把页签挤进溢出下拉 */
- &__summary {
- max-width: 360px;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- &__tab-count {
- display: inline-block;
- min-width: 18px;
- margin-left: var(--ai-sp-2);
- padding: 0 5px;
- font-size: 11px;
- line-height: 17px;
- text-align: center;
- color: var(--ai-text-2);
- background: var(--ai-bg-subtle);
- border: 1px solid var(--ai-border);
- border-radius: 999px;
- }
- &__ops {
- display: flex;
- gap: var(--ai-sp-2);
- margin-bottom: var(--ai-sp-2);
- }
- /* 上传入口用按钮触发,原生 input 只做文件选择 */
- &__file {
- display: none;
- }
- /* 卡片网格:间距取分析页那一档(8px);圆角比页面外壳(12px)低一档,用 --ai-radius(8px) */
- &__grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
- gap: var(--ai-sp-2);
- }
- &__card {
- display: flex;
- flex-direction: column;
- gap: var(--ai-sp-2);
- min-width: 0;
- padding: var(--ai-sp-3);
- background: var(--ai-bg);
- border: 1px solid var(--ai-border);
- border-radius: var(--ai-radius);
- transition:
- border-color 0.2s ease,
- box-shadow 0.2s ease,
- transform 0.2s ease;
- &:hover {
- border-color: var(--ai-border-strong);
- box-shadow: var(--ai-shadow);
- transform: translateY(-2px);
- }
- }
- &__card-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--ai-sp-3);
- min-width: 0;
- }
- &__card-desc {
- display: -webkit-box;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 3;
- min-height: calc(12.5px * 1.7 * 2);
- margin: 0;
- overflow: hidden;
- font-size: 12.5px;
- line-height: 1.7;
- color: var(--ai-text-2);
- }
- &__card-tools {
- display: flex;
- flex-wrap: wrap;
- gap: var(--ai-sp-1);
- align-items: center;
- min-width: 0;
- }
- /* 字号/字体沿用 __mono,这里只管盒子 */
- &__tool {
- max-width: 148px;
- padding: 0 6px;
- line-height: 18px;
- background-color: var(--ai-bg-subtle);
- border: 1px solid var(--ai-border);
- border-radius: var(--ai-radius-sm);
- }
- &__card-ops {
- display: flex;
- align-items: center;
- gap: var(--ai-sp-1);
- margin-top: auto;
- padding-top: var(--ai-sp-2);
- border-top: 1px solid var(--ai-border);
- }
- &__empty {
- padding: var(--ai-sp-4) var(--ai-sp-3);
- font-size: 12.5px;
- line-height: 1.7;
- text-align: center;
- color: var(--ai-text-2);
- background: var(--ai-bg-subtle);
- border: 1px dashed var(--ai-border);
- border-radius: var(--ai-radius);
- b {
- color: var(--ai-text);
- }
- }
- /* 卡片头里它是 flex item,不给 min-width:0 就会把右侧开关挤出去 */
- &__name {
- display: flex;
- align-items: center;
- gap: var(--ai-sp-2);
- min-width: 0;
- font-size: 13.5px;
- font-weight: 600;
- color: var(--ai-text);
- }
- &__sub {
- margin-top: 2px;
- overflow: hidden;
- font-family: 'SFMono-Regular', Consolas, monospace;
- font-size: 11.5px;
- color: var(--ai-text-2);
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- &__chip {
- display: inline-block;
- flex: 0 0 auto;
- max-width: 220px;
- padding: 1px 8px;
- overflow: hidden;
- font-size: 12px;
- line-height: 20px;
- white-space: nowrap;
- color: var(--ai-text);
- text-overflow: ellipsis;
- background: var(--ai-bg-subtle);
- border: 1px solid var(--ai-border);
- border-radius: 999px;
- &.is-success {
- color: @success-color;
- background-color: fade(@success-color, 10%);
- border-color: fade(@success-color, 26%);
- }
- }
- &__mono {
- font-family: 'SFMono-Regular', Consolas, monospace;
- font-size: 12px;
- color: var(--ai-text-2);
- }
- &__ellipsis {
- display: inline-block;
- max-width: 100%;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- &__status {
- font-size: 12px;
- color: var(--ai-text-2);
- }
- /* 筛选栏:分类下拉 + 工具名搜索 + 计数 */
- &__select {
- min-width: 180px;
- }
- &__search {
- max-width: 300px;
- }
- &__filter-count {
- margin-left: auto;
- white-space: nowrap;
- }
- /* 工具卡片整卡可点开详情:手型 + 轻微上浮(与技能卡片的 hover 一致) */
- &__card.is-clickable {
- cursor: pointer;
- }
- &__tool-name {
- min-width: 0;
- font-size: 12.5px;
- font-weight: 600;
- color: var(--ai-text);
- }
- /* 参数表里的「必填」列:是=红,否=灰 */
- &__req {
- color: @error-color;
- }
- /* 工具详情弹框 */
- &__detail {
- display: flex;
- flex-direction: column;
- gap: var(--ai-sp-3);
- }
- &__detail-tags {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: var(--ai-sp-2);
- }
- &__detail-label {
- margin-bottom: 4px;
- font-size: 11.5px;
- color: var(--ai-text-2);
- }
- &__detail-text {
- margin: 0;
- font-size: 12.5px;
- line-height: 1.8;
- color: var(--ai-text);
- white-space: pre-wrap;
- word-break: break-word;
- }
- &__preview {
- display: flex;
- flex-direction: column;
- gap: var(--ai-sp-2);
- min-height: 0;
- }
- &__preview-meta {
- display: flex;
- align-items: center;
- gap: var(--ai-sp-2);
- min-width: 0;
- }
- &__preview-files {
- display: flex;
- flex-wrap: wrap;
- gap: var(--ai-sp-1);
- align-items: center;
- min-width: 0;
- }
- &__pre {
- flex: 1 1 auto;
- margin: 0;
- padding: var(--ai-sp-3);
- overflow: auto;
- font-family: 'SFMono-Regular', Consolas, monospace;
- font-size: 12.5px;
- line-height: 1.8;
- color: var(--ai-text);
- white-space: pre-wrap;
- word-break: break-word;
- background: var(--ai-bg-subtle);
- border: 1px solid var(--ai-border);
- border-radius: var(--ai-radius);
- }
- }
- </style>
|