diff --git a/frontend/src/api/agent/index.ts b/frontend/src/api/agent/index.ts index c2cda726..9a783693 100644 --- a/frontend/src/api/agent/index.ts +++ b/frontend/src/api/agent/index.ts @@ -135,8 +135,17 @@ export const BUILTIN_AGENT_AGENT_ID = BUILTIN_SMART_REASONING_ID; // 获取智能体列表(包括内置智能体) // disabled_own_agent_ids: 当前租户在对话下拉中停用的「我的」智能体 ID,仅影响本租户 -export function listAgents() { - return get<{ data: CustomAgent[]; disabled_own_agent_ids?: string[] }>('/api/v1/agents'); +export function listAgents(params?: { + /** + * Optional creator filter; mirrors listKnowledgeBases. Built-in agents + * (is_builtin=true) are always returned regardless of this filter so + * the conversation dropdown never silently loses quick-answer / + * smart-reasoning when a user picks "Created by me". + */ + creator?: 'all' | 'mine' | 'others'; +}) { + const qs = params?.creator && params.creator !== 'all' ? `?creator=${params.creator}` : ''; + return get<{ data: CustomAgent[]; disabled_own_agent_ids?: string[] }>(`/api/v1/agents${qs}`); } // 获取智能体详情 diff --git a/frontend/src/api/knowledge-base/index.ts b/frontend/src/api/knowledge-base/index.ts index 61b6bb38..dfed0590 100644 --- a/frontend/src/api/knowledge-base/index.ts +++ b/frontend/src/api/knowledge-base/index.ts @@ -1,9 +1,21 @@ import { get, post, put, del, postUpload, getDown } from "../../utils/request"; // 知识库管理 API(列表、创建、获取、更新、删除、复制) -export function listKnowledgeBases(params?: { agent_id?: string }) { +export function listKnowledgeBases(params?: { + agent_id?: string; + /** + * Optional creator filter. Server-side semantics: + * - "mine" → only KBs whose creator_id matches the caller + * - "others" → only KBs created by someone else in this tenant + * - omitted/"all" → no filter + * KBs predating the RBAC backfill (creator_id="") never match + * mine/others — they fall out of both views by design. + */ + creator?: 'all' | 'mine' | 'others'; +}) { const query = new URLSearchParams(); if (params?.agent_id) query.set('agent_id', params.agent_id); + if (params?.creator && params.creator !== 'all') query.set('creator', params.creator); const qs = query.toString(); return get(qs ? `/api/v1/knowledge-bases?${qs}` : '/api/v1/knowledge-bases'); } diff --git a/frontend/src/api/user-favorites.ts b/frontend/src/api/user-favorites.ts new file mode 100644 index 00000000..8de318e4 --- /dev/null +++ b/frontend/src/api/user-favorites.ts @@ -0,0 +1,34 @@ +// API client for per-user starred resources (DB-backed; see migration +// 000047 and internal/handler/user_resource_favorite.go). +// +// The backend authoritatively scopes favorites to the active (user, tenant) +// pair from the auth context — these helpers therefore never pass user_id +// or tenant_id, and a tenant switch automatically points reads at the +// right namespace. + +import { get, post, del } from '@/utils/request' + +export type FavoriteResourceType = 'kb' | 'agent' + +export interface FavoriteEntry { + user_id: string + tenant_id: number + resource_type: FavoriteResourceType + resource_id: string + /** ISO timestamp from the server (created_at column). */ + created_at: string +} + +export function listFavorites(type: FavoriteResourceType) { + return get<{ success: boolean; data: FavoriteEntry[] }>( + `/api/v1/user/favorites?type=${encodeURIComponent(type)}` + ) +} + +export function addFavorite(type: FavoriteResourceType, id: string) { + return post('/api/v1/user/favorites', { type, id }) +} + +export function removeFavorite(type: FavoriteResourceType, id: string) { + return del(`/api/v1/user/favorites/${encodeURIComponent(type)}/${encodeURIComponent(id)}`) +} diff --git a/frontend/src/components/ListSpaceSidebar.vue b/frontend/src/components/ListSpaceSidebar.vue index 132c7481..bffa8b3d 100644 --- a/frontend/src/components/ListSpaceSidebar.vue +++ b/frontend/src/components/ListSpaceSidebar.vue @@ -11,19 +11,32 @@ {{ $t('listSpaceSidebar.all') }} - - - - {{ $t('listSpaceSidebar.mine') }} - - - - - - {{ $t('listSpaceSidebar.sharedToMe') }} + + + {{ $t('listSpaceSidebar.favorites') }} + + + + {{ $t('listSpaceSidebar.recents') }} + + + + + + {{ workspaceLabel }} + + + + + + + {{ $t('listSpaceSidebar.favorites') }} + + {{ countFavorites }} + + + + + {{ $t('listSpaceSidebar.recents') }} + + {{ countRecents }} + + - - {{ $t('listSpaceSidebar.mine') }} + + {{ workspaceLabel }} {{ countMine }} - - - - {{ $t('listSpaceSidebar.sharedToMe') }} - - {{ countShared }} - + {{ $t('listSpaceSidebar.spaces') }} @@ -128,6 +152,7 @@ + + diff --git a/frontend/src/composables/useListUrlState.ts b/frontend/src/composables/useListUrlState.ts new file mode 100644 index 00000000..c4cccd96 --- /dev/null +++ b/frontend/src/composables/useListUrlState.ts @@ -0,0 +1,111 @@ +// useListUrlState keeps a list view's filter state (sidebar scope, creator +// filter, free-text query) reflected in the route's query string so the +// URL is shareable, bookmarkable and survives a browser back/forward. +// +// Why a composable instead of inlining: KnowledgeBaseList and AgentList +// share the same three filters and the same URL-encoding rules. Centralising +// here means changing the schema (e.g. renaming `scope` -> `view`) is a +// one-line change for both pages. +// +// Schema: +// ?scope= +// ?creator= +// ?q= +// +// Defaults are omitted from the URL to keep it terse and to avoid producing +// a no-op history entry on first mount. We use router.replace (not push) +// for state changes so the back button still leaves the list view, rather +// than cycling through filter changes the user didn't think of as +// navigation. + +import { ref, watch, type Ref } from 'vue' +import { useRoute, useRouter } from 'vue-router' + +export type CreatorFilter = 'all' | 'mine' | 'others' + +export interface ListUrlState { + scope: Ref + creator: Ref + query: Ref +} + +export interface ListUrlStateOptions { + /** Default scope when no `?scope=` is present. Usually role-derived. */ + defaultScope: string + /** Default creator filter when no `?creator=` is present. */ + defaultCreator?: CreatorFilter +} + +export function useListUrlState(opts: ListUrlStateOptions): ListUrlState { + const route = useRoute() + const router = useRouter() + + const initScope = typeof route.query.scope === 'string' && route.query.scope + ? route.query.scope + : opts.defaultScope + + const initCreator: CreatorFilter = + route.query.creator === 'mine' || route.query.creator === 'others' || route.query.creator === 'all' + ? (route.query.creator as CreatorFilter) + : (opts.defaultCreator ?? 'all') + + const initQuery = typeof route.query.q === 'string' ? route.query.q : '' + + const scope = ref(initScope) + const creator = ref(initCreator) + const query = ref(initQuery) + + // Push URL when any filter changes. We diff against current route.query + // to avoid a redundant router.replace when nothing actually changed (the + // route guard would still fire and cause unnecessary watcher churn). + const sync = () => { + const next: Record = { ...(route.query as Record) } + if (scope.value && scope.value !== opts.defaultScope) { + next.scope = scope.value + } else { + delete next.scope + } + const cdef = opts.defaultCreator ?? 'all' + if (creator.value && creator.value !== cdef) { + next.creator = creator.value + } else { + delete next.creator + } + if (query.value) { + next.q = query.value + } else { + delete next.q + } + const changed = + next.scope !== route.query.scope || + next.creator !== route.query.creator || + next.q !== route.query.q + if (!changed) return + router.replace({ path: route.path, query: next }).catch(() => { + // navigation duplication errors are harmless here; vue-router throws + // when the user clicks the same scope twice in rapid succession. + }) + } + + watch([scope, creator, query], sync) + + // When the route changes from elsewhere (e.g. user pastes a link or hits + // back), pull the new values back into our refs. Guards against re-emit + // by only updating when the parsed value differs. + watch( + () => route.query, + (q) => { + const ns = (typeof q.scope === 'string' && q.scope) ? q.scope : opts.defaultScope + const nc: CreatorFilter = + q.creator === 'mine' || q.creator === 'others' || q.creator === 'all' + ? (q.creator as CreatorFilter) + : (opts.defaultCreator ?? 'all') + const nq = typeof q.q === 'string' ? q.q : '' + if (scope.value !== ns) scope.value = ns + if (creator.value !== nc) creator.value = nc + if (query.value !== nq) query.value = nq + } + ) + + return { scope, creator, query } +} diff --git a/frontend/src/composables/useResourcePins.ts b/frontend/src/composables/useResourcePins.ts new file mode 100644 index 00000000..1a2724a5 --- /dev/null +++ b/frontend/src/composables/useResourcePins.ts @@ -0,0 +1,260 @@ +// useResourcePins – per-(user, tenant) favorites + per-user recents for +// KBs and custom agents. +// +// Favorites are DB-backed (see migration 000047) so they sync across +// devices and survive a logout. They are scoped per (user, tenant) on +// the server, which means switching tenants automatically re-points the +// reads — see `refresh()` and the tenant-change watcher inside the +// composable. +// +// Recents stay in localStorage. They're a personal navigation aid, not a +// shareable resource; round-tripping every card click through the +// network would add latency for marginal value. If the product later +// needs cross-device recents we can promote them to a server-side +// audit-log derivation, which is a much smaller migration than starting +// from scratch. + +import { ref, computed, watch, type ComputedRef, type Ref } from 'vue' +import { + listFavorites, + addFavorite, + removeFavorite, + type FavoriteResourceType, +} from '@/api/user-favorites' +import { safeGetItem, safeSetItem, readUserId } from './preferenceStorage' +import { useAuthStore } from '@/stores/auth' + +export type ResourceType = FavoriteResourceType + +export interface PinEntry { + type: ResourceType + id: string + /** Unix ms when the pin was created (favorite) or last accessed (recent). */ + ts: number +} + +const RECENTS_SUFFIX = 'resource_recents' +const RECENTS_CAP = 30 + +// Recents are scoped by (user, tenant) — same rationale as favorites. The +// hydration step in the list views joins recent entries against the +// current tenant's KB/Agent index, so a recent from tenant A would +// silently drop out of the rendered list but still count in the sidebar +// badge ("最近 (2)" but empty body). Keying the storage by tenant +// prevents that mismatch entirely. +// +// `tenantSegmentForKey` returns "" when no tenant is resolvable (e.g. +// during the very first render before auth state hydrates); the resulting +// key is still per-user, so we never accidentally read another user's +// recents. Once the tenant id resolves, the tenant watcher below bumps +// the revision so views pick up the right namespace. +function tenantSegmentForKey(): string { + try { + const authStore = useAuthStore() + const tid = authStore.effectiveTenantId + return tid ? `t${tid}_` : '' + } catch { + return '' + } +} + +function recentsKey(): string { + return `WeKnora_${readUserId()}_${tenantSegmentForKey()}${RECENTS_SUFFIX}` +} + +function readRecents(): PinEntry[] { + const raw = safeGetItem(recentsKey()) + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter( + (e: unknown): e is PinEntry => + !!e && + typeof (e as PinEntry).type === 'string' && + ((e as PinEntry).type === 'kb' || (e as PinEntry).type === 'agent') && + typeof (e as PinEntry).id === 'string' && + typeof (e as PinEntry).ts === 'number' + ) + } catch { + return [] + } +} + +function writeRecents(list: PinEntry[]): void { + safeSetItem(recentsKey(), JSON.stringify(list)) +} + +// Module-level shared state: a single source of truth for the whole tab +// so all list views stay in sync after a star toggle, and so two list +// views don't double-fetch the same data. +const favoritesByType: Record> = { + kb: ref([]), + agent: ref([]), +} +const loaded: Record = { kb: false, agent: false } +const inFlight: Record | null> = { kb: null, agent: null } + +// recents revision counter — same bump-to-invalidate pattern as before. +const recentsRevision = ref(0) +function bumpRecents() { + recentsRevision.value++ +} + +if (typeof window !== 'undefined') { + // Cross-tab sync for recents (localStorage event); favorites changes from + // another tab are not propagated because the server is the source of + // truth and a manual refresh / next mutation will pull fresh state. + window.addEventListener('storage', (e) => { + if (e.key?.endsWith(`_${RECENTS_SUFFIX}`)) bumpRecents() + }) +} + +async function fetchFavorites(type: ResourceType): Promise { + // Single-flight guard: list views mount in parallel and would otherwise + // each fire a request. Reuse the in-flight promise instead. + if (inFlight[type]) return inFlight[type]! + inFlight[type] = (async () => { + try { + const res = (await listFavorites(type)) as unknown as { success: boolean; data?: any[] } + const data = res?.data || [] + favoritesByType[type].value = data.map((e: any) => ({ + type: e.resource_type as ResourceType, + id: e.resource_id as string, + ts: e.created_at ? new Date(e.created_at).getTime() : Date.now(), + })) + loaded[type] = true + } catch (err) { + console.warn(`[useResourcePins] failed to load ${type} favorites:`, err) + favoritesByType[type].value = [] + } finally { + inFlight[type] = null + } + })() + return inFlight[type]! +} + +// Invalidate caches when the active tenant changes — favorites are +// (user, tenant)-scoped on the server, so a tenant switch means the +// currently loaded list belongs to the *previous* tenant. We trigger one +// watcher at module load so any later switch refetches automatically. +let tenantWatcherInstalled = false +function installTenantWatcher(): void { + if (tenantWatcherInstalled) return + tenantWatcherInstalled = true + const authStore = useAuthStore() + watch( + () => authStore.effectiveTenantId, + () => { + loaded.kb = false + loaded.agent = false + favoritesByType.kb.value = [] + favoritesByType.agent.value = [] + // Eagerly refetch both types so any already-mounted list view sees + // fresh data without a manual refresh. + void fetchFavorites('kb') + void fetchFavorites('agent') + // Recents are keyed on the tenant id (see `recentsKey`); bump the + // revision so any active computed re-reads against the new key. + bumpRecents() + } + ) +} + +export interface UseResourcePinsResult { + favorites: ComputedRef + recents: ComputedRef + isFavorite: (type: ResourceType, id: string) => boolean + toggleFavorite: (type: ResourceType, id: string) => Promise + touchRecent: (type: ResourceType, id: string) => void + removeRecent: (type: ResourceType, id: string) => void + /** Force a refetch (e.g. after a debug-y state mismatch). */ + refresh: () => Promise +} + +export function useResourcePins(): UseResourcePinsResult { + installTenantWatcher() + + // Lazy first load. Calling the composable always kicks off (or reuses) + // the in-flight fetch for both types; the templates render with empty + // arrays until data lands, which is fine because the counts default to + // 0 and no view depends on a synchronous read. + if (!loaded.kb) void fetchFavorites('kb') + if (!loaded.agent) void fetchFavorites('agent') + + const favorites = computed(() => { + // Merge both type lists and sort by ts desc. + return [...favoritesByType.kb.value, ...favoritesByType.agent.value].sort( + (a, b) => b.ts - a.ts + ) + }) + + const recents = computed(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _ = recentsRevision.value + return readRecents().sort((a, b) => b.ts - a.ts) + }) + + const isFavorite = (type: ResourceType, id: string): boolean => { + return favoritesByType[type].value.some((e) => e.id === id) + } + + const toggleFavorite = async (type: ResourceType, id: string): Promise => { + const list = favoritesByType[type].value + const idx = list.findIndex((e) => e.id === id) + // Optimistic update first so the star animation feels instant; on + // network failure we roll back to the previous state. Errors are + // swallowed here (logged + rollback) because callers wire this to a + // click handler that can't surface a useful message — the rolled-back + // UI itself is the error indication. + if (idx >= 0) { + const removed = list[idx] + favoritesByType[type].value = list.filter((_, i) => i !== idx) + try { + await removeFavorite(type, id) + return false + } catch (err) { + favoritesByType[type].value = [removed, ...favoritesByType[type].value] + console.warn('[useResourcePins] removeFavorite failed; reverted:', err) + return true + } + } + const optimistic: PinEntry = { type, id, ts: Date.now() } + favoritesByType[type].value = [optimistic, ...list] + try { + await addFavorite(type, id) + return true + } catch (err) { + favoritesByType[type].value = favoritesByType[type].value.filter((e) => e.id !== id) + console.warn('[useResourcePins] addFavorite failed; reverted:', err) + return false + } + } + + const touchRecent = (type: ResourceType, id: string): void => { + const list = readRecents() + const idx = list.findIndex((e) => e.type === type && e.id === id) + if (idx >= 0) list.splice(idx, 1) + list.unshift({ type, id, ts: Date.now() }) + if (list.length > RECENTS_CAP) list.length = RECENTS_CAP + writeRecents(list) + bumpRecents() + } + + const removeRecent = (type: ResourceType, id: string): void => { + const list = readRecents() + const next = list.filter((e) => !(e.type === type && e.id === id)) + if (next.length !== list.length) { + writeRecents(next) + bumpRecents() + } + } + + const refresh = async (): Promise => { + loaded.kb = false + loaded.agent = false + await Promise.all([fetchFavorites('kb'), fetchFavorites('agent')]) + } + + return { favorites, recents, isFavorite, toggleFavorite, touchRecent, removeRecent, refresh } +} diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 670510ba..25fb0017 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -43,9 +43,29 @@ export default { listSpaceSidebar: { title: 'Filter', all: 'All', + workspace: 'Workspace', mine: 'Mine', - sharedToMe: 'Collaborative', - spaces: 'Spaces' + spaces: 'Shared with me', + favorites: 'Starred', + recents: 'Recent' + }, + listFilter: { + creator: 'Creator', + allCreators: 'All', + createdByMe: 'Created by me', + createdByOthers: 'Created by others' + }, + resourceOrigin: { + mine: 'Mine', + mineTooltip: 'Created by you', + tenant: 'Workspace', + tenantTooltip: 'Created by another member of this workspace', + tenantTooltipWithCreator: 'Created by {creator}', + space: 'Space', + spaceTooltip: 'Shared via space "{space}"', + spaceTooltipWithTenant: 'Shared via space "{space}" · from {tenant}', + shared: 'External', + sharedTooltip: 'Accessed from an external tenant via a shared space' }, knowledgeBase: { title: 'Knowledge Base', @@ -410,6 +430,10 @@ export default { description: 'Click the button in the top right to create your first agent', sharedTitle: 'No shared agents yet', sharedDescription: 'You can join a space or ask others to share agents with you', + favoritesTitle: 'No favorites yet', + favoritesDescription: 'Star an agent from its card to add it here', + recentsTitle: 'Nothing here yet', + recentsDescription: 'Agents you recently used will show up here', }, detail: { title: 'Agent Details', @@ -636,11 +660,11 @@ export default { tenantInfo: 'Tenant Info', apiInfo: 'API Info', navGroups: { - basic: 'Basic', - ai: 'AI Capabilities', - data: 'Data & Engines', - workspace: 'Workspace & Access', - system: 'System Info', + workspaceAccount: 'Workspace', + modelsRuntime: 'Models', + integrations: 'Extensions', + knowledgeInfra: 'Engines', + platform: 'Platform', }, roleDenied: { title: 'Insufficient permissions', @@ -1570,7 +1594,11 @@ export default { title: 'No knowledge bases yet', description: 'Click "Create Knowledge Base" in the top-right corner to add your first one.', sharedTitle: 'No shared knowledge bases', - sharedDescription: 'You can join a shared space or request others to share knowledge bases with you' + sharedDescription: 'You can join a shared space or request others to share knowledge bases with you', + favoritesTitle: 'No favorites yet', + favoritesDescription: 'Star a knowledge base from its card to add it here', + recentsTitle: 'Nothing here yet', + recentsDescription: 'Knowledge bases you recently opened will show up here', }, delete: { confirmTitle: 'Delete Confirmation', @@ -3283,7 +3311,7 @@ export default { }, organization: { title: 'Shared Spaces', - subtitle: 'Create or join shared spaces to share knowledge bases and agents with your team', + subtitle: 'Create or join shared spaces so multiple workspaces can collaborate and share knowledge bases and agents', createOrg: 'Create Space', createOrgShort: 'New', joinOrg: 'Join Space', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 3bec2e1a..77d0e3b4 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -43,9 +43,29 @@ export default { listSpaceSidebar: { title: "필터", all: "모두", + workspace: "워크스페이스", mine: "소유", - sharedToMe: "공유", - spaces: "스페이스", + spaces: "나와 공유됨", + favorites: "즐겨찾기", + recents: "최근", + }, + listFilter: { + creator: "생성자", + allCreators: "전체", + createdByMe: "내가 생성", + createdByOthers: "다른 사람 생성", + }, + resourceOrigin: { + mine: "내 생성", + mineTooltip: "현재 사용자가 생성", + tenant: "워크스페이스", + tenantTooltip: "워크스페이스의 다른 멤버가 생성", + tenantTooltipWithCreator: "{creator}님이 생성", + space: "스페이스", + spaceTooltip: "공유 스페이스 「{space}」에서 가져옴", + spaceTooltipWithTenant: "공유 스페이스 「{space}」 · 출처 {tenant}", + shared: "외부 공유", + sharedTooltip: "공유 스페이스를 통해 외부 테넌트에서 접근", }, knowledgeBase: { title: "지식베이스", @@ -512,11 +532,11 @@ export default { tenantInfo: "테넌트 정보", apiInfo: "API 정보", navGroups: { - basic: "기본", - ai: "AI 기능", - data: "데이터 및 엔진", - workspace: "공간 및 권한", - system: "시스템 정보", + workspaceAccount: "공간 및 계정", + modelsRuntime: "모델", + integrations: "확장", + knowledgeInfra: "엔진", + platform: "플랫폼", }, roleDenied: { title: "권한 없음", @@ -1303,6 +1323,10 @@ export default { sharedTitle: "아직 공유 에이전트가 없습니다.", sharedDescription: "스페이스에 참여하거나 다른 사람에게 에이전트를 공유하도록 요청할 수 있습니다.", + favoritesTitle: "즐겨찾기 없음", + favoritesDescription: "카드의 별표 아이콘을 눌러 즐겨찾기에 추가하세요", + recentsTitle: "최근 항목 없음", + recentsDescription: "최근에 사용한 에이전트가 여기에 표시됩니다", }, detail: { title: "에이전트 세부정보", @@ -2224,6 +2248,10 @@ export default { sharedTitle: "아직 공유 지식베이스가 없습니다.", sharedDescription: "공유 스페이스에 참여하거나 다른 사람에게 지식베이스를 공유해 달라고 요청할 수 있습니다.", + favoritesTitle: "즐겨찾기 없음", + favoritesDescription: "카드의 별표 아이콘을 눌러 즐겨찾기에 추가하세요", + recentsTitle: "최근 항목 없음", + recentsDescription: "최근에 연 지식베이스가 여기에 표시됩니다", }, delete: { confirmTitle: "삭제 확인", @@ -3326,7 +3354,7 @@ export default { organization: { title: "공유 스페이스", subtitle: - "지식베이스와 에이전트를 팀 구성원과 공유하기 위해 공유 스페이스를 만들거나 참여하세요.", + "공유 스페이스를 만들거나 참여하여 여러 스페이스가 함께 협업하고 지식베이스와 에이전트를 공유하세요.", createOrg: "스페이스 생성", createOrgShort: "새로 만들기", joinOrg: "스페이스에 참여하기", diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 63d4540e..dc287726 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -521,7 +521,11 @@ export default { title: 'No Custom Agents', description: 'Click the button in the top right to create your first agent', sharedTitle: 'No shared agents yet', - sharedDescription: 'You can join a space or ask others to share agents with you' + sharedDescription: 'You can join a space or ask others to share agents with you', + favoritesTitle: 'No favorites yet', + favoritesDescription: 'Star an agent from its card to add it here', + recentsTitle: 'Nothing here yet', + recentsDescription: 'Agents you recently used will show up here' }, detail: { title: 'Agent Details', @@ -621,11 +625,11 @@ export default { tenantInfo: 'Информация о арендаторе', apiInfo: 'Информация API', navGroups: { - basic: 'Основное', - ai: 'AI-возможности', - data: 'Данные и движки', - workspace: 'Пространство и доступ', - system: 'Сведения о системе', + workspaceAccount: 'Пространство и аккаунт', + modelsRuntime: 'Модели', + integrations: 'Расширения', + knowledgeInfra: 'Движки', + platform: 'Платформа', }, roleDenied: { title: 'Недостаточно прав', @@ -1821,7 +1825,11 @@ export default { title: 'Базы знаний отсутствуют', description: 'Нажмите «Создать базу знаний» в левом быстром действии, чтобы добавить первую базу.', sharedTitle: 'No shared knowledge bases', - sharedDescription: 'You can join a shared space or request others to share knowledge bases with you' + sharedDescription: 'You can join a shared space or request others to share knowledge bases with you', + favoritesTitle: 'No favorites yet', + favoritesDescription: 'Star a knowledge base from its card to add it here', + recentsTitle: 'Nothing here yet', + recentsDescription: 'Knowledge bases you recently opened will show up here' }, delete: { confirmTitle: 'Подтверждение удаления', @@ -3794,9 +3802,29 @@ export default { listSpaceSidebar: { title: 'Фильтр', all: 'Все', + workspace: 'Рабочая область', mine: 'Мои', - sharedToMe: 'Совместные', - spaces: 'Пространства' + spaces: 'Доступно мне', + favorites: 'Избранное', + recents: 'Недавние' + }, + listFilter: { + creator: 'Автор', + allCreators: 'Все', + createdByMe: 'Созданные мной', + createdByOthers: 'Созданные другими' + }, + resourceOrigin: { + mine: 'Мои', + mineTooltip: 'Создано вами', + tenant: 'Рабочая область', + tenantTooltip: 'Создано другим участником рабочей области', + tenantTooltipWithCreator: 'Создано {creator}', + space: 'Пространство', + spaceTooltip: 'Через пространство "{space}"', + spaceTooltipWithTenant: 'Через пространство "{space}" · от {tenant}', + shared: 'Внешнее', + sharedTooltip: 'Доступ из внешнего арендатора через общее пространство' }, promptTemplate: { noTemplates: 'No templates available', @@ -3809,7 +3837,7 @@ export default { }, organization: { title: 'Shared Spaces', - subtitle: 'Create or join shared spaces to share knowledge bases and agents with your team', + subtitle: 'Create or join shared spaces so multiple workspaces can collaborate and share knowledge bases and agents', createOrg: 'Create Space', createOrgShort: 'New', joinOrg: 'Join Space', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index ba8f1114..359cb780 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -43,9 +43,29 @@ export default { listSpaceSidebar: { title: "筛选", all: "全部", + workspace: "本空间", mine: "我的", - sharedToMe: "协作", - spaces: "空间", + spaces: "共享给我", + favorites: "收藏", + recents: "最近", + }, + listFilter: { + creator: "创建者", + allCreators: "全部", + createdByMe: "我创建的", + createdByOthers: "他人创建的", + }, + resourceOrigin: { + mine: "我创建", + mineTooltip: "由当前用户创建", + tenant: "本空间", + tenantTooltip: "本空间其他成员创建", + tenantTooltipWithCreator: "由 {creator} 创建", + space: "共享空间", + spaceTooltip: "来自共享空间「{space}」", + spaceTooltipWithTenant: "来自共享空间「{space}」· 源空间 {tenant}", + shared: "外部共享", + sharedTooltip: "通过共享空间从其他空间访问", }, knowledgeBase: { title: "知识库", @@ -506,18 +526,18 @@ export default { storageEngine: "存储引擎", mcpService: "MCP服务", systemSettings: "系统设置", - tenantInfo: "租户信息", + tenantInfo: "空间信息", apiInfo: "API信息", navGroups: { - basic: "基础", - ai: "AI 能力", - data: "数据与引擎", - workspace: "空间与权限", - system: "系统信息", + workspaceAccount: "空间与账户", + modelsRuntime: "模型", + integrations: "扩展", + knowledgeInfra: "引擎", + platform: "平台", }, roleDenied: { title: "权限不足", - desc: "你当前的角色无权访问此设置项。请联系本租户的管理员获取所需角色。", + desc: "你当前的角色无权访问此设置项。请联系本空间的管理员获取所需角色。", }, weknoraCloud: { title: "WeKnora Cloud", description: "配置 WeKnora Cloud 的 APPID 和 APPSECRET 凭证。凭证用于模型服务和文档解析引擎。", @@ -1128,10 +1148,10 @@ export default { noAccount: "还没有账户?", backToLogin: "返回登录", registerNow: "立即注册", - registerSuccess: "注册成功!系统已为您创建专属租户,请登录", + registerSuccess: "注册成功!系统已为您创建专属空间,请登录", registerFailed: "注册失败", subtitle: "基于大模型的文档理解和语义搜索框架", - registerSubtitle: "注册后系统将为您创建专属租户", + registerSubtitle: "注册后系统将为您创建专属空间", emailPlaceholder: "输入邮箱地址", passwordPlaceholder: "输入密码(8-32个字符,包含字母和数字)", confirmPasswordPlaceholder: "再次输入密码", @@ -1157,7 +1177,7 @@ export default { authStore: { errors: { parseUserFailed: "解析用户信息失败", - parseTenantFailed: "解析租户信息失败", + parseTenantFailed: "解析空间信息失败", parseKnowledgeBasesFailed: "解析知识库列表失败", parseCurrentKnowledgeBaseFailed: "解析当前知识库失败", }, @@ -1296,6 +1316,10 @@ export default { description: "点击右上角按钮创建您的第一个智能体", sharedTitle: "暂无共享智能体", sharedDescription: "您可以加入空间或请求他人将智能体共享给您", + favoritesTitle: "暂无收藏", + favoritesDescription: "在智能体卡片右上角点击星标即可收藏", + recentsTitle: "暂无最近访问", + recentsDescription: "最近使用过的智能体会出现在这里", }, detail: { title: "智能体详情", @@ -1514,23 +1538,23 @@ export default { selectFile: "选择文件", }, tenant: { - title: "租户信息", - currentTenant: "当前租户", - switchTenant: "切换工作区", + title: "空间信息", + currentTenant: "当前空间", + switchTenant: "切换空间", switcher: { - menuLabel: "切换工作区", + menuLabel: "切换空间", currentBadge: "当前", homeBadge: "我的", - homeTooltip: "我的工作区", - empty: "你目前只属于这一个工作区", + homeTooltip: "我的空间", + empty: "你目前只属于这一个空间", }, currentChip: { - home: "我的租户", + home: "我的空间", }, - sectionDescription: "查看租户的详细配置信息", + sectionDescription: "查看空间的详细配置信息", apiDocument: "API文档", - name: "租户名称", - id: "租户ID", + name: "空间名称", + id: "空间 ID", createdAt: "创建时间", updatedAt: "更新时间", status: "状态", @@ -1545,9 +1569,9 @@ export default { userId: "用户ID", username: "用户名", email: "邮箱", - tenantInfo: "租户信息", - tenantId: "租户ID", - tenantName: "租户名称", + tenantInfo: "空间信息", + tenantId: "空间 ID", + tenantName: "空间名称", description: "描述", business: "业务", noDescription: "无描述", @@ -1572,58 +1596,58 @@ export default { apiKeyCopied: "API密钥已复制到剪贴板", unknown: "未知", formatError: "格式错误", - searchPlaceholder: "搜索租户名称或输入租户ID...", - searchHint: "支持按名称搜索或直接输入租户ID", - noMatch: "未找到匹配的租户", - switchSuccess: "租户切换成功", - loadTenantsFailed: "加载租户列表失败", + searchPlaceholder: "搜索空间名称或输入空间 ID...", + searchHint: "支持按名称搜索或直接输入空间 ID", + noMatch: "未找到匹配的空间", + switchSuccess: "空间切换成功", + loadTenantsFailed: "加载空间列表失败", loading: "加载中...", loadMore: "加载更多", create: { - action: "创建新工作区", - dialogTitle: "创建新工作区", - dialogSubtitle: "工作区拥有独立的知识库与成员,你将自动成为新工作区的所有者。", - nameLabel: "工作区名称", + action: "创建新空间", + dialogTitle: "创建新空间", + dialogSubtitle: "空间拥有独立的知识库与成员,你将自动成为新空间的所有者。", + nameLabel: "空间名称", namePlaceholder: "例如:我的新项目", - nameRequired: "请输入工作区名称", + nameRequired: "请输入空间名称", descriptionLabel: "描述(可选)", - descriptionPlaceholder: "简单描述一下这个工作区的用途", + descriptionPlaceholder: "简单描述一下这个空间的用途", submit: "创建", cancel: "取消", - success: "工作区创建成功", - failed: "工作区创建失败", + success: "空间创建成功", + failed: "空间创建失败", }, details: { - idLabel: "租户 ID", - idDescription: "您所属租户的唯一标识", - nameLabel: "租户名称", - nameDescription: "您所属的租户名称", - descriptionLabel: "租户描述", - descriptionDescription: "租户的详细描述信息", - businessLabel: "租户业务", - businessDescription: "租户所属的业务类型", - statusLabel: "租户状态", - statusDescription: "租户当前的运行状态", - createdAtLabel: "租户创建时间", - createdAtDescription: "租户创建的时间", + idLabel: "空间 ID", + idDescription: "您所属空间的唯一标识", + nameLabel: "空间名称", + nameDescription: "您所属的空间名称", + descriptionLabel: "空间描述", + descriptionDescription: "空间的详细描述信息", + businessLabel: "空间业务", + businessDescription: "空间所属的业务类型", + statusLabel: "空间状态", + statusDescription: "空间当前的运行状态", + createdAtLabel: "空间创建时间", + createdAtDescription: "空间创建的时间", editName: "修改名称", - editNamePlaceholder: "请输入新的租户名称", + editNamePlaceholder: "请输入新的空间名称", editNameConfirm: "保存", editNameCancel: "取消", - editNameRequired: "租户名称不能为空", - editNameSuccess: "租户名称已更新", - editNameFailed: "更新租户名称失败", + editNameRequired: "空间名称不能为空", + editNameSuccess: "空间名称已更新", + editNameFailed: "更新空间名称失败", }, storage: { quotaLabel: "存储配额", - quotaDescription: "租户的总存储空间配额", + quotaDescription: "空间的总存储空间配额", usedLabel: "已使用存储", usedDescription: "已经使用的存储空间", usageLabel: "存储使用率", usageDescription: "存储空间的使用百分比", }, messages: { - fetchFailed: "获取租户信息失败", + fetchFailed: "获取空间信息失败", networkError: "网络错误,请稍后重试", }, api: { @@ -1750,7 +1774,7 @@ export default { loginFailed: "登录失败", registerFailed: "注册失败", getUserFailed: "获取用户信息失败", - getTenantFailed: "获取租户信息失败", + getTenantFailed: "获取空间信息失败", refreshTokenFailed: "刷新Token失败", logoutFailed: "登出失败", validateTokenFailed: "Token验证失败", @@ -1762,10 +1786,10 @@ export default { deleteFailed: "删除模型失败", }, tenant: { - listFailed: "获取租户列表失败", - searchFailed: "搜索租户失败", + listFailed: "获取空间列表失败", + searchFailed: "搜索空间失败", resetApiKeyFailed: "重置 API Key 失败", - updateFailed: "更新租户信息失败", + updateFailed: "更新空间信息失败", }, initialization: { checkFailed: "检查失败", @@ -2200,6 +2224,10 @@ export default { description: '点击左侧快捷操作"新建知识库"按钮创建第一个知识库', sharedTitle: "暂无共享知识库", sharedDescription: "您可以加入共享空间或请求他人共享知识库给您", + favoritesTitle: "暂无收藏", + favoritesDescription: "在知识库卡片右上角点击星标即可收藏", + recentsTitle: "暂无最近访问", + recentsDescription: "最近打开过的知识库会出现在这里", }, delete: { confirmTitle: "删除确认", @@ -3278,7 +3306,7 @@ export default { }, organization: { title: "共享空间", - subtitle: "创建或加入共享空间,与团队成员共享知识库与智能体", + subtitle: "创建或加入共享空间,让多个空间互相协作,共享知识库与智能体", createOrg: "创建空间", createOrgShort: "新建", joinOrg: "加入空间", @@ -3303,11 +3331,11 @@ export default { inviteCodeRefreshed: "邀请码已刷新", inviteCodeRefreshFailed: "刷新邀请码失败", rbac: { - needTenantAdmin: "需要租户管理员权限", - needTenantAdminTip: "此操作需要当前租户的 admin 或更高角色,请联系租户 Owner 调整权限。", - cannotCreate: "当前租户角色不足,无法创建共享空间", - cannotJoin: "当前租户角色不足,无法加入或申请加入共享空间", - cannotManage: "当前租户角色不足,无法管理共享空间", + needTenantAdmin: "需要空间管理员权限", + needTenantAdminTip: "此操作需要当前空间的 admin 或更高角色,请联系空间 Owner 调整权限。", + cannotCreate: "当前空间角色不足,无法创建共享空间", + cannotJoin: "当前空间角色不足,无法加入或申请加入共享空间", + cannotManage: "当前空间角色不足,无法管理共享空间", }, join: { title: "加入空间", @@ -3415,7 +3443,7 @@ export default { editTitle: "空间设置", detailTitle: "空间详情", myRoleDesc: "您在此空间中的角色决定了您的权限范围", - membersDesc: "查看和管理空间成员、调整成员角色。每个成员对应一个租户,同一租户下的所有用户共享该空间的访问权限。", + membersDesc: "查看和管理共享空间成员、调整成员角色。成员的最小单位是空间——加入后该空间下的所有用户都将获得访问权限。", sharedDesc: "查看共享到此空间的所有知识库", noSharedKB: "暂无共享的知识库", noSharedKBTip: "知识库拥有者可以在知识库设置中将其共享到此空间", @@ -3518,13 +3546,13 @@ export default { button: "添加成员", dialogTitle: "添加成员", tip: "添加的用户将立即成为空间成员,可以访问空间内共享的知识库。", - tipTenant: "空间成员的最小单位是租户:选定一个租户后,其下全部用户都将共享该空间的访问权限。下方搜索结果按租户去重。", + tipTenant: "共享空间的成员单位是空间:选定一个空间后,其下全部用户都将获得访问权限。下方搜索结果按空间去重。", searchUser: "选择用户", - searchTenant: "选择租户", + searchTenant: "选择空间", searchPlaceholder: "输入用户名或邮箱搜索...", - searchTenantPlaceholder: "输入租户名、用户名或邮箱搜索...", - searchHint: "输入至少2个字符开始搜索", - searchTenantHint: "输入至少2个字符开始搜索,按租户去重并自动过滤已加入的租户", + searchTenantPlaceholder: "输入空间名、用户名或邮箱搜索...", + searchHint: "输入至少 2 个字符开始搜索", + searchTenantHint: "输入至少 2 个字符开始搜索,按空间去重并自动过滤已加入的空间", selectRole: "分配角色", confirmBtn: "添加", success: "成员添加成功", @@ -4312,9 +4340,9 @@ export default { imOverview: { menuTitle: "已接入的 IM", pageTitle: "已接入的 IM", - subtitle: "当前租户下所有智能体已接入的 IM 渠道,可快速启停或跳转到对应智能体", + subtitle: "当前空间下所有智能体已接入的 IM 渠道,可快速启停或跳转到对应智能体", totalCount: "共 {count} 个", - empty: "当前租户还没有配置 IM 机器人", + empty: "当前空间还没有配置 IM 机器人", loadFailed: "加载 IM 机器人列表失败", builtinAgent: "内置智能体", liveIndicator: "有已启用的 IM 渠道正在运行", @@ -4355,7 +4383,7 @@ export default { }, tenantMember: { title: "成员管理", - sectionDescription: "邀请伙伴加入当前租户并分配角色。只有 Owner 可以新增或移除成员。", + sectionDescription: "邀请伙伴加入当前空间并分配角色。只有 Owner 可以新增或移除成员。", totalCount: "共 {n} 位成员", loading: "正在加载成员…", retry: "重试", @@ -4364,13 +4392,13 @@ export default { searchPlaceholder: "按姓名或邮箱搜索", permissions: { title: "角色权限说明", - desc: "每个角色在当前租户内能做的事情。服务端强制执行,UI 控件仅作展示。", + desc: "每个角色在当前空间内能做的事情。服务端强制执行,UI 控件仅作展示。", iconHint: "悬停查看角色权限说明", manageMembers: "管理成员", - manageTenantConfig: "修改租户配置", + manageTenantConfig: "修改空间配置", manageInfra: "配置模型 / 向量库 / IM 通道", createOwnKB: "创建并编辑自己的知识库和智能体", - readAll: "查看租户内容", + readAll: "查看空间内容", }, columns: { member: "姓名与邮箱", @@ -4398,16 +4426,16 @@ export default { remove: { button: "移除", confirmTitle: "确认移除?", - confirmBody: "确定要将 {name} 从当前租户中移除吗?该用户会立即失去访问权限。", + confirmBody: "确定要将 {name} 从当前空间中移除吗?该用户会立即失去访问权限。", confirm: "移除", success: "已移除成员", }, leave: { - button: "退出租户", - confirmTitle: "确认退出当前租户?", - confirmBody: "退出后您将无法访问当前租户内的所有知识库和智能体。后续可被再次邀请加入。", + button: "退出空间", + confirmTitle: "确认退出当前空间?", + confirmBody: "退出后您将无法访问当前空间内的所有知识库和智能体。后续可被再次邀请加入。", confirm: "退出", - success: "已退出当前租户", + success: "已退出当前空间", }, roleChange: { success: "角色已更新", @@ -4417,10 +4445,10 @@ export default { emailFormat: "邮箱格式不正确", roleRequired: "请选择角色", userNotFound: "该邮箱尚未注册,请先邀请对方注册账号。", - alreadyMember: "该用户已经是当前租户的成员。", + alreadyMember: "该用户已经是当前空间的成员。", lastOwner: "无法降级、移除或退出最后一位 Owner,请先把其他成员提升为 Owner。", notFound: "未找到对应的成员。", - noTenant: "当前没有可操作的租户。", + noTenant: "当前没有可操作的空间。", invalidRole: "角色必须是 owner / admin / contributor / viewer 之一。", generic: "操作失败,请稍后重试。", }, @@ -4431,7 +4459,7 @@ export default { audit: { tabLabel: "审计日志", description: - "记录当前租户的成员变更与访问拒绝事件,按时间倒序展示。一分钟内的重复拒绝会自动去重。", + "记录当前空间的成员变更与访问拒绝事件,按时间倒序展示。一分钟内的重复拒绝会自动去重。", refresh: "刷新", loadMore: "加载更多", end: "已经到底了。", diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index bed7c37d..48490000 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -47,6 +47,30 @@ export const useAuthStore = defineStore('auth', () => { return tenant.value?.id || '' }) + // currentTenantName resolves the active tenant's display name across the + // app (sidebar, breadcrumbs, headers). It MUST follow the same active- + // tenant rule as currentTenantRole / effectiveTenantId: the user's + // selected override takes precedence over their home tenant. Reading + // tenant.value.name alone would render the home tenant's name even after + // the user switched, which is the bug TenantSelector already worked + // around inline; this computed centralises that fallback ladder. + const currentTenantName = computed(() => { + const tid = + selectedTenantId.value !== null && selectedTenantId.value !== undefined + ? String(selectedTenantId.value) + : tenant.value?.id + ? String(tenant.value.id) + : '' + if (!tid) return '' + if (selectedTenantId.value && selectedTenantName.value) { + return selectedTenantName.value + } + const fromMembership = memberships.value.find((m) => String(m.tenant_id) === tid) + if (fromMembership?.tenant_name) return fromMembership.tenant_name + if (tenant.value && String(tenant.value.id) === tid) return tenant.value.name || '' + return '' + }) + const currentUserId = computed(() => { return user.value?.id || '' }) @@ -322,6 +346,7 @@ export const useAuthStore = defineStore('auth', () => { isLoggedIn, hasValidTenant, currentTenantId, + currentTenantName, currentUserId, canAccessAllTenants, currentTenantRole, diff --git a/frontend/src/views/agent/AgentList.vue b/frontend/src/views/agent/AgentList.vue index e40f8a3b..3d1ed05d 100644 --- a/frontend/src/views/agent/AgentList.vue +++ b/frontend/src/views/agent/AgentList.vue @@ -1,7 +1,8 @@ + :count-mine="agents.length" :count-by-org="effectiveSharedCountByOrg" :count-favorites="agentFavoritesCount" + :count-recents="agentRecentsCount" hide-all /> @@ -36,6 +37,9 @@ + + @@ -56,8 +60,10 @@ - - + + + + + + + + + + - + {{ $t('agent.empty.title') }} @@ -475,6 +491,18 @@ {{ $t('agent.createAgent') }} + + + + + {{ $t('agent.empty.favoritesTitle') }} + {{ $t('agent.empty.favoritesDescription') }} + + + + {{ $t('agent.empty.recentsTitle') }} + {{ $t('agent.empty.recentsDescription') }} + @@ -622,6 +650,8 @@ import AgentEditorModal from './AgentEditorModal.vue' import AgentAvatar from '@/components/AgentAvatar.vue' import ListSpaceSidebar from '@/components/ListSpaceSidebar.vue' import { useAuthStore } from '@/stores/auth' +import { useListUrlState } from '@/composables/useListUrlState' +import { useResourcePins } from '@/composables/useResourcePins' const { t } = useI18n() const route = useRoute() @@ -641,16 +671,35 @@ type DisplayAgent = (AgentWithUI & { isMine: true }) | (CustomAgent & { isMine: // 左侧空间选择:默认根据当前角色决定。 // 与 KnowledgeBaseList 同款逻辑:Viewer 在当前租户里通常没有自建智能体, // 默认落到 "all" 才能看到内置 + 共享给我的;Contributor 以上仍默认 "mine"。 -const initialSpaceSelection = (): 'all' | 'mine' => - authStore.hasRole('contributor') ? 'mine' : 'all' -const spaceSelection = ref<'all' | 'mine' | string>(initialSpaceSelection()) +// State synced to `?scope=` so links are shareable. The "mine" value is +// retained for back-compat with existing links; its display label is +// rebranded to the active tenant name inside ListSpaceSidebar. +const defaultScope: 'all' | 'mine' = authStore.hasRole('contributor') ? 'mine' : 'all' +const { scope: spaceSelection, creator: creatorFilter } = useListUrlState({ + defaultScope, + defaultCreator: 'all', +}) + +// Per-user favorites + recents (localStorage-backed). See useResourcePins. +const pins = useResourcePins() +const agentFavoritesCount = computed( + () => pins.favorites.value.filter((e) => e.type === 'agent').length +) +const agentRecentsCount = computed( + () => pins.recents.value.filter((e) => e.type === 'agent').length +) const agents = ref([]) const sharedAgents = computed(() => orgStore.sharedAgents || []) const allAgentsCount = computed(() => agents.value.length + sharedAgents.value.length) +// Same gotcha as KnowledgeBaseList: keep the reserved-scope set in sync +// with ListSpaceSidebar's pseudo-scopes (favorites / recents / shared / +// mine / all). Anything not in here is treated as an org/space id, which +// is what triggers the per-space fetch + "no shared agents" empty state. +const RESERVED_SCOPES = new Set(['all', 'mine', 'shared', 'favorites', 'recents']) const spaceSelectionOrgId = computed(() => { const s = spaceSelection.value - return s !== 'all' && s !== 'mine' && !!s + return !!s && !RESERVED_SCOPES.has(s) }) const sharedAgentsByOrg = computed(() => { @@ -686,7 +735,61 @@ const effectiveSharedCountByOrg = computed>(() => { return merged }) +// Favorites / Recents view: hydrate pinned ids against own + shared agents. +const agentResourceIndex = computed(() => { + const map = new Map() + for (const a of agents.value) map.set(a.id, { agent: a, isMine: true }) + for (const s of sharedAgents.value) { + if (s.agent && !map.has(s.agent.id)) map.set(s.agent.id, { agent: s.agent, isMine: false, shared: s }) + } + return map +}) + +const favoritesAgentList = computed(() => { + return pins.favorites.value + .filter((e) => e.type === 'agent') + .map((e) => { + const entry = agentResourceIndex.value.get(e.id) + if (!entry) return null + if (entry.isMine) return { ...entry.agent, isMine: true as const, showMore: false } + const s = entry.shared! + return { + ...entry.agent, + isMine: false as const, + org_name: s.org_name, + source_tenant_id: s.source_tenant_id, + share_id: s.share_id, + disabled_by_me: s.disabled_by_me, + showMore: false, + } as DisplayAgent + }) + .filter((x): x is DisplayAgent => x !== null) +}) + +const recentsAgentList = computed(() => { + return pins.recents.value + .filter((e) => e.type === 'agent') + .map((e) => { + const entry = agentResourceIndex.value.get(e.id) + if (!entry) return null + if (entry.isMine) return { ...entry.agent, isMine: true as const, showMore: false } + const s = entry.shared! + return { + ...entry.agent, + isMine: false as const, + org_name: s.org_name, + source_tenant_id: s.source_tenant_id, + share_id: s.share_id, + disabled_by_me: s.disabled_by_me, + showMore: false, + } as DisplayAgent + }) + .filter((x): x is DisplayAgent => x !== null) +}) + const filteredAgents = computed(() => { + if (spaceSelection.value === 'favorites') return favoritesAgentList.value + if (spaceSelection.value === 'recents') return recentsAgentList.value if (spaceSelection.value === 'mine') { return agents.value.map(a => ({ ...a, isMine: true as const })) } @@ -741,7 +844,7 @@ const openMoreAgentId = ref(null) const fetchList = () => { loading.value = true return Promise.all([ - listAgents().then((res: any) => { + listAgents({ creator: creatorFilter.value }).then((res: any) => { const data = res.data || [] const disabledOwnIds = res.disabled_own_agent_ids || [] agents.value = data.map((agent: CustomAgent) => ({ @@ -772,8 +875,10 @@ const checkAndOpenEditModal = () => { editorInitialSection.value = section || 'basic' editorVisible.value = true } - // 清除 URL 中的参数 - router.replace({ path: route.path, query: {} }) + // Drop the transient edit/section params but preserve other filter + // state (scope / creator / q) so refreshing doesn't reset the view. + const { edit: _e, section: _s, ...rest } = route.query + router.replace({ path: route.path, query: rest }) } } @@ -816,6 +921,13 @@ watch(spaceSelection, (val) => { }) }, { immediate: true }) +// Refetch when the creator filter flips so the server applies the +// predicate uniformly (also keeps built-in agents always present, see +// the matching block in custom_agent.go). +watch(creatorFilter, () => { + fetchList() +}) + onMounted(() => { fetchList() window.addEventListener('openAgentEditor', handleOpenAgentEditor as EventListener) @@ -838,6 +950,9 @@ const toggleMore = (e: Event, agentId: string) => { const handleCardClick = (agent: DisplayAgent | AgentWithUI) => { if (openMoreAgentId.value === agent.id) return + // Track recency before any branch — Recents should reflect what the + // user *looked at*, not only what they edited. + pins.touchRecent('agent', agent.id) if ('isMine' in agent && !agent.isMine) { const shared = sharedAgents.value.find(s => s.agent?.id === agent.id && s.source_tenant_id === agent.source_tenant_id) if (shared) openSharedAgentDetail(shared) @@ -846,6 +961,12 @@ const handleCardClick = (agent: DisplayAgent | AgentWithUI) => { handleEdit(agent as AgentWithUI) } +const toggleFavoriteAgent = (agentId: string, evt?: Event) => { + evt?.stopPropagation() + pins.toggleFavorite('agent', agentId) +} +const isAgentFavorited = (agentId: string) => pins.isFavorite('agent', agentId) + function openSharedAgentDetail(shared: SharedAgentInfo) { currentSharedAgent.value = shared sharedDetailVisible.value = true @@ -1354,6 +1475,39 @@ defineExpose({ box-shadow: 0 4px 12px rgba(7, 192, 95, 0.12); } + .agent-favorite-star { + position: absolute; + top: 6px; + right: 6px; + z-index: 3; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 6px; + color: var(--td-text-color-secondary); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease; + + &:hover { + background: var(--td-bg-color-secondarycontainer); + color: var(--td-warning-color, #e37318); + } + + &.is-favorited { + opacity: 1; + color: var(--td-warning-color, #e37318); + } + } + + &:hover .agent-favorite-star { + opacity: 1; + } + // 普通模式样式 &.agent-mode-normal { background: linear-gradient(135deg, var(--td-bg-color-container) 0%, rgba(7, 192, 95, 0.04) 100%); diff --git a/frontend/src/views/knowledge/KnowledgeBaseList.vue b/frontend/src/views/knowledge/KnowledgeBaseList.vue index 641e2f92..0a9940c9 100644 --- a/frontend/src/views/knowledge/KnowledgeBaseList.vue +++ b/frontend/src/views/knowledge/KnowledgeBaseList.vue @@ -1,7 +1,8 @@ + :count-mine="kbs.length" :count-by-org="effectiveSharedCountByOrg" :count-favorites="kbFavoritesCount" + :count-recents="kbRecentsCount" /> @@ -18,6 +19,14 @@ + + + @@ -79,8 +88,11 @@ - - + + + + + + {{ kb.name }} @@ -199,6 +216,11 @@ 'kb-type-document': (kb.type || 'document') === 'document', 'kb-type-faq': kb.type === 'faq' }" @click="handleSharedKbClickFromAll(kb)"> + + + + {{ kb.name }} @@ -229,7 +251,7 @@ {{ kb.type === 'faq' ? (kb.chunk_count || '-') : (kb.knowledge_count || '-') - }} + }} + + + + {{ kb.name }} @@ -380,60 +407,7 @@ - - - - - {{ shared.knowledge_base.name }} - - - - - - - - - {{ shared.knowledge_base.description || $t('knowledgeBase.noDescription') }} - - - - - - - - - {{ shared.knowledge_base.type === 'faq' ? - (shared.knowledge_base.chunk_count || - '-') : (shared.knowledge_base.knowledge_count || '-') }} - - - - - - - - - {{ shared.org_name }} - - - - - - - - - - - {{ $t('knowledgeList.emptyShared') }} - + @@ -497,7 +471,7 @@ - + {{ $t('knowledgeList.empty.title') }} @@ -509,6 +483,22 @@ + + + + {{ $t('knowledgeList.empty.favoritesTitle') }} + {{ $t('knowledgeList.empty.favoritesDescription') }} + + + + + + {{ $t('knowledgeList.empty.recentsTitle') }} + {{ $t('knowledgeList.empty.recentsDescription') }} + + @@ -544,7 +534,7 @@ {{ $t('common.cancel') }} {{ $t('knowledgeList.delete.confirmButton') - }} + }} @@ -603,7 +593,7 @@ {{ $t('knowledgeList.detail.sharedAt') }} {{ formatStringDate(new Date(currentSharedKbForDetail.shared_at)) - }} + }} {{ $t('knowledgeList.detail.myPermission') }} @@ -615,7 +605,7 @@
{{ $t('knowledgeList.emptyShared') }}