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

@@ -137,6 +137,18 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
return Result.ok(service.updateDraft(agent)); return Result.ok(service.updateDraft(agent));
} }
/**
* 更新 Agent 可见范围。
*
* @param agent 包含 Agent ID 和可见范围的请求数据
* @return 更新后的 Agent
*/
@PostMapping("visibilityScope/update")
@SaCheckPermission("/api/v1/agent/save")
public Result<Agent> updateVisibilityScope(@JsonBody Agent agent) {
return Result.ok(service.updateVisibilityScope(agent.getId(), agent.getVisibilityScope()));
}
/** /**
* 查询 Agent 列表。 * 查询 Agent 列表。
* *

View File

@@ -59,21 +59,36 @@ public class AgentVisibilityQueryHelper {
return; return;
} }
QueryCondition owner = AGENT.CREATED_BY.eq(accountId); QueryCondition owner = AGENT.CREATED_BY.eq(accountId);
if (access.isRestricted() && access.getCategoryIds().isEmpty()) {
queryWrapper.and(owner);
return;
}
Set<BigInteger> readableDeptIds = account.getDeptId() == null Set<BigInteger> readableDeptIds = account.getDeptId() == null
? Collections.emptySet() ? Collections.emptySet()
: sysDeptService.getSelfAndAncestorDeptIds(account.getDeptId()); : sysDeptService.getSelfAndAncestorDeptIds(account.getDeptId());
QueryCondition visible = AGENT.VISIBILITY_SCOPE.eq(VisibilityScope.PUBLIC.name()); QueryCondition visible = buildScopeVisibleCondition(readableDeptIds);
if (!readableDeptIds.isEmpty()) {
visible = visible.or(AGENT.VISIBILITY_SCOPE.eq(VisibilityScope.DEPT.name())
.and(AGENT.DEPT_ID.in(readableDeptIds)));
}
if (access.isRestricted()) { if (access.isRestricted()) {
visible = AGENT.CATEGORY_ID.in(access.getCategoryIds()).and(visible); // Agent 未设置分类时表示不受分类白名单限制,仍需满足其可见范围。
QueryCondition readableCategories = AGENT.CATEGORY_ID.isNull()
.and(buildScopeVisibleCondition(readableDeptIds));
if (!access.getCategoryIds().isEmpty()) {
readableCategories = readableCategories.or(
AGENT.CATEGORY_ID.in(access.getCategoryIds())
.and(buildScopeVisibleCondition(readableDeptIds)));
}
visible = readableCategories;
} }
queryWrapper.and(owner.or(visible)); queryWrapper.and(owner.or(visible));
} }
/**
* 构建可见范围条件。
*
* @param readableDeptIds 当前账号可读取的部门 ID 集合
* @return 可见范围条件
*/
private QueryCondition buildScopeVisibleCondition(Set<BigInteger> readableDeptIds) {
QueryCondition scopeVisible = AGENT.VISIBILITY_SCOPE.eq(VisibilityScope.PUBLIC.name());
if (!readableDeptIds.isEmpty()) {
scopeVisible = scopeVisible.or(AGENT.VISIBILITY_SCOPE.eq(VisibilityScope.DEPT.name())
.and(AGENT.DEPT_ID.in(readableDeptIds)));
}
return scopeVisible;
}
} }

View File

@@ -35,6 +35,15 @@ public interface AgentService extends IService<Agent> {
*/ */
Agent updateDraft(Agent agent); Agent updateDraft(Agent agent);
/**
* 更新 Agent 的可见范围。
*
* @param agentId Agent ID
* @param visibilityScope 可见范围编码
* @return 更新后的 Agent
*/
Agent updateVisibilityScope(BigInteger agentId, String visibilityScope);
/** /**
* 获取已发布运行视图。 * 获取已发布运行视图。
* *

View File

@@ -114,6 +114,29 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
}); });
} }
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Agent updateVisibilityScope(BigInteger agentId, String visibilityScope) {
if (agentId == null) {
throw new BusinessException("Agent ID 不能为空");
}
VisibilityScope scope = parseVisibilityScope(visibilityScope);
return agentBindingLockExecutor.execute(agentId, () -> {
Agent existing = requireAgentForUpdate(agentId);
resourceAccessService.assertAccess(
CategoryResourceType.AGENT, existing, ResourceAction.MANAGE, "无权限管理该 Agent");
LoginAccount account = requireCurrentLoginAccount();
existing.setVisibilityScope(scope.name());
existing.setModified(new Date());
existing.setModifiedBy(account.getId());
updateById(existing);
return getDetail(existing.getId());
});
}
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
@@ -244,6 +267,20 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
agent.setExecutionConfigJson(normalizeExecutionConfig(agent.getExecutionConfigJson())); agent.setExecutionConfigJson(normalizeExecutionConfig(agent.getExecutionConfigJson()));
} }
/**
* 解析并校验 Agent 可见范围。
*
* @param visibilityScope 可见范围编码
* @return 标准化后的可见范围
*/
private VisibilityScope parseVisibilityScope(String visibilityScope) {
try {
return VisibilityScope.from(visibilityScope);
} catch (IllegalArgumentException error) {
throw new BusinessException(error.getMessage());
}
}
/** /**
* 规范并校验 Agent 运行配置中的文档上下文预算。 * 规范并校验 Agent 运行配置中的文档上下文预算。
* *

View File

@@ -0,0 +1,70 @@
package tech.easyflow.agent.security;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.MockedStatic;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysDeptService;
import java.math.BigInteger;
import java.util.Locale;
import java.util.Set;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
/**
* {@link AgentVisibilityQueryHelper} 未分类 Agent 查询权限回归测试。
*/
public class AgentVisibilityQueryHelperTest {
/**
* 验证受限分类角色的读取查询仍包含未分类公开 Agent。
*/
@Test
public void restrictedCategoryQueryShouldIncludeUnclassifiedAgents() {
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
SysDeptService sysDeptService = mock(SysDeptService.class);
AgentVisibilityQueryHelper helper = new AgentVisibilityQueryHelper(
categoryPermissionService, sysDeptService);
LoginAccount account = account(7, 42);
when(categoryPermissionService.getCurrentAccess(CategoryResourceType.AGENT.getCode()))
.thenReturn(new RoleCategoryAccessSnapshot(
CategoryResourceType.AGENT.getCode(), account.getId(), false, false,
Set.of(BigInteger.valueOf(99))));
QueryWrapper query = QueryWrapper.create().from(Agent.class);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
helper.applyReadableAccess(query);
}
String sql = query.toSQL().toLowerCase(Locale.ROOT);
assertTrue("受限分类查询缺少未分类 Agent 分支: " + sql,
sql.contains("category_id") && sql.contains("is null"));
assertTrue("受限分类查询缺少已授权分类分支: " + sql, sql.contains("category_id` = 99"));
assertTrue("未分类 Agent 分支未附加可见范围: " + sql,
sql.matches("(?s).*category_id` is null\\s+and\\s+`visibility_scope` = 'public'.*"));
}
/**
* 创建测试使用的登录账号。
*
* @param accountId 账号 ID
* @param tenantId 租户 ID
* @return 登录账号
*/
private LoginAccount account(long accountId, long tenantId) {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(accountId));
account.setTenantId(BigInteger.valueOf(tenantId));
return account;
}
}

View File

@@ -75,7 +75,12 @@ public class ResourceAccessServiceImpl implements ResourceAccessService {
&& categoryPermissionService.getAccess(resourceType.getCode(), loginAccount).isAllAccess()) { && categoryPermissionService.getAccess(resourceType.getCode(), loginAccount).isAllAccess()) {
return true; return true;
} }
if (!categoryPermissionService.canAccessCategory(loginAccount, resourceType.getCode(), resource.getCreatedBy(), resource.getCategoryId())) { // Agent 的未分类语义为“全部分类可访问”,只跳过分类白名单,不能跳过可见范围校验。
boolean agentWithoutCategoryRestriction = CategoryResourceType.AGENT == resourceType
&& resource.getCategoryId() == null;
if (!agentWithoutCategoryRestriction
&& !categoryPermissionService.canAccessCategory(
loginAccount, resourceType.getCode(), resource.getCreatedBy(), resource.getCategoryId())) {
return false; return false;
} }
VisibilityScope scope = VisibilityScope.fromOrDefault(resource.getVisibilityScope(), VisibilityScope.PRIVATE); VisibilityScope scope = VisibilityScope.fromOrDefault(resource.getVisibilityScope(), VisibilityScope.PRIVATE);

View File

@@ -129,6 +129,36 @@ public class ResourceAccessServiceImplTest {
assertFalse(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.MANAGE)); assertFalse(service.canAccess(account, CategoryResourceType.SKILL, resource, ResourceAction.MANAGE));
} }
/**
* 验证未分类公开 Agent 不受角色分类白名单限制,但不会影响其它资源类型。
*/
@Test
public void unclassifiedPublicAgentShouldBypassCategoryWhitelist() {
LoginAccount account = account(8, 80);
VisibilityResource resource = new TestVisibilityResource(
BigInteger.ONE, BigInteger.valueOf(7), BigInteger.valueOf(90), null,
VisibilityScope.PUBLIC.name());
assertTrue(service.canAccess(account, CategoryResourceType.AGENT, resource, ResourceAction.USE));
Mockito.verify(categoryPermissionService, Mockito.never()).canAccessCategory(
Mockito.any(), Mockito.anyString(), Mockito.any(), Mockito.any());
}
/**
* 验证未分类 Agent 仍受可见范围约束,私有 Agent 不会因分类放开而被读取。
*/
@Test
public void unclassifiedPrivateAgentShouldRemainPrivate() {
LoginAccount account = account(8, 80);
VisibilityResource resource = new TestVisibilityResource(
BigInteger.ONE, BigInteger.valueOf(7), BigInteger.valueOf(90), null,
VisibilityScope.PRIVATE.name());
assertFalse(service.canAccess(account, CategoryResourceType.AGENT, resource, ResourceAction.READ));
Mockito.verify(categoryPermissionService, Mockito.never()).canAccessCategory(
Mockito.any(), Mockito.anyString(), Mockito.any(), Mockito.any());
}
/** /**
* 验证资源动作不能跨越租户边界,即使资源是公开状态。 * 验证资源动作不能跨越租户边界,即使资源是公开状态。
*/ */

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

@@ -2,14 +2,27 @@
/* cspell:ignore tryit */ /* 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 CardList from '#/components/page/CardList.vue';
import {markRaw, onMounted, ref} from 'vue'; import { computed, markRaw, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import {Delete, Edit, Plus, Promotion} from '@element-plus/icons-vue'; import { useAccess } from '@easyflow/access';
import {ElMessage, ElMessageBox, ElTag} from 'element-plus';
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 { tryit } from 'radash';
import defaultAgentAvatar from '#/assets/defaultUserAvatar.png'; import defaultAgentAvatar from '#/assets/defaultUserAvatar.png';
@@ -17,6 +30,7 @@ import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PageData from '#/components/page/PageData.vue'; import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.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 { import {
canAiResourceDelete, canAiResourceDelete,
canAiResourceOffline, canAiResourceOffline,
@@ -31,6 +45,7 @@ import {
submitAgentDeleteApproval, submitAgentDeleteApproval,
submitAgentOfflineApproval, submitAgentOfflineApproval,
submitAgentPublishApproval, submitAgentPublishApproval,
updateAgentVisibilityScope,
} from './api'; } from './api';
const router = useRouter(); const router = useRouter();
@@ -38,6 +53,35 @@ const pageDataRef = ref();
const sideList = ref<any[]>([]); const sideList = ref<any[]>([]);
const AGENT_TAB_PAGE_KEY = '/ai/agents'; const AGENT_TAB_PAGE_KEY = '/ai/agents';
const DEFAULT_AGENT_TITLE = '未命名智能体'; 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 = [ const headerButtons = [
{ {
@@ -128,6 +172,54 @@ function resolveNavTitle(row: AgentInfo) {
return String(row.name || '').trim() || DEFAULT_AGENT_TITLE; 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) { function changeCategory(category: any) {
pageDataRef.value?.setQuery({ categoryId: category.id }); pageDataRef.value?.setQuery({ categoryId: category.id });
} }
@@ -148,22 +240,22 @@ function resolvePublishStatusMeta(
) { ) {
switch (resolveAiResourceDisplayStatus(displayPublishStatus, publishStatus)) { switch (resolveAiResourceDisplayStatus(displayPublishStatus, publishStatus)) {
case 'DELETE_PENDING': { case 'DELETE_PENDING': {
return { label: '删除中', type: 'danger' as const }; return { label: '删除中', tone: 'danger' };
} }
case 'OFFLINE': { case 'OFFLINE': {
return { label: '已下线', type: 'info' as const }; return { label: '已下线', tone: 'draft' };
} }
case 'OFFLINE_PENDING': { case 'OFFLINE_PENDING': {
return { label: '下线中', type: 'warning' as const }; return { label: '下线中', tone: 'pending' };
} }
case 'PUBLISH_PENDING': { case 'PUBLISH_PENDING': {
return { label: '发布中', type: 'warning' as const }; return { label: '发布中', tone: 'pending' };
} }
case 'PUBLISHED': { case 'PUBLISHED': {
return { label: '已发布', type: 'success' as const }; return { label: '已发布', tone: 'published' };
} }
default: { default: {
return { label: '草稿', type: 'info' as const }; return { label: '草稿', tone: 'draft' };
} }
} }
} }
@@ -267,25 +359,124 @@ async function handleDeleteAction(row: AgentInfo) {
:primary-action="primaryAction" :primary-action="primaryAction"
:actions="actions" :actions="actions"
> >
<template #corner="{ item }"> <template #details="{ item }">
<ElTag <AiResourceCornerMeta>
size="small" <template #publish>
effect="plain" <div
round class="agent-publish-chip"
:type=" :class="
'agent-publish-chip--' +
resolvePublishStatusMeta( resolvePublishStatusMeta(
item.displayPublishStatus, item.displayPublishStatus,
item.publishStatus, item.publishStatus,
).type ).tone
" "
> >
{{ <span class="agent-publish-chip__dot"></span>
<span>{{
resolvePublishStatusMeta( resolvePublishStatusMeta(
item.displayPublishStatus, item.displayPublishStatus,
item.publishStatus, item.publishStatus,
).label ).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
}} }}
</ElTag> </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> </template>
</CardList> </CardList>
</template> </template>
@@ -317,4 +508,252 @@ async function handleDeleteAction(row: AgentInfo) {
height: calc(100vh - 192px); height: calc(100vh - 192px);
overflow: auto; 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> </style>

View File

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

View File

@@ -26,6 +26,12 @@ const props = defineProps<{
const emit = defineEmits<{ change: [] }>(); const emit = defineEmits<{ change: [] }>();
const visibilityScopeOptions = [
{ label: '个人', value: 'PRIVATE' },
{ label: '部门', value: 'DEPT' },
{ label: '公开', value: 'PUBLIC' },
];
function handleModelChange(modelId: AgentInfo['modelId']) { function handleModelChange(modelId: AgentInfo['modelId']) {
const selectedModel = props.models.find( const selectedModel = props.models.find(
(model) => model.value === String(modelId ?? ''), (model) => model.value === String(modelId ?? ''),
@@ -86,6 +92,16 @@ function handleModelChange(modelId: AgentInfo['modelId']) {
/> />
</ElSelect> </ElSelect>
</ElFormItem> </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> <ElFormItem label="模型" required>
<ElSelect v-model="agent.modelId" filterable @change="handleModelChange"> <ElSelect v-model="agent.modelId" filterable @change="handleModelChange">
<ElOption <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', () => { describe('useAgentDesignerState document context budget', () => {
it('defaults new and legacy agents to twenty thousand tokens', () => { it('defaults new and legacy agents to twenty thousand tokens', () => {
expect( expect(

View File

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