Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ Dialog 和 Drawer 的 `show-close` 不根据 loading 或提交状态动态判断

布尔 `v-model` 控制显示,默认宽度 `700`、挂载到 body、显示关闭按钮、关闭后销毁内容,
禁止点击遮罩和 Escape 关闭;原生属性、事件通过 `$attrs` 覆盖或透传。
小于 `md` 断点(48rem,默认 768px)时,抽屉宽高均为 `100%`,优先于调用方传入的 `size`;
横向和纵向抽屉均全屏展示,桌面端仍使用默认或传入的尺寸。
提供默认、`header`、`footer` 插槽,内容使用 `el-scrollbar` 和 `p-6`,可用 `contentClass` 调整。
全高内容可传 `content-class="h-full p-0"` 并复用 `MkViewLayout`。
Drawer 没有 `MkDialog` 的外壳挂载控制;需要按需挂载时,由业务在 `closed` 后卸载。
Expand Down Expand Up @@ -636,6 +638,7 @@ JSON 专用输入框,通过 `v-model` 接收并回传解析后的 JSON 值,
手动导入 `mk-search-list/index.vue`,放在有明确高度的纵向 Flex 容器中。
`v-model` 为搜索词;`data` 传全量数组,默认按 `name` 忽略大小写与首尾空格过滤,以 `id` 识别行。
`props.label` / `props.value` 可映射字段,唯一值不能重复。`defaultActive` 设置选中值,后续变更也会同步。
设置 `remote` 后不做前端过滤,输入(含清空)停止 300ms 后触发 `search(keyword)`(去除首尾空格),由调用方请求并更新 `data`、管理 loading。
每批渲染 50 条,搜索或数据变化重置滚动位置;这是前端分批渲染,不替代接口分页。

默认插槽提供 `{ row, index, active }`。`action`、`action-dropdown` 的
Expand Down
1 change: 1 addition & 0 deletions ui/src/components/global/mk-drawer/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ defineSlots<{ default(): unknown; footer(): unknown; header(): unknown }>()
<template>
<el-drawer
v-model="visible"
class="max-md:w-full! max-md:h-full!"
:append-to-body="true"
size="700"
:close-on-click-modal="false"
Expand Down
16 changes: 14 additions & 2 deletions ui/src/components/mk-search-list/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ defineOptions({ name: 'MkSearchList' })
*/
const componentProps = withDefaults(
defineProps<{
/** 列表全量数据,搜索在前端完成 */
/** 列表数据,默认在前端搜索;remote 模式直接展示接口结果 */
data?: T[]
/** 输入防抖后通知调用方异步搜索,不进行前端过滤 */
remote?: boolean
/** 初始选中项的唯一值 */
defaultActive?: string | number
/** 列表为空或无搜索结果时的提示 */
Expand All @@ -33,7 +35,7 @@ const componentProps = withDefaults(
{ defaultActive: '', emptyText: '暂无数据', props: () => ({}) },
)

const emit = defineEmits<{ click: [row: T, index: number] }>()
const emit = defineEmits<{ click: [row: T, index: number]; search: [keyword: string] }>()

defineSlots<{
action(props: { row: T; index: number }): unknown
Expand All @@ -51,6 +53,7 @@ const valueField = computed(() => componentProps.props.value ?? ('id' as keyof T
const searchKeyword = defineModel<string>({ default: '' })
const filteredData = computed(() => {
if (!componentProps.data) return []
if (componentProps.remote) return componentProps.data

const normalizedKeyword = searchKeyword.value.trim().toLocaleLowerCase()
if (!normalizedKeyword) return componentProps.data
Expand All @@ -62,6 +65,14 @@ const filteredData = computed(() => {
})
})

// 远程搜索在停止输入 300ms 后触发,继续输入或卸载时取消待执行查询。
watch(searchKeyword, (keyword, _previousKeyword, onCleanup) => {
if (!componentProps.remote) return

const searchTimer = setTimeout(() => emit('search', keyword.trim()), 300)
onCleanup(() => clearTimeout(searchTimer))
})

// 全量数据先过滤,再每次渲染 50 条,滚动到底部时继续追加。
const pageSize = 50
const currentPage = ref(1)
Expand Down Expand Up @@ -94,6 +105,7 @@ function selectRow(row: T, index: number) {

<template>
<div class="flex-column min-h-0 flex-1">
<!-- 搜索列表,远程模式输入后防抖查询 -->
<MkSearchInput v-model="searchKeyword" class="shrink-0 px-4" />

<el-scrollbar ref="scrollbarRef" class="min-h-0 flex-1 px-4 py-2" @end-reached="loadMore">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export interface PortalConversationListDeps {

// 门户可访问的智能体数量有限,一次加载全部
const APPLICATION_PAGE_SIZE = 100
// 每个智能体分组最多展示最近 5 条会话
const GROUP_CONVERSATION_LIMIT = 5
// 总数不超过 20 条时展示全部会话,分组查询需覆盖完整展示范围。
const GROUP_CONVERSATION_LIMIT = 20

/**
* 管理门户侧栏的智能体分组与全部智能体列表。当前智能体的会话由会话列表 Store 维护,
Expand All @@ -51,13 +51,22 @@ export function createPortalConversationListStore(deps: PortalConversationListDe
const applicationGroups = ref<PortalApplicationGroup[]>([])
const isApplicationGroupsLoaded = ref(false)

// 总数不超过 20 条时,补齐分组接口可能只返回的最近 5 条会话。
const completeGroupConversations = async () => {
if (applicationGroups.value.reduce((total, group) => total + group.count, 0) > GROUP_CONVERSATION_LIMIT) return
await Promise.all(
applicationGroups.value.filter((group) => group.conversations.length < group.count).map((group) => loadGroupConversations(group)),
)
}

const loadApplicationGroups = () =>
pageApplicationConversations(1, APPLICATION_PAGE_SIZE).then((response) => {
applicationGroups.value = (response.records ?? []).map((application) => ({
...application,
expanded: application.conversations.length > 0 || application.id === currentApplicationId.value,
}))
isApplicationGroupsLoaded.value = true
return completeGroupConversations()
})

// 展开其他智能体时查询其最近会话;当前智能体以会话列表 Store 为准,无需查询
Expand All @@ -84,6 +93,7 @@ export function createPortalConversationListStore(deps: PortalConversationListDe
if (group) {
group.conversations = group.conversations.filter((conversation) => conversation.id !== chatId)
group.count = Math.max(0, group.count - 1)
await completeGroupConversations()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useStore } from '@/stores/chat'
import { MsgConfirm, MsgSuccess } from '@/utils/message'
import { relativeTimeFormat } from '@/utils/time'
import type { Conversation } from '../../core/types'
Expand All @@ -9,11 +10,18 @@ import AccountMenu from '../account-menu/index.vue'
import { usePortalConversationListStore, type PortalApplicationGroup } from './index'
import RenameConversation from '../../components/rename/index.vue'
import { useMessageListStore } from '../../components/message-list/index'
import auth from '@/api/chat/auth.ts'
import { useStore } from '@/stores/chat/index.ts'
const { auth: authStore }=useStore()

defineOptions({ name: 'PortalConversationList' })

// 门户名称和 Logo 使用认证配置,配置未加载时保留默认展示。
const { auth: authStore } = useStore()
const portalName = computed(() => {
const meta = authStore.authProfile?.meta
const name = meta && typeof meta === 'object' && 'portal_name' in meta ? meta.portal_name : undefined
return typeof name === 'string' && name ? name : '智能体门户'
})
const portalLogo = computed(() => authStore.authProfile?.logo)

const msgs = useMessageListStore()
const canShare = computed(() => msgs.messages.value.length > 0 && !msgs.streamLoading.value && !msgs.loading.value)
const portalList = usePortalConversationListStore()
Expand All @@ -27,18 +35,23 @@ const handleOpenHistory = (group: PortalApplicationGroup) => {
historyVisible.value = true
}

// 每个智能体最多展示最近 5 条会话
// 总数超过 20 条时,每个智能体最多展示最近 5 条会话
const RECENT_CONVERSATION_LIMIT = 5

const isCurrentApplication = (group: PortalApplicationGroup) => group.id === currentApplicationId.value

// 当前智能体以会话列表 Store 为准,其他智能体使用分组接口返回的会话
const groupConversations = (group: PortalApplicationGroup) =>
(isCurrentApplication(group) ? conversations.value : group.conversations).slice(0, RECENT_CONVERSATION_LIMIT)

const groupConversationCount = (group: PortalApplicationGroup) =>
Math.max(group.count, isCurrentApplication(group) ? conversations.value.length : group.conversations.length)

// 所有智能体的对话总数超过 20 条时,才显示分组的查看更多入口。
const totalConversationCount = computed(() => applicationGroups.value.reduce((total, group) => total + groupConversationCount(group), 0))

// 当前智能体以会话列表 Store 为准;无需查看更多时展示全部已加载会话。
const groupConversations = (group: PortalApplicationGroup) => {
const applicationConversations = isCurrentApplication(group) ? conversations.value : group.conversations
return totalConversationCount.value > 20 ? applicationConversations.slice(0, RECENT_CONVERSATION_LIMIT) : applicationConversations
}

// 仅展示有历史记录的智能体,随当前会话的新建、删除同步更新。
const historyApplicationGroups = computed(() => applicationGroups.value.filter((group) => groupConversations(group).length > 0))

Expand Down Expand Up @@ -80,22 +93,15 @@ const handleClearConversations = () => {
clearing.value = false
})
}
const name=computed(()=>{
const meta:any=authStore.authProfile?.meta
return meta.portal_name ||'智能体门户'
})
const logo=computed(()=>{
return authStore.authProfile?.logo
})
</script>

<template>
<div class="flex-column h-full overflow-hidden p-4">
<!-- 门户名称与收起侧栏 -->
<div class="mb-4 flex-between shrink-0 gap-2">
<div class="min-w-0 flex-align-center gap-2">
<PortalIcon :icon="logo"/>
<h4 class="truncate">{{name}}</h4>
<PortalIcon :icon="portalLogo" />
<h4 class="truncate">{{ portalName }}</h4>
</div>
<!-- 收起左侧 -->
<MkTooltip content="收起侧边栏">
Expand Down Expand Up @@ -224,8 +230,12 @@ const logo=computed(()=>{
</MkDropdown>
</template>
</MkListItem>
<!-- 超过5条 查看更多 -->
<MkListItem class="pl-8!" v-if="groupConversationCount(group) > RECENT_CONVERSATION_LIMIT" @click="handleOpenHistory(group)">
<!-- 总数超过 20 条且当前智能体超过 5 条时查看更多 -->
<MkListItem
class="pl-8!"
v-if="totalConversationCount > 20 && groupConversationCount(group) > RECENT_CONVERSATION_LIMIT"
@click="handleOpenHistory(group)"
>
<template #default>
<span>查看更多</span>
<span>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Did the diff apply cleanly?

The provided diff is a delta, not standalone code, so I reviewed the changes in context. The changes are logically consistent, but there are a few issues worth flagging.


Issues & Observations

1. portalName meta handling is over-defensive but inconsistent

const meta = authStore.authProfile?.meta
const name = meta && typeof meta === 'object' && 'portal_name' in meta ? meta.portal_name : undefined
  • Improvement over the removed version (which did meta.portal_name on possibly undefined — that was a latent runtime crash). Good fix.
  • However, consider typing authProfile.meta properly (e.g. { portal_name?: string; [k: string]: unknown }) so you can drop the runtime guards. The in + typeof chain is verbose here.
  • Suggest (meta as Record<string, unknown> | undefined)?.portal_name.

2. groupConversations changed semantics — verify with consumers
Previously it always sliced to 5. Now it returns all conversations when total ≤ 20. It's used in:

  • historyApplicationGroups filter (.length > 0) — fine.
  • Template rendering of conversation items — now renders potentially more than 5 items per group. Ensure the UI/layout handles this (scroll, height). This looks intentional (matches "无需查看更多时展示全部已加载会话"), but confirm layout tests.

3. totalConversationCount > 20 threshold is a magic number
Extract constants for clarity:

const TOTAL_CONVERSATION_THRESHOLD = 20

And use in both the computed and template (v-if). Right now 20 is duplicated in script and template.

4. groupConversationCount may double-count the current application

Math.max(group.count, isCurrentApplication(group) ? conversations.value.length : group.conversations.length)

If applicationGroups includes a group whose count already reflects server-side totals, the sum in totalConversationCount could mismatch actual rendered counts. Confirm group.count semantics (server total vs. loaded). If the server count is larger than loaded, users may see "查看更多" even though only some are loaded — acceptable, but document intent.

5. Redundant import path change
@/stores/chat/index.ts → @/stores/chat is cleaner. ✅ But ensure no other file relies on the explicit .ts path consistency style; align project-wide.

6. RECENT_CONVERSATION_LIMIT comment now stale elsewhere?
The inline comment near line ~230 was updated ("总数超过 20 条…"). Good. But check that any docs/tests referencing "每个智能体最多展示最近 5 条会话" are updated to reflect the new conditional behavior.

7. Minor: template binding order
class="pl-8!" before v-if — put v-if first for readability (convention).

<MkListItem v-if="..." class="pl-8!" @click="...">

8. portalLogo type
authStore.authProfile?.logo — if logo can be undefined, <PortalIcon :icon="portalLogo" /> must tolerate undefined. Verify PortalIcon prop allows optional icon (else add default).


Suggested refined snippet

const TOTAL_CONVERSATION_THRESHOLD = 20

const { auth: authStore } = useStore()

const portalName = computed(() => {
  const name = (authStore.authProfile?.meta as Record<string, unknown> | undefined)?.portal_name
  return typeof name === 'string' && name ? name : '智能体门户'
})
const portalLogo = computed(() => authStore.authProfile?.logo)

const groupConversations = (group: PortalApplicationGroup) => {
  const list = isCurrentApplication(group) ? conversations.value : group.conversations
  return totalConversationCount.value > TOTAL_CONVERSATION_THRESHOLD
    ? list.slice(0, RECENT_CONVERSATION_LIMIT)
    : list
}

Template:

<MkListItem
  v-if="totalConversationCount > TOTAL_CONVERSATION_THRESHOLD && groupConversationCount(group) > RECENT_CONVERSATION_LIMIT"
  ...
>

Summary

The refactor is an improvement — it fixes a real null-access bug (meta.portal_name) and moves the name/logo logic into computed properties. Main follow-ups: define named constants, type meta, confirm rendering of >5 items per group, and verify group.count semantics to avoid incorrect "查看更多" visibility.

Expand Down
3 changes: 2 additions & 1 deletion ui/src/views/VIEW_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,8 @@ System 资源管理的修正内容与添加知识库选项仅包含当前智能
同目录的 `UserPwdDialog.vue` 修改当前对话用户密码,`ApiKeyDialog.vue` 展示门户地址及用户唯一 API Key,支持复制完整密钥和重新生成;密钥在界面中仅显示末四位。
门户首页默认进入全部智能体列表,不自动选择智能体;没有当前智能体时不查询其信息/会话、禁用输入,无可用智能体显示空态。
全部智能体与选择抽屉复用卡片,抽屉每次打开重新查询,搜索状态独立。
门户历史侧栏仅展示有历史的智能体及最近 5 条会话,剩余数量取分组总数;“查看更多”打开历史对话抽屉,
门户历史侧栏仅展示有历史的智能体;对话总数不超过 20 条时展示全部,超过 20 条时每个智能体展示最近 5 条,
分组超过 5 条才显示“查看更多”,剩余数量取分组总数;“查看更多”打开历史对话抽屉,
支持切换智能体、摘要搜索、滚动分页及打开/删除会话,删除后同步刷新侧栏。

### 对话分享
Expand Down
96 changes: 32 additions & 64 deletions ui/src/views/home/components/HomeResourceOverview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,11 @@ interface HomeResourceCard {
details: [string, number | undefined][]
}

const ZERO_APPLICATION: HomeApplicationAggregation = { total: 0, publish_count: 0, un_publish_count: 0 }
const ZERO_KNOWLEDGE: HomeKnowledgeAggregation = { total: 0, document_count: 0, failure_count: 0 }
const ZERO_TOOL: HomeToolAggregation = {
total: 0,
custom_count: 0,
workflow_count: 0,
skill_count: 0,
mcp_count: 0,
data_source_count: 0,
}
const ZERO_MODEL: HomeModelAggregation = { total: 0, llm_count: 0, embedding_count: 0 }

/* 资源概况与列表导航 */
const props = defineProps<{ workspaceId: string; api: typeof HomepageApi }>()
const router = useRouter()
const route = useRoute()
const isSystemScope = computed(() => route.meta.scope === 'system')
const canReadApplication = computed(() => !isSystemScope.value || perm.application.system.read())
const canReadKnowledge = computed(() => !isSystemScope.value || perm.knowledge.system.read())
const canReadTool = computed(() => !isSystemScope.value || perm.tool.system.read())
const canReadModel = computed(() => !isSystemScope.value || perm.model.system.read())
const loading = ref(false)
const application = ref<HomeApplicationAggregation>()
const knowledge = ref<HomeKnowledgeAggregation>()
Expand Down Expand Up @@ -97,54 +81,38 @@ function loadResources() {
if (loading.value) return
loading.value = true
return Promise.allSettled([
canReadApplication.value
? props.api
.getApplicationAggregation(props.workspaceId)
.then((value) => {
application.value = value
})
.catch(() => {
application.value = undefined
})
: Promise.resolve().then(() => {
application.value = { ...ZERO_APPLICATION }
}),
canReadKnowledge.value
? props.api
.getKnowledgeAggregation(props.workspaceId)
.then((value) => {
knowledge.value = value
})
.catch(() => {
knowledge.value = undefined
})
: Promise.resolve().then(() => {
knowledge.value = { ...ZERO_KNOWLEDGE }
}),
canReadTool.value
? props.api
.getToolAggregation(props.workspaceId)
.then((value) => {
tool.value = value
})
.catch(() => {
tool.value = undefined
})
: Promise.resolve().then(() => {
tool.value = { ...ZERO_TOOL }
}),
canReadModel.value
? props.api
.getModelAggregation(props.workspaceId)
.then((value) => {
model.value = value
})
.catch(() => {
model.value = undefined
})
: Promise.resolve().then(() => {
model.value = { ...ZERO_MODEL }
}),
props.api
.getApplicationAggregation(props.workspaceId)
.then((value) => {
application.value = value
})
.catch(() => {
application.value = undefined
}),
props.api
.getKnowledgeAggregation(props.workspaceId)
.then((value) => {
knowledge.value = value
})
.catch(() => {
knowledge.value = undefined
}),
props.api
.getToolAggregation(props.workspaceId)
.then((value) => {
tool.value = value
})
.catch(() => {
tool.value = undefined
}),
props.api
.getModelAggregation(props.workspaceId)
.then((value) => {
model.value = value
})
.catch(() => {
model.value = undefined
}),
]).finally(() => {
loading.value = false
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the diff

Main issue: removed permission gating (behavior regression)

The diff removes the four canRead* computed guards and always calls every aggregation API. Before, in system scope, requests were only issued for resources the user had *.system.read() permission for; unauthorized resources were short-circuited with zero-value objects.

Consequences to consider:

  1. Unnecessary/unauthorized requests — users without system-level read permission now trigger 4 API calls that will likely return 401/403. They'll land in .catch() anyway, so UI result is undefined, but you've traded a cheap client-side guard for server round-trips + error logging noise.
  2. Lost "zero state" rendering — previously authorized-but-denied resources got ZERO_* objects so the card could render 0s. Now they become undefined. Verify the template handles undefined for all four refs gracefully (if it dereferences .total, it may crash).
  3. If this removal was intentional (e.g., backend now scopes data per workspace and returns zeros itself), document that rationale in the commit. Otherwise, restoring the permission checks is safer.

Other observations

  • ZERO_* constants deleted — fine if no longer referenced, but confirm nothing else imports them. Also, they were shallow-copied ({ ...ZERO_X }); nested mutable fields (none here) would have been shared anyway.
  • Empty catch handlers swallow errors silently. Keep at least a console.warn or set a per-resource error flag, otherwise debugging failures becomes hard — especially now that auth failures are expected.
  • No handling of stale results — if loadResources() is called again while a prior call is in flight, the loading guard prevents overlap, good. But finally(() => loading.value = false) runs even for the first settled branch? No — Promise.allSettled(...).finally fires after all settle, so this is correct.
  • Repetition — four near-identical blocks could be DRY'd:
    const tasks = [
      [application, props.api.getApplicationAggregation],
      [knowledge, props.api.getKnowledgeAggregation],
      [tool, props.api.getToolAggregation],
      [model, props.api.getModelAggregation],
    ] as const
    ...
    return Promise.allSettled(
      tasks.map(([ref, fn]) =>
        fn(props.workspaceId).then(v => { ref.value = v }, () => { ref.value = undefined })
      )
    ).finally(() => { loading.value = false })
  • Race on prop change — props.workspaceId changes don't auto-reload; presumably handled by a watcher elsewhere. Worth double-checking there's a watch(() => props.workspaceId, loadResources).
  • isSystemScope now appears unused — if nothing else references it, remove to avoid a lint warning.
  • perm import may now be unused too — verify and clean up.

Recommendation

Unless the backend reliably returns zeroed aggregations for unauthorized resources, restore the canRead* guards. If it does, keep the simplification but add a brief comment explaining the backend guarantee and ensure the template renders undefined safely.

Expand Down
7 changes: 0 additions & 7 deletions ui/src/views/home/components/HomeStatistics.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@ const props = defineProps<{
workspaceId: string
applicationId: string
api: typeof HomepageApi | typeof SystemHomepageApi | typeof OverviewApi
canReadApplication?: boolean
}>()
defineSlots<{ application(props: { loading: boolean }): unknown }>()
const canReadApplicationStatistics = computed(() => props.canReadApplication ?? true)

/* 日期、智能体筛选与使用统计 */
const range = ref({ start_time: beforeDay(7), end_time: beforeDay(0) })
Expand Down Expand Up @@ -53,11 +51,6 @@ const metrics = computed(() =>
})),
)
function loadMonitoring() {
if (!canReadApplicationStatistics.value) {
monitoring.value = []
loading.value = false
return Promise.resolve()
}
loading.value = true
return props.api
.getMonitoring(props.workspaceId, range.value, props.applicationId === 'all' ? '' : props.applicationId)
Expand Down
Loading
Loading