feat(web): UI小幅调整,多处细节优化

This commit is contained in:
Wenjie Zhang 2026-03-24 02:32:35 +08:00
parent 950f289ffb
commit 3b61571bd8
21 changed files with 942 additions and 758 deletions

View File

@ -31,11 +31,12 @@ class DeepAgent(BaseAgent):
name = "深度分析智能体"
description = "具备规划、深度分析和子智能体协作能力的智能体,可以处理复杂的多步骤任务"
context_schema = DeepContext
capabilities = [
"file_upload",
"todo",
"files",
]
capabilities = ["file_upload", "files", "todo"] # 支持文件上传功能
metadata = {
"examples": [
"调研一下多模态 GraphRAG 的相关论文"
]
}
def __init__(self, **kwargs):
super().__init__(**kwargs)

View File

@ -11,12 +11,18 @@ organization:
# 项目信息
branding:
name: "Yuxi"
title: "Yuxi: 更智能的知识库智能体平台" # 系统标题
subtitle: "大模型驱动的知识库智能体平台" # 副标题
title: "让智能体可构建、可编排、可落地" # 系统标题
subtitle: "开源智能体平台套件,融合 RAG 与知识图谱" # 副标题subtitles 为空时使用)
subtitles:
- "开源智能体平台套件,融合 RAG 与知识图谱"
- "统一编排 Agent、知识库、图谱与工具链"
- "让智能体可构建,让知识可连接,让决策可验证"
- "让智能体可落地,让流程可编排,让协作可扩展"
- "让数据可沉淀,让能力可复用,让系统可进化"
features:
- label: "GitHub Stars"
value: "4100+"
value: "4600+"
description: "开发者社区的认可与支持"
icon: "stars"
- label: "已解决 Issues"
@ -24,11 +30,11 @@ features:
description: "持续改进和问题解决能力"
icon: "issues"
- label: "累计 Commits"
value: "1300+"
value: "1500+"
description: "活跃的开发迭代和功能更新"
icon: "commits"
- label: "开源协议"
value: "MIT 协议"
value: "MIT License"
description: "完全免费,支持商业使用"
icon: "license"

View File

@ -24,7 +24,7 @@
## v0.6
<!-- 添加到这里 -->
- 调整 Agent 路由为 `/agent/{thread_id}``/agent` 进入未选中对话空态,不再在 URL 中展示 `agent_id`;发送首条消息时基于当前 `selectedAgentId` 与配置创建新对话,并自动跳转到对应线程路由。
- 新增 API Key 管理功能,支持外部系统通过 API Key 调用 Agent 对话接口(`POST /api/chat/agent/{agent_id}`)。统一使用 `Authorization: Bearer <api_key>` 认证API Key 以 `yxkey_` 开头。获取 API Key 即代表拥有绑定用户的所有接口访问权限。
- 将 后端代码 和 agents 解耦agents 作为单独的 package 使用
- 添加 subagents

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 157 KiB

View File

@ -251,6 +251,7 @@ const props = defineProps({
agentId: { type: String, default: '' },
singleMode: { type: Boolean, default: true }
})
const emit = defineEmits(['thread-change'])
// ==================== STORE MANAGEMENT ====================
const agentStore = useAgentStore()
@ -516,7 +517,7 @@ onMounted(() => {
for (const entry of entries) {
const width = entry.contentRect.width
const isTakingSpace = chatUIStore.isSidebarOpen && !isSidebarFloating.value
if (isTakingSpace) {
if (width < 600) {
isSidebarFloating.value = true
@ -849,58 +850,21 @@ const sendMessage = async ({
}
// ==================== CHAT ACTIONS ====================
//
const isFirstChatEmpty = () => {
if (threads.value.length === 0) return false
const chatToReuse = getFirstNonPinnedChat(threads.value)
const messages = threadMessages.value[chatToReuse.id]
// true
return messages !== undefined && messages.length === 0
}
//
const getFirstNonPinnedChat = (chatList) => {
if (!chatList || chatList.length === 0) return null
return chatList.find((chat) => !chat.is_pinned) || chatList[0]
}
//
const switchToFirstChatIfEmpty = async () => {
if (threads.value.length > 0 && isFirstChatEmpty()) {
const chatToReuse = getFirstNonPinnedChat(threads.value)
if (chatState.currentThreadId !== chatToReuse.id) {
await selectChat(chatToReuse.id)
}
return true
}
return false
}
const createNewChat = async () => {
if (
!AgentValidator.validateAgentId(currentAgentId.value, '创建对话') ||
chatUIStore.creatingNewChat
)
return
//
if (await switchToFirstChatIfEmpty()) return
chatUIStore.creatingNewChat = true
try {
const newThread = await createThread(currentAgentId.value, '新的对话')
if (newThread) {
// 线
const previousThreadId = chatState.currentThreadId
stopThreadStream(previousThreadId)
chatState.currentThreadId = newThread.id
}
} catch (error) {
handleChatError(error, 'create')
} finally {
chatUIStore.creatingNewChat = false
const previousThreadId = chatState.currentThreadId
if (previousThreadId) {
stopThreadStream(previousThreadId)
// run SSE
stopRunStreamSubscription(previousThreadId)
}
// thread-change /agent
chatState.currentThreadId = null
}
const selectChat = async (chatId) => {
@ -951,6 +915,38 @@ const selectChat = async (chatId) => {
await resumeActiveRunForThread(chatId)
}
const selectThreadFromRoute = async (threadId) => {
if (!agentStore.isInitialized) {
await initAll()
}
if (!threadId) {
const previousThreadId = chatState.currentThreadId
if (previousThreadId) {
stopThreadStream(previousThreadId)
stopRunStreamSubscription(previousThreadId)
}
chatState.currentThreadId = null
return true
}
if (chatState.currentThreadId === threadId) {
return true
}
if (!threads.value.length || !threads.value.find((thread) => thread.id === threadId)) {
await loadChatsList()
}
const targetThread = threads.value.find((thread) => thread.id === threadId)
if (!targetThread) {
return false
}
await selectChat(threadId)
return true
}
const deleteChat = async (chatId) => {
if (
!AgentValidator.validateAgentIdWithError(
@ -964,11 +960,8 @@ const deleteChat = async (chatId) => {
await deleteThread(chatId)
if (chatState.currentThreadId === chatId) {
chatState.currentThreadId = null
//
// 线
await createNewChat()
} else if (chatsList.value.length > 0) {
//
await selectChat(getFirstNonPinnedChat(chatsList.value).id)
}
} catch (error) {
handleChatError(error, 'delete')
@ -1229,7 +1222,8 @@ const buildExportPayload = () => {
}
defineExpose({
getExportPayload: buildExportPayload
getExportPayload: buildExportPayload,
selectThreadFromRoute
})
const toggleSidebar = () => {
@ -1341,8 +1335,8 @@ const loadChatsList = async () => {
chatState.currentThreadId = null
}
// 线线
if (threads.value.length > 0 && !chatState.currentThreadId) {
// singleMode
if (props.singleMode && threads.value.length > 0 && !chatState.currentThreadId) {
await selectChat(getFirstNonPinnedChat(threads.value).id)
}
} catch (error) {
@ -1401,6 +1395,11 @@ watch(
},
{ deep: true, flush: 'post' }
)
watch(currentChatId, (threadId, oldThreadId) => {
if (threadId === oldThreadId) return
emit('thread-change', threadId || '')
})
</script>
<style lang="less" scoped>

View File

@ -51,12 +51,20 @@
<!-- 侧边栏内容 -->
<div class="sidebar-content">
<div class="agent-info" v-if="selectedAgent">
<div class="agent-basic-info">
<!-- <div class="agent-basic-info">
<p class="agent-description">{{ selectedAgent.description }}</p>
</div>
</div> -->
<!-- <a-divider /> -->
<div class="config-segment" v-if="!isEmptyConfig">
<a-segmented
v-model:value="currentSegment"
:options="segmentOptions"
block
/>
</div>
<div
v-if="selectedAgentId && configurableItems"
class="config-form-content"
@ -72,7 +80,7 @@
class="config-alert"
/>
<!-- 统一显示所有配置项 -->
<template v-for="(value, key) in configurableItems" :key="key">
<template v-for="(value, key) in filteredConfigurableItems" :key="key">
<a-form-item
v-if="shouldShowConfig(key, value)"
:label="getConfigLabel(key, value)"
@ -524,6 +532,12 @@ const createConfigModalOpen = ref(false)
const createConfigLoading = ref(false)
const createConfigName = ref('')
const CREATE_CONFIG_OPTION_VALUE = '__create_config__'
const currentSegment = ref('model')
const segmentOptions = [
{ label: '模型', value: 'model' },
{ label: '工具', value: 'tools' },
{ label: '其他', value: 'other' }
]
const isEmptyConfig = computed(() => {
return !selectedAgentId.value || Object.keys(configurableItems.value).length === 0
@ -554,6 +568,34 @@ const hasOtherConfigs = computed(() => {
})
})
const segmentConfigKeys = computed(() => {
const keys = Object.keys(configurableItems.value)
return {
model: keys.filter(key => {
const meta = configurableItems.value[key]?.template_metadata?.kind
return meta === 'llm' || meta === 'prompt'
}),
tools: keys.filter(key => {
const meta = configurableItems.value[key]?.template_metadata?.kind
return ['tools', 'knowledges', 'mcps', 'skills', 'subagents'].includes(meta)
}),
other: keys.filter(key => {
const meta = configurableItems.value[key]?.template_metadata?.kind
return !['llm', 'prompt', 'tools', 'knowledges', 'mcps', 'skills', 'subagents'].includes(meta)
})
}
})
const filteredConfigurableItems = computed(() => {
if (isEmptyConfig.value) return {}
const keys = segmentConfigKeys.value[currentSegment.value] || []
const filtered = {}
keys.forEach(key => {
filtered[key] = configurableItems.value[key]
})
return filtered
})
const configSwitchOptions = computed(() => {
if (!selectedAgentId.value) return []
const list = agentConfigs.value[selectedAgentId.value] || []
@ -1233,6 +1275,13 @@ const confirmDeleteConfig = async () => {
}
}
.config-segment {
margin: 0 auto;
margin-bottom: 6px;
padding: 4px 0;
width: 80%;
}
.config-form-content {
margin-bottom: 20px;

View File

@ -3,12 +3,12 @@
<!-- 头部区域 -->
<div class="header-section">
<div class="header-content">
<h3 class="title">API Key 管理</h3>
<p class="description">
<div class="section-title">API Key 管理</div>
<p class="section-description">
创建和管理 API Key用于外部系统调用 Agent 对话接口密钥仅显示一次请妥善保管
</p>
</div>
<a-button type="primary" @click="showCreateModal" class="add-btn">
<a-button type="primary" @click="showCreateModal" class="add-btn lucide-icon-btn">
<Plus :size="14" />
创建 API Key
</a-button>
@ -59,7 +59,7 @@
type="text"
size="small"
@click="regenerateKey(key)"
class="action-btn"
class="action-btn lucide-icon-btn"
>
<RefreshCw :size="14" />
<span>重新生成</span>
@ -72,7 +72,7 @@
cancel-text="取消"
>
<a-tooltip title="删除">
<a-button type="text" size="small" danger class="action-btn">
<a-button type="text" size="small" danger class="action-btn lucide-icon-btn">
<Trash2 :size="14" />
<span>删除</span>
</a-button>
@ -128,7 +128,7 @@
/>
<div class="secret-value-container">
<code class="secret-value">{{ createdSecret }}</code>
<a-button type="primary" @click="copySecret" class="copy-btn">
<a-button type="primary" @click="copySecret" class="copy-btn lucide-icon-btn">
<Copy :size="14" />
复制
</a-button>
@ -262,40 +262,6 @@ onMounted(() => {
<style lang="less" scoped>
.apikey-management {
padding: 12px;
min-height: 50vh;
.header-section {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
gap: 16px;
.header-content {
.title {
font-size: 16px;
font-weight: 600;
color: var(--gray-900);
margin: 0 0 4px 0;
}
.description {
font-size: 13px;
color: var(--gray-600);
margin: 0;
line-height: 1.4;
}
}
.add-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
}
}
.content-section {
.error-message {
margin-bottom: 16px;

View File

@ -1,91 +1,112 @@
<template>
<div class="basic-settings-section">
<h3 class="section-title">检索配置</h3>
<div class="settings-panel">
<div class="setting-row two-cols">
<div class="col-item">
<div class="setting-label">{{ items?.default_model?.des || '默认对话模型' }}</div>
<div class="setting-content">
<ModelSelectorComponent
@select-model="handleChatModelSelect"
:model_spec="configStore.config?.default_model"
placeholder="请选择默认模型"
/>
<template v-if="userStore.isSuperAdmin">
<div class="section-title">检索配置</div>
<div class="settings-panel">
<div class="setting-row two-cols">
<div class="col-item">
<div class="setting-label">{{ items?.default_model?.des || '默认对话模型' }}</div>
<div class="setting-content">
<ModelSelectorComponent
@select-model="handleChatModelSelect"
:model_spec="configStore.config?.default_model"
placeholder="请选择默认模型"
/>
</div>
</div>
<div class="col-item">
<div class="setting-label">{{ items?.fast_model?.des }}</div>
<div class="setting-content">
<ModelSelectorComponent
@select-model="handleFastModelSelect"
:model_spec="configStore.config?.fast_model"
placeholder="请选择模型"
/>
</div>
</div>
</div>
<div class="col-item">
<div class="setting-label">{{ items?.fast_model.des }}</div>
<div class="setting-content">
<ModelSelectorComponent
@select-model="handleFastModelSelect"
:model_spec="configStore.config?.fast_model"
placeholder="请选择模型"
/>
<div class="setting-row two-cols">
<div class="col-item">
<div class="setting-label">{{ items?.embed_model?.des }}</div>
<div class="setting-content">
<EmbeddingModelSelector
:value="configStore.config?.embed_model"
@change="handleChange('embed_model', $event)"
style="width: 100%"
/>
</div>
</div>
<div class="col-item">
<div class="setting-label">{{ items?.reranker?.des }}</div>
<div class="setting-content">
<a-select
class="full-width"
:value="configStore.config?.reranker"
@change="handleChange('reranker', $event)"
placeholder="请选择重排序模型"
>
<a-select-option v-for="(name, idx) in rerankerChoices" :key="idx" :value="name"
>{{ name }}
</a-select-option>
</a-select>
</div>
</div>
</div>
</div>
<div class="setting-row two-cols">
<div class="col-item">
<div class="setting-label">{{ items?.embed_model.des }}</div>
<div class="setting-content">
<EmbeddingModelSelector
:value="configStore.config?.embed_model"
@change="handleChange('embed_model', $event)"
style="width: 100%"
/>
</div>
</div>
<div class="col-item">
<div class="setting-label">{{ items?.reranker.des }}</div>
<div class="setting-content">
<a-select
class="full-width"
:value="configStore.config?.reranker"
@change="handleChange('reranker', $event)"
placeholder="请选择重排序模型"
>
<a-select-option v-for="(name, idx) in rerankerChoices" :key="idx" :value="name"
>{{ name }}
</a-select-option>
</a-select>
</div>
</div>
</div>
</div>
<h3 class="section-title">内容审查配置</h3>
<div class="section">
<div class="card">
<span class="label">{{ items?.enable_content_guard.des }}</span>
<a-switch
:checked="configStore.config?.enable_content_guard"
@change="handleChange('enable_content_guard', $event)"
/>
<div class="section-title">内容审查配置</div>
<div class="section">
<div class="card">
<span class="label">{{ items?.enable_content_guard?.des }}</span>
<a-switch
:checked="configStore.config?.enable_content_guard"
@change="handleChange('enable_content_guard', $event)"
/>
</div>
<div class="card" v-if="configStore.config?.enable_content_guard">
<span class="label">{{ items?.enable_content_guard_llm?.des }}</span>
<a-switch
:checked="configStore.config?.enable_content_guard_llm"
@change="handleChange('enable_content_guard_llm', $event)"
/>
</div>
<div
class="card card-select"
v-if="
configStore.config?.enable_content_guard && configStore.config?.enable_content_guard_llm
"
>
<span class="label">{{ items?.content_guard_llm_model?.des }}</span>
<ModelSelectorComponent
@select-model="handleContentGuardModelSelect"
:model_spec="configStore.config?.content_guard_llm_model"
placeholder="请选择模型"
/>
</div>
</div>
<div class="card" v-if="configStore.config?.enable_content_guard">
<span class="label">{{ items?.enable_content_guard_llm.des }}</span>
<a-switch
:checked="configStore.config?.enable_content_guard_llm"
@change="handleChange('enable_content_guard_llm', $event)"
/>
</div>
<div
class="card card-select"
v-if="
configStore.config?.enable_content_guard && configStore.config?.enable_content_guard_llm
"
>
<span class="label">{{ items?.content_guard_llm_model.des }}</span>
<ModelSelectorComponent
@select-model="handleContentGuardModelSelect"
:model_spec="configStore.config?.content_guard_llm_model"
placeholder="请选择模型"
</template>
<div v-if="userStore.isAdmin" class="section-title">智能体配置</div>
<div v-if="userStore.isAdmin" class="section">
<div class="card card-select agent-card">
<div class="label-group">
<span class="label">默认智能体</span>
<span class="helper-text">用于默认进入的智能体也作为新会话的默认选择</span>
</div>
<a-select
class="agent-select"
:value="agentStore.defaultAgentId"
:options="agentOptions"
:loading="isSettingDefaultAgent"
:disabled="isSettingDefaultAgent || !agentOptions.length"
placeholder="请选择默认智能体"
@change="handleDefaultAgentChange"
/>
</div>
</div>
<!-- 服务链接部分 -->
<h3 v-if="userStore.isAdmin" class="section-title">服务链接</h3>
<div v-if="userStore.isAdmin" class="section-title">服务链接</div>
<div v-if="userStore.isAdmin">
<p class="service-description">
快速访问系统相关的外部服务需要将 localhost 替换为实际的 IP 地址
@ -98,8 +119,9 @@
</div>
<a-button
type="default"
class="lucide-icon-btn"
@click="openLink('http://localhost:7474/')"
:icon="h(GlobalOutlined)"
:icon="h(Globe, { size: 18 })"
>
访问
</a-button>
@ -112,8 +134,9 @@
</div>
<a-button
type="default"
class="lucide-icon-btn"
@click="openLink('http://localhost:5050/docs')"
:icon="h(GlobalOutlined)"
:icon="h(Globe, { size: 18 })"
>
访问
</a-button>
@ -126,8 +149,9 @@
</div>
<a-button
type="default"
class="lucide-icon-btn"
@click="openLink('http://localhost:9001')"
:icon="h(GlobalOutlined)"
:icon="h(Globe, { size: 18 })"
>
访问
</a-button>
@ -140,8 +164,9 @@
</div>
<a-button
type="default"
class="lucide-icon-btn"
@click="openLink('http://localhost:9091/webui/')"
:icon="h(GlobalOutlined)"
:icon="h(Globe, { size: 18 })"
>
访问
</a-button>
@ -152,41 +177,36 @@
</template>
<script setup>
import { computed, h } from 'vue'
import { computed, h, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { useConfigStore } from '@/stores/config'
import { useAgentStore } from '@/stores/agent'
import { useUserStore } from '@/stores/user'
import { GlobalOutlined } from '@ant-design/icons-vue'
import { Globe } from 'lucide-vue-next'
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
import EmbeddingModelSelector from '@/components/EmbeddingModelSelector.vue'
const configStore = useConfigStore()
const agentStore = useAgentStore()
const userStore = useUserStore()
const items = computed(() => configStore.config._config_items)
const items = computed(() => configStore.config?._config_items || {})
const isSettingDefaultAgent = ref(false)
const rerankerChoices = computed(() => {
return Object.keys(configStore?.config?.reranker_names || {}) || []
})
const preHandleChange = (key, e) => {
return true
}
const agentOptions = computed(() =>
(agentStore.agents || []).map((agent) => ({
label: agent.name || 'Unknown',
value: agent.id
}))
)
const handleChange = (key, e) => {
if (!preHandleChange(key, e)) {
return
}
configStore.setConfigValue(key, e)
}
const handleChanges = (items) => {
for (const key in items) {
if (!preHandleChange(key, items[key])) {
return
}
}
configStore.setConfigValues(items)
}
const handleChatModelSelect = (spec) => {
if (typeof spec === 'string' && spec) {
configStore.setConfigValue('default_model', spec)
@ -205,9 +225,30 @@ const handleContentGuardModelSelect = (spec) => {
}
}
const handleDefaultAgentChange = async (agentId) => {
if (!agentId || agentId === agentStore.defaultAgentId) return
isSettingDefaultAgent.value = true
try {
await agentStore.setDefaultAgent(agentId)
message.success('默认智能体已更新')
} finally {
isSettingDefaultAgent.value = false
}
}
const openLink = (url) => {
window.open(url, '_blank')
}
onMounted(async () => {
if (!agentStore.agents.length) {
await agentStore.fetchAgents()
}
if (!agentStore.defaultAgentId) {
await agentStore.fetchDefaultAgent()
}
})
</script>
<style lang="less" scoped>
@ -312,6 +353,28 @@ const openLink = (url) => {
}
}
.agent-card {
align-items: center;
}
.label-group {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.helper-text {
color: var(--gray-500);
font-size: 12px;
line-height: 1.5;
}
.agent-select {
width: 320px;
max-width: 100%;
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
@ -331,8 +394,8 @@ const openLink = (url) => {
min-height: 70px;
&:hover {
box-shadow: 0 1px 8px var(--gray-200);
border-color: var(--main-200);
box-shadow: 0 1px 8px var(--gray-150);
border-color: var(--gray-100);
}
.service-info {
@ -343,7 +406,7 @@ const openLink = (url) => {
margin: 0 0 4px 0;
color: var(--gray-900);
font-size: 15px;
font-weight: 600;
font-weight: 500;
}
p {
@ -354,5 +417,16 @@ const openLink = (url) => {
}
}
}
@media (max-width: 768px) {
.agent-card {
align-items: flex-start;
flex-direction: column;
}
.agent-select {
width: 100%;
}
}
}
</style>

View File

@ -3,11 +3,11 @@
<!-- 头部区域 -->
<div class="header-section">
<div class="header-content">
<h3 class="title">部门管理</h3>
<p class="description">管理系统部门部门下的用户会被隔离管理</p>
<div class="section-title">部门管理</div>
<p class="section-description">管理系统部门部门下的用户会被隔离管理</p>
</div>
<a-button type="primary" @click="showAddDepartmentModal" class="add-btn">
<template #icon><PlusOutlined /></template>
<a-button type="primary" @click="showAddDepartmentModal" class="add-btn lucide-icon-btn">
<template #icon><Plus :size="16" /></template>
添加部门
</a-button>
</div>
@ -46,9 +46,9 @@
type="text"
size="small"
@click="showEditDepartmentModal(record)"
class="action-btn"
class="action-btn lucide-icon-btn"
>
<EditOutlined />
<Pencil :size="14" />
</a-button>
</a-tooltip>
<a-tooltip title="删除部门">
@ -58,9 +58,9 @@
danger
@click="confirmDeleteDepartment(record)"
:disabled="record.user_count > 0"
class="action-btn"
class="action-btn lucide-icon-btn"
>
<DeleteOutlined />
<Trash2 :size="14" />
</a-button>
</a-tooltip>
</a-space>
@ -110,7 +110,7 @@
<template v-if="!departmentManagement.editMode">
<div class="admin-section-title">
<TeamOutlined />
<Users :size="16" />
<span>部门管理员</span>
</div>
<p class="admin-section-hint">
@ -170,7 +170,7 @@
import { reactive, onMounted, watch } from 'vue'
import { notification, Modal } from 'ant-design-vue'
import { departmentApi, apiSuperAdminGet } from '@/apis'
import { DeleteOutlined, EditOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons-vue'
import { Delete as Trash2, Edit3 as Pencil, Plus, Users } from 'lucide-vue-next'
//
const columns = [
@ -454,27 +454,6 @@ onMounted(() => {
<style lang="less" scoped>
.department-management {
margin-top: 12px;
min-height: 50vh;
.header-section {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
.header-content {
flex: 1;
.description {
font-size: 14px;
color: var(--gray-600);
margin: 0;
line-height: 1.4;
}
}
}
.content-section {
overflow: hidden;

View File

@ -299,31 +299,6 @@
</div>
</a-tab-pane>
<a-tab-pane key="prompts">
<template #tab>
<span class="tab-title"><MessageSquare :size="14" />提示</span>
</template>
<div class="tab-content empty-tab">
<a-empty description="提示功能即将推出">
<template #image>
<span style="font-size: 48px">📝</span>
</template>
</a-empty>
</div>
</a-tab-pane>
<a-tab-pane key="resources">
<template #tab>
<span class="tab-title"><Box :size="14" />资源</span>
</template>
<div class="tab-content empty-tab">
<a-empty description="资源功能即将推出">
<template #image>
<span style="font-size: 48px">📦</span>
</template>
</a-empty>
</div>
</a-tab-pane>
</a-tabs>
</template>
</div>
@ -485,9 +460,7 @@ import {
Info,
Copy,
Settings2,
Wrench,
MessageSquare,
Box
Wrench
} from 'lucide-vue-next'
import { mcpApi } from '@/apis/mcp_api'
import { formatFullDateTime } from '@/utils/time'

View File

@ -1,23 +1,22 @@
<template>
<div class="model-providers-section">
<!-- 自定义供应商管理区域 -->
<h3>模型配置</h3>
<p>请在 <code>.env</code> 文件中配置对应的 APIKEY并重新启动服务</p>
<a-divider />
<div class="custom-providers-section">
<div class="section-header">
<h3>自定义供应商</h3>
<a-button type="primary" @click="openAddCustomProviderModal">
<template #icon>
<PlusOutlined />
</template>
添加自定义供应商
</a-button>
</div>
<p class="section-description">
添加自定义的LLM供应商支持OpenAI兼容的API格式API密钥支持直接配置或使用环境变量名
</p>
<div class="header-section">
<div class="header-content">
<div class="section-title">自定义供应商</div>
<p class="section-description">
添加自定义的LLM供应商支持OpenAI兼容的API格式
</p>
</div>
<a-button type="primary" @click="openAddCustomProviderModal" class="add-btn lucide-icon-btn">
<template #icon>
<Plus :size="16" />
</template>
添加自定义供应商
</a-button>
</div>
<div class="custom-providers-section">
<!-- 自定义供应商列表 -->
<div
class="custom-provider-card"
@ -33,20 +32,22 @@
<a-button
type="text"
size="small"
class="lucide-icon-btn"
@click="testCustomProvider(providerId, provider.default)"
>
<template #icon>
<ApiOutlined />
<PlugZap :size="14" />
</template>
测试连接
</a-button>
<a-button
type="text"
size="small"
class="lucide-icon-btn"
@click="openEditCustomProviderModal(providerId, provider)"
>
<template #icon>
<EditOutlined />
<Pencil :size="14" />
</template>
编辑
</a-button>
@ -56,9 +57,9 @@
ok-text="确定"
cancel-text="取消"
>
<a-button type="text" size="small" danger>
<a-button type="text" size="small" danger class="lucide-icon-btn">
<template #icon>
<DeleteOutlined />
<Trash2 :size="14" />
</template>
删除
</a-button>
@ -86,7 +87,7 @@
<!-- 无自定义供应商时的提示 -->
<div v-if="Object.keys(customProviders).length === 0" class="empty-state">
<a-empty description="暂无自定义供应商">
<a-button type="primary" @click="openAddCustomProviderModal">添加自定义供应商</a-button>
<!-- <a-button type="primary" @click="openAddCustomProviderModal">添加自定义供应商</a-button> -->
</a-empty>
</div>
</div>
@ -96,12 +97,13 @@
<!-- 系统内置供应商 -->
<div class="builtin-providers-section">
<div class="section-header">
<h3>系统内置供应商</h3>
<div class="section-subtitle">系统内置供应商</div>
<div class="providers-stats">
<span class="stats-item available"> {{ modelKeys.length }} 可用 </span>
<span class="stats-item unavailable"> {{ notModelKeys.length }} 未配置 </span>
</div>
</div>
<p class="section-description">请在 <code>.env</code> 文件中配置对应的 APIKEY并重新启动服务</p>
<!-- 已配置的供应商 -->
<div
@ -114,16 +116,16 @@
<img :src="modelIcons[item] || modelIcons.default" alt="模型图标" />
</div>
<div class="model-title-container">
<h3>{{ modelNames[item].name }}</h3>
<div class="model-name">{{ modelNames[item].name }}</div>
</div>
<div class="provider-meta">
<a-button
type="text"
class="expand-button"
class="expand-button lucide-icon-btn"
@click.stop="openProviderConfig(item)"
title="配置模型"
>
<SettingOutlined /> 已选 {{ modelNames[item].models?.length || 0 }} 个模型
<Settings :size="14" /> 已选 {{ modelNames[item].models?.length || 0 }} 个模型
</a-button>
</div>
</div>
@ -147,9 +149,9 @@
<img :src="modelIcons[item] || modelIcons.default" alt="模型图标" />
</div>
<div class="model-title-container">
<h3>{{ modelNames[item].name }}</h3>
<div class="model-name">{{ modelNames[item].name }}</div>
<a :href="modelNames[item].url" target="_blank" class="model-url">
查看信息 <InfoCircleOutlined />
查看信息 <CircleHelp :size="13" />
</a>
</div>
<div class="missing-keys">
@ -173,9 +175,7 @@
>
<div v-if="providerConfig.loading" class="modal-loading-container">
<a-spin
:indicator="
h(LoadingOutlined, { style: { fontSize: '32px', color: 'var(--main-color)' } })
"
:indicator="h(LoaderCircle, { size: 32, color: 'var(--main-color)' })"
/>
<div class="loading-text">正在获取模型列表...</div>
</div>
@ -225,7 +225,7 @@
allow-clear
>
<template #prefix>
<SearchOutlined />
<Search :size="14" />
</template>
</a-input>
</div>
@ -384,16 +384,15 @@
import { computed, reactive, watch, h, ref } from 'vue'
import { message } from 'ant-design-vue'
import {
InfoCircleOutlined, // Keep if still used for other things, if not, remove. For now assume it might be used elsewhere.
SettingOutlined,
DownCircleOutlined,
LoadingOutlined,
SearchOutlined,
PlusOutlined,
EditOutlined,
DeleteOutlined,
ApiOutlined
} from '@ant-design/icons-vue'
CircleHelp,
Settings,
LoaderCircle,
Search,
Plus,
Pencil,
Trash2,
PlugZap
} from 'lucide-vue-next'
import { useConfigStore } from '@/stores/config'
import { modelIcons } from '@/utils/modelIcon'
import { agentApi } from '@/apis/agent_api'
@ -821,20 +820,9 @@ const testCustomProvider = async (providerId, modelName) => {
</script>
<style lang="less" scoped>
.model-providers-section {
padding-top: 12px;
}
.custom-providers-section {
margin-bottom: 24px;
.section-description {
margin: 0 0 16px 0;
color: var(--gray-600);
font-size: 14px;
line-height: 1.5;
}
.custom-provider-card {
border: 1px solid var(--gray-200);
background: var(--gray-0);
@ -858,7 +846,7 @@ const testCustomProvider = async (providerId, modelName) => {
h4 {
margin: 0;
font-weight: 600;
font-weight: 500;
color: var(--gray-900);
}
@ -906,7 +894,7 @@ const testCustomProvider = async (providerId, modelName) => {
.empty-state {
text-align: center;
padding: 40px 20px;
padding: 10px 20px;
background: var(--gray-25);
border-radius: 8px;
border: 1px dashed var(--gray-300);
@ -919,14 +907,16 @@ const testCustomProvider = async (providerId, modelName) => {
align-items: center;
margin-bottom: 12px;
h3 {
.section-subtitle {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--gray-900);
}
}
.builtin-providers-section {
.section-header {
.providers-stats {
display: flex;
@ -964,7 +954,7 @@ const testCustomProvider = async (providerId, modelName) => {
border: 1px solid var(--gray-150);
background-color: var(--gray-0);
border-radius: 8px;
margin-bottom: 16px;
margin: 16px 0;
padding: 0;
overflow: hidden;
@ -980,7 +970,7 @@ const testCustomProvider = async (providerId, modelName) => {
.card-header {
background: var(--gray-25);
h3 {
.model-name {
color: var(--gray-700);
font-weight: 500;
}
@ -1004,7 +994,7 @@ const testCustomProvider = async (providerId, modelName) => {
flex-direction: column;
flex: 1;
h3 {
.model-name {
margin: 0;
font-size: 14px;
font-weight: 600;

View File

@ -1,25 +1,30 @@
<template>
<a-modal
v-model:open="visible"
title="系统设置"
:title="null"
width="90%"
:style="{ maxWidth: '980px', minWidth: '320px', top: '10%' }"
:footer="null"
:closable="false"
@cancel="handleClose"
class="settings-modal"
:destroyOnClose="true"
:bodyStyle="{ padding: 0 }"
>
<div class="settings-container">
<button class="settings-close-btn lucide-icon-btn" @click="handleClose" aria-label="关闭设置">
<X :size="16" />
</button>
<!-- 侧边栏 (Desktop) -->
<div class="settings-sider">
<div
class="sider-item"
:class="{ activesec: activeTab === 'base' }"
@click="activeTab = 'base'"
v-if="userStore.isSuperAdmin"
v-if="userStore.isAdmin"
>
<SettingOutlined class="icon" />
<Settings class="icon" :size="18" />
<span>基本设置</span>
</div>
<div
@ -28,7 +33,7 @@
@click="activeTab = 'model'"
v-if="userStore.isSuperAdmin"
>
<CodeOutlined class="icon" />
<SquareCode class="icon" :size="18" />
<span>模型配置</span>
</div>
<div
@ -37,7 +42,7 @@
@click="activeTab = 'user'"
v-if="userStore.isAdmin"
>
<UserOutlined class="icon" />
<User class="icon" :size="18" />
<span>用户管理</span>
</div>
<div
@ -46,7 +51,7 @@
@click="activeTab = 'department'"
v-if="userStore.isSuperAdmin"
>
<TeamOutlined class="icon" />
<Users class="icon" :size="18" />
<span>部门管理</span>
</div>
<div
@ -55,7 +60,7 @@
@click="activeTab = 'apikey'"
v-if="userStore.isSuperAdmin"
>
<KeyIcon class="icon" :size="14" />
<KeyIcon class="icon" :size="18" />
<span>API Key</span>
</div>
</div>
@ -66,7 +71,7 @@
class="nav-item"
:class="{ active: activeTab === 'base' }"
@click="activeTab = 'base'"
v-if="userStore.isSuperAdmin"
v-if="userStore.isAdmin"
>
基本设置
</div>
@ -107,7 +112,7 @@
<!-- 内容区域 -->
<div class="settings-content-wrapper">
<div class="settings-content">
<div v-show="activeTab === 'base'" v-if="userStore.isSuperAdmin">
<div v-show="activeTab === 'base'" v-if="userStore.isAdmin">
<BasicSettingsSection />
</div>
@ -135,8 +140,7 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { SettingOutlined, CodeOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons-vue'
import { Key as KeyIcon } from 'lucide-vue-next'
import { Key as KeyIcon, Settings, SquareCode, User, Users, X } from 'lucide-vue-next'
import BasicSettingsSection from '@/components/BasicSettingsSection.vue'
import ModelProvidersComponent from '@/components/ModelProvidersComponent.vue'
import UserManagementComponent from '@/components/UserManagementComponent.vue'
@ -169,10 +173,8 @@ watch(
() => props.visible,
(newVal) => {
if (newVal) {
if (userStore.isSuperAdmin) {
if (userStore.isAdmin) {
activeTab.value = 'base'
} else if (userStore.isAdmin) {
activeTab.value = 'user'
}
}
}
@ -180,46 +182,68 @@ watch(
</script>
<style lang="less">
.settings-modal {
:deep(.ant-modal-header) {
padding: 16px 24px;
border-bottom: 1px solid var(--gray-150);
.ant-modal-title {
font-size: 18px;
font-weight: 600;
color: var(--gray-900);
}
}
:deep(.ant-modal-content) {
border-radius: 8px;
.settings-modal.ant-modal {
.ant-modal-content {
border-radius: 12px;
display: flex;
flex-direction: column;
position: relative;
padding: 0;
overflow: hidden;
}
.ant-modal-body {
padding: 0;
}
}
.settings-container {
display: flex;
height: 100%;
height: 70vh;
width: 100%;
gap: 6px;
position: relative;
@media (max-width: 900px) {
flex-direction: column;
height: auto;
min-height: 70vh;
}
}
.settings-close-btn {
position: absolute;
top: 10px;
left: 14px;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: var(--gray-50);
color: var(--gray-700);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2;
&:hover {
background: var(--gray-200);
color: var(--gray-900);
}
}
/* Sidebar Styles - Matching SettingView.vue style */
.settings-sider {
width: 128px;
width: 176px;
height: 100%;
padding-top: 8px;
padding: 52px 10px 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex-shrink: 0;
background: var(--gray-50);
border-right: 1px solid var(--gray-150);
@media (max-width: 900px) {
display: none;
@ -237,7 +261,6 @@ watch(
display: flex;
align-items: center;
gap: 10px;
margin-left: -20px;
.icon {
font-size: 14px; /* Slightly adjusted to align better, SettingView uses h() icon defaults */
@ -248,7 +271,7 @@ watch(
}
&.activesec {
background: var(--gray-100);
background: var(--gray-150);
color: var(--main-700);
}
}
@ -258,30 +281,79 @@ watch(
.settings-content-wrapper {
flex: 1;
height: 100%;
max-width: calc(100% - 128px);
max-width: calc(100% - 176px);
min-width: 0;
display: flex;
flex-direction: column;
background: var(--gray-0);
padding: 0;
@media (max-width: 900px) {
max-width: 100%;
padding: 8px;
}
.settings-content {
padding: 0; /* Matches SettingView .setting padding */
padding: 12px 16px 16px; /* Keep inner readability without outer panel padding */
// margin-bottom: 40px; /* Matches SettingView .setting margin-bottom */
overflow-y: scroll;
height: 70vh;
height: auto;
flex: 1;
min-height: 0;
.model-providers-section,
.user-management,
.department-management,
.apikey-management {
min-height: auto;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
margin-bottom: 16px;
}
.header-content {
flex: 1;
min-width: 0;
}
.section-title {
font-size: 16px;
font-weight: 500;
color: var(--gray-900);
line-height: 1.4;
margin: 12px 0 4px;
}
.section-description {
font-size: 14px;
color: var(--gray-600);
line-height: 1.4;
margin: 0;
}
.section-subtitle {
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--gray-900);
}
.add-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
}
@media (max-width: 900px) {
height: 70vh;
padding: 0px;
height: auto;
padding: 10px 12px 12px;
}
h3 {
font-weight: 600;
color: var(--gray-900);
margin-bottom: 0.5em;
}
/* BasicSettingsSection has its own h3 styles which might conflict slightly but are mostly self-contained */
}
}
@ -292,6 +364,7 @@ watch(
border-bottom: 1px solid var(--gray-150);
background: var(--gray-0);
padding: 0;
padding-left: 42px;
flex-shrink: 0;
@media (max-width: 900px) {

View File

@ -66,9 +66,8 @@
<!-- 个人资料弹窗 -->
<a-modal
v-model:open="profileModalVisible"
title="个人资料"
:footer="null"
width="520px"
width="420px"
class="profile-modal"
>
<div class="profile-content">
@ -93,7 +92,7 @@
@change="handleAvatarChange"
accept="image/*"
>
<a-button type="primary" size="small" :loading="avatarUploading">
<a-button type="primary" class="lucide-icon-btn" size="small" :loading="avatarUploading">
<template #icon><Upload size="14" /></template>
{{ userStore.avatar ? '更换头像' : '上传头像' }}
</a-button>
@ -144,10 +143,8 @@
</div>
<div class="info-item">
<div class="info-label">角色</div>
<div class="info-value">
<a-tag :color="getRoleColor(userStore.userRole)" class="role-tag">
{{ userRoleText }}
</a-tag>
<div class="info-value" :style="{'color': getRoleColor(userStore.userRole) }">
{{ userRoleText }}
</div>
</div>
<div class="info-item" v-if="userStore.departmentId">
@ -315,11 +312,11 @@ const openProfile = async () => {
const getRoleColor = (role) => {
switch (role) {
case 'superadmin':
return 'red'
return 'var(--color-error-700)'
case 'admin':
return 'blue'
return 'var(--color-primary-500)'
case 'user':
return 'green'
return 'var(--color-success-500)'
default:
return 'default'
}
@ -549,14 +546,8 @@ const handleAvatarChange = async (info) => {
.profile-modal {
:deep(.ant-modal-header) {
padding: 20px 24px;
padding: 40px 24px;
border-bottom: 1px solid var(--gray-150);
.ant-modal-title {
font-size: 18px;
font-weight: 600;
color: var(--gray-900);
}
}
:deep(.ant-modal-body) {
@ -622,7 +613,7 @@ const handleAvatarChange = async (info) => {
.info-item {
display: flex;
align-items: center;
padding: 12px 0;
padding: 12px;
border-bottom: 1px solid var(--gray-50);
&:last-child {
@ -630,9 +621,9 @@ const handleAvatarChange = async (info) => {
}
.info-label {
width: 80px;
width: 120px;
font-weight: 500;
color: var(--gray-500);
color: var(--gray-600);
flex-shrink: 0;
}
@ -649,12 +640,6 @@ const handleAvatarChange = async (info) => {
display: inline-block;
}
}
.role-tag {
font-weight: 500;
border-radius: 4px;
padding: 4px 12px;
}
}
}

View File

@ -3,11 +3,11 @@
<!-- 头部区域 -->
<div class="header-section">
<div class="header-content">
<h3 class="title">用户管理</h3>
<p class="description">管理系统用户请谨慎操作删除用户后该用户将无法登录系统</p>
<div class="section-title">用户管理</div>
<p class="section-description">管理系统用户请谨慎操作删除用户后该用户将无法登录系统</p>
</div>
<a-button type="primary" @click="showAddUserModal" class="add-btn">
<template #icon><PlusOutlined /></template>
<a-button type="primary" @click="showAddUserModal" class="add-btn lucide-icon-btn">
<template #icon><Plus :size="16" /></template>
添加用户
</a-button>
</div>
@ -85,9 +85,9 @@
type="text"
size="small"
@click="showEditUserModal(user)"
class="action-btn"
class="action-btn lucide-icon-btn"
>
<EditOutlined />
<Pencil :size="14" />
<span>编辑</span>
</a-button>
</a-tooltip>
@ -101,9 +101,9 @@
user.id === userStore.userId ||
(user.role === 'superadmin' && userStore.userRole !== 'superadmin')
"
class="action-btn"
class="action-btn lucide-icon-btn"
>
<DeleteOutlined />
<Trash2 :size="14" />
<span>删除</span>
</a-button>
</a-tooltip>
@ -238,8 +238,7 @@ import { reactive, onMounted, watch } from 'vue'
import { notification, Modal } from 'ant-design-vue'
import { useUserStore } from '@/stores/user'
import { departmentApi } from '@/apis'
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { User, UserLock, UserStar } from 'lucide-vue-next'
import { Plus, Pencil, Trash2, User, UserLock, UserStar } from 'lucide-vue-next'
import { formatDateTime } from '@/utils/time'
const userStore = useUserStore()
@ -585,27 +584,6 @@ onMounted(async () => {
<style lang="less" scoped>
.user-management {
margin-top: 12px;
min-height: 50vh;
.header-section {
display: flex;
justify-content: space-between;
align-items: flex-start;
.header-content {
flex: 1;
.description {
font-size: 14px;
color: var(--gray-600);
margin: 0;
line-height: 1.4;
margin-bottom: 16px;
}
}
}
.content-section {
overflow: hidden;

View File

@ -46,7 +46,7 @@
</div>
</div>
<div class="stat-card secondary">
<div class="stat-card secondary clickable" @click="handleFeedbackClick">
<div class="stat-icon">
<BarChart3 class="icon" />
</div>
@ -89,6 +89,14 @@ const props = defineProps({
}
})
// Emits
const emit = defineEmits(['open-feedback'])
// Methods
const handleFeedbackClick = () => {
emit('open-feedback')
}
// Methods
const getSatisfactionClass = () => {
const rate = props.basicStats?.feedback_stats?.satisfaction_rate || 0
@ -165,6 +173,10 @@ const getSatisfactionClass = () => {
}
}
&.clickable {
cursor: pointer;
}
&.satisfaction-high {
.stat-icon {
background-color: var(--color-success-50);

View File

@ -155,7 +155,9 @@ const initToolsChart = () => {
toolsChart = echarts.init(toolsChartRef.value)
const data = props.toolStats.most_used_tools.slice(0, 10) // 10
const data = [...props.toolStats.most_used_tools]
.sort((a, b) => a.count - b.count)
.slice(0, 10) // 10
const option = {
tooltip: {

View File

@ -38,8 +38,8 @@ const router = createRouter({
meta: { keepAlive: true, requiresAuth: true }
},
{
path: ':agent_id',
name: 'AgentCompWithId',
path: ':thread_id',
name: 'AgentCompWithThreadId',
component: () => import('../views/AgentView.vue'),
meta: { keepAlive: true, requiresAuth: true }
}
@ -156,27 +156,14 @@ router.beforeEach(async (to, from, next) => {
// 如果路由需要管理员权限但用户不是管理员
if (requiresAdmin && !isAdmin) {
// 如果是普通用户,跳转到默认智能体页面
// 如果是普通用户,跳转到聊天页空态
try {
const agentStore = useAgentStore()
// 等待 store 初始化完成
if (!agentStore.isInitialized) {
await agentStore.initialize()
}
const defaultAgent = agentStore.defaultAgent
if (defaultAgent && defaultAgent.id) {
next(`/agent/${defaultAgent.id}`)
} else {
// 如果没有默认智能体,可以考虑跳转到第一个可用的智能体,或者一个特定的页面
const agentIds = Object.keys(agentStore.agents)
if (agentIds.length > 0) {
next(`/agent/${agentIds[0]}`)
} else {
// 没有可用的智能体,跳转到聊天页
next('/agent')
}
}
next('/agent')
} catch (error) {
console.error('获取智能体信息失败:', error)
next('/agent')
@ -191,12 +178,7 @@ router.beforeEach(async (to, from, next) => {
if (!agentStore.isInitialized) {
await agentStore.initialize()
}
const defaultAgent = agentStore.defaultAgent
if (defaultAgent && defaultAgent.id) {
next(`/agent/${defaultAgent.id}`)
} else {
next('/agent')
}
next('/agent')
} catch (error) {
console.error('获取智能体信息失败:', error)
next('/agent')

View File

@ -26,7 +26,8 @@ export const useInfoStore = defineStore('info', () => {
infoConfig.value.branding || {
name: '',
title: '',
subtitle: ''
subtitle: '',
subtitles: []
}
)

View File

@ -6,6 +6,7 @@
<AgentChatComponent
ref="chatComponentRef"
:single-mode="false"
@thread-change="handleThreadChange"
>
<template #input-actions-left>
<button
@ -106,61 +107,70 @@ const route = useRoute()
const router = useRouter()
// agentStore
const { agents, selectedAgentId, selectedConfigSummary, isLoadingConfig } = storeToRefs(agentStore)
const { selectedAgentId, defaultAgentId, selectedConfigSummary, isLoadingConfig } =
storeToRefs(agentStore)
const syncingRouteAgent = ref(false)
const syncingRouteThread = ref(false)
const getRouteAgentId = () => {
const value = route.params.agent_id
const getRouteThreadId = () => {
const value = route.params.thread_id
return typeof value === 'string' ? value : ''
}
const syncSelectedAgentFromRoute = async () => {
const routeAgentId = getRouteAgentId()
if (!routeAgentId) return
const syncSelectedThreadFromRoute = async () => {
const chatComponent = chatComponentRef.value
if (!chatComponent?.selectThreadFromRoute) return
syncingRouteAgent.value = true
const threadId = getRouteThreadId()
syncingRouteThread.value = true
try {
if (!agentStore.isInitialized) {
await agentStore.initialize()
}
const routeAgentExists = (agents.value || []).some((agent) => agent.id === routeAgentId)
if (!routeAgentExists) {
if (selectedAgentId.value) {
await router.replace({
name: 'AgentCompWithId',
params: { agent_id: selectedAgentId.value }
})
if (!threadId) {
if (!agentStore.isInitialized) {
await agentStore.initialize()
}
const targetAgentId = defaultAgentId.value
if (targetAgentId && selectedAgentId.value !== targetAgentId) {
await agentStore.selectAgent(targetAgentId)
}
return
}
if (selectedAgentId.value !== routeAgentId) {
await agentStore.selectAgent(routeAgentId)
const ok = await chatComponent.selectThreadFromRoute(threadId)
if (threadId && !ok) {
await router.replace({ name: 'AgentComp' })
}
} catch (error) {
handleChatError(error, 'load')
} finally {
syncingRouteAgent.value = false
syncingRouteThread.value = false
}
}
watch(
() => route.params.agent_id,
() => route.params.thread_id,
() => {
syncSelectedAgentFromRoute()
syncSelectedThreadFromRoute()
},
{ immediate: true }
)
watch(selectedAgentId, (newAgentId) => {
if (!newAgentId || syncingRouteAgent.value) return
const routeAgentId = getRouteAgentId()
if (routeAgentId === newAgentId) return
router.replace({ name: 'AgentCompWithId', params: { agent_id: newAgentId } })
watch(chatComponentRef, (instance) => {
if (!instance) return
syncSelectedThreadFromRoute()
})
const handleThreadChange = (threadId) => {
if (syncingRouteThread.value) return
const currentRouteThreadId = getRouteThreadId()
const nextThreadId = threadId || ''
if (currentRouteThreadId === nextThreadId) return
if (nextThreadId) {
router.replace({ name: 'AgentCompWithThreadId', params: { thread_id: nextThreadId } })
} else {
router.replace({ name: 'AgentComp' })
}
}
const openConfigSidebar = () => {
chatUIStore.isConfigSidebarOpen = !chatUIStore.isConfigSidebarOpen
}
@ -177,7 +187,7 @@ const toggleMoreMenu = (event) => {
if (chatUIStore.moreMenuOpen) {
//
const rect = event.currentTarget.getBoundingClientRect()
chatUIStore.openMoreMenu(rect.right - 130, rect.bottom + 8)
chatUIStore.openMoreMenu(rect.right - 110, rect.bottom + 8)
}
}

View File

@ -5,7 +5,7 @@
<!-- 现代化顶部统计栏 -->
<div class="modern-stats-header">
<StatusBar />
<StatsOverviewComponent :basic-stats="basicStats" />
<StatsOverviewComponent :basic-stats="basicStats" @open-feedback="handleOpenFeedback" />
</div>
<!-- Grid布局的主要内容区域 -->
@ -48,75 +48,6 @@
ref="knowledgeStatsRef"
/>
</div>
<!-- 对话记录 - 占据1x1网格 -->
<div class="grid-item conversations">
<a-card class="conversations-section" title="对话记录" :loading="loading">
<template #extra>
<a-space>
<a-input
v-model:value="filters.user_id"
placeholder="用户ID"
size="small"
style="width: 120px"
@change="handleFilterChange"
/>
<a-select
v-model:value="filters.status"
placeholder="状态"
size="small"
style="width: 100px"
@change="handleFilterChange"
>
<a-select-option value="active">活跃</a-select-option>
<a-select-option value="deleted">已删除</a-select-option>
<a-select-option value="all">全部</a-select-option>
</a-select>
<a-button size="small" @click="loadConversations" :loading="loading"> 刷新 </a-button>
<a-button size="small" @click="feedbackModal.show()"> 反馈详情 </a-button>
</a-space>
</template>
<a-table
:columns="conversationColumns"
:data-source="conversations"
:loading="loading"
:pagination="conversationPagination"
@change="handleTableChange"
row-key="thread_id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<a
@click="handleViewDetail(record)"
class="conversation-title"
:class="{ loading: loadingDetail }"
>{{ record.title || '未命名对话' }}</a
>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'active' ? 'green' : 'red'" size="small">
{{ record.status === 'active' ? '活跃' : '已删除' }}
</a-tag>
</template>
<template v-if="column.key === 'updated_at'">
<span class="time-text">{{ formatDate(record.updated_at) }}</span>
</template>
<template v-if="column.key === 'actions'">
<a-button
type="link"
size="small"
@click="handleViewDetail(record)"
:loading="loadingDetail"
>
详情
</a-button>
</template>
</template>
</a-table>
</a-card>
</div>
</div>
<!-- 反馈模态框 -->
@ -128,7 +59,6 @@
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { message } from 'ant-design-vue'
import { dashboardApi } from '@/apis/dashboard_api'
import dayjs, { parseToShanghai } from '@/utils/time'
//
import StatusBar from '@/components/StatusBar.vue'
@ -152,74 +82,13 @@ const allStatsData = ref({
agents: null
})
//
const filters = reactive({
user_id: '',
agent_id: '',
status: 'active'
})
//
const conversations = ref([])
const loading = ref(false)
const loadingDetail = ref(false)
//
const callStatsRef = ref(null)
//
const conversationPagination = reactive({
current: 1,
pageSize: 8,
total: 0,
showSizeChanger: false,
showQuickJumper: false,
showTotal: (total, range) => `${range[0]}-${range[1]} / ${total}`
})
//
const conversationColumns = [
{
title: '对话标题',
dataIndex: 'title',
key: 'title',
ellipsis: true
},
{
title: '用户',
dataIndex: 'user_id',
key: 'user_id',
width: '80px',
ellipsis: true
},
{
title: '消息数',
dataIndex: 'message_count',
key: 'message_count',
width: '60px',
align: 'center'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: '70px',
align: 'center'
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: '120px'
},
{
title: '操作',
key: 'actions',
width: '60px',
align: 'center'
}
]
//
const userStatsRef = ref(null)
const toolStatsRef = ref(null)
@ -267,72 +136,9 @@ const loadAllStats = async () => {
// loadStats
const loadStats = loadAllStats
//
const loadConversations = async () => {
try {
const params = {
user_id: filters.user_id || undefined,
agent_id: filters.agent_id || undefined,
status: filters.status,
limit: conversationPagination.pageSize,
offset: (conversationPagination.current - 1) * conversationPagination.pageSize
}
const response = await dashboardApi.getConversations(params)
conversations.value = response
// Note:
conversationPagination.total = response.length
} catch (error) {
console.error('加载对话列表失败:', error)
message.error('加载对话列表失败')
}
}
//
const formatDate = (dateString) => {
if (!dateString) return '-'
const parsed = parseToShanghai(dateString)
if (!parsed) return '-'
const now = dayjs().tz('Asia/Shanghai')
const diffDays = now.startOf('day').diff(parsed.startOf('day'), 'day')
if (diffDays === 0) {
return parsed.format('HH:mm')
}
if (diffDays === 1) {
return '昨天'
}
if (diffDays < 7) {
return `${diffDays}天前`
}
return parsed.format('MM-DD')
}
//
const handleViewDetail = async (record) => {
try {
loadingDetail.value = true
const detail = await dashboardApi.getConversationDetail(record.thread_id)
console.log(detail)
} catch (error) {
console.error('获取对话详情失败:', error)
message.error('获取对话详情失败')
} finally {
loadingDetail.value = false
}
}
//
const handleFilterChange = () => {
conversationPagination.current = 1
loadConversations()
}
//
const handleTableChange = (pag) => {
conversationPagination.current = pag.current
conversationPagination.pageSize = pag.pageSize
loadConversations()
//
const handleOpenFeedback = () => {
feedbackModal.value?.show()
}
// -
@ -347,7 +153,6 @@ const cleanupCharts = () => {
//
onMounted(() => {
loadAllStats()
loadConversations()
})
//
@ -422,17 +227,10 @@ onUnmounted(() => {
grid-row: 2 / 3;
min-height: 350px;
}
&.conversations {
grid-column: 1 / 4;
grid-row: 3 / 4;
min-height: 300px;
}
}
}
// Dashboard
.conversations-section,
.call-stats-section {
background-color: var(--gray-0);
border: 1px solid var(--gray-200);
@ -510,27 +308,6 @@ onUnmounted(() => {
}
}
// Dashboard
.conversations-section {
.conversation-title {
color: var(--main-500);
text-decoration: none;
font-weight: 500;
font-size: 13px;
transition: color 0.2s ease;
&:hover {
color: var(--main-600);
text-decoration: underline;
}
}
.time-text {
color: var(--gray-600);
font-size: 12px;
}
}
//
.call-stats-section {
.call-stats-container {
@ -617,12 +394,6 @@ onUnmounted(() => {
grid-row: 3 / 4;
min-height: 300px;
}
&.conversations {
grid-column: 1 / 3;
grid-row: 4 / 5;
min-height: 300px;
}
}
}
}
@ -641,8 +412,7 @@ onUnmounted(() => {
&.agent-stats,
&.user-stats,
&.tool-stats,
&.knowledge-stats,
&.conversations {
&.knowledge-stats {
grid-column: 1 / 2;
grid-row: auto;
min-height: 300px;

View File

@ -11,6 +11,7 @@
<a-result status="error" :title="error.title" :sub-title="error.message">
<template #extra>
<a-button type="primary" @click="retryLoad">重试</a-button>
<a-button :href="faqUrl" target="_blank" rel="noopener noreferrer">常见问题</a-button>
</template>
</a-result>
</div>
@ -27,29 +28,6 @@
/>
<span class="logo-text">{{ infoStore.organization.name }}</span>
</div>
<nav class="nav-links">
<router-link
to="/agent"
class="nav-link"
v-if="userStore.isLoggedIn && userStore.isAdmin"
>
<span>智能体</span>
</router-link>
<router-link
to="/graph"
class="nav-link"
v-if="userStore.isLoggedIn && userStore.isAdmin"
>
<span>知识图谱</span>
</router-link>
<router-link
to="/database"
class="nav-link"
v-if="userStore.isLoggedIn && userStore.isAdmin"
>
<span>知识库</span>
</router-link>
</nav>
<div class="header-actions">
<div class="github-link">
<a href="https://github.com/xerrors/Yuxi-Know" target="_blank">
@ -66,22 +44,41 @@
</div>
<div class="hero-layout">
<div class="hero-content">
<h1 class="title">{{ infoStore.branding.title }}</h1>
<p class="subtitle">{{ infoStore.branding.subtitle }}</p>
<div class="hero-content reveal-up">
<p v-if="typedBadge" class="hero-badge" :class="{ typing: isBadgeTyping }">
<template v-if="badgeParts.number">
<span>{{ badgeParts.prefix }}</span>
<a
class="hero-badge-link"
:href="repoUrl"
target="_blank"
rel="noopener noreferrer"
>
<span class="hero-badge-number">{{ badgeParts.number }}</span>
</a>
<span>{{ badgeParts.suffix }}</span>
</template>
<template v-else>{{ typedBadge }}</template>
</p>
<h1 class="title reveal-up delay-1">{{ infoStore.branding.title }}</h1>
<Transition name="subtitle-switch" mode="out-in">
<p v-if="currentSubtitle" class="subtitle" :key="currentSubtitle">{{ currentSubtitle }}</p>
</Transition>
<!-- <p class="description">{{ infoStore.branding.description }}</p> -->
<div class="hero-actions">
<button class="button-base primary" @click="goToChat">开始对话</button>
<a
class="button-base secondary"
href="https://xerrors.github.io/Yuxi-Know/"
target="_blank"
<button class="button-base primary" @click="goToChat">开始体验</button>
<a class="doc-text-link" href="https://xerrors.github.io/Yuxi-Know/" target="_blank"
>查看文档</a
>
</div>
</div>
<div class="insight-panel" v-if="featureCards.length">
<div class="stat-card" v-for="card in featureCards" :key="card.label">
<div
class="stat-card"
v-for="(card, index) in featureCards"
:key="card.label"
:style="{ '--card-stagger': `${index}` }"
>
<div class="stat-headline">
<span class="stat-icon" v-if="card.icon">
<component :is="card.icon" />
@ -128,14 +125,12 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useInfoStore } from '@/stores/info'
import { useAgentStore } from '@/stores/agent'
import { useThemeStore } from '@/stores/theme'
import { healthApi } from '@/apis/system_api'
import { Result, Button } from 'ant-design-vue'
import UserInfoComponent from '@/components/UserInfoComponent.vue'
import ProjectOverview from '@/components/ProjectOverview.vue'
import {
@ -150,18 +145,176 @@ import {
ShieldCheck
} from 'lucide-vue-next'
const AResult = Result
const AButton = Button
const router = useRouter()
const userStore = useUserStore()
const infoStore = useInfoStore()
const agentStore = useAgentStore()
const themeStore = useThemeStore()
const repoUrl = 'https://github.com/xerrors/Yuxi-Know'
const faqUrl = 'https://xerrors.github.io/Yuxi-Know/latest/changelog/faq.html'
//
const isLoading = ref(true)
const error = ref(null)
const typedBadge = ref('')
const isBadgeTyping = ref(false)
let badgeTimer = null
let subtitleTimer = null
let starsFetchController = null
const GITHUB_REPO_API = 'https://api.github.com/repos/xerrors/Yuxi-Know'
const GITHUB_STARS_TIMEOUT = 3000
const formatStars = (count) => {
if (!Number.isFinite(count) || count <= 0) {
return ''
}
return `${count}`
}
const subtitleIndex = ref(0)
const subtitleOptions = computed(() => {
const subtitles = infoStore.branding?.subtitles
if (Array.isArray(subtitles)) {
const list = subtitles
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
if (list.length) {
return list
}
}
const fallback = (infoStore.branding?.subtitle || '').trim()
return fallback ? [fallback] : []
})
const currentSubtitle = computed(() => subtitleOptions.value[subtitleIndex.value] || '')
const badgeParts = computed(() => {
const text = typedBadge.value || ''
const match = text.match(/^(.*?)(\d[\d,]*\+?)(\s+GitHub Stars.*)?$/)
if (!match) {
return {
prefix: text,
number: '',
suffix: ''
}
}
return {
prefix: match[1] || '',
number: match[2] || '',
suffix: match[3] || ''
}
})
const stopSubtitleCarousel = () => {
if (subtitleTimer) {
clearInterval(subtitleTimer)
subtitleTimer = null
}
}
const startSubtitleCarousel = () => {
stopSubtitleCarousel()
subtitleIndex.value = 0
if (subtitleOptions.value.length <= 1) {
return
}
subtitleTimer = setInterval(() => {
subtitleIndex.value = (subtitleIndex.value + 1) % subtitleOptions.value.length
}, 2800)
}
const stopStarsFetch = () => {
if (starsFetchController) {
starsFetchController.abort()
starsFetchController = null
}
}
const fetchGithubStars = async () => {
stopStarsFetch()
const controller = new AbortController()
starsFetchController = controller
const timer = setTimeout(() => {
controller.abort()
}, GITHUB_STARS_TIMEOUT)
try {
const response = await fetch(GITHUB_REPO_API, { signal: controller.signal })
if (!response.ok) {
return null
}
const data = await response.json()
const stars = Number(data?.stargazers_count)
return Number.isFinite(stars) && stars > 0 ? stars : null
} catch (e) {
return null
} finally {
clearTimeout(timer)
if (starsFetchController === controller) {
starsFetchController = null
}
}
}
const getHeroBadgeText = (starsCount = null) => {
const realtimeStars = formatStars(starsCount)
if (realtimeStars) {
return `已获得 ${realtimeStars} GitHub Stars`
}
const features = Array.isArray(infoStore.features) ? infoStore.features : []
const starFeature = features.find((item) => {
if (typeof item === 'string') {
return /star/i.test(item)
}
return /star|github/i.test(item?.label || '') || /stars|github/i.test(item?.icon || '')
})
if (!starFeature) {
return ''
}
const starValue =
typeof starFeature === 'string'
? ''
: (starFeature?.value || '').toString().trim()
return starValue ? `已获得 ${starValue} GitHub Stars` : '已获得 GitHub Stars'
}
const stopBadgeTyping = () => {
if (badgeTimer) {
clearInterval(badgeTimer)
badgeTimer = null
}
isBadgeTyping.value = false
}
const startBadgeTyping = (starsCount = null) => {
stopBadgeTyping()
const text = getHeroBadgeText(starsCount)
typedBadge.value = ''
if (!text) {
return
}
let index = 0
isBadgeTyping.value = true
badgeTimer = setInterval(() => {
index += 1
typedBadge.value = text.slice(0, index)
if (index >= text.length) {
stopBadgeTyping()
}
}, 45)
}
const checkHealth = async () => {
try {
@ -187,8 +340,15 @@ const loadData = async () => {
await checkHealth()
//
await infoStore.loadInfoConfig()
startSubtitleCarousel()
const starsCount = await fetchGithubStars()
startBadgeTyping(starsCount)
} catch (e) {
console.error('加载失败:', e)
stopBadgeTyping()
stopSubtitleCarousel()
stopStarsFetch()
typedBadge.value = ''
} finally {
isLoading.value = false
}
@ -235,6 +395,12 @@ onMounted(() => {
loadData()
})
onUnmounted(() => {
stopBadgeTyping()
stopSubtitleCarousel()
stopStarsFetch()
})
const iconKey = (value) => (typeof value === 'string' ? value.toLowerCase() : '')
// region icon_mapping
@ -485,6 +651,57 @@ const actionLinks = computed(() => {
gap: 1.25rem;
}
.reveal-up {
opacity: 0;
transform: translateY(14px);
animation: revealUp 0.7s ease forwards;
}
.reveal-up.delay-1 {
animation-delay: 120ms;
}
.hero-badge {
color: var(--main-600);
font-size: 0.92rem;
letter-spacing: 0.04em;
font-weight: 600;
margin: 0;
}
.hero-badge-link {
color: inherit;
text-decoration: none;
}
.hero-badge-number {
color: var(--main-700);
text-decoration: underline;
text-decoration-color: var(--main-500);
text-underline-offset: 0.15em;
text-decoration-thickness: 1.5px;
font-weight: 700;
transition:
color 0.2s ease,
text-decoration-color 0.2s ease;
}
.hero-badge-link:hover .hero-badge-number {
color: var(--main-800);
text-decoration-color: var(--main-700);
}
.hero-badge.typing::after {
content: '';
display: inline-block;
width: 1px;
height: 1em;
margin-left: 6px;
background: var(--main-600);
vertical-align: -0.1em;
animation: caretBlink 0.8s steps(1, end) infinite;
}
.title {
font-size: clamp(2.5rem, 4vw, 4rem);
font-weight: 800;
@ -509,6 +726,21 @@ const actionLinks = computed(() => {
font-weight: 600;
color: var(--gray-700);
line-height: 1.4;
margin: 0;
min-height: calc(1.4em * 1.3);
}
.subtitle-switch-enter-active,
.subtitle-switch-leave-active {
transition:
opacity 0.32s ease,
transform 0.32s ease;
}
.subtitle-switch-enter-from,
.subtitle-switch-leave-to {
opacity: 0;
transform: translateY(7px);
}
.description {
@ -523,6 +755,22 @@ const actionLinks = computed(() => {
align-items: center;
}
.doc-text-link {
color: var(--main-700);
font-weight: 600;
text-decoration: none;
border-bottom: 1px dashed var(--main-300);
padding-bottom: 0.15rem;
transition:
color 0.2s ease,
border-color 0.2s ease;
&:hover {
color: var(--main-800);
border-color: var(--main-500);
}
}
.button-base {
display: inline-flex;
align-items: center;
@ -543,10 +791,27 @@ const actionLinks = computed(() => {
background: linear-gradient(135deg, var(--main-600), var(--main-500));
color: var(--gray-0);
border-color: transparent;
position: relative;
isolation: isolate;
&:hover {
background: linear-gradient(135deg, var(--main-700), var(--main-600));
}
&::after {
content: '';
position: absolute;
inset: -2px;
border-radius: inherit;
background: radial-gradient(circle, rgba(36, 131, 154, 0.35), transparent 68%);
opacity: 0;
z-index: -1;
transition: opacity 0.25s ease;
}
&:hover::after {
opacity: 1;
}
}
.button-base.secondary {
@ -575,6 +840,10 @@ const actionLinks = computed(() => {
display: flex;
flex-direction: column;
gap: 0.4rem;
opacity: 0;
transform: translateY(12px);
animation: cardRise 0.55s ease forwards;
animation-delay: calc(var(--card-stagger, 0) * 90ms + 200ms);
}
.stat-headline {
@ -791,6 +1060,71 @@ const actionLinks = computed(() => {
opacity: 1;
}
@keyframes revealUp {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes cardRise {
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes caretBlink {
50% {
opacity: 0;
}
}
:global(:root.dark) {
.hero-badge-number {
color: var(--main-200);
text-decoration-color: var(--main-300);
}
.hero-badge-link:hover .hero-badge-number {
color: var(--main-100);
text-decoration-color: var(--main-100);
}
.doc-text-link {
color: var(--main-300);
border-bottom-color: var(--main-500);
&:hover {
color: var(--main-200);
border-bottom-color: var(--main-300);
}
}
.button-base.primary::after {
background: radial-gradient(circle, rgba(130, 195, 214, 0.28), transparent 72%);
}
}
@media (prefers-reduced-motion: reduce) {
.reveal-up,
.stat-card,
.hero-badge.typing::after {
animation: none;
}
.reveal-up,
.stat-card {
opacity: 1;
transform: none;
}
.subtitle-switch-enter-active,
.subtitle-switch-leave-active {
transition: none;
}
}
@media (max-width: 768px) {
.glass-header {
padding: 0.8rem 1.25rem;