refactor: 前端 Agent 组件重构

- AgentRuntimeConfigForm 从 AgentConfigSidebar 重命名
- AgentChatComponent、AgentInputArea、AgentMessageComponent 重写交互逻辑
- AgentPanel、AgentArtifactsCard、AgentView 适配新数据模型
- stores/agent.js 简化状态管理
- BasicSettingsSection、ShareConfigForm、DebugComponent 收敛配置
- SubagentCardList、ToolsCardList 适配新 API
- useApproval 逻辑优化
This commit is contained in:
Wenjie Zhang 2026-05-24 00:47:08 +08:00
parent 42636477c2
commit 749ed8d757
15 changed files with 725 additions and 1305 deletions

View File

@ -3,7 +3,6 @@ import {
apiPost,
apiDelete,
apiPut,
apiAdminPost,
apiRequest
} from './base'
import { useUserStore } from '@/stores/user'
@ -31,7 +30,7 @@ export const agentApi = {
...useUserStore().getAuthHeaders()
}
return fetch('/api/chat/agent', {
return fetch('/api/agent/chat', {
method: 'POST',
body: JSON.stringify(data),
signal,
@ -64,24 +63,20 @@ export const agentApi = {
return response.response
},
/**
* 获取默认智能体
* @returns {Promise} - 默认智能体信息
*/
getDefaultAgent: () => apiGet('/api/chat/default_agent'),
/**
* 获取智能体列表
* @returns {Promise} - 智能体列表
*/
getAgents: () => apiGet('/api/chat/agent'),
getAgents: () => apiGet('/api/agent'),
getAgentBackends: () => apiGet('/api/agent/backends'),
/**
* 获取单个智能体详情
* @param {string} agentId - 智能体ID
* @returns {Promise} - 智能体详情
*/
getAgentDetail: (agentId) => apiGet(`/api/chat/agent/${agentId}`),
getAgentDetail: (agentId) => apiGet(`/api/agent/${agentId}`),
/**
* 获取智能体历史消息
@ -117,36 +112,16 @@ export const agentApi = {
getMessageFeedback: (messageId) => apiGet(`/api/chat/message/${messageId}/feedback`),
getAgentConfigs: (agentId) => apiGet(`/api/chat/agent/${agentId}/configs`),
createAgent: (payload) => apiPost('/api/agent', payload),
getAgentConfigProfile: (agentId, configId) =>
apiGet(`/api/chat/agent/${agentId}/configs/${configId}`),
updateAgent: (agentId, payload) => apiPut(`/api/agent/${agentId}`, payload),
createAgentConfigProfile: (agentId, payload) =>
apiPost(`/api/chat/agent/${agentId}/configs`, payload),
updateAgentConfigProfile: (agentId, configId, payload) =>
apiPut(`/api/chat/agent/${agentId}/configs/${configId}`, payload),
setAgentConfigDefault: (agentId, configId) =>
apiPost(`/api/chat/agent/${agentId}/configs/${configId}/set_default`, {}),
deleteAgentConfigProfile: (agentId, configId) =>
apiDelete(`/api/chat/agent/${agentId}/configs/${configId}`),
/**
* 设置默认智能体
* @param {string} agentId - 智能体ID
* @returns {Promise} - 设置结果
*/
setDefaultAgent: async (agentId) => {
return apiAdminPost('/api/chat/set_default_agent', { agent_id: agentId })
},
deleteAgent: (agentId) => apiDelete(`/api/agent/${agentId}`),
/**
* 恢复被人工审批中断的对话流式响应
* @param {string} agentId - 智能体ID
* @param {Object} data - 恢复数据 { thread_id, answer: { question_id: answer }, approved }
* @param {string} threadId - 会话 ID
* @param {Object} data - 恢复数据 { answer: { question_id: answer }, approved }
* @param {Object} options - 可选参数signal, headers等
* @returns {Promise} - 恢复响应流
*/
@ -175,9 +150,9 @@ export const agentApi = {
* @returns {Promise<Object>}
*/
createAgentRun: (data) =>
apiPost('/api/chat/runs', {
apiPost('/api/agent/runs', {
query: data.query,
agent_config_id: data.agent_config_id,
agent_id: data.agent_id,
thread_id: data.thread_id,
meta: data.meta || {},
image_content: data.image_content || null
@ -188,21 +163,21 @@ export const agentApi = {
* @param {string} runId - run ID
* @returns {Promise<Object>}
*/
getAgentRun: (runId) => apiGet(`/api/chat/runs/${runId}`),
getAgentRun: (runId) => apiGet(`/api/agent/runs/${runId}`),
/**
* 取消 Run
* @param {string} runId - run ID
* @returns {Promise<Object>}
*/
cancelAgentRun: (runId) => apiPost(`/api/chat/runs/${runId}/cancel`, {}),
cancelAgentRun: (runId) => apiPost(`/api/agent/runs/${runId}/cancel`, {}),
/**
* 获取线程活跃 Run
* @param {string} threadId - 线程ID
* @returns {Promise<Object>}
*/
getThreadActiveRun: (threadId) => apiGet(`/api/chat/thread/${threadId}/active_run`),
getThreadActiveRun: (threadId) => apiGet(`/api/agent/thread/${threadId}/active_run`),
/**
* 打开 Run 事件 SSE 连接调用方负责关闭
@ -214,7 +189,7 @@ export const agentApi = {
streamAgentRunEvents: (runId, afterSeq = '0-0', options = {}) => {
const { signal } = options
return fetch(
`/api/chat/runs/${runId}/events?after_seq=${encodeURIComponent(String(afterSeq))}`,
`/api/agent/runs/${runId}/events?after_seq=${encodeURIComponent(String(afterSeq))}`,
{
method: 'GET',
headers: {

View File

@ -47,14 +47,6 @@ const props = defineProps({
threadId: {
type: String,
default: null
},
agentId: {
type: String,
default: null
},
agentConfigId: {
type: [String, Number],
default: null
}
})
const emit = defineEmits(['saved', 'open-preview'])
@ -104,12 +96,7 @@ const downloadFile = async (file) => {
if (!props.threadId || !file?.path) return
try {
const response = await downloadViewerFile(
props.threadId,
file.path,
props.agentId,
props.agentConfigId
)
const response = await downloadViewerFile(props.threadId, file.path)
const blob = await response.blob()
const contentDisposition =
response.headers.get('Content-Disposition') || response.headers.get('content-disposition')

View File

@ -52,8 +52,6 @@
v-if="shouldShowArtifacts(row.conv)"
:artifacts="currentArtifacts"
:thread-id="currentChatId"
:agent-id="currentThread?.agent_id || currentAgentId"
:agent-config-id="selectedAgentConfigId"
@saved="handleArtifactSaved"
@open-preview="openPanelPreview"
/>
@ -82,7 +80,6 @@
<span class="generating-text">正在生成回复...</span>
</div>
</div>
</div>
<div class="bottom" :class="{ 'start-screen': !conversations.length }">
<!-- 人工审批弹窗 - 放在输入框上方 -->
@ -101,54 +98,10 @@
</div>
<!-- 打招呼区域 - 在输入框上方 -->
<div v-if="!conversations.length" class="chat-examples-input">
<div v-if="!conversations.length" class="chat-greeting-input">
<h1>{{ randomGreeting }}</h1>
</div>
<div v-if="showStartAgentSegment" class="agent-segment-wrapper">
<a-segmented
:value="currentAgentId"
:options="agentSegmentOptions"
@change="handleStartAgentChange"
/>
</div>
<div v-else-if="showStartAgentDropdown" class="agent-switcher-wrapper">
<a-dropdown :trigger="['click']" placement="bottomCenter">
<button type="button" class="agent-switcher-btn">
<component :is="currentAgentIcon" size="16" class="agent-switcher-icon" />
<span class="agent-switcher-text">{{ currentAgentName }}</span>
<ChevronDown size="16" class="agent-switcher-chevron" />
</button>
<template #overlay>
<a-menu class="agent-switcher-menu">
<a-menu-item
v-for="agent in startAgents"
:key="agent.id"
@click="handleStartAgentChange(agent.id)"
>
<div class="agent-switcher-menu-item">
<component
:is="getAgentIconComponent(agent.id)"
size="16"
class="agent-switcher-menu-icon"
/>
<span class="agent-switcher-menu-text">{{
agent.name || 'Unknown'
}}</span>
<span
v-if="agent.id === currentAgentId"
class="agent-switcher-menu-badge"
>
当前
</span>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<AgentInputArea
v-model="userInput"
:is-loading="isProcessing"
@ -159,31 +112,16 @@
:supports-file-upload="supportsFileUpload"
:has-active-thread="!!currentChatId"
:todos="currentTodos"
:attachments="currentPendingThreadAttachments"
@send="handleSendOrStop"
@upload-attachment="handleAttachmentUpload"
@remove-attachment="handleAttachmentRemove"
>
<template #actions-left-extra>
<slot name="input-actions-left"></slot>
<slot name="input-actions-left" :has-active-thread="!!currentChatId"></slot>
</template>
</AgentInputArea>
<!-- 示例问题 -->
<div
class="example-questions"
v-if="!conversations.length && exampleQuestions.length > 0"
>
<div class="example-chips">
<div
v-for="question in exampleQuestions"
:key="question.id"
class="example-chip"
@click="handleExampleClick(question.text)"
>
{{ question.text }}
</div>
</div>
</div>
<div class="bottom-actions" v-if="conversations.length > 0">
<p class="note">当前智能体{{ currentThreadAgentName }}请注意辨别内容的可靠性</p>
</div>
@ -208,8 +146,6 @@
v-if="isAgentPanelOpen"
:agent-state="currentAgentState"
:thread-id="currentChatId"
:agent-id="currentThread?.agent_id || currentAgentId"
:agent-config-id="selectedAgentConfigId"
:panel-ratio="panelRatio"
:preview-tabs="agentPanelPreviewTabs"
:active-preview-path="agentPanelActivePreviewPath"
@ -239,15 +175,13 @@ import {
computed,
onUnmounted,
onActivated,
onDeactivated,
h
onDeactivated
} from 'vue'
import { message } from 'ant-design-vue'
import AgentInputArea from '@/components/AgentInputArea.vue'
import AgentMessageComponent from '@/components/AgentMessageComponent.vue'
import RefsComponent from '@/components/RefsComponent.vue'
import ToolCallsGroupComponent from '@/components/ToolCallsGroupComponent.vue'
import { Bot, Telescope, ChevronDown } from 'lucide-vue-next'
import { handleChatError, handleValidationError } from '@/utils/errorHandler'
import { ScrollController } from '@/utils/scrollController'
import { AgentValidator } from '@/utils/agentValidator'
@ -280,15 +214,8 @@ const agentStore = useAgentStore()
const chatThreadsStore = useChatThreadsStore()
const chatUIStore = useChatUIStore()
const configStore = useConfigStore()
const {
agents,
selectedAgentId,
defaultAgentId,
selectedAgentConfigId,
agentConfig,
configurableItems,
availableKnowledgeBases
} = storeToRefs(agentStore)
const { agents, selectedAgentId, agentConfig, configurableItems, availableKnowledgeBases } =
storeToRefs(agentStore)
const { threads, currentThreadId, currentThread } = storeToRefs(chatThreadsStore)
// ==================== LOCAL CHAT & UI STATE ====================
@ -309,20 +236,6 @@ const greetingMessages = [
//
const randomGreeting = greetingMessages[Math.floor(Math.random() * greetingMessages.length)]
//
const exampleQuestions = computed(() => {
const agentId = currentAgentId.value
let examples = []
if (agentId && agents.value && agents.value.length > 0) {
const agent = agents.value.find((a) => a.id === agentId)
examples = agent ? agent.metadata?.examples || [] : []
}
return examples.map((text, index) => ({
id: index + 1,
text: text
}))
})
//
const chatState = reactive({
currentThreadId: null,
@ -383,7 +296,10 @@ const getPanelContainerWidth = () => {
const getMaxPanelRatio = (containerWidth = getPanelContainerWidth()) => {
if (!containerWidth) return maxPanelRatio
return Math.max(minPanelRatio, Math.min(maxPanelRatio, (containerWidth - minChatMainWidth) / containerWidth))
return Math.max(
minPanelRatio,
Math.min(maxPanelRatio, (containerWidth - minChatMainWidth) / containerWidth)
)
}
const clampPanelRatio = (ratio, containerWidth = getPanelContainerWidth()) => {
@ -416,7 +332,8 @@ const resetAgentPanelState = () => {
}
const setAgentPanelViewMode = (mode) => {
agentPanelViewMode.value = mode === 'preview' && agentPanelActivePreviewPath.value ? 'preview' : 'tree'
agentPanelViewMode.value =
mode === 'preview' && agentPanelActivePreviewPath.value ? 'preview' : 'tree'
if (agentPanelActivePreviewPath.value) {
panelRatio.value = clampPanelRatio(previewPanelRatio)
@ -487,10 +404,9 @@ const closePanelPreviewPath = (targetPath) => {
// ==================== COMPUTED PROPERTIES ====================
const currentAgentId = computed(() => {
if (props.singleMode) {
return props.agentId || defaultAgentId.value
} else {
return selectedAgentId.value
return props.agentId || selectedAgentId.value || agents.value[0]?.id || ''
}
return selectedAgentId.value
})
const currentAgentName = computed(() => {
@ -502,7 +418,6 @@ const currentAgent = computed(() => {
if (!currentAgentId.value || !agents.value || !agents.value.length) return null
return agents.value.find((a) => a.id === currentAgentId.value) || null
})
const startAgents = computed(() => agents.value || [])
const currentChatId = computed(() => currentThreadId.value)
const currentThreadAgentName = computed(() => {
@ -515,8 +430,6 @@ const currentThreadAgentName = computed(() => {
}
return currentAgentName.value
})
const currentAgentIcon = computed(() => getAgentIconComponent(currentAgentId.value))
//
const supportsFileUpload = computed(() => {
if (!currentAgent.value) return false
@ -542,6 +455,9 @@ const currentThreadAttachments = computed(() => {
if (!currentChatId.value) return []
return threadAttachmentsMap.value[currentChatId.value] || []
})
const currentPendingThreadAttachments = computed(() =>
currentThreadAttachments.value.filter((attachment) => !attachment?.request_id)
)
const currentArtifacts = computed(() => {
const artifacts = currentAgentState.value?.artifacts
return Array.isArray(artifacts) ? artifacts : []
@ -653,55 +569,6 @@ const conversationRows = computed(() => {
return rows
})
//
const agentIconMap = {
ChatbotAgent: Bot,
DeepAgent: Telescope
}
const getAgentIconComponent = (agentId) => {
return agentIconMap[agentId] || Bot
}
const agentSegmentOptions = computed(() => {
return startAgents.value.map((agent) => {
const IconComponent = getAgentIconComponent(agent.id)
return {
label: () =>
h('div', { class: 'agent-option-label' }, [
h(IconComponent, { size: 16, class: 'agent-option-icon' }),
h('span', null, agent.name || 'Unknown')
]),
value: agent.id
}
})
})
const showStartAgentSelector = computed(() => {
return !props.singleMode && !conversations.value.length && startAgents.value.length > 1
})
const showStartAgentDropdown = computed(() => {
return (
showStartAgentSelector.value &&
(startAgents.value.length >= 4 || localUIState.chatMainWidth < 380)
)
})
const showStartAgentSegment = computed(() => {
return showStartAgentSelector.value && !showStartAgentDropdown.value
})
const handleStartAgentChange = async (agentId) => {
if (!agentId || agentId === currentAgentId.value) return
if (conversations.value.length > 0) return
try {
await agentStore.selectAgent(agentId)
} catch (error) {
handleChatError(error, 'load')
}
}
const isLoadingMessages = computed(() => chatUIStore.isLoadingMessages)
const isStreaming = computed(() => {
const threadState = currentThreadState.value
@ -736,7 +603,12 @@ const createClientRequestId = () => {
return `req-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
const buildOptimisticHumanMessage = ({ requestId, text, imageContent = null }) => {
const buildOptimisticHumanMessage = ({
requestId,
text,
imageContent = null,
attachments = []
}) => {
const message = {
id: requestId,
role: 'user',
@ -744,7 +616,8 @@ const buildOptimisticHumanMessage = ({ requestId, text, imageContent = null }) =
content: text,
message_type: imageContent ? 'multimodal_image' : 'text',
extra_metadata: {
request_id: requestId
request_id: requestId,
attachments
}
}
@ -799,15 +672,33 @@ const stripDuplicatedOngoingHumanMessage = (historyConvs, ongoingMessages) => {
}
// runs worker
const insertOptimisticHumanMessage = (threadState, { requestId, text, imageContent = null }) => {
const insertOptimisticHumanMessage = (
threadState,
{ requestId, text, imageContent = null, attachments = [] }
) => {
if (!threadState || !requestId) return
threadState.pendingRequestId = requestId
threadState.replyLoadingVisible = false
threadState.onGoingConv.msgChunks[requestId] = [
buildOptimisticHumanMessage({ requestId, text, imageContent })
buildOptimisticHumanMessage({ requestId, text, imageContent, attachments })
]
}
const markAttachmentsRequestId = (threadId, attachments, requestId) => {
if (!threadId || !attachments.length) return null
const previousAttachments = threadAttachmentsMap.value[threadId] || []
const fileIds = new Set(attachments.map((attachment) => attachment.file_id).filter(Boolean))
threadAttachmentsMap.value[threadId] = previousAttachments.map((attachment) =>
fileIds.has(attachment.file_id) ? { ...attachment, request_id: requestId } : attachment
)
return previousAttachments
}
const rollbackAttachments = (threadId, previousAttachments) => {
if (!threadId || !Array.isArray(previousAttachments)) return
threadAttachmentsMap.value[threadId] = previousAttachments
}
const CONFIG_CHANGE_NOTICE_MESSAGE =
'在运行过程中切换或修改配置可能会影响最终效果,建议新建一个对话。'
@ -823,7 +714,6 @@ const withConfigNoticeSync = async (task) => {
const buildThreadConfigSnapshot = () => {
return {
agentId: currentAgentId.value || '',
agentConfigId: selectedAgentConfigId.value ?? null,
configJson: JSON.stringify(agentConfig.value || {})
}
}
@ -912,7 +802,6 @@ const maybeInsertThreadConfigNotice = () => {
if (
previousSnapshot.agentId === currentSnapshot.agentId &&
previousSnapshot.agentConfigId === currentSnapshot.agentConfigId &&
previousSnapshot.configJson === currentSnapshot.configJson
) {
return
@ -1021,34 +910,6 @@ onUnmounted(() => {
})
// ==================== 线 ====================
const setThreadAgentConfigId = (threadId, agentConfigId) => {
if (!threadId) return
const thread = threads.value.find((item) => item.id === threadId)
if (thread) {
thread.metadata = {
...(thread.metadata || {}),
agent_config_id: agentConfigId ?? null
}
}
}
const syncSelectedConfigForThread = async (thread) => {
const threadAgentConfigId = thread?.metadata?.agent_config_id
if (!threadAgentConfigId) return
const targetAgentId = thread.agent_id || currentAgentId.value
if (!targetAgentId) return
const configList = agentStore.agentConfigs[targetAgentId] || []
if (!configList.length) {
await agentStore.fetchAgentConfigs(targetAgentId)
}
if (selectedAgentConfigId.value !== threadAgentConfigId) {
await agentStore.selectAgentConfig(threadAgentConfigId)
}
}
// 线
const fetchThreads = async (agentId = null) => {
const targetAgentId = props.singleMode ? agentId || currentAgentId.value : agentId
@ -1205,6 +1066,28 @@ const handleAttachmentUpload = async (files) => {
}
}
const handleAttachmentRemove = async (attachment) => {
const threadId = currentChatId.value
const fileId = attachment?.file_id
if (!threadId || !fileId) return
const previousAttachments = threadAttachmentsMap.value[threadId] || []
threadAttachmentsMap.value[threadId] = previousAttachments.filter(
(item) => item.file_id !== fileId
)
try {
await threadApi.deleteThreadAttachment(threadId, fileId)
await Promise.all([
fetchAgentState(currentAgentId.value, threadId),
refreshThreadFilesAndAttachments(threadId)
])
} catch (error) {
threadAttachmentsMap.value[threadId] = previousAttachments
handleChatError(error, 'delete')
}
}
// ==================== ====================
const { approvalState, handleApproval, processApprovalInStream } = useApproval({
getThreadState,
@ -1238,7 +1121,9 @@ const sendMessage = async ({
threadId,
text,
signal = undefined,
imageData = undefined
imageData = undefined,
requestId = '',
attachmentFileIds = []
}) => {
if (!agentId || !threadId || !text) {
const error = new Error('Missing agent, thread, or message text')
@ -1246,18 +1131,14 @@ const sendMessage = async ({
return Promise.reject(error)
}
if (!selectedAgentConfigId.value) {
const error = new Error('Missing agent_config_id')
handleChatError(error, 'send')
return Promise.reject(error)
}
setThreadAgentConfigId(threadId, selectedAgentConfigId.value)
const requestData = {
query: text,
agent_id: agentId,
thread_id: threadId,
agent_config_id: selectedAgentConfigId.value
meta: {
request_id: requestId,
attachment_file_ids: attachmentFileIds
}
}
//
@ -1317,7 +1198,6 @@ const selectChat = async (chatId) => {
await agentStore.selectAgent(targetChat.agent_id)
}
await syncSelectedConfigForThread(targetChat)
syncThreadConfigSnapshot(chatId)
})
} catch (error) {
@ -1382,11 +1262,6 @@ const handleSendMessage = async ({ image } = {}) => {
if ((!text && !image) || !currentAgent.value || isProcessing.value || sendCooldownActive.value)
return
if (!selectedAgentConfigId.value) {
message.error('请先选择智能体配置后再发送消息')
return
}
//
startSendCooldown()
@ -1407,6 +1282,11 @@ const handleSendMessage = async ({ image } = {}) => {
const threadState = getThreadState(threadId)
if (!threadState) return
const pendingAttachments = [...currentPendingThreadAttachments.value]
const pendingAttachmentFileIds = pendingAttachments
.map((attachment) => attachment.file_id)
.filter(Boolean)
if (useRunsApi) {
if ((threadMessages.value[threadId] || []).length === 0) {
const autoTitle = text.replace(/\s+/g, ' ').trim().slice(0, 2000)
@ -1434,19 +1314,25 @@ const handleSendMessage = async ({ image } = {}) => {
resetOnGoingConv(threadId)
const requestId = createClientRequestId()
const previousAttachments = markAttachmentsRequestId(threadId, pendingAttachments, requestId)
insertOptimisticHumanMessage(threadState, {
requestId,
text,
imageContent
imageContent,
attachments: pendingAttachments.map((attachment) => ({
...attachment,
request_id: requestId
}))
})
threadState.isStreaming = true
try {
const runResp = await agentApi.createAgentRun({
query: text,
agent_config_id: selectedAgentConfigId.value,
agent_id: currentAgentId.value,
thread_id: threadId,
meta: {
request_id: requestId
request_id: requestId,
attachment_file_ids: pendingAttachmentFileIds
},
image_content: imageContent
})
@ -1459,6 +1345,7 @@ const handleSendMessage = async ({ image } = {}) => {
threadState.isStreaming = false
threadState.replyLoadingVisible = false
threadState.pendingRequestId = null
rollbackAttachments(threadId, previousAttachments)
resetOnGoingConv(threadId)
handleChatError(error, 'send')
}
@ -1493,10 +1380,12 @@ const handleSendMessage = async ({ image } = {}) => {
threadState.isStreaming = true
resetOnGoingConv(threadId)
const requestId = createClientRequestId()
const previousAttachments = markAttachmentsRequestId(threadId, pendingAttachments, requestId)
insertOptimisticHumanMessage(threadState, {
requestId,
text,
imageContent
imageContent,
attachments: pendingAttachments.map((attachment) => ({ ...attachment, request_id: requestId }))
})
threadState.streamAbortController = new AbortController()
@ -1506,13 +1395,16 @@ const handleSendMessage = async ({ image } = {}) => {
threadId: threadId,
text: text,
signal: threadState.streamAbortController?.signal,
imageData: image
imageData: image,
requestId,
attachmentFileIds: pendingAttachmentFileIds
})
await handleAgentResponse(response, threadId)
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Stream error:', error)
rollbackAttachments(threadId, previousAttachments)
handleChatError(error, 'send')
} else {
console.warn('[Interrupted] Catch')
@ -1586,7 +1478,7 @@ const handleApprovalWithStream = async (answer) => {
try {
// 使 composable
const response = await handleApproval(answer, currentAgentId.value, selectedAgentConfigId.value)
const response = await handleApproval(answer)
if (!response) return // handleApproval
@ -1619,14 +1511,6 @@ const handleQuestionCancel = () => {
handleApprovalWithStream('reject')
}
//
const handleExampleClick = (questionText) => {
userInput.value = questionText
nextTick(() => {
handleSendMessage()
})
}
const buildExportPayload = () => {
const agentId = currentAgentId.value
let agentDescription = ''
@ -1966,11 +1850,6 @@ watch(currentAgentId, (newAgentId, oldAgentId) => {
maybeInsertThreadConfigNotice()
})
watch(selectedAgentConfigId, (newConfigId, oldConfigId) => {
if (oldConfigId === undefined || newConfigId === oldConfigId) return
maybeInsertThreadConfigNotice()
})
watch(
() => JSON.stringify(agentConfig.value || {}),
(newConfigJson, oldConfigJson) => {
@ -2122,7 +2001,7 @@ watch(currentChatId, (threadId, oldThreadId) => {
transition: none !important;
}
.chat-examples-input {
.chat-greeting-input {
padding: 24px 0;
text-align: center;
@ -2244,45 +2123,6 @@ watch(currentChatId, (threadId, oldThreadId) => {
font-size: 12px;
}
.example-questions {
margin-top: 16px;
text-align: center;
.example-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.example-chip {
padding: 6px 12px;
background: var(--gray-25);
// border: 1px solid var(--gray-100);
border-radius: 16px;
cursor: pointer;
font-size: 0.8rem;
color: var(--gray-700);
transition: all 0.15s ease;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
// background: var(--main-25);
border-color: var(--main-200);
color: var(--main-700);
box-shadow: 0 0px 4px rgba(0, 0, 0, 0.03);
}
&:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
}
.chat-loading {
padding: 0 50px;
text-align: center;

View File

@ -13,12 +13,57 @@
@keydown="handleKeyDown"
>
<template #top>
<div v-if="currentImage" class="input-top-stack">
<div v-if="currentImage || previewAttachments.length" class="input-top-stack">
<ImagePreviewComponent
v-if="currentImage"
:image-data="currentImage"
@remove="handleImageRemoved"
class="image-preview-wrapper"
/>
<div v-if="previewAttachments.length" class="attachment-preview-list">
<div
v-for="attachment in previewImageAttachments"
:key="attachment.fileId"
class="attachment-preview-image"
>
<img
:src="attachment.previewUrl"
:alt="attachment.name"
class="attachment-image-thumb"
/>
<button
class="attachment-remove-btn"
type="button"
:aria-label="`移除附件 ${attachment.name}`"
@click.stop="handleAttachmentRemoved(attachment)"
>
<X :size="14" />
</button>
</div>
<div
v-for="attachment in previewFileAttachments"
:key="attachment.fileId"
class="attachment-file-card"
>
<div class="attachment-file-icon" :style="{ color: attachment.iconColor }">
<component :is="attachment.icon" />
</div>
<div class="attachment-file-body">
<div class="attachment-file-name" :title="attachment.name">{{ attachment.name }}</div>
<div class="attachment-file-meta">{{ attachment.meta }}</div>
</div>
<button
class="attachment-remove-btn"
type="button"
:aria-label="`移除附件 ${attachment.name}`"
@click.stop="handleAttachmentRemoved(attachment)"
>
<X :size="14" />
</button>
</div>
</div>
</div>
</template>
<template #options-left>
@ -99,7 +144,8 @@ import { computed, ref, watch } from 'vue'
import MessageInputComponent from '@/components/MessageInputComponent.vue'
import ImagePreviewComponent from '@/components/ImagePreviewComponent.vue'
import AttachmentOptionsComponent from '@/components/AttachmentOptionsComponent.vue'
import { SquareCheck } from 'lucide-vue-next'
import { SquareCheck, X } from 'lucide-vue-next'
import { normalizeAttachmentPreviews } from '@/utils/file_utils'
import {
CheckCircleOutlined,
ClockCircleOutlined,
@ -120,10 +166,20 @@ const props = defineProps({
todos: {
type: Array,
default: () => []
},
attachments: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:modelValue', 'send', 'keydown', 'upload-attachment'])
const emit = defineEmits([
'update:modelValue',
'send',
'keydown',
'upload-attachment',
'remove-attachment'
])
const inputRef = ref(null)
const currentImage = ref(null)
@ -139,6 +195,13 @@ const todoProgress = computed(() => {
if (!totalTodoCount.value) return 0
return Math.round((completedTodoCount.value / totalTodoCount.value) * 100)
})
const previewAttachments = computed(() => normalizeAttachmentPreviews(props.attachments))
const previewImageAttachments = computed(() =>
previewAttachments.value.filter((attachment) => attachment.isImage && attachment.previewUrl)
)
const previewFileAttachments = computed(() =>
previewAttachments.value.filter((attachment) => !attachment.isImage || !attachment.previewUrl)
)
watch(showTodoEntry, (visible) => {
if (!visible) {
@ -171,6 +234,10 @@ const handleImageRemoved = () => {
currentImage.value = null
}
const handleAttachmentRemoved = (attachment) => {
emit('remove-attachment', attachment.raw)
}
const handleSend = () => {
emit('send', { image: currentImage.value })
currentImage.value = null
@ -229,6 +296,105 @@ const getTodoStatusLabel = (status) => {
margin-bottom: 10px;
}
.attachment-preview-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.attachment-preview-image {
position: relative;
width: 80px;
height: 80px;
border-radius: 12px;
border: 1px solid var(--gray-150);
background: var(--gray-25);
overflow: hidden;
}
.attachment-image-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.attachment-file-card {
position: relative;
display: flex;
align-items: center;
gap: 12px;
width: 220px;
min-width: 0;
padding: 10px 34px 10px 12px;
border: 1px solid var(--gray-150);
border-radius: 12px;
background: var(--gray-0);
box-shadow: 0 1px 4px var(--shadow-0);
}
.attachment-file-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--main-700);
background: var(--main-30);
}
.attachment-file-body {
min-width: 0;
}
.attachment-file-name {
overflow: hidden;
color: var(--gray-900);
font-size: 14px;
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-file-meta {
margin-top: 2px;
color: var(--gray-500);
font-size: 12px;
line-height: 1.3;
}
.attachment-remove-btn {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--gray-0);
background: var(--gray-900);
cursor: pointer;
transition:
background-color 0.15s ease,
transform 0.15s ease;
&:hover {
background: var(--gray-700);
}
&:active {
transform: scale(0.96);
}
}
// 穿 slot
:deep(.input-action-btn) {
display: flex;
@ -411,6 +577,10 @@ const getTodoStatusLabel = (status) => {
margin-bottom: 10px;
}
.attachment-file-card {
width: min(220px, 100%);
}
.todo-popover-card {
width: min(320px, calc(100vw - 24px));
padding: 12px;

View File

@ -5,7 +5,14 @@
>
<img :src="`data:image/jpeg;base64,${message.image_content}`" alt="用户上传的图片" />
</div>
<div class="message-box" :class="[message.type, customClasses]">
<div
class="message-box"
:class="[
message.type,
customClasses,
{ 'has-attachments': message.type === 'human' && messageAttachments.length }
]"
>
<!-- 用户消息 -->
<div
v-if="message.type === 'human'"
@ -94,6 +101,35 @@
<!-- 自定义内容 -->
<slot></slot>
</div>
<div
v-if="message.type === 'human' && messageAttachments.length"
class="human-message-attachments"
>
<div
v-for="attachment in imageAttachments"
:key="attachment.fileId"
class="message-attachment-image"
>
<img :src="attachment.previewUrl" :alt="attachment.name" class="message-attachment-thumb" />
</div>
<div
v-for="attachment in fileAttachments"
:key="attachment.fileId"
class="message-attachment-file"
>
<div class="message-attachment-icon" :style="{ color: attachment.iconColor }">
<component :is="attachment.icon" />
</div>
<div class="message-attachment-body">
<div class="message-attachment-name" :title="attachment.name">
{{ attachment.name }}
</div>
<div class="message-attachment-meta">{{ attachment.meta }}</div>
</div>
</div>
</div>
</template>
<script setup>
@ -107,6 +143,7 @@ import { useAgentStore } from '@/stores/agent'
import { useInfoStore } from '@/stores/info'
import { storeToRefs } from 'pinia'
import { MessageProcessor } from '@/utils/messageProcessor'
import { normalizeAttachmentPreviews } from '@/utils/file_utils'
const props = defineProps({
// 'user'|'assistant'|'sent'|'received'
@ -216,7 +253,17 @@ const getErrorMessage = computed(() => {
const agentStore = useAgentStore()
const { availableKnowledgeBases } = storeToRefs(agentStore)
const infoStore = useInfoStore()
//
const messageAttachments = computed(() =>
normalizeAttachmentPreviews(props.message.extra_metadata?.attachments)
)
const imageAttachments = computed(() =>
messageAttachments.value.filter((attachment) => attachment.isImage && attachment.previewUrl)
)
const fileAttachments = computed(() =>
messageAttachments.value.filter((attachment) => !attachment.isImage || !attachment.previewUrl)
)
const messageSources = computed(() => {
if (props.message.type === 'ai') {
return MessageProcessor.extractSourcesFromMessage(props.message, availableKnowledgeBases.value)
@ -317,6 +364,11 @@ const parsedData = computed(() => {
white-space: pre-line;
}
&.human.has-attachments,
&.sent.has-attachments {
margin-bottom: 0.375rem;
}
.message-copy-btn {
cursor: pointer;
color: var(--gray-400);
@ -460,6 +512,78 @@ const parsedData = computed(() => {
}
}
.human-message-attachments {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
align-self: flex-end;
max-width: 95%;
margin-bottom: 0.8rem;
}
.message-attachment-image {
width: 112px;
height: 112px;
overflow: hidden;
border: 1px solid var(--gray-200);
border-radius: 0.5rem;
background: var(--gray-0);
}
.message-attachment-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.message-attachment-file {
width: 220px;
min-width: 0;
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
border: 1px solid var(--gray-200);
border-radius: 0.625rem;
background: var(--gray-0);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.message-attachment-icon {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.5rem;
color: var(--main-color);
background: var(--main-50);
}
.message-attachment-body {
min-width: 0;
flex: 1;
}
.message-attachment-name {
overflow: hidden;
color: var(--gray-900);
font-size: 0.875rem;
line-height: 1.25rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-attachment-meta {
margin-top: 0.125rem;
color: var(--gray-500);
font-size: 0.75rem;
line-height: 1rem;
}
.retry-hint {
margin-top: 8px;
padding: 8px 16px;

View File

@ -151,14 +151,6 @@ const props = defineProps({
type: String,
default: null
},
agentId: {
type: String,
default: null
},
agentConfigId: {
type: [String, Number],
default: null
},
panelRatio: {
type: Number,
default: 0.35
@ -341,12 +333,7 @@ const getFileName = (fileItem) => {
}
const loadDirectoryChildren = async (directoryPath) => {
const res = await getViewerFileSystemTree(
props.threadId,
directoryPath,
props.agentId,
props.agentConfigId
)
const res = await getViewerFileSystemTree(props.threadId, directoryPath)
return sortEntries(res?.entries || []).map((entry) => createTreeNode(entry))
}
@ -361,12 +348,7 @@ const refreshFileSystem = async () => {
filesystemError.value = ''
try {
const res = await getViewerFileSystemTree(
props.threadId,
'/',
props.agentId,
props.agentConfigId
)
const res = await getViewerFileSystemTree(props.threadId, '/')
if (res?.entries) {
const displayRootEntry = res.entries.find(
(entry) => entry?.is_dir && entry.name === DISPLAY_ROOT_DIRECTORY_NAME
@ -439,22 +421,14 @@ const loadActivePreview = async () => {
}
try {
const res = await getViewerFileContent(
props.threadId,
filePath,
props.agentId,
props.agentConfigId
)
const res = await getViewerFileContent(props.threadId, filePath)
if (requestSeq !== previewRequestSeq) return
const previewType = res?.preview_type || 'text'
let previewUrl = ''
if ((previewType === 'image' || previewType === 'pdf') && res?.supported) {
const response = await downloadViewerFile(
props.threadId,
filePath,
props.agentId,
props.agentConfigId
)
const response = await downloadViewerFile(props.threadId, filePath)
const blob = await response.blob()
previewUrl = window.URL.createObjectURL(blob)
}
@ -525,7 +499,7 @@ const confirmDeleteNode = (node) => {
deletingPaths.value = nextDeletingPaths
try {
await deleteViewerFile(props.threadId, node.key, props.agentId, props.agentConfigId)
await deleteViewerFile(props.threadId, node.key)
dynamicTreeData.value = removeTreeNode(dynamicTreeData.value, node.key)
pruneTreeStateAfterDelete(node.key)
message.success(isDirectory ? '文件夹删除成功' : '文件删除成功')
@ -545,12 +519,7 @@ const downloadFile = async (fileItem) => {
if (!props.threadId || !fileItem?.path) return
try {
const response = await downloadViewerFile(
props.threadId,
fileItem.path,
props.agentId,
props.agentConfigId
)
const response = await downloadViewerFile(props.threadId, fileItem.path)
const blob = await response.blob()
const contentDisposition =
response.headers.get('Content-Disposition') || response.headers.get('content-disposition')
@ -650,7 +619,7 @@ onUnmounted(() => {
revokeCurrentPreviewUrl()
})
watch([() => props.threadId, () => props.agentId, () => props.agentConfigId], ([threadId]) => {
watch(() => props.threadId, (threadId) => {
if (threadId) {
refreshFileSystem()
} else {
@ -661,11 +630,7 @@ watch([() => props.threadId, () => props.agentId, () => props.agentConfigId], ([
}
})
watch(
[() => props.threadId, () => props.agentId, () => props.agentConfigId, () => props.activePreviewPath],
loadActivePreview,
{ immediate: true }
)
watch([() => props.threadId, () => props.activePreviewPath], loadActivePreview, { immediate: true })
watch(
() => props.activePreviewPath,

View File

@ -1,43 +1,8 @@
<template>
<div class="agent-config-sidebar" :class="{ open: isOpen }">
<!-- 侧边栏头部 -->
<div class="sidebar-header">
<div class="header-top-row">
<div v-if="selectedAgentId" class="config-title-row">
<span class="config-title-text">{{ currentConfigName }}</span>
</div>
<div class="header-actions">
<a-tooltip
v-if="!isEmptyConfig"
:title="isCurrentDefault ? '当前已是默认配置' : '设为默认配置'"
>
<button
type="button"
class="header-icon-action"
:class="{ 'is-default': isCurrentDefault }"
@click="setAsDefault"
>
<Star :size="18" :fill="isCurrentDefault ? 'currentColor' : 'none'" />
</button>
</a-tooltip>
<button type="button" @click="closeSidebar" class="header-icon-action">
<X :size="16" />
</button>
</div>
</div>
</div>
<!-- 侧边栏内容 -->
<div class="sidebar-content">
<div class="agent-runtime-config-form">
<div class="runtime-config-content">
<div class="agent-info" v-if="selectedAgent">
<!-- <div class="agent-basic-info">
<p class="agent-description">{{ selectedAgent.description }}</p>
</div> -->
<!-- <a-divider /> -->
<div class="config-segment" v-if="!isEmptyConfig">
<div class="config-segment" v-if="props.showSegmented && !isEmptyConfig">
<a-segmented v-model:value="currentSegment" :options="segmentOptions" block />
</div>
@ -56,6 +21,11 @@
class="config-alert"
/>
<!-- 统一显示所有配置项 -->
<a-empty
v-if="isCurrentSegmentEmpty"
description="暂无配置项"
class="config-empty"
/>
<template v-for="(value, key) in filteredConfigurableItems" :key="key">
<a-form-item :label="getConfigLabel(key, value)" :name="key" class="config-item">
<p v-if="value.description" class="config-description">{{ value.description }}</p>
@ -277,32 +247,6 @@
</div>
</div>
<!-- 固定在底部的操作按钮 -->
<div class="sidebar-footer" v-if="selectedAgentId">
<div class="form-actions">
<a-button
type="primary"
@click="saveConfig"
class="footer-main-btn save-btn"
:class="{ changed: agentStore.hasConfigChanges }"
:disabled="isSavingConfig || isEmptyConfig"
>
保存
</a-button>
<a-tooltip v-if="!isEmptyConfig" title="删除配置">
<button
type="button"
class="footer-icon-btn"
@click="confirmDeleteConfig"
:disabled="isDeletingConfig"
>
<Trash2 :size="16" />
</button>
</a-tooltip>
</div>
</div>
<a-modal
v-model:open="selectionModalOpen"
:title="`选择${configurableItems[currentConfigKey]?.name || '项目'}`"
@ -418,10 +362,10 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { X, Trash2, Check, Plus, Search, Star, RotateCw, Settings } from 'lucide-vue-next'
import { Check, Plus, Search, RotateCw, Settings } from 'lucide-vue-next'
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
import { useAgentStore } from '@/stores/agent'
import {
@ -433,52 +377,21 @@ import {
} from '@/utils/agentConfigUtils'
import { storeToRefs } from 'pinia'
// Props
const props = defineProps({
isOpen: {
type: Boolean,
default: false
segment: {
type: String,
default: 'model'
},
disabled: {
showSegmented: {
type: Boolean,
default: false
default: true
}
})
// Emits
const emit = defineEmits(['close'])
// Store
const agentStore = useAgentStore()
const router = useRouter()
watch(
() => props.isOpen,
async (val) => {
if (val) {
if (selectedAgentId.value) {
agentStore.fetchAgentConfigs(selectedAgentId.value).catch((error) => {
console.error('刷新智能体配置列表失败:', error)
})
try {
await agentStore.fetchAgentDetail(selectedAgentId.value, true)
} catch (error) {
console.error('刷新智能体配置项失败:', error)
}
}
}
}
)
const {
selectedAgent,
selectedAgentId,
selectedAgentConfigId,
selectedConfigSummary,
agentConfigs,
agentConfig,
configurableItems
} = storeToRefs(agentStore)
const { selectedAgent, selectedAgentId, agentConfig, configurableItems } = storeToRefs(agentStore)
// console.log(availableTools.value)
@ -496,24 +409,15 @@ const segmentOptions = [
{ label: '工具', value: 'tools' },
{ label: '其他', value: 'other' }
]
const activeSegment = computed(() => (props.showSegmented ? currentSegment.value : props.segment))
const isToolResourceKind = (kind) => isDefaultAllAgentResourceKind(kind)
const isEmptyConfig = computed(() => {
return !selectedAgentId.value || Object.keys(configurableItems.value).length === 0
})
const isCurrentDefault = computed(() => {
return !!selectedConfigSummary.value?.is_default
})
const currentConfigName = computed(() => {
return selectedConfigSummary.value?.name || '当前配置'
})
const isReadOnlyConfig = computed(() => false)
const isSavingConfig = ref(false)
const isDeletingConfig = ref(false)
const canManageCurrentAgent = computed(() => !!selectedAgent.value?.can_manage)
const isReadOnlyConfig = computed(() => !canManageCurrentAgent.value)
const segmentConfigKeys = computed(() => {
const keys = Object.keys(configurableItems.value)
@ -535,7 +439,7 @@ const segmentConfigKeys = computed(() => {
const filteredConfigurableItems = computed(() => {
if (isEmptyConfig.value) return {}
const keys = segmentConfigKeys.value[currentSegment.value] || []
const keys = segmentConfigKeys.value[activeSegment.value] || []
const filtered = {}
keys.forEach((key) => {
filtered[key] = configurableItems.value[key]
@ -543,6 +447,10 @@ const filteredConfigurableItems = computed(() => {
return filtered
})
const isCurrentSegmentEmpty = computed(
() => !isEmptyConfig.value && Object.keys(filteredConfigurableItems.value).length === 0
)
//
const isToolsKind = (kind) => {
return isToolResourceKind(kind)
@ -634,10 +542,6 @@ const updateConfigValue = (key, value) => {
})
}
const closeSidebar = () => {
emit('close')
}
const getConfigLabel = (key, value) => {
// console.log(configurableItems)
if (value.description && value.name !== key) {
@ -799,177 +703,14 @@ const validateAndFilterConfig = () => {
return validatedConfig
}
//
const saveConfig = async () => {
if (!selectedAgentId.value) {
message.error('没有选择智能体')
return
}
if (!agentStore.hasConfigChanges) return
try {
isSavingConfig.value = true
//
const validatedConfig = validateAndFilterConfig()
// store
if (JSON.stringify(validatedConfig) !== JSON.stringify(agentConfig.value)) {
agentStore.updateAgentConfig(validatedConfig)
message.info('检测到无效配置项,已自动过滤')
}
await agentStore.saveAgentConfig()
message.success('配置已保存到服务器')
} catch (error) {
console.error('保存配置到服务器出错:', error)
message.error('保存配置到服务器失败')
} finally {
isSavingConfig.value = false
}
}
const setAsDefault = async () => {
if (!selectedAgentId.value || !selectedAgentConfigId.value) return
try {
await agentStore.setSelectedAgentConfigDefault()
message.success('已设为默认配置')
} catch (error) {
console.error('设置默认配置出错:', error)
message.error('设置默认配置失败')
}
}
const confirmDeleteConfig = async () => {
if (!selectedAgentId.value || !selectedAgentConfigId.value) return
const currentName = selectedConfigSummary.value?.name || '当前配置'
const list = agentConfigs.value[selectedAgentId.value] || []
const content =
list.length <= 1
? `将删除「${currentName}」。删除后系统会自动创建一个新的默认配置。`
: `将删除「${currentName}」。`
Modal.confirm({
title: '确认删除配置?',
content,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
isDeletingConfig.value = true
try {
await agentStore.deleteSelectedAgentConfigProfile()
message.success('配置已删除')
} catch (error) {
console.error('删除配置出错:', error)
message.error('删除配置失败')
} finally {
isDeletingConfig.value = false
}
}
})
}
defineExpose({ validateAndFilterConfig })
</script>
<style lang="less" scoped>
.agent-config-sidebar {
position: relative;
width: 0;
height: 100vh;
.agent-runtime-config-form {
background: var(--gray-0);
border-left: 1px solid var(--gray-200);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
display: flex;
flex-direction: column;
flex-shrink: 0;
&.open {
width: 360px;
}
.sidebar-header {
display: flex;
align-items: center;
padding: 0 12px;
height: var(--header-height);
border-bottom: 1px solid var(--gray-150);
background: var(--gray-0);
flex-shrink: 0;
min-width: 360px;
z-index: 10;
.header-top-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.config-title-row {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.config-title-text {
min-width: 0;
font-size: 14px;
font-weight: 600;
color: var(--gray-900);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
}
.header-icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
border-radius: 6px;
color: var(--gray-600);
transition:
color 0.2s ease,
background-color 0.2s ease;
cursor: pointer;
&:hover:not(:disabled) {
color: var(--main-600);
background: var(--gray-100);
}
&.is-default,
&.is-default:hover:not(:disabled) {
color: var(--color-warning-500);
}
&:disabled {
cursor: not-allowed;
background: transparent;
color: var(--gray-400);
&.is-default {
opacity: 1;
}
}
}
.sidebar-content {
.runtime-config-content {
flex: 1;
overflow-y: auto;
padding: 10px 12px 8px;
@ -1040,7 +781,8 @@ const confirmDeleteConfig = async () => {
}
.config-form {
.config-alert {
.config-alert,
.config-empty {
margin-bottom: 16px;
}
@ -1137,87 +879,6 @@ const confirmDeleteConfig = async () => {
}
}
}
.sidebar-footer {
padding: 8px 12px;
border-top: 1px solid var(--gray-100);
background: var(--gray-0);
// min-width: 400px;
z-index: 10;
flex-shrink: 0; // Ensure footer doesn't shrink
.form-actions {
display: flex;
gap: 10px;
align-items: center;
.footer-main-btn {
flex: 1;
height: 36px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
transition:
opacity 0.2s ease,
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease;
}
.save-btn {
background-color: var(--gray-100);
border: 1px solid var(--gray-200);
color: var(--gray-600);
&.changed {
background-color: var(--main-color);
color: var(--gray-0);
border-color: var(--main-color);
}
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
cursor: not-allowed;
background-color: var(--gray-100);
border-color: var(--gray-200);
color: var(--gray-400);
}
}
.footer-icon-btn {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1px solid var(--gray-200);
border-radius: 8px;
background: var(--gray-0);
color: var(--gray-500);
cursor: pointer;
transition:
color 0.2s ease,
border-color 0.2s ease,
background-color 0.2s ease;
&:hover:not(:disabled) {
color: var(--color-error-700);
border-color: var(--color-error-100);
background: var(--color-error-50);
}
&:disabled {
cursor: not-allowed;
color: var(--gray-400);
background: var(--gray-50);
}
}
}
}
}
//
@ -1298,11 +959,12 @@ const confirmDeleteConfig = async () => {
.options-grid {
display: grid;
grid-template-columns: 1fr;
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
gap: 8px;
}
.option-card {
min-width: 0;
border: 1px solid var(--gray-300);
border-radius: 8px;
padding: 8px 12px;
@ -1343,11 +1005,14 @@ const confirmDeleteConfig = async () => {
justify-content: space-between;
align-items: center;
gap: 8px;
min-width: 0;
.option-text {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1.4;
overflow-wrap: anywhere;
}
.option-indicator {
@ -1576,16 +1241,4 @@ const confirmDeleteConfig = async () => {
.selection-search .inline-action-btn {
font-size: 13px;
}
//
@media (max-width: 768px) {
.agent-config-sidebar.open {
width: 100%;
}
.sidebar-header,
.sidebar-content {
min-width: 100% !important;
}
}
</style>

View File

@ -49,22 +49,6 @@
</div>
</div>
</template>
<div class="setting-row">
<div class="col-item">
<div class="setting-label">默认智能体</div>
<div class="setting-content">
<a-select
class="agent-select"
:value="agentStore.defaultAgentId"
:options="agentOptions"
:loading="isSettingDefaultAgent"
:disabled="isSettingDefaultAgent || !agentOptions.length"
placeholder="请选择默认智能体"
@change="handleDefaultAgentChange"
/>
</div>
</div>
</div>
</div>
<template v-if="userStore.isSuperAdmin">
@ -174,10 +158,8 @@
</template>
<script setup>
import { computed, h, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { computed, h } from 'vue'
import { useConfigStore } from '@/stores/config'
import { useAgentStore } from '@/stores/agent'
import { useUserStore } from '@/stores/user'
import { Globe } from 'lucide-vue-next'
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
@ -185,16 +167,8 @@ import EmbeddingModelSelector from '@/components/EmbeddingModelSelector.vue'
import RerankModelSelector from '@/components/RerankModelSelector.vue'
const configStore = useConfigStore()
const agentStore = useAgentStore()
const userStore = useUserStore()
const items = computed(() => configStore.config?._config_items || {})
const isSettingDefaultAgent = ref(false)
const agentOptions = computed(() =>
(agentStore.agents || []).map((agent) => ({
label: agent.name || 'Unknown',
value: agent.id
}))
)
const handleChange = (key, e) => {
configStore.setConfigValue(key, e)
@ -218,30 +192,9 @@ 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>

View File

@ -161,7 +161,7 @@ watch(showModal, (isOpen) => {
import { useConfigStore } from '@/stores/config'
import { useUserStore } from '@/stores/user'
import { useDatabaseStore } from '@/stores/database'
import { useAgentStore } from '@/stores/agent'
import { isBuiltinAgent, useAgentStore } from '@/stores/agent'
import { useInfoStore } from '@/stores/info'
import { useThrottleFn } from '@vueuse/core'
import {
@ -484,7 +484,6 @@ const printAgentConfig = async () => {
console.log('Store 状态:', {
isInitialized: agentStore.isInitialized,
selectedAgentId: agentStore.selectedAgentId,
defaultAgentId: agentStore.defaultAgentId,
agentCount: agentStore.agentsList.length,
loadingStates: {
isLoadingAgents: agentStore.isLoadingAgents,
@ -505,7 +504,7 @@ const printAgentConfig = async () => {
if (agentStore.selectedAgent) {
console.log('当前选中智能体:', {
agent: toRaw(agentStore.selectedAgent),
isDefault: agentStore.isDefaultAgent,
isBuiltin: isBuiltinAgent(agentStore.selectedAgent),
configurableItemsCount: Object.keys(agentStore.configurableItems).length
})

View File

@ -1,6 +1,11 @@
<template>
<div class="share-config-form">
<div class="share-mode-cards" :class="`active-${config.access_level}`" role="radiogroup" aria-label="共享设置">
<div class="share-config-form" :class="{ disabled }">
<div
class="share-mode-cards"
:class="`active-${config.access_level}`"
role="radiogroup"
aria-label="共享设置"
>
<div
v-for="option in shareModeOptions"
:key="option.value"
@ -8,7 +13,7 @@
class="share-mode-card"
:class="{ active: config.access_level === option.value }"
:aria-checked="config.access_level === option.value"
:tabindex="config.access_level === option.value ? 0 : -1"
:tabindex="!disabled && config.access_level === option.value ? 0 : -1"
@click="setAccessLevel(option.value)"
@keydown.enter.prevent="setAccessLevel(option.value)"
@keydown.space.prevent="setAccessLevel(option.value)"
@ -29,6 +34,7 @@
size="small"
class="select-action lucide-icon-btn"
:aria-label="option.value === 'department' ? '选择部门' : '选择用户'"
:disabled="disabled"
>
<UserPlus class="select-action-icon" :size="14" />
<span class="access-count">{{ getAccessCount(option.value) }}</span>
@ -88,6 +94,7 @@
</div>
</div>
</div>
<a-alert v-if="disabled && disabledReason" type="info" show-icon class="share-disabled-alert" :message="disabledReason" />
</div>
</template>
@ -103,7 +110,7 @@ const departments = ref([])
const users = ref([])
const syncingFromProps = ref(false)
const shareModeOptions = [
const baseShareModeOptions = [
{
value: 'global',
title: '全局共享',
@ -137,6 +144,18 @@ const props = defineProps({
autoSelectUserDept: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
disabledReason: {
type: String,
default: ''
},
allowedAccessLevels: {
type: Array,
default: () => ['global', 'department', 'user']
}
})
@ -159,6 +178,13 @@ const currentDepartmentId = computed(() => {
})
const currentUserUid = computed(() => userStore.uid || '')
const normalizedAllowedAccessLevels = computed(() => {
const allowed = props.allowedAccessLevels.filter((level) => ['global', 'department', 'user'].includes(level))
return allowed.length ? allowed : ['global']
})
const shareModeOptions = computed(() =>
baseShareModeOptions.filter((option) => normalizedAllowedAccessLevels.value.includes(option.value))
)
const departmentOptions = computed(() =>
departments.value.map((dept) => {
@ -220,10 +246,12 @@ const normalizeActiveConfig = () => {
const initConfig = () => {
syncingFromProps.value = true
const accessLevel = ['global', 'department', 'user'].includes(props.modelValue?.access_level)
const requestedAccessLevel = ['global', 'department', 'user'].includes(props.modelValue?.access_level)
? props.modelValue.access_level
: 'global'
config.access_level = accessLevel
config.access_level = normalizedAllowedAccessLevels.value.includes(requestedAccessLevel)
? requestedAccessLevel
: normalizedAllowedAccessLevels.value[0]
config.department_ids = normalizeDepartmentIds(props.modelValue?.department_ids)
config.user_uids = normalizeUserUids(props.modelValue?.user_uids)
normalizeActiveConfig()
@ -241,6 +269,7 @@ const emitConfig = () => {
}
const setAccessLevel = (accessLevel) => {
if (props.disabled || !normalizedAllowedAccessLevels.value.includes(accessLevel)) return
if (config.access_level === accessLevel) return
config.access_level = accessLevel
normalizeActiveConfig()
@ -273,6 +302,7 @@ const isSelected = (accessLevel, value) => {
}
const toggleSelection = (accessLevel, value, checked) => {
if (props.disabled) return
if (accessLevel === 'department') {
const departmentId = Number(value)
const selected = checked
@ -316,6 +346,8 @@ watch(
{ deep: true }
)
watch(normalizedAllowedAccessLevels, () => initConfig())
watch(
config,
() => {
@ -383,6 +415,15 @@ defineExpose({
}
}
.share-disabled-alert {
margin-top: 10px;
}
&.disabled .share-mode-card {
cursor: not-allowed;
opacity: 0.78;
}
.share-mode-card {
position: relative;
display: flex;

View File

@ -26,8 +26,9 @@
<ExtensionCardGrid>
<InfoCard
v-for="agent in filteredEnabledSubAgents"
:key="agent.name"
:key="agent.slug"
:title="agent.name"
:subtitle="agent.slug"
:description="agent.description || '暂无描述'"
:default-icon="BotIcon"
:tags="agent.is_builtin ? [{ name: '内置' }] : [{ name: '添加' }]"
@ -41,8 +42,9 @@
<ExtensionCardGrid v-if="filteredDisabledSubAgents.length">
<InfoCard
v-for="agent in filteredDisabledSubAgents"
:key="agent.name"
:key="agent.slug"
:title="agent.name"
:subtitle="agent.slug"
:description="agent.description || '暂无描述'"
:default-icon="BotIcon"
:tags="agent.is_builtin ? [{ name: '内置' }] : [{ name: '添加' }]"

View File

@ -30,11 +30,11 @@
<ExtensionCardGrid v-else>
<InfoCard
v-for="tool in filteredTools"
:key="tool.id"
:key="getToolSlug(tool)"
:title="tool.name"
:subtitle="tool.id"
:subtitle="getToolSlug(tool)"
:description="tool.description || '无描述'"
:default-icon="getToolIcon(tool.id) || WrenchIcon"
:default-icon="getToolIcon(getToolSlug(tool)) || WrenchIcon"
:tags="toolTags(tool)"
@click="selectTool(tool)"
>
@ -133,6 +133,8 @@ const categories = ['buildin', 'knowledge', 'mysql', 'debug']
const categoryLabels = { buildin: '内置工具', knowledge: '知识库', mysql: 'MySQL', debug: '调试' }
const categoryColors = { buildin: 'blue', knowledge: 'purple', mysql: 'green', debug: 'orange' }
const getToolSlug = (tool) => tool?.slug || tool?.id || ''
const toolTags = (tool) => {
const tags = []
if (tool.category) {
@ -161,7 +163,7 @@ const filteredTools = computed(() => {
result = result.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
t.id.toLowerCase().includes(q) ||
getToolSlug(t).toLowerCase().includes(q) ||
t.description?.toLowerCase().includes(q) ||
t.config_guide?.toLowerCase().includes(q)
)

View File

@ -50,7 +50,7 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa
threadId: null
})
const handleApproval = async (answer, currentAgentId, agentConfigId) => {
const handleApproval = async (answer) => {
const threadId = approvalState.threadId
if (!threadId) {
message.error('无效的提问请求')
@ -65,12 +65,6 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa
return
}
if (!agentConfigId) {
message.error('缺少智能体配置,请重新选择配置后重试')
approvalState.showModal = false
return
}
approvalState.showModal = false
if (threadState.streamAbortController) {
@ -83,8 +77,7 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa
threadState.streamAbortController = new AbortController()
const requestBody = {
thread_id: threadId,
config: { agent_config_id: agentConfigId }
thread_id: threadId
}
if (approvalState.status === 'human_approval_required') {

View File

@ -4,85 +4,67 @@ import { agentApi, databaseApi, mcpApi, skillApi } from '@/apis'
import { isDefaultAllAgentResourceKind } from '@/utils/agentConfigUtils'
import { handleChatError } from '@/utils/errorHandler'
const CHATBOT_AGENT_ID = 'ChatbotAgent'
function normalizeAgent(agent) {
const agentId = agent?.agent_id || agent?.slug || agent?.id
return agentId ? { ...agent, id: agentId, agent_id: agentId, slug: agent?.slug || agentId } : agent
}
export const BUILTIN_AGENT_ID = 'default-chatbot'
export function isBuiltinAgent(agent) {
return agent?.is_builtin || agent?.id === BUILTIN_AGENT_ID || agent?.slug === BUILTIN_AGENT_ID
}
function sortAgents(agents) {
return [...agents].sort((a, b) => {
const isAChatbotAgent = a.id === CHATBOT_AGENT_ID
const isBChatbotAgent = b.id === CHATBOT_AGENT_ID
if (isAChatbotAgent && !isBChatbotAgent) return -1
if (!isAChatbotAgent && isBChatbotAgent) return 1
return a.id.localeCompare(b.id)
if (isBuiltinAgent(a) !== isBuiltinAgent(b)) return isBuiltinAgent(a) ? -1 : 1
return String(a.name || a.id).localeCompare(String(b.name || b.id), 'zh-CN')
})
}
function getPreferredAgentId(agents, persistedId) {
if (persistedId && agents.some((agent) => agent.id === persistedId)) return persistedId
return agents.find(isBuiltinAgent)?.id || agents[0]?.id || null
}
function extractContext(agent) {
const configJson = agent?.config_json || {}
return { ...(configJson.context || configJson || {}) }
}
export const useAgentStore = defineStore(
'agent',
() => {
// ==================== 状态定义 ====================
// 智能体相关状态
const agents = ref([])
const selectedAgentId = ref(null)
const defaultAgentId = ref(null)
// 资源相关状态
const availableKnowledgeBases = ref([])
const availableMcps = ref([])
const availableSkills = ref([])
// 智能体配置相关状态
const agentConfig = ref({})
const originalAgentConfig = ref({})
const agentConfigs = ref({})
const selectedAgentConfigId = ref(null)
const agentDetails = ref({})
// 智能体详情相关状态
const agentDetails = ref({}) // 存储每个智能体的详细信息(含 configurable_items
// 加载状态
const isLoadingAgents = ref(false)
const isLoadingConfig = ref(false)
const isLoadingAgentConfigs = ref(false)
const isLoadingAgentDetail = ref(false)
// 错误状态
const error = ref(null)
// 初始化状态
const isInitialized = ref(false)
const isInitializing = ref(false)
// ==================== 计算属性 ====================
const selectedAgent = computed(() =>
selectedAgentId.value ? agents.value.find((a) => a.id === selectedAgentId.value) : null
)
const defaultAgent = computed(() =>
defaultAgentId.value
? agents.value.find((a) => a.id === defaultAgentId.value)
: agents.value[0]
)
const selectedAgent = computed(() => {
const agentId = selectedAgentId.value
return agentId ? agentDetails.value[agentId] || agents.value.find((a) => a.id === agentId) || null : null
})
const agentsList = computed(() => agents.value)
const isDefaultAgent = computed(() => selectedAgentId.value === defaultAgentId.value)
const configurableItems = computed(() => {
const agentId = selectedAgentId.value
if (
!agentId ||
!agentDetails.value[agentId] ||
!agentDetails.value[agentId].configurable_items
) {
return {}
}
const agentConfigurableItems = agentDetails.value[agentId].configurable_items
const items = { ...agentConfigurableItems }
const items = { ...(selectedAgent.value?.configurable_items || {}) }
Object.keys(items).forEach((key) => {
const item = items[key]
if (item && item.x_oap_ui_config) {
if (item?.x_oap_ui_config) {
items[key] = { ...item, ...item.x_oap_ui_config }
delete items[key].x_oap_ui_config
}
@ -90,27 +72,11 @@ export const useAgentStore = defineStore(
return items
})
// 工具相关状态
const availableTools = computed(() => {
return configurableItems.value.tools?.options || []
})
const availableTools = computed(() => configurableItems.value.tools?.options || [])
const hasConfigChanges = computed(
() => JSON.stringify(agentConfig.value) !== JSON.stringify(originalAgentConfig.value)
)
const selectedConfigSummary = computed(() => {
const agentId = selectedAgentId.value
const configId = selectedAgentConfigId.value
if (!agentId || !configId) return null
const list = agentConfigs.value[agentId] || []
return list.find((c) => c.id === configId) || null
})
// ==================== 方法 ====================
/**
* 获取可提及的资源知识库MCPSkills
*/
async function fetchMentionResources() {
try {
const [dbsRes, mcpsRes, skillsRes] = await Promise.all([
@ -126,57 +92,16 @@ export const useAgentStore = defineStore(
}
}
/**
* 初始化 store
*/
async function initialize() {
if (isInitialized.value) return
// 防止并发初始化
if (isInitializing.value) return
if (isInitialized.value || isInitializing.value) return
isInitializing.value = true
try {
await fetchAgents()
await fetchDefaultAgent()
await fetchMentionResources()
await Promise.all([fetchAgents(), fetchMentionResources()])
if (!selectedAgent.value) {
if (defaultAgent.value) {
await selectAgent(defaultAgentId.value)
} else if (agents.value.length > 0) {
const firstAgentId = agents.value[0].id
await selectAgent(firstAgentId)
}
} else {
console.log('Condition FALSE: Persisted selected agent is valid. Keeping it.')
// 确保已缓存的智能体详细信息存在
if (selectedAgentId.value && !agentDetails.value[selectedAgentId.value]) {
try {
await fetchAgentDetail(selectedAgentId.value)
} catch (err) {
console.warn(`Failed to fetch agent detail for ${selectedAgentId.value}:`, err)
}
}
if (selectedAgentId.value) {
try {
await fetchAgentConfigs(selectedAgentId.value)
const list = agentConfigs.value[selectedAgentId.value] || []
const persistedId = selectedAgentConfigId.value
const persistedItem = persistedId ? list.find((c) => c.id === persistedId) : null
const defaultItem = list.find((c) => c.is_default) || list[0]
const pickId = (persistedItem || defaultItem)?.id || null
selectedAgentConfigId.value = pickId
if (pickId) {
await loadAgentConfig(selectedAgentId.value, pickId)
}
} catch (err) {
console.warn(`Failed to init agent configs for ${selectedAgentId.value}:`, err)
}
}
const targetAgentId = getPreferredAgentId(agents.value, selectedAgentId.value)
if (targetAgentId) {
await selectAgent(targetAgentId)
}
isInitialized.value = true
} catch (err) {
console.error('Failed to initialize agent store:', err)
@ -187,16 +112,12 @@ export const useAgentStore = defineStore(
}
}
/**
* 获取智能体列表
*/
async function fetchAgents() {
isLoadingAgents.value = true
error.value = null
try {
const response = await agentApi.getAgents()
agents.value = sortAgents(response.agents || [])
agents.value = sortAgents((response.agents || []).map(normalizeAgent))
} catch (err) {
console.error('Failed to fetch agents:', err)
handleChatError(err, 'fetch')
@ -207,26 +128,40 @@ export const useAgentStore = defineStore(
}
}
/**
* 获取单个智能体的详细信息包含配置选项
* @param {string} agentId - 智能体ID
*/
async function fetchAgentDetail(agentId, forceRefresh = false) {
if (!agentId) return
function applyConfigDefaults(loadedConfig, configItems = configurableItems.value) {
const items = { ...configItems }
Object.keys(items).forEach((key) => {
const item = items[key]?.x_oap_ui_config ? { ...items[key], ...items[key].x_oap_ui_config } : items[key]
const isDefaultAllList = isDefaultAllAgentResourceKind(item?.kind)
if (loadedConfig[key] === undefined || (loadedConfig[key] === null && !isDefaultAllList)) {
if (item.default !== undefined) loadedConfig[key] = item.default
}
if (
loadedConfig[key] !== undefined &&
loadedConfig[key] !== null &&
loadedConfig[key] !== '' &&
(item?.type === 'number' || item?.type === 'int' || item?.type === 'float')
) {
const numericValue = Number(loadedConfig[key])
if (!Number.isNaN(numericValue)) {
loadedConfig[key] = item.type === 'int' ? Math.trunc(numericValue) : numericValue
}
}
})
return loadedConfig
}
// 如果已经缓存了详细信息且不强制刷新,直接返回
if (!forceRefresh && agentDetails.value[agentId]) {
return agentDetails.value[agentId]
}
async function fetchAgentDetail(agentId, forceRefresh = false) {
if (!agentId) return null
if (!forceRefresh && agentDetails.value[agentId]) return agentDetails.value[agentId]
isLoadingAgentDetail.value = true
error.value = null
try {
const response = await agentApi.getAgentDetail(agentId)
agentDetails.value[agentId] = response
// availableTools.value[agentId] = response.available_tools || []
return response
const agent = normalizeAgent(response.agent || response)
agentDetails.value[agent.id] = agent
return agent
} catch (err) {
console.error(`Failed to fetch agent detail for ${agentId}:`, err)
handleChatError(err, 'fetch')
@ -237,172 +172,31 @@ export const useAgentStore = defineStore(
}
}
/**
* 获取默认智能体
*/
async function fetchDefaultAgent() {
try {
const response = await agentApi.getDefaultAgent()
defaultAgentId.value = response.default_agent_id
} catch (err) {
console.error('Failed to fetch default agent:', err)
handleChatError(err, 'fetch')
error.value = err.message
}
}
/**
* 设置默认智能体
*/
async function setDefaultAgent(agentId) {
try {
await agentApi.setDefaultAgent(agentId)
defaultAgentId.value = agentId
} catch (err) {
console.error('Failed to set default agent:', err)
handleChatError(err, 'save')
error.value = err.message
throw err
}
}
/**
* 选择智能体
*/
async function selectAgent(agentId) {
if (agents.value.find((a) => a.id === agentId)) {
selectedAgentId.value = agentId
// 清空之前的配置
agentConfig.value = {}
originalAgentConfig.value = {}
selectedAgentConfigId.value = null
// 并行获取智能体详情和配置列表
await Promise.all([
(async () => {
try {
await fetchAgentDetail(agentId)
} catch (err) {
console.warn(`Failed to fetch agent detail for ${agentId}:`, err)
}
})(),
(async () => {
try {
await fetchAgentConfigs(agentId)
const list = agentConfigs.value[agentId] || []
const defaultItem = list.find((c) => c.is_default) || list[0]
selectedAgentConfigId.value = defaultItem ? defaultItem.id : null
if (selectedAgentConfigId.value) {
await loadAgentConfig(agentId, selectedAgentConfigId.value)
}
} catch (err) {
console.warn(`Failed to fetch agent configs for ${agentId}:`, err)
}
})()
])
}
}
/**
* 加载智能体配置
*/
async function fetchAgentConfigs(agentId = null) {
const targetAgentId = agentId || selectedAgentId.value
if (!targetAgentId) return
isLoadingAgentConfigs.value = true
error.value = null
try {
const response = await agentApi.getAgentConfigs(targetAgentId)
agentConfigs.value[targetAgentId] = response.configs || []
} catch (err) {
console.error('Failed to load agent configs:', err)
handleChatError(err, 'load')
error.value = err.message
throw err
} finally {
isLoadingAgentConfigs.value = false
}
}
async function loadAgentConfig(agentId = null, configId = null) {
const targetAgentId = agentId || selectedAgentId.value
const targetConfigId = configId || selectedAgentConfigId.value
if (!targetAgentId || !targetConfigId) return
if (!agentId || !agents.value.find((a) => a.id === agentId)) return
isLoadingConfig.value = true
error.value = null
try {
const response = await agentApi.getAgentConfigProfile(targetAgentId, targetConfigId)
const configJson = response.config?.config_json || {}
const contextConfig = configJson.context || configJson
const loadedConfig = { ...contextConfig }
// 确保 configurableItems 已加载
if (!agentDetails.value[targetAgentId]) {
await fetchAgentDetail(targetAgentId)
}
// 使用 configurableItems 中的默认值补全缺失的配置项
const items = configurableItems.value
Object.keys(items).forEach((key) => {
const item = items[key]
const isDefaultAllList = isDefaultAllAgentResourceKind(item?.kind)
if (loadedConfig[key] === undefined || (loadedConfig[key] === null && !isDefaultAllList)) {
// 只有当默认值存在时才设置
if (item.default !== undefined) {
loadedConfig[key] = item.default
}
}
if (
loadedConfig[key] !== undefined &&
loadedConfig[key] !== null &&
loadedConfig[key] !== '' &&
(item?.type === 'number' || item?.type === 'int' || item?.type === 'float')
) {
const numericValue = Number(loadedConfig[key])
if (!Number.isNaN(numericValue)) {
loadedConfig[key] = item.type === 'int' ? Math.trunc(numericValue) : numericValue
}
}
})
const detail = await fetchAgentDetail(agentId)
const loadedConfig = applyConfigDefaults(extractContext(detail), detail?.configurable_items || {})
selectedAgentId.value = agentId
agentConfig.value = loadedConfig
originalAgentConfig.value = { ...loadedConfig }
} catch (err) {
console.error('Failed to load agent config profile:', err)
handleChatError(err, 'load')
error.value = err.message
throw err
} finally {
isLoadingConfig.value = false
}
}
async function selectAgentConfig(configId) {
async function saveAgentConfig() {
const targetAgentId = selectedAgentId.value
if (!targetAgentId || !configId) return
selectedAgentConfigId.value = configId
await loadAgentConfig(targetAgentId, configId)
}
/**
* 保存智能体配置
* @param {Object} options - 额外参数 (e.g., { reload_graph: true })
*/
// eslint-disable-next-line no-unused-vars
async function saveAgentConfig(options = {}) {
const targetAgentId = selectedAgentId.value
const targetConfigId = selectedAgentConfigId.value
if (!targetAgentId || !targetConfigId) return
if (!targetAgentId) return
try {
await agentApi.updateAgentConfigProfile(targetAgentId, targetConfigId, {
const response = await agentApi.updateAgent(targetAgentId, {
config_json: { context: agentConfig.value }
})
const updated = normalizeAgent(response.agent)
agentDetails.value[targetAgentId] = updated
const index = agents.value.findIndex((item) => item.id === targetAgentId)
if (index >= 0) agents.value.splice(index, 1, updated)
originalAgentConfig.value = { ...agentConfig.value }
} catch (err) {
console.error('Failed to save agent config:', err)
@ -412,100 +206,66 @@ export const useAgentStore = defineStore(
}
}
async function createAgentConfigProfile({ name, setDefault = false, fromCurrent = true } = {}) {
const targetAgentId = selectedAgentId.value
if (!targetAgentId) return null
if (!name) throw new Error('配置名称不能为空')
const baseContext = fromCurrent ? { ...agentConfig.value } : {}
const response = await agentApi.createAgentConfigProfile(targetAgentId, {
name,
config_json: { context: baseContext },
set_default: setDefault
})
await fetchAgentConfigs(targetAgentId)
const created = response?.config
async function createAgent(payload) {
const response = await agentApi.createAgent(payload)
const created = normalizeAgent(response.agent)
if (created?.id) {
await selectAgentConfig(created.id)
agentDetails.value[created.id] = created
agents.value = sortAgents([created, ...agents.value.filter((item) => item.id !== created.id)])
await selectAgent(created.id)
}
return created
}
async function deleteSelectedAgentConfigProfile() {
const targetAgentId = selectedAgentId.value
const targetConfigId = selectedAgentConfigId.value
if (!targetAgentId || !targetConfigId) return
async function updateAgentProfile(agentId, payload) {
const response = await agentApi.updateAgent(agentId, payload)
const updated = normalizeAgent(response.agent)
agentDetails.value[updated.id] = updated
const index = agents.value.findIndex((item) => item.id === updated.id)
if (index >= 0) agents.value.splice(index, 1, updated)
return updated
}
await agentApi.deleteAgentConfigProfile(targetAgentId, targetConfigId)
await fetchAgentConfigs(targetAgentId)
const list = agentConfigs.value[targetAgentId] || []
const defaultItem = list.find((c) => c.is_default) || list[0]
selectedAgentConfigId.value = defaultItem ? defaultItem.id : null
if (selectedAgentConfigId.value) {
await loadAgentConfig(targetAgentId, selectedAgentConfigId.value)
} else {
async function deleteAgent(agentId) {
await agentApi.deleteAgent(agentId)
agents.value = agents.value.filter((item) => item.id !== agentId)
delete agentDetails.value[agentId]
if (selectedAgentId.value === agentId) {
selectedAgentId.value = null
agentConfig.value = {}
originalAgentConfig.value = {}
const nextAgentId = getPreferredAgentId(agents.value)
if (nextAgentId) await selectAgent(nextAgentId)
}
}
async function setSelectedAgentConfigDefault() {
const targetAgentId = selectedAgentId.value
const targetConfigId = selectedAgentConfigId.value
if (!targetAgentId || !targetConfigId) return
await agentApi.setAgentConfigDefault(targetAgentId, targetConfigId)
await fetchAgentConfigs(targetAgentId)
}
/**
* 重置智能体配置
*/
function resetAgentConfig() {
agentConfig.value = { ...originalAgentConfig.value }
}
/**
* 更新配置项
*/
function updateConfigItem(key, value) {
agentConfig.value[key] = value
}
/**
* 更新智能体配置支持批量更新
*/
function updateAgentConfig(updates) {
Object.assign(agentConfig.value, updates)
}
/**
* 清除错误状态
*/
function clearError() {
error.value = null
}
/**
* 重置 store 状态
*/
function reset() {
agents.value = []
selectedAgentId.value = null
defaultAgentId.value = null
availableKnowledgeBases.value = []
availableMcps.value = []
availableSkills.value = []
agentConfig.value = {}
originalAgentConfig.value = {}
agentConfigs.value = {}
selectedAgentConfigId.value = null
agentDetails.value = {}
isLoadingAgents.value = false
isLoadingConfig.value = false
isLoadingAgentConfigs.value = false
isLoadingAgentDetail.value = false
error.value = null
isInitialized.value = false
@ -513,10 +273,8 @@ export const useAgentStore = defineStore(
}
return {
// 状态
agents,
selectedAgentId,
defaultAgentId,
availableKnowledgeBases,
availableMcps,
availableSkills,
@ -526,37 +284,22 @@ export const useAgentStore = defineStore(
isLoadingAgents,
isLoadingConfig,
isLoadingAgentDetail,
isLoadingAgentConfigs,
error,
isInitialized,
// 计算属性
selectedAgent,
defaultAgent,
agentsList,
isDefaultAgent,
configurableItems,
availableTools,
hasConfigChanges,
agentConfigs,
selectedAgentConfigId,
selectedConfigSummary,
// 方法
initialize,
fetchAgents,
fetchAgentDetail,
fetchDefaultAgent,
fetchMentionResources,
setDefaultAgent,
selectAgent,
selectAgentConfig,
fetchAgentConfigs,
loadAgentConfig,
saveAgentConfig,
createAgentConfigProfile,
deleteSelectedAgentConfigProfile,
setSelectedAgentConfigDefault,
createAgent,
updateAgentProfile,
deleteAgent,
resetAgentConfig,
updateConfigItem,
updateAgentConfig,
@ -565,11 +308,10 @@ export const useAgentStore = defineStore(
}
},
{
// 持久化配置
persist: {
key: 'agent-store',
storage: localStorage,
pick: ['selectedAgentId', 'selectedAgentConfigId']
pick: ['selectedAgentId']
}
}
)

View File

@ -8,10 +8,10 @@
:single-mode="false"
@thread-change="handleThreadChange"
>
<template #input-actions-left>
<template #input-actions-left="{ hasActiveThread }">
<a-dropdown
v-if="selectedAgentId"
v-model:open="configDropdownOpen"
v-model:open="agentDropdownOpen"
:trigger="['click']"
placement="topLeft"
overlay-class-name="config-dropdown-overlay"
@ -23,49 +23,58 @@
@click.stop
@mousedown.stop
>
<Settings2 size="18" class="nav-btn-icon" />
<span class="hide-text config-dropdown-text">{{ currentConfigLabel }}</span>
<img
class="config-dropdown-agent-icon nav-btn-icon"
:src="currentAgentIcon"
:alt="`${currentAgentLabel}图标`"
/>
<span class="hide-text config-dropdown-text">{{ currentAgentLabel }}</span>
<ChevronDown size="15" class="config-dropdown-chevron" />
</button>
<template #overlay>
<div class="config-dropdown-panel" @click.stop>
<button
v-for="config in configQuickSwitchOptions"
:key="config.value"
v-for="agent in agentQuickSwitchOptions"
:key="agent.value"
type="button"
class="config-dropdown-item"
:class="{ selected: config.value === selectedAgentConfigId }"
@click="handleConfigSwitch(config.value)"
:class="{
selected: agent.value === selectedAgentId,
disabled: hasActiveThread && agent.value !== selectedAgentId
}"
@click="handleAgentSwitch(agent.value, hasActiveThread)"
>
<span class="config-dropdown-item-label">{{ config.label }}</span>
<span v-if="config.isDefault" class="config-dropdown-item-badge">默认</span>
<img
class="config-dropdown-item-icon-image"
:src="agent.icon"
:alt="`${agent.label}图标`"
/>
<span class="config-dropdown-item-label">{{ agent.label }}</span>
<span v-if="agent.isBuiltin" class="config-dropdown-item-badge">内置</span>
<Check
v-if="config.value === selectedAgentConfigId"
v-if="agent.value === selectedAgentId"
:size="14"
class="config-dropdown-item-check"
/>
</button>
<div class="config-dropdown-divider"></div>
<div v-if="hasActiveThread" class="config-dropdown-hint">
当前对话已绑定智能体新对话可切换
</div>
<button
type="button"
class="config-dropdown-item action-item"
@click="toggleConfigSidebar"
>
<Settings2 :size="15" class="config-dropdown-item-icon" />
<span class="config-dropdown-item-label">{{ configSidebarActionLabel }}</span>
</button>
<template v-if="userStore.isAdmin">
<div class="config-dropdown-divider"></div>
<button
type="button"
class="config-dropdown-item action-item"
@click="openCreateConfigModal"
>
<Plus :size="15" class="config-dropdown-item-icon" />
<span class="config-dropdown-item-label">新建配置</span>
</button>
<button
type="button"
class="config-dropdown-item action-item"
@click="openAgentManagement"
>
<Settings2 :size="15" class="config-dropdown-item-icon" />
<span class="config-dropdown-item-label">管理智能体</span>
</button>
</template>
</div>
</template>
</a-dropdown>
@ -96,12 +105,6 @@
</AgentChatComponent>
</div>
<!-- 配置侧边栏 -->
<AgentConfigSidebar
:isOpen="chatUIStore.isConfigSidebarOpen"
@close="() => (chatUIStore.isConfigSidebarOpen = false)"
/>
<!-- 反馈模态框 -->
<FeedbackModalComponent
v-if="userStore.isAdmin"
@ -109,17 +112,6 @@
:agent-id="selectedAgentId"
/>
<a-modal
v-model:open="createConfigModalOpen"
title="新建配置"
:width="360"
:confirm-loading="createConfigLoading"
@ok="handleCreateConfig"
@cancel="closeCreateConfigModal"
>
<a-input v-model:value="createConfigName" placeholder="请输入配置名称" allow-clear />
</a-modal>
<!-- 自定义更多菜单 -->
<Teleport to="body">
<Transition name="menu-fade">
@ -151,17 +143,17 @@
import { computed, ref, watch } from 'vue'
import { MessageOutlined, ShareAltOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { Settings2, Ellipsis, ChevronDown, Check, Plus, FolderKanban } from 'lucide-vue-next'
import { Settings2, Ellipsis, ChevronDown, Check, FolderKanban } from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'
import AgentChatComponent from '@/components/AgentChatComponent.vue'
import AgentConfigSidebar from '@/components/AgentConfigSidebar.vue'
import FeedbackModalComponent from '@/components/dashboard/FeedbackModalComponent.vue'
import { useUserStore } from '@/stores/user'
import { useAgentStore } from '@/stores/agent'
import { isBuiltinAgent, useAgentStore } from '@/stores/agent'
import { useChatUIStore } from '@/stores/chatUI'
import { ChatExporter } from '@/utils/chatExporter'
import { handleChatError } from '@/utils/errorHandler'
import { onClickOutside } from '@vueuse/core'
import defaultAgentIcon from '@/assets/defaults/agent.png'
import { storeToRefs } from 'pinia'
@ -177,8 +169,7 @@ const route = useRoute()
const router = useRouter()
// agentStore
const { selectedAgentId, defaultAgentId, selectedAgentConfigId, agentConfigs, isLoadingConfig } =
storeToRefs(agentStore)
const { agents, selectedAgentId, isLoadingConfig } = storeToRefs(agentStore)
const syncingRouteThread = ref(false)
@ -194,14 +185,8 @@ const syncSelectedThreadFromRoute = async () => {
const threadId = getRouteThreadId()
syncingRouteThread.value = true
try {
if (!threadId) {
if (!agentStore.isInitialized) {
await agentStore.initialize()
}
const targetAgentId = defaultAgentId.value
if (targetAgentId && selectedAgentId.value !== targetAgentId) {
await agentStore.selectAgent(targetAgentId)
}
if (!threadId && !agentStore.isInitialized) {
await agentStore.initialize()
}
const ok = await chatComponent.selectThreadFromRoute(threadId)
@ -241,88 +226,48 @@ const handleThreadChange = (threadId) => {
}
}
// /
const configQuickSwitchOptions = computed(() => {
if (!selectedAgentId.value) return []
const list = agentConfigs.value[selectedAgentId.value] || []
return list.map((config) => ({
label: config.name,
value: config.id,
isDefault: !!config.is_default
const agentQuickSwitchOptions = computed(() =>
(agents.value || []).map((agent) => ({
label: agent.name || agent.id,
value: agent.id,
icon: agent.icon || defaultAgentIcon,
isBuiltin: isBuiltinAgent(agent)
}))
})
)
const currentConfigLabel = computed(() => {
const currentAgentOption = computed(() =>
agentQuickSwitchOptions.value.find((agent) => agent.value === selectedAgentId.value)
)
const currentAgentLabel = computed(() => {
if (isLoadingConfig.value) return '加载中...'
const current = configQuickSwitchOptions.value.find(
(config) => config.value === selectedAgentConfigId.value
)
return current?.label || '配置'
return currentAgentOption.value?.label || '智能体'
})
const configSidebarActionLabel = computed(() => {
return chatUIStore.isConfigSidebarOpen ? '收起配置侧边栏' : '查看/编辑配置'
})
const currentAgentIcon = computed(() => currentAgentOption.value?.icon || defaultAgentIcon)
const configDropdownOpen = ref(false)
const createConfigModalOpen = ref(false)
const createConfigLoading = ref(false)
const createConfigName = ref('')
const agentDropdownOpen = ref(false)
const handleConfigSwitch = async (configId) => {
if (!configId || configId === selectedAgentConfigId.value) return
try {
await agentStore.selectAgentConfig(configId)
configDropdownOpen.value = false
} catch (error) {
console.error('切换配置出错:', error)
message.error('切换配置失败')
}
}
const toggleConfigSidebar = () => {
chatUIStore.isConfigSidebarOpen = !chatUIStore.isConfigSidebarOpen
configDropdownOpen.value = false
}
const openCreateConfigModal = () => {
configDropdownOpen.value = false
createConfigName.value = ''
createConfigModalOpen.value = true
}
const closeCreateConfigModal = () => {
createConfigModalOpen.value = false
createConfigName.value = ''
}
const handleCreateConfig = async () => {
if (!selectedAgentId.value) return
const name = createConfigName.value.trim()
if (!name) {
message.error('请输入配置名称')
const handleAgentSwitch = async (agentId, hasActiveThread) => {
if (!agentId || agentId === selectedAgentId.value) return
if (hasActiveThread) {
message.info('当前对话已绑定智能体,请新建对话后切换')
return
}
createConfigLoading.value = true
try {
await agentStore.createAgentConfigProfile({
name,
setDefault: false,
fromCurrent: false
})
closeCreateConfigModal()
chatUIStore.isConfigSidebarOpen = true
message.success('配置已创建')
await agentStore.selectAgent(agentId)
agentDropdownOpen.value = false
} catch (error) {
console.error('创建配置出错:', error)
message.error(error.message || '创建配置失败')
} finally {
createConfigLoading.value = false
console.error('切换智能体出错:', error)
message.error('切换智能体失败')
}
}
const openAgentManagement = () => {
agentDropdownOpen.value = false
router.push({ name: 'ModelManageComp', query: { tab: 'agents' } })
}
//
const moreMenuRef = ref(null)
const moreButtonRef = ref(null)
@ -443,6 +388,13 @@ const handleFeedback = () => {
color: currentColor;
}
.config-dropdown-agent-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
object-fit: contain;
}
.config-dropdown-trigger :deep(svg) {
color: currentColor;
}
@ -596,6 +548,11 @@ const handleFeedback = () => {
background: var(--gray-50);
}
.config-dropdown-overlay .config-dropdown-item.disabled {
cursor: not-allowed;
opacity: 0.55;
}
.config-dropdown-overlay .config-dropdown-item.selected {
background: var(--gray-50);
}
@ -615,11 +572,21 @@ const handleFeedback = () => {
color: var(--gray-800);
}
.config-dropdown-overlay .config-dropdown-item-icon {
.config-dropdown-overlay .config-dropdown-item-icon,
.config-dropdown-overlay .config-dropdown-item-icon-image {
flex-shrink: 0;
}
.config-dropdown-overlay .config-dropdown-item-icon {
color: var(--gray-500);
}
.config-dropdown-overlay .config-dropdown-item-icon-image {
width: 24px;
height: 24px;
object-fit: contain;
}
.config-dropdown-overlay .config-dropdown-item-badge {
flex-shrink: 0;
padding: 1px 6px;
@ -635,6 +602,13 @@ const handleFeedback = () => {
color: var(--main-600);
}
.config-dropdown-overlay .config-dropdown-hint {
padding: 6px 8px;
color: var(--gray-500);
font-size: 12px;
line-height: 1.4;
}
.config-dropdown-overlay .config-dropdown-divider {
height: 1px;
margin: 4px 4px;