发布 v1.10 #5
@@ -137,6 +137,18 @@ public class AgentController extends BaseCurdController<AgentService, 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 列表。
|
||||
*
|
||||
|
||||
@@ -59,21 +59,36 @@ public class AgentVisibilityQueryHelper {
|
||||
return;
|
||||
}
|
||||
QueryCondition owner = AGENT.CREATED_BY.eq(accountId);
|
||||
if (access.isRestricted() && access.getCategoryIds().isEmpty()) {
|
||||
queryWrapper.and(owner);
|
||||
return;
|
||||
}
|
||||
Set<BigInteger> readableDeptIds = account.getDeptId() == null
|
||||
? Collections.emptySet()
|
||||
: sysDeptService.getSelfAndAncestorDeptIds(account.getDeptId());
|
||||
QueryCondition visible = AGENT.VISIBILITY_SCOPE.eq(VisibilityScope.PUBLIC.name());
|
||||
if (!readableDeptIds.isEmpty()) {
|
||||
visible = visible.or(AGENT.VISIBILITY_SCOPE.eq(VisibilityScope.DEPT.name())
|
||||
.and(AGENT.DEPT_ID.in(readableDeptIds)));
|
||||
}
|
||||
QueryCondition visible = buildScopeVisibleCondition(readableDeptIds);
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可见范围条件。
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,15 @@ public interface AgentService extends IService<Agent> {
|
||||
*/
|
||||
Agent updateDraft(Agent agent);
|
||||
|
||||
/**
|
||||
* 更新 Agent 的可见范围。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @param visibilityScope 可见范围编码
|
||||
* @return 更新后的 Agent
|
||||
*/
|
||||
Agent updateVisibilityScope(BigInteger agentId, String visibilityScope);
|
||||
|
||||
/**
|
||||
* 获取已发布运行视图。
|
||||
*
|
||||
|
||||
@@ -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}
|
||||
*/
|
||||
@@ -244,6 +267,20 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
||||
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 运行配置中的文档上下文预算。
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,12 @@ public class ResourceAccessServiceImpl implements ResourceAccessService {
|
||||
&& categoryPermissionService.getAccess(resourceType.getCode(), loginAccount).isAllAccess()) {
|
||||
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;
|
||||
}
|
||||
VisibilityScope scope = VisibilityScope.fromOrDefault(resource.getVisibilityScope(), VisibilityScope.PRIVATE);
|
||||
|
||||
@@ -129,6 +129,36 @@ public class ResourceAccessServiceImplTest {
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证资源动作不能跨越租户边界,即使资源是公开状态。
|
||||
*/
|
||||
|
||||
18
easyflow-ui-admin/app/src/views/ai/agents/AgentList.test.ts
Normal file
18
easyflow-ui-admin/app/src/views/ai/agents/AgentList.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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="
|
||||
<template #details="{ item }">
|
||||
<AiResourceCornerMeta>
|
||||
<template #publish>
|
||||
<div
|
||||
class="agent-publish-chip"
|
||||
:class="
|
||||
'agent-publish-chip--' +
|
||||
resolvePublishStatusMeta(
|
||||
item.displayPublishStatus,
|
||||
item.publishStatus,
|
||||
).type
|
||||
).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
|
||||
}}
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -94,6 +94,7 @@ export function createEmptyAgent(): AgentInfo {
|
||||
description: '',
|
||||
avatar: '',
|
||||
categoryId: '',
|
||||
visibilityScope: 'PRIVATE',
|
||||
executionConfigJson: {
|
||||
documentContextBudgetTokens: 20_000,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user