feat: 支持智能体可见范围管理

- 未分类智能体按可见范围绕过分类白名单

- 提供个人、部门、公开范围配置及列表状态标签
This commit is contained in:
2026-07-31 16:45:41 +08:00
parent 527336bfc9
commit 6df3dd9981
13 changed files with 725 additions and 44 deletions

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import agentListSource from './AgentList.vue?raw';
import apiSource from './api.ts?raw';
describe('AgentList 可见范围入口', () => {
it('exposes the three scope choices and uses the dedicated partial-update API', () => {
expect(agentListSource).toContain("label: '个人'");
expect(agentListSource).toContain("label: '部门'");
expect(agentListSource).toContain("label: '公开'");
expect(agentListSource).toContain('updateAgentVisibilityScope');
expect(agentListSource).toContain('agent-publish-chip');
expect(agentListSource).toContain('agent-publish-chip__dot');
expect(agentListSource).toContain('<template #details="{ item }">');
expect(agentListSource).not.toContain('<template #corner="{ item }">');
expect(apiSource).toContain('/api/v1/agent/visibilityScope/update');
});
});

View File

@@ -1,22 +1,36 @@
<script setup lang="ts">
/* cspell:ignore tryit */
import type {AgentInfo} from './types';
import type { AgentInfo } from './types';
import type {ActionButton, CardPrimaryAction,} from '#/components/page/CardList.vue';
import type {
ActionButton,
CardPrimaryAction,
} from '#/components/page/CardList.vue';
import CardList from '#/components/page/CardList.vue';
import {markRaw, onMounted, ref} from 'vue';
import {useRouter} from 'vue-router';
import { computed, markRaw, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {Delete, Edit, Plus, Promotion} from '@element-plus/icons-vue';
import {ElMessage, ElMessageBox, ElTag} from 'element-plus';
import {tryit} from 'radash';
import { useAccess } from '@easyflow/access';
import {
Check,
Delete,
Edit,
Lock,
OfficeBuilding,
Plus,
Promotion,
} from '@element-plus/icons-vue';
import { ElIcon, ElMessage, ElMessageBox, ElPopover } from 'element-plus';
import { tryit } from 'radash';
import defaultAgentAvatar from '#/assets/defaultUserAvatar.png';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import {$t} from '#/locales';
import { $t } from '#/locales';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import {
canAiResourceDelete,
canAiResourceOffline,
@@ -31,6 +45,7 @@ import {
submitAgentDeleteApproval,
submitAgentOfflineApproval,
submitAgentPublishApproval,
updateAgentVisibilityScope,
} from './api';
const router = useRouter();
@@ -38,6 +53,35 @@ const pageDataRef = ref();
const sideList = ref<any[]>([]);
const AGENT_TAB_PAGE_KEY = '/ai/agents';
const DEFAULT_AGENT_TITLE = '未命名智能体';
type VisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
const { hasAccessByCodes } = useAccess();
const canManageAgent = computed(() => hasAccessByCodes(['/api/v1/agent/save']));
const updatingVisibilityScopeId = ref<null | number | string>(null);
const visibilityScopePopoverRefs = ref<Record<string, any>>({});
const visibilityScopeMeta = {
PRIVATE: {
description: '仅创建者可使用',
icon: Lock,
label: '个人',
tone: 'private',
},
DEPT: {
description: '本部门及下级部门可使用',
icon: OfficeBuilding,
label: '部门',
tone: 'dept',
},
PUBLIC: {
description: '租户内所有有聊天权限的用户可使用',
icon: Promotion,
label: '公开',
tone: 'public',
},
};
const visibilityScopeOptions = (
['PRIVATE', 'DEPT', 'PUBLIC'] as VisibilityScope[]
).map((value) => ({ value, ...visibilityScopeMeta[value] }));
const headerButtons = [
{
@@ -128,6 +172,54 @@ function resolveNavTitle(row: AgentInfo) {
return String(row.name || '').trim() || DEFAULT_AGENT_TITLE;
}
function resolveVisibilityScopeMeta(scope?: string) {
return (
visibilityScopeMeta[scope as VisibilityScope] || visibilityScopeMeta.PRIVATE
);
}
function setVisibilityScopePopoverRef(id: number | string, el: any) {
const cacheKey = String(id);
if (el) {
visibilityScopePopoverRefs.value[cacheKey] = el;
return;
}
delete visibilityScopePopoverRefs.value[cacheKey];
}
function closeVisibilityScopePopover(id: number | string) {
visibilityScopePopoverRefs.value[String(id)]?.hide?.();
}
async function updateVisibilityScope(
row: AgentInfo,
visibilityScope: VisibilityScope,
) {
if (!row?.id) {
return;
}
if (
!canManageAgent.value ||
updatingVisibilityScopeId.value === row.id ||
row.visibilityScope === visibilityScope
) {
closeVisibilityScopePopover(row.id);
return;
}
updatingVisibilityScopeId.value = row.id;
try {
const res = await updateAgentVisibilityScope(row.id, visibilityScope);
if (res.errorCode === 0) {
row.visibilityScope = visibilityScope;
ElMessage.success(res.message || $t('message.saveOkMessage'));
closeVisibilityScopePopover(row.id);
pageDataRef.value?.reload?.();
}
} finally {
updatingVisibilityScopeId.value = null;
}
}
function changeCategory(category: any) {
pageDataRef.value?.setQuery({ categoryId: category.id });
}
@@ -148,22 +240,22 @@ function resolvePublishStatusMeta(
) {
switch (resolveAiResourceDisplayStatus(displayPublishStatus, publishStatus)) {
case 'DELETE_PENDING': {
return { label: '删除中', type: 'danger' as const };
return { label: '删除中', tone: 'danger' };
}
case 'OFFLINE': {
return { label: '已下线', type: 'info' as const };
return { label: '已下线', tone: 'draft' };
}
case 'OFFLINE_PENDING': {
return { label: '下线中', type: 'warning' as const };
return { label: '下线中', tone: 'pending' };
}
case 'PUBLISH_PENDING': {
return { label: '发布中', type: 'warning' as const };
return { label: '发布中', tone: 'pending' };
}
case 'PUBLISHED': {
return { label: '已发布', type: 'success' as const };
return { label: '已发布', tone: 'published' };
}
default: {
return { label: '草稿', type: 'info' as const };
return { label: '草稿', tone: 'draft' };
}
}
}
@@ -267,25 +359,124 @@ async function handleDeleteAction(row: AgentInfo) {
:primary-action="primaryAction"
:actions="actions"
>
<template #corner="{ item }">
<ElTag
size="small"
effect="plain"
round
:type="
resolvePublishStatusMeta(
item.displayPublishStatus,
item.publishStatus,
).type
"
>
{{
resolvePublishStatusMeta(
item.displayPublishStatus,
item.publishStatus,
).label
}}
</ElTag>
<template #details="{ item }">
<AiResourceCornerMeta>
<template #publish>
<div
class="agent-publish-chip"
:class="
'agent-publish-chip--' +
resolvePublishStatusMeta(
item.displayPublishStatus,
item.publishStatus,
).tone
"
>
<span class="agent-publish-chip__dot"></span>
<span>{{
resolvePublishStatusMeta(
item.displayPublishStatus,
item.publishStatus,
).label
}}</span>
</div>
</template>
<template #scope>
<ElPopover
v-if="canManageAgent"
:ref="(el) => setVisibilityScopePopoverRef(item.id, el)"
trigger="click"
placement="bottom-end"
:width="208"
popper-class="agent-visibility-popover"
>
<template #reference>
<button
type="button"
class="agent-scope-chip"
:class="`agent-scope-chip--${resolveVisibilityScopeMeta(item.visibilityScope).tone}`"
:disabled="updatingVisibilityScopeId === item.id"
@click.stop
>
<ElIcon class="agent-scope-chip__icon">
<component
:is="
resolveVisibilityScopeMeta(item.visibilityScope)
.icon
"
/>
</ElIcon>
<span class="agent-scope-chip__label">
{{
resolveVisibilityScopeMeta(item.visibilityScope)
.label
}}
</span>
</button>
</template>
<div class="agent-scope-panel" @click.stop>
<button
v-for="option in visibilityScopeOptions"
:key="option.value"
type="button"
class="agent-scope-option"
:class="[
`agent-scope-option--${option.tone}`,
{
'agent-scope-option--active':
item.visibilityScope === option.value,
},
]"
:disabled="updatingVisibilityScopeId === item.id"
@click.stop="
updateVisibilityScope(item, option.value)
"
>
<span class="agent-scope-option__leading">
<span class="agent-scope-option__icon-wrap">
<ElIcon class="agent-scope-option__icon">
<component :is="option.icon" />
</ElIcon>
</span>
<span class="agent-scope-option__text">
<span class="agent-scope-option__label">
{{ option.label }}
</span>
<span class="agent-scope-option__desc">
{{ option.description }}
</span>
</span>
</span>
<ElIcon
v-if="item.visibilityScope === option.value"
class="agent-scope-option__check"
>
<Check />
</ElIcon>
</button>
</div>
</ElPopover>
<div
v-else
class="agent-scope-chip agent-scope-chip--readonly"
:class="`agent-scope-chip--${resolveVisibilityScopeMeta(item.visibilityScope).tone}`"
>
<ElIcon class="agent-scope-chip__icon">
<component
:is="
resolveVisibilityScopeMeta(item.visibilityScope)
.icon
"
/>
</ElIcon>
<span class="agent-scope-chip__label">
{{
resolveVisibilityScopeMeta(item.visibilityScope).label
}}
</span>
</div>
</template>
</AiResourceCornerMeta>
</template>
</CardList>
</template>
@@ -317,4 +508,252 @@ async function handleDeleteAction(row: AgentInfo) {
height: calc(100vh - 192px);
overflow: auto;
}
.agent-publish-chip {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
min-height: 22px;
padding: 0 8px;
font-size: 11px;
font-weight: 500;
line-height: 1;
border: 1px solid transparent;
border-radius: 999px;
box-shadow: inset 0 1px 0 hsl(var(--card) / 46%);
}
.agent-publish-chip__dot {
width: 6px;
height: 6px;
background: currentColor;
border-radius: 999px;
opacity: 0.88;
}
.agent-publish-chip--draft {
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 42%);
border-color: hsl(var(--line-subtle));
}
.agent-publish-chip--pending {
color: hsl(var(--warning));
background: hsl(var(--warning) / 12%);
border-color: hsl(var(--warning) / 14%);
}
.agent-publish-chip--published {
color: hsl(var(--success));
background: hsl(var(--success) / 12%);
border-color: hsl(var(--success) / 14%);
}
.agent-publish-chip--danger {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 10%);
border-color: hsl(var(--destructive) / 14%);
}
.agent-scope-chip {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
min-height: 22px;
padding: 0 8px;
font-size: 11px;
font-weight: 500;
line-height: 1;
color: hsl(var(--text-strong));
background: transparent;
border: 1px solid transparent;
border-radius: 999px;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease,
transform 0.18s ease,
box-shadow 0.18s ease;
}
button.agent-scope-chip {
cursor: pointer;
}
button.agent-scope-chip:hover {
background: hsl(var(--card) / 76%);
box-shadow: 0 8px 16px -16px hsl(var(--foreground) / 28%);
}
button.agent-scope-chip:focus-visible {
outline: none;
box-shadow:
0 0 0 4px hsl(var(--primary) / 12%),
0 10px 22px -18px hsl(var(--foreground) / 32%);
}
button.agent-scope-chip:disabled {
cursor: not-allowed;
opacity: 0.72;
}
.agent-scope-chip--readonly {
cursor: default;
}
.agent-scope-chip__icon {
font-size: 12px;
}
.agent-scope-chip__label {
white-space: nowrap;
}
.agent-scope-chip--private {
color: hsl(var(--primary));
background: hsl(var(--primary) / 10%);
border-color: hsl(var(--primary) / 14%);
}
.agent-scope-chip--dept {
color: hsl(var(--warning));
background: hsl(var(--warning) / 12%);
border-color: hsl(var(--warning) / 14%);
}
.agent-scope-chip--public {
color: hsl(var(--success));
background: hsl(var(--success) / 12%);
border-color: hsl(var(--success) / 14%);
}
.agent-scope-panel {
display: flex;
flex-direction: column;
gap: 2px;
}
.agent-scope-option {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 8px;
text-align: left;
background: transparent;
border: none;
border-radius: 12px;
transition:
background-color 0.18s ease,
transform 0.18s ease,
color 0.18s ease;
}
.agent-scope-option:hover {
background: hsl(var(--foreground) / 4%);
}
.agent-scope-option:focus-visible {
outline: none;
box-shadow: 0 0 0 4px hsl(var(--primary) / 12%);
}
.agent-scope-option:disabled {
cursor: not-allowed;
opacity: 0.72;
}
.agent-scope-option__leading {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.agent-scope-option__icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: transparent;
border-radius: 9px;
}
.agent-scope-option__icon {
font-size: 15px;
}
.agent-scope-option__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.agent-scope-option__label {
font-size: 13px;
font-weight: 600;
color: hsl(var(--text-strong));
}
.agent-scope-option__desc {
font-size: 11px;
line-height: 1.35;
color: hsl(var(--text-muted));
}
.agent-scope-option__check {
flex-shrink: 0;
font-size: 15px;
}
.agent-scope-option--private .agent-scope-option__icon-wrap {
color: hsl(var(--primary));
background: hsl(var(--primary) / 10%);
}
.agent-scope-option--dept .agent-scope-option__icon-wrap {
color: hsl(var(--warning));
background: hsl(var(--warning) / 12%);
}
.agent-scope-option--public .agent-scope-option__icon-wrap {
color: hsl(var(--success));
background: hsl(var(--success) / 12%);
}
.agent-scope-option--private.agent-scope-option--active {
background: hsl(var(--primary) / 8%);
}
.agent-scope-option--dept.agent-scope-option--active {
background: hsl(var(--warning) / 8%);
}
.agent-scope-option--public.agent-scope-option--active {
background: hsl(var(--success) / 8%);
}
.agent-scope-option--private .agent-scope-option__check {
color: hsl(var(--primary));
}
.agent-scope-option--dept .agent-scope-option__check {
color: hsl(var(--warning));
}
.agent-scope-option--public .agent-scope-option__check {
color: hsl(var(--success));
}
:global(.agent-visibility-popover.el-popover.el-popper) {
padding: 8px;
border-color: hsl(var(--line-subtle));
border-radius: 16px;
box-shadow: 0 18px 34px -28px hsl(var(--foreground) / 20%);
}
</style>

View File

@@ -26,6 +26,16 @@ export function updateAgent(agent: AgentInfo) {
return api.post<RequestResult<AgentInfo>>('/api/v1/agent/update', agent);
}
export function updateAgentVisibilityScope(
id: number | string,
visibilityScope: string,
) {
return api.post<RequestResult<AgentInfo>>(
'/api/v1/agent/visibilityScope/update',
{ id, visibilityScope },
);
}
export function updateAgentToolBindings(
agentId: number | string,
bindings: AgentToolBinding[],

View File

@@ -26,6 +26,12 @@ const props = defineProps<{
const emit = defineEmits<{ change: [] }>();
const visibilityScopeOptions = [
{ label: '个人', value: 'PRIVATE' },
{ label: '部门', value: 'DEPT' },
{ label: '公开', value: 'PUBLIC' },
];
function handleModelChange(modelId: AgentInfo['modelId']) {
const selectedModel = props.models.find(
(model) => model.value === String(modelId ?? ''),
@@ -86,6 +92,16 @@ function handleModelChange(modelId: AgentInfo['modelId']) {
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="可见范围">
<ElSelect v-model="agent.visibilityScope" @change="emit('change')">
<ElOption
v-for="item in visibilityScopeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="模型" required>
<ElSelect v-model="agent.modelId" filterable @change="handleModelChange">
<ElOption

View File

@@ -32,6 +32,25 @@ describe('useAgentDesignerState generation stream', () => {
});
});
describe('useAgentDesignerState visibility scope', () => {
it('defaults new and legacy agents to private visibility', () => {
expect(createEmptyAgent().visibilityScope).toBe('PRIVATE');
const designer = useAgentDesignerState();
designer.reset({ name: '旧智能体' });
expect(designer.state.agent.visibilityScope).toBe('PRIVATE');
expect(designer.buildPayloadAgent().visibilityScope).toBe('PRIVATE');
});
it('preserves the selected visibility scope in the save payload', () => {
const designer = useAgentDesignerState();
designer.reset({ name: '部门智能体', visibilityScope: 'DEPT' });
expect(designer.buildPayloadAgent().visibilityScope).toBe('DEPT');
});
});
describe('useAgentDesignerState document context budget', () => {
it('defaults new and legacy agents to twenty thousand tokens', () => {
expect(

View File

@@ -94,6 +94,7 @@ export function createEmptyAgent(): AgentInfo {
description: '',
avatar: '',
categoryId: '',
visibilityScope: 'PRIVATE',
executionConfigJson: {
documentContextBudgetTokens: 20_000,
},