fix(agent): reconcile agent stream mode and shared-agent readiness UX

Derive stream pipeline from selectedAgentId to fix quick-answer/isAgentEnabled drift, gate configure actions for shared agents, and polish selector detail panel positioning.
This commit is contained in:
wizardchen
2026-06-25 23:31:22 +08:00
committed by lyingbug
parent 47478491c9
commit 5f2ca6c975
16 changed files with 443 additions and 196 deletions
+44 -1
View File
@@ -181,7 +181,38 @@
padding: 4px;
min-width: 148px;
animation: dropdownSlideIn 0.18s cubic-bezier(0.2, 0, 0, 1) both;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.2s ease;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
border: none;
border-radius: 6px;
transition: background-color 0.2s ease;
}
&:hover {
scrollbar-color: var(--td-scrollbar-color, rgba(0, 0, 0, 0.18)) transparent;
&::-webkit-scrollbar-thumb {
background-color: var(--td-scrollbar-color, rgba(0, 0, 0, 0.18));
}
}
&::-webkit-scrollbar-thumb:hover {
background-color: var(--td-scrollbar-hover-color, rgba(0, 0, 0, 0.32));
}
}
.t-dropdown__item {
@@ -493,5 +524,17 @@
0 0 0 0.5px rgba(255, 255, 255, 0.05),
0 2px 4px rgba(0, 0, 0, 0.12),
0 8px 32px rgba(0, 0, 0, 0.28);
&:hover {
scrollbar-color: rgba(255, 255, 255, 0.22) transparent;
&::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.22);
}
}
&::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.38);
}
}
}
+192 -140
View File
@@ -1,11 +1,7 @@
<template>
<Teleport to="body">
<div v-if="visible" class="agent-selector-overlay" @click="$emit('close')">
<div
class="agent-selector-dropdown"
:style="dropdownStyle"
@click.stop
>
<div class="agent-selector-dropdown" :style="dropdownStyle" @click.stop>
<div class="agent-selector-header">
<span>{{ $t('agent.selectAgent') }}</span>
<router-link to="/platform/agents" class="agent-selector-add" @click="$emit('close')">
@@ -18,32 +14,22 @@
<!-- 内置智能体 -->
<div class="agent-group">
<div class="agent-group-title">{{ $t('agent.builtinAgents') }}</div>
<div
v-for="agent in builtinAgents"
:key="agent.id"
class="agent-option"
:class="{ selected: isMyAgentSelected(agent) }"
@mouseenter="onOptionEnter(agent, $event)"
@mouseleave="onOptionLeave"
@click="selectAgent(agent)"
>
<div
v-if="agent.id === BUILTIN_QUICK_ANSWER_ID || agent.id === BUILTIN_SMART_REASONING_ID"
class="builtin-icon"
:class="agent.config?.agent_mode === 'smart-reasoning' ? 'agent' : 'normal'"
>
<TIcon :name="agent.config?.agent_mode === 'smart-reasoning' ? 'control-platform' : 'chat'" size="14px" />
<div v-for="agent in builtinAgents" :key="agent.id" class="agent-option"
:class="{ selected: isMyAgentSelected(agent) }" @mouseenter="onOptionEnter(agent, $event)"
@mouseleave="onOptionLeave" @click="selectAgent(agent)">
<div v-if="agent.id === BUILTIN_QUICK_ANSWER_ID || agent.id === BUILTIN_SMART_REASONING_ID"
class="builtin-icon" :class="agent.config?.agent_mode === 'smart-reasoning' ? 'agent' : 'normal'">
<TIcon :name="agent.config?.agent_mode === 'smart-reasoning' ? 'control-platform' : 'chat'"
size="13px" />
</div>
<div v-else-if="agent.avatar" class="builtin-avatar">{{ agent.avatar }}</div>
<div v-else class="builtin-icon normal">
<TIcon name="app" size="14px" />
<TIcon name="app" size="13px" />
</div>
<span class="agent-option-name">{{ agent.name }}</span>
<div v-if="getAgentNotReadyLabels(agent).length" class="agent-option-actions">
<t-tooltip
:content="$t('agent.selector.notReadyHint', { items: formatNotReadyHint(agent) })"
placement="top"
>
<t-tooltip :content="$t('agent.selector.notReadyHint', { items: formatNotReadyHint(agent) })"
placement="top">
<TIcon name="error-circle" size="14px" class="not-ready-icon" @click.stop />
</t-tooltip>
</div>
@@ -53,22 +39,14 @@
<!-- 自定义智能体 -->
<div v-if="customAgents.length > 0" class="agent-group">
<div class="agent-group-title">{{ $t('agent.customAgents') }}</div>
<div
v-for="agent in customAgents"
:key="agent.id"
class="agent-option"
:class="{ selected: isMyAgentSelected(agent) }"
@mouseenter="onOptionEnter(agent, $event)"
@mouseleave="onOptionLeave"
@click="selectAgent(agent)"
>
<div v-for="agent in customAgents" :key="agent.id" class="agent-option"
:class="{ selected: isMyAgentSelected(agent) }" @mouseenter="onOptionEnter(agent, $event)"
@mouseleave="onOptionLeave" @click="selectAgent(agent)">
<AgentAvatar :name="agent.name" size="small" />
<span class="agent-option-name">{{ agent.name }}</span>
<div v-if="getAgentNotReadyLabels(agent).length" class="agent-option-actions">
<t-tooltip
:content="$t('agent.selector.notReadyHint', { items: formatNotReadyHint(agent) })"
placement="top"
>
<t-tooltip :content="$t('agent.selector.notReadyHint', { items: formatNotReadyHint(agent) })"
placement="top">
<TIcon name="error-circle" size="14px" class="not-ready-icon" @click.stop />
</t-tooltip>
</div>
@@ -78,36 +56,26 @@
<!-- 共享给我 -->
<div v-if="sharedAgentsList.length > 0" class="agent-group">
<div class="agent-group-title">{{ $t('agent.tabs.sharedToMe') }}</div>
<div
v-for="shared in sharedAgentsList"
:key="`${shared.agent.id}-${shared.source_tenant_id}`"
class="agent-option"
:class="{ selected: isSharedAgentSelected(shared) }"
@mouseenter="onSharedOptionEnter(shared, $event)"
@mouseleave="onOptionLeave"
@click="selectSharedAgent(shared)"
>
<div v-for="shared in sharedAgentsList" :key="`${shared.agent.id}-${shared.source_tenant_id}`"
class="agent-option" :class="{ selected: isSharedAgentSelected(shared) }"
@mouseenter="onSharedOptionEnter(shared, $event)" @mouseleave="onOptionLeave"
@click="selectSharedAgent(shared)">
<AgentAvatar :name="shared.agent.name" size="small" />
<span class="agent-option-name">{{ shared.agent.name }}</span>
<span class="shared-tag">{{ $t('agent.selector.sharedLabel') }}</span>
<div
v-if="getAgentNotReadyLabels(shared.agent, String(shared.source_tenant_id)).length"
class="agent-option-actions"
>
<div v-if="getAgentNotReadyLabels(shared.agent, String(shared.source_tenant_id)).length"
class="agent-option-actions">
<t-tooltip
:content="$t('agent.selector.notReadyHint', { items: formatNotReadyHint(shared.agent, String(shared.source_tenant_id)) })"
placement="top"
>
placement="top">
<TIcon name="error-circle" size="14px" class="not-ready-icon" @click.stop />
</t-tooltip>
</div>
</div>
</div>
<div
v-if="builtinAgents.length === 0 && customAgents.length === 0 && sharedAgentsList.length === 0"
class="agent-option empty"
>
<div v-if="builtinAgents.length === 0 && customAgents.length === 0 && sharedAgentsList.length === 0"
class="agent-option empty">
{{ $t('agent.noAgents') }}
</div>
</div>
@@ -115,57 +83,41 @@
</div>
<!-- 详情浮层 -->
<div
v-if="visible && activeDetail"
class="agent-detail-panel"
:style="detailPanelStyle"
@mouseenter="onDetailPanelEnter"
@mouseleave="onDetailPanelLeave"
@click.stop
>
<div v-if="visible && activeDetail" ref="detailPanelRef" class="agent-detail-panel" :style="detailPanelStyle"
@mouseenter="onDetailPanelEnter" @mouseleave="onDetailPanelLeave" @click.stop>
<div class="agent-detail-panel-inner">
<div class="agent-detail-content">
<div class="detail-header">
<template v-if="activeDetail.agent.id === BUILTIN_QUICK_ANSWER_ID || activeDetail.agent.id === BUILTIN_SMART_REASONING_ID">
<div
class="builtin-icon detail-icon"
:class="activeDetail.agent.config?.agent_mode === 'smart-reasoning' ? 'agent' : 'normal'"
>
<TIcon
:name="activeDetail.agent.config?.agent_mode === 'smart-reasoning' ? 'control-platform' : 'chat'"
size="14px"
/>
<template
v-if="activeDetail.agent.id === BUILTIN_QUICK_ANSWER_ID || activeDetail.agent.id === BUILTIN_SMART_REASONING_ID">
<div class="builtin-icon detail-icon"
:class="activeDetail.agent.config?.agent_mode === 'smart-reasoning' ? 'agent' : 'normal'">
<TIcon :name="activeDetail.agent.config?.agent_mode === 'smart-reasoning' ? 'control-platform' : 'chat'"
size="14px" />
</div>
</template>
<div v-else-if="activeDetail.agent.avatar" class="builtin-avatar detail-icon">{{ activeDetail.agent.avatar }}</div>
<div v-else-if="activeDetail.agent.avatar" class="builtin-avatar detail-icon">{{ activeDetail.agent.avatar
}}</div>
<AgentAvatar v-else :name="activeDetail.agent.name" size="small" />
<div class="detail-title-wrap">
<div class="detail-title-row">
<span class="detail-name">{{ activeDetail.agent.name }}</span>
<button
type="button"
class="detail-header-action"
:class="{ 'detail-header-action--warn': activeDetailNotReadyLabels.length }"
:title="activeDetailNotReadyLabels.length
<button v-if="canShowDetailHeaderAction" type="button" class="detail-header-action"
:class="{ 'detail-header-action--warn': activeDetailNotReadyLabels.length }" :title="activeDetailNotReadyLabels.length
? $t('agent.selector.configureAction')
: $t('agent.selector.goToSettings')"
@click="goToSettings(activeDetail.agent, activeDetail.sourceTenantId)"
>
<TIcon
:name="activeDetailNotReadyLabels.length ? 'jump' : 'setting'"
size="14px"
/>
@click="goToSettings(activeDetail.agent, activeDetail.sourceTenantId)">
<TIcon :name="activeDetailNotReadyLabels.length ? 'jump' : 'setting'" size="14px" />
</button>
</div>
<span v-if="isDetailCurrent" class="detail-current">{{ $t('agent.selector.current') }}</span>
<div v-else-if="activeDetailNotReadyLabels.length" class="detail-not-ready">
<TIcon name="error-circle" size="13px" class="detail-not-ready-icon" />
<span class="detail-not-ready-label">{{ $t('agent.selector.notReadyStatus') }}</span>
<span
v-for="item in activeDetailNotReadyLabels"
:key="item"
class="detail-not-ready-item"
>{{ item }}</span>
<span v-for="item in activeDetailNotReadyLabels" :key="item" class="detail-not-ready-item">{{ item
}}</span>
<span v-if="activeDetail.sourceTenantId && activeDetailNotReadyLabels.length"
class="detail-not-ready-shared-hint">{{ $t('agent.selector.sharedNotReadyContact') }}</span>
</div>
</div>
</div>
@@ -174,15 +126,23 @@
<div class="detail-tags">
<span class="detail-tag">
{{ activeDetail.agent.config?.agent_mode === 'smart-reasoning' ? $t('agent.type.agent') : $t('agent.type.normal') }}
{{ activeDetail.agent.config?.agent_mode === 'smart-reasoning' ? $t('agent.type.agent') :
$t('agent.type.normal') }}
</span>
<span v-if="getKbCapability(activeDetail.agent)" class="detail-tag">{{ getKbCapability(activeDetail.agent) }}</span>
<span v-if="activeDetail.agent.config?.web_search_enabled" class="detail-tag">{{ $t('agent.capabilities.webSearchOn') }}</span>
<span v-if="getMcpCapability(activeDetail.agent)" class="detail-tag">{{ getMcpCapability(activeDetail.agent) }}</span>
<span v-if="activeDetail.agent.config?.multi_turn_enabled" class="detail-tag">{{ $t('agent.capabilities.multiTurn') }}</span>
<span v-if="getKbCapability(activeDetail.agent)" class="detail-tag">{{ getKbCapability(activeDetail.agent)
}}</span>
<span v-if="activeDetail.agent.config?.web_search_enabled" class="detail-tag">{{
$t('agent.capabilities.webSearchOn')
}}</span>
<span v-if="getMcpCapability(activeDetail.agent)" class="detail-tag">{{ getMcpCapability(activeDetail.agent)
}}</span>
<span v-if="activeDetail.agent.config?.multi_turn_enabled" class="detail-tag">{{
$t('agent.capabilities.multiTurn')
}}</span>
</div>
<div v-if="activeDetail.sharedMeta?.org_name || activeDetail.sharedMeta?.shared_by_username" class="detail-meta">
<div v-if="activeDetail.sharedMeta?.org_name || activeDetail.sharedMeta?.shared_by_username"
class="detail-meta">
<div v-if="activeDetail.sharedMeta.org_name" class="detail-meta-row">
<img src="@/assets/img/organization-green.svg" class="detail-meta-icon" alt="" aria-hidden="true" />
<span>{{ activeDetail.sharedMeta.org_name }}</span>
@@ -214,6 +174,7 @@ import {
getAgentNotReadyReasonKeys,
resolveAgentNotReadySection,
resolveAgentNotReadyHighlight,
canLocallyConfigureAgent,
type AgentNotReadyReasonKey,
} from '@/utils/agent-readiness';
import { formatLocalizedList } from '@/utils/format-list';
@@ -246,9 +207,14 @@ type AgentDetailTarget = {
const dropdownStyle = ref<Record<string, string>>({});
const activeDetail = ref<AgentDetailTarget | null>(null);
const detailAnchorEl = ref<HTMLElement | null>(null);
const detailPanelRef = ref<HTMLElement | null>(null);
const detailPanelStyle = ref<Record<string, string>>({});
let detailHideTimer: ReturnType<typeof setTimeout> | null = null;
const DETAIL_PANEL_WIDTH = 200;
const DETAIL_BRIDGE_OVERLAP = 10;
const DETAIL_HIDE_DELAY_MS = 400;
const agentsList = computed(() => props.agents ?? []);
const builtinAgents = computed(() => {
@@ -294,6 +260,13 @@ const activeDetailNotReadyLabels = computed(() => {
return getAgentNotReadyLabels(detail.agent, detail.sourceTenantId);
});
const canShowDetailHeaderAction = computed(() => {
const detail = activeDetail.value;
if (!detail) return false;
if (canLocallyConfigureAgent(detail.sourceTenantId)) return true;
return activeDetailNotReadyLabels.value.length === 0;
});
const getKbCapability = (agent: CustomAgent): string => {
const config = agent.config || {};
if (config.kb_selection_mode === 'none') return '';
@@ -371,34 +344,54 @@ const updateDetailPanelPosition = () => {
if (!el || !activeDetail.value) return;
const zoom = getRootZoom();
const rect = rectToCssPx(el.getBoundingClientRect(), zoom);
const rowRect = rectToCssPx(el.getBoundingClientRect(), zoom);
const { width: vw, height: vh } = cssViewportSize(zoom);
const panelWidth = 200;
const gap = 6;
let left = rect.right + gap;
let top = rect.top;
if (left + panelWidth > vw - 8) {
left = Math.max(8, vw - panelWidth - 8);
// 向左重叠一段透明区域,避免鼠标从选项移向浮层时经过空隙触发 mouseleave
let left = rowRect.right - DETAIL_BRIDGE_OVERLAP;
if (left + DETAIL_PANEL_WIDTH > vw - 8) {
left = Math.max(8, vw - DETAIL_PANEL_WIDTH - 8);
}
if (top + 240 > vh - 8) {
top = Math.max(8, vh - 248);
const panelHeight = detailPanelRef.value?.offsetHeight || 180;
const rowCenter = rowRect.top + rowRect.height / 2;
let top = rowCenter - panelHeight / 2;
const minTop = 8;
const maxTop = vh - panelHeight - 8;
if (top < minTop) {
top = minTop;
} else if (top > maxTop) {
// 贴近当前行:优先让浮层与 hover 行在垂直方向仍有交集
top = Math.min(rowRect.top, maxTop);
top = Math.max(minTop, Math.min(top, rowRect.bottom - panelHeight));
if (top + panelHeight < rowRect.top) {
top = rowRect.top;
}
top = Math.max(minTop, Math.min(top, maxTop));
}
detailPanelStyle.value = {
position: 'fixed',
left: `${Math.round(left)}px`,
top: `${Math.round(top)}px`,
width: `${panelWidth}px`,
width: `${DETAIL_PANEL_WIDTH}px`,
zIndex: '10002',
};
};
const scheduleDetailPanelPosition = () => {
nextTick(() => {
updateDetailPanelPosition();
requestAnimationFrame(() => updateDetailPanelPosition());
});
};
const onOptionEnter = (agent: CustomAgent, event: MouseEvent, sourceTenantId?: string, sharedMeta?: AgentDetailTarget['sharedMeta']) => {
clearDetailHideTimer();
detailAnchorEl.value = event.currentTarget as HTMLElement;
activeDetail.value = { agent, sourceTenantId, sharedMeta };
nextTick(() => updateDetailPanelPosition());
scheduleDetailPanelPosition();
};
const onSharedOptionEnter = (shared: SharedAgentInfo, event: MouseEvent) => {
@@ -412,7 +405,7 @@ const onOptionLeave = () => {
detailHideTimer = setTimeout(() => {
activeDetail.value = null;
detailAnchorEl.value = null;
}, 300);
}, DETAIL_HIDE_DELAY_MS);
};
const onDetailPanelEnter = () => {
@@ -447,6 +440,9 @@ const selectSharedAgent = (shared: SharedAgentInfo) => {
};
const goToSettings = (agent: CustomAgent, sourceTenantId?: string) => {
if (!canLocallyConfigureAgent(sourceTenantId) && getAgentNotReadyLabels(agent, sourceTenantId).length > 0) {
return;
}
const reasonKeys = getAgentNotReadyReasonKeysFor(agent, sourceTenantId);
const section = reasonKeys.length > 0 ? resolveAgentNotReadySection(reasonKeys) : 'basic';
const highlight = resolveAgentNotReadyHighlight(reasonKeys);
@@ -470,15 +466,15 @@ const updateDropdownPosition = () => {
const rect = rectToCssPx(props.anchorEl.getBoundingClientRect(), zoom);
const { width: vw, height: vh } = cssViewportSize(zoom);
const dropdownWidth = 240;
const offsetY = 8;
const dropdownWidth = 196;
const offsetY = 6;
let left = Math.floor(rect.left);
const minLeft = 16;
const maxLeft = Math.max(16, vw - dropdownWidth - 16);
left = Math.max(minLeft, Math.min(maxLeft, left));
const preferredDropdownHeight = 320;
const preferredDropdownHeight = 280;
const minDropdownHeight = 100;
const topMargin = 20;
const spaceBelow = vh - rect.bottom;
@@ -520,6 +516,12 @@ watch(() => props.visible, (newVal) => {
hideDetailPanel();
}
});
watch(activeDetail, (detail) => {
if (detail) {
scheduleDetailPanelPosition();
}
});
</script>
<style scoped lang="less">
@@ -542,7 +544,7 @@ watch(() => props.visible, (newVal) => {
position: fixed !important;
background: var(--td-bg-color-container);
border: .5px solid var(--td-component-border);
border-radius: 10px;
border-radius: 8px;
box-shadow: var(--td-shadow-2);
display: flex;
flex-direction: column;
@@ -553,32 +555,45 @@ watch(() => props.visible, (newVal) => {
}
@keyframes agentSelectorFadeIn {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}
.agent-selector-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
gap: 8px;
min-height: 32px;
padding: 0 8px;
border-bottom: .5px solid var(--td-component-stroke);
font-size: 12px;
font-weight: 500;
line-height: 1;
color: var(--td-text-color-secondary);
}
.agent-selector-add {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 6px;
gap: 2px;
height: 22px;
padding: 0 6px;
border-radius: 5px;
color: var(--td-brand-color);
font-size: 12px;
font-weight: 500;
line-height: 1;
cursor: pointer;
text-decoration: none;
flex-shrink: 0;
&:hover {
background: var(--td-bg-color-secondarycontainer);
@@ -591,13 +606,13 @@ watch(() => props.visible, (newVal) => {
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 6px 8px;
padding: 4px 0;
}
.agent-group {
&:not(:last-child) {
margin-bottom: 6px;
padding-bottom: 6px;
margin-bottom: 4px;
padding-bottom: 4px;
border-bottom: .5px solid var(--td-component-stroke);
}
}
@@ -605,23 +620,20 @@ watch(() => props.visible, (newVal) => {
.agent-group-title {
font-size: 11px;
color: var(--td-text-color-placeholder);
padding: 4px 8px 6px;
padding: 6px 8px 4px;
font-weight: 600;
line-height: 16px;
}
.agent-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
min-height: 32px;
padding: 0 8px;
cursor: pointer;
transition: background 0.12s;
border-radius: 6px;
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
border-radius: 5px;
&:hover,
&.selected {
@@ -632,12 +644,19 @@ watch(() => props.visible, (newVal) => {
color: var(--td-text-color-placeholder);
cursor: default;
text-align: center;
padding: 20px 8px;
padding: 16px 8px;
min-height: auto;
&:hover {
background: transparent;
}
}
:deep(.agent-avatar-small) {
width: 22px;
height: 22px;
border-radius: 5px;
}
}
.agent-option-name {
@@ -648,19 +667,22 @@ watch(() => props.visible, (newVal) => {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
line-height: 22px;
}
.shared-tag {
font-size: 10px;
color: var(--td-text-color-placeholder);
flex-shrink: 0;
line-height: 22px;
}
.agent-option-actions {
display: flex;
align-items: center;
gap: 4px;
justify-content: center;
width: 22px;
height: 22px;
flex-shrink: 0;
}
@@ -668,16 +690,24 @@ watch(() => props.visible, (newVal) => {
flex-shrink: 0;
color: var(--td-warning-color, #ed7b2f);
cursor: default;
display: block;
line-height: 1;
}
.builtin-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
width: 22px;
height: 22px;
border-radius: 5px;
flex-shrink: 0;
line-height: 1;
:deep(.t-icon) {
display: block;
line-height: 1;
}
&.normal {
background: var(--td-brand-color-light);
@@ -699,12 +729,14 @@ watch(() => props.visible, (newVal) => {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
width: 22px;
height: 22px;
border-radius: 5px;
flex-shrink: 0;
font-size: 16px;
font-size: 14px;
line-height: 1;
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
overflow: hidden;
&.detail-icon {
width: 28px;
@@ -716,6 +748,17 @@ watch(() => props.visible, (newVal) => {
/* 详情浮层 */
.agent-detail-panel {
box-sizing: border-box;
position: relative;
// 左侧透明桥接区:承接从选项移入的鼠标,避免经过间隙时浮层消失
&::before {
content: '';
position: absolute;
left: -12px;
top: 0;
width: 12px;
height: 100%;
}
}
.agent-detail-panel-inner {
@@ -830,6 +873,15 @@ watch(() => props.visible, (newVal) => {
background: var(--td-bg-color-container);
}
.detail-not-ready-shared-hint {
display: block;
width: 100%;
margin-top: 2px;
font-size: 10px;
line-height: 1.4;
color: var(--td-text-color-placeholder);
}
.detail-desc {
margin: 0 0 8px;
font-size: 12px;
+42 -32
View File
@@ -30,6 +30,7 @@ import {
getAgentNotReadyReasonKeys,
resolveAgentNotReadySection,
resolveAgentNotReadyHighlight,
canLocallyConfigureAgent,
type AgentNotReadyReasonKey,
} from '@/utils/agent-readiness';
import { formatLocalizedList } from '@/utils/format-list';
@@ -2047,25 +2048,34 @@ const showAgentNotReadyMessage = (
sourceTenantId?: string,
) => {
const reasonsText = formatLocalizedList(reasons, locale.value)
const section = resolveAgentNotReadySection(reasonKeys || ['summary_model'])
const highlight = resolveAgentNotReadyHighlight(reasonKeys || ['summary_model'])
const isRemoteShared = !canLocallyConfigureAgent(sourceTenantId)
const messageContent = h('div', { style: 'display: flex; flex-direction: column; gap: 8px; max-width: 320px;' }, [
h('span', { style: 'color: var(--td-text-color-primary); line-height: 1.5;' }, t('input.agentNotReadyDetail', { agentName: agent.name, reasons: reasonsText })),
h('a', {
href: '#',
onClick: (e: Event) => {
e.preventDefault();
goToAgentEditor(agent, section, highlight, sourceTenantId);
},
style: 'color: var(--td-brand-color); text-decoration: none; font-weight: 500; cursor: pointer; align-self: flex-start;',
onMouseenter: (e: Event) => {
(e.target as HTMLElement).style.textDecoration = 'underline';
},
onMouseleave: (e: Event) => {
(e.target as HTMLElement).style.textDecoration = 'none';
}
}, t('input.goToAgentEditor'))
h(
'span',
{ style: 'color: var(--td-text-color-primary); line-height: 1.5;' },
isRemoteShared
? t('input.sharedAgentNotReadyDetail', { agentName: agent.name, reasons: reasonsText })
: t('input.agentNotReadyDetail', { agentName: agent.name, reasons: reasonsText }),
),
...(isRemoteShared ? [] : [
h('a', {
href: '#',
onClick: (e: Event) => {
e.preventDefault();
const section = resolveAgentNotReadySection(reasonKeys || ['summary_model'])
const highlight = resolveAgentNotReadyHighlight(reasonKeys || ['summary_model'])
goToAgentEditor(agent, section, highlight, sourceTenantId);
},
style: 'color: var(--td-brand-color); text-decoration: none; font-weight: 500; cursor: pointer; align-self: flex-start;',
onMouseenter: (e: Event) => {
(e.target as HTMLElement).style.textDecoration = 'underline';
},
onMouseleave: (e: Event) => {
(e.target as HTMLElement).style.textDecoration = 'none';
}
}, t('input.goToAgentEditor')),
]),
]);
MessagePlugin.warning({
@@ -2233,9 +2243,7 @@ defineExpose({
<!-- Agent 选择器下拉菜单 -->
<AgentSelector :visible="showAgentModeSelector" :anchorEl="agentModeButtonRef"
:currentAgentId="selectedAgentId" :agents="enabledAgents" :all-models="allModels"
@close="closeAgentModeSelector"
@select="handleSelectAgent"
@not-ready="handleAgentNotReady" />
@close="closeAgentModeSelector" @select="handleSelectAgent" @not-ready="handleAgentNotReady" />
<!-- WebSearch 开关按钮 -->
<t-tooltip placement="top" theme="light" :popupProps="{ overlayClassName: 'input-field-tooltip' }">
@@ -2243,7 +2251,7 @@ defineExpose({
<div v-if="isWebSearchDisabledByAgent" class="tooltip-with-link">
<span>{{ $t('input.webSearchDisabledByAgent') }}</span>
<a href="#" @click.prevent="handleGoToAgentSettings('websearch')">{{ $t('input.goToAgentSettings')
}}</a>
}}</a>
</div>
<span v-else-if="isWebSearchConfigured">{{ isWebSearchEnabled ? $t('input.webSearch.toggleOff') :
$t('input.webSearch.toggleOn') }}</span>
@@ -2308,7 +2316,7 @@ defineExpose({
d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
<span v-if="uploadedAttachments.length > 0" class="attachment-count">{{ uploadedAttachments.length
}}</span>
}}</span>
</div>
</t-tooltip>
@@ -2318,7 +2326,7 @@ defineExpose({
<div v-if="isKnowledgeBaseDisabledByAgent" class="tooltip-with-link">
<span>{{ $t('input.kbDisabledByAgent') }}</span>
<a href="#" @click.prevent="handleGoToAgentSettings('knowledge')">{{ $t('input.goToAgentSettings')
}}</a>
}}</a>
</div>
<span v-else>{{ allSelectedItems.length > 0 ? $t('input.knowledgeBaseWithCount', {
count:
@@ -2368,13 +2376,8 @@ defineExpose({
</button>
</div>
<div class="model-selector-content">
<div
v-for="model in availableModels"
:key="model.id"
class="model-option"
:class="{ selected: model.id === selectedModelId }"
@click="handleModelChange(model.id || '')"
>
<div v-for="model in availableModels" :key="model.id" class="model-option"
:class="{ selected: model.id === selectedModelId }" @click="handleModelChange(model.id || '')">
<div class="model-option-left">
<div class="model-option-icon">
<t-icon name="chat" size="14px" />
@@ -3191,8 +3194,15 @@ const getImgSrc = (url: string) => {
}
@keyframes modelSelectorFadeIn {
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}
.model-selector-header {
@@ -1,18 +1,8 @@
<template>
<t-dropdown
:options="options"
trigger="click"
placement="bottom-left"
attach="body"
:max-column-width="240"
@click="onSelect"
>
<button
type="button"
class="integrations-agent-filter"
:class="{ 'integrations-agent-filter--active': modelValue }"
:aria-label="ariaLabel"
>
<t-dropdown :options="options" trigger="click" placement="bottom-left" attach="body" :max-column-width="240"
:max-height="280" @click="onSelect">
<button type="button" class="integrations-agent-filter" :class="{ 'integrations-agent-filter--active': modelValue }"
:aria-label="ariaLabel">
<t-icon name="filter" size="14px" class="integrations-agent-filter__icon" />
<span v-if="selectedAgentName" class="integrations-agent-filter__name">{{ selectedAgentName }}</span>
<t-icon name="chevron-down" size="12px" class="integrations-agent-filter__chevron" />
@@ -500,7 +500,7 @@ export function useChatStreamHandler(options: UseChatStreamHandlerOptions) {
onAgentChunkBound?.(message, true)
}
message.isAgentMode = true
ensureAgentMessageShell(message, dataId)
if (
loading.value &&
+2
View File
@@ -1124,6 +1124,7 @@ export default {
notReadyStatus: 'Setup needed',
notReadyMissing: 'Missing: {items}',
configureAction: 'Configure',
sharedNotReadyContact: 'Ask the sharing organization admin to finish setup',
},
// Built-in agent information
builtinInfo: {
@@ -2131,6 +2132,7 @@ export default {
customAgentMissingRerankModel: 'Rerank model',
goToAgentEditor: 'Go to configure →',
agentNotReadyDetail: 'Agent "{agentName}" is not ready. Please configure: {reasons}',
sharedAgentNotReadyDetail: 'Shared agent "{agentName}" is not ready (missing: {reasons}). Ask the sharing organization admin to finish setup.',
builtinAgentNotReadyDetail: 'Built-in agent "{agentName}" is not ready. Please configure: {reasons}',
builtinAgentSettingName: 'Intelligent Reasoning',
builtinNormalSettingName: 'Quick Q&A',
+3
View File
@@ -2120,6 +2120,7 @@ export default {
notReadyStatus: "구성 필요",
notReadyMissing: "누락: {items}",
configureAction: "구성",
sharedNotReadyContact: "공유한 조직 관리자에게 구성을 요청하세요",
},
// 내장 에이전트 정보
builtinInfo: {
@@ -3904,6 +3905,8 @@ export default {
goToAgentEditor: "구성으로 이동 →",
agentNotReadyDetail:
'Agent "{agentName}"가 준비되지 않았습니다. 다음 항목을 설정해야 합니다: {reasons}',
sharedAgentNotReadyDetail:
'공유 Agent "{agentName}"가 준비되지 않았습니다(누락: {reasons}). 공유한 조직 관리자에게 구성을 요청하세요.',
builtinAgentNotReadyDetail:
'내장 에이전트 "{agentName}"가 준비되지 않았습니다. 다음 항목을 설정해야 합니다: {reasons}',
builtinAgentSettingName: "에이전트 추론",
+2
View File
@@ -1038,6 +1038,7 @@ export default {
notReadyStatus: 'Setup needed',
notReadyMissing: 'Missing: {items}',
configureAction: 'Configure',
sharedNotReadyContact: 'Ask the sharing organization admin to finish setup',
},
builtinInfo: {
quickAnswer: {
@@ -4242,6 +4243,7 @@ export default {
agentModeDesc: 'Многошаговое мышление, глубокий анализ',
agentNotReadyTooltip: 'Agent не готов. Пожалуйста, завершите настройку.',
agentNotReadyDetail: 'Agent не готов. Пожалуйста, настройте следующее: {reasons}',
sharedAgentNotReadyDetail: 'Общий agent «{agentName}» не готов (отсутствует: {reasons}). Обратитесь к администратору организации, которая поделилась agent.',
agentMissingAllowedTools: 'Разрешённые инструменты',
agentMissingSummaryModel: 'Модель беседы',
agentMissingRerankModel: 'Модель переранжирования',
+2
View File
@@ -2116,6 +2116,7 @@ export default {
notReadyStatus: "待配置",
notReadyMissing: "缺少 {items}",
configureAction: "去配置",
sharedNotReadyContact: "请联系分享方管理员完成配置",
},
// 内置智能体信息
builtinInfo: {
@@ -3912,6 +3913,7 @@ export default {
customAgentMissingRerankModel: "重排模型",
goToAgentEditor: "前往配置 →",
agentNotReadyDetail: "智能体「{agentName}」未就绪,需要配置以下内容:{reasons}",
sharedAgentNotReadyDetail: "共享智能体「{agentName}」尚未就绪(缺少:{reasons}),请联系分享方管理员完成配置。",
builtinAgentNotReadyDetail: "内置智能体「{agentName}」未就绪,需要配置以下内容:{reasons}",
builtinAgentSettingName: "智能推理",
builtinNormalSettingName: "快速问答",
+1
View File
@@ -246,6 +246,7 @@ export const useAuthStore = defineStore('auth', () => {
if (parsed && typeof parsed === 'object') {
parsed.selectedAgentId = BUILTIN_QUICK_ANSWER_ID
parsed.selectedAgentSourceTenantId = null
parsed.isAgentEnabled = false
if (parsed.conversationModels && typeof parsed.conversationModels === 'object') {
parsed.conversationModels.summaryModelId = ''
parsed.conversationModels.rerankModelId = ''
+24 -1
View File
@@ -3,6 +3,7 @@ import { nextTick } from "vue";
import { BUILTIN_QUICK_ANSWER_ID, BUILTIN_SMART_REASONING_ID } from "@/api/agent";
import { getApiBaseUrl } from "@/utils/api-base";
import { updateMyPreferences, type UserPreferences } from "@/api/auth";
import { isAgentStreamAgentId, reconcileBuiltinAgentMode } from "@/utils/agent-mode";
// 定义设置接口
interface Settings {
@@ -102,10 +103,21 @@ const defaultSettings: Settings = {
autoCheckUpdate: true,
};
/** Keep builtin agent id and isAgentEnabled in sync after localStorage reload. */
function loadAndReconcileSettings(): Settings {
const loaded = JSON.parse(
localStorage.getItem("WeKnora_settings") || JSON.stringify(defaultSettings),
) as Settings;
if (reconcileBuiltinAgentMode(loaded)) {
localStorage.setItem("WeKnora_settings", JSON.stringify(loaded));
}
return loaded;
}
export const useSettingsStore = defineStore("settings", {
state: () => ({
// 从本地存储加载设置,如果没有则使用默认设置
settings: JSON.parse(localStorage.getItem("WeKnora_settings") || JSON.stringify(defaultSettings)) as Settings,
settings: loadAndReconcileSettings(),
// 进入会话时拍下"全局默认"的快照;离开会话时还原。非持久化字段:
// 刷新页面相当于重新走"进入会话"流程,自然会重新拍快照。
_defaultsSnapshot: null as Settings | null,
@@ -116,6 +128,17 @@ export const useSettingsStore = defineStore("settings", {
getters: {
// Agent 是否启用
isAgentEnabled: (state) => state.settings.isAgentEnabled || false,
// 当前是否为内置快速问答(优先看 selectedAgentId,避免与 isAgentEnabled 漂移)
isQuickAnswerMode: (state) =>
(state.settings.selectedAgentId || BUILTIN_QUICK_ANSWER_ID) === BUILTIN_QUICK_ANSWER_ID,
// 是否走 Agent 流式管线(智能推理 / 自定义 Agent);快速问答走 RAG 管线
isAgentStreamMode: (state) =>
isAgentStreamAgentId(
state.settings.selectedAgentId,
state.settings.isAgentEnabled || false,
),
// Agent 是否就绪(配置完整)
// 需要满足:1) 配置了允许的工具 2) 设置了对话模型 3) 设置了重排模型
+59
View File
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict'
import test from 'node:test'
const BUILTIN_QUICK_ANSWER_ID = 'builtin-quick-answer'
const BUILTIN_SMART_REASONING_ID = 'builtin-smart-reasoning'
function isQuickAnswerAgentId(agentId) {
return (agentId || BUILTIN_QUICK_ANSWER_ID) === BUILTIN_QUICK_ANSWER_ID
}
function isAgentStreamAgentId(agentId, isAgentEnabled) {
const id = agentId || BUILTIN_QUICK_ANSWER_ID
if (id === BUILTIN_QUICK_ANSWER_ID) return false
if (id === BUILTIN_SMART_REASONING_ID) return true
return isAgentEnabled
}
function reconcileBuiltinAgentMode(settings) {
const agentId = settings.selectedAgentId || BUILTIN_QUICK_ANSWER_ID
if (agentId === BUILTIN_QUICK_ANSWER_ID && settings.isAgentEnabled) {
settings.isAgentEnabled = false
return true
}
if (agentId === BUILTIN_SMART_REASONING_ID && !settings.isAgentEnabled) {
settings.isAgentEnabled = true
return true
}
return false
}
test('isQuickAnswerAgentId treats builtin quick-answer as RAG mode', () => {
assert.equal(isQuickAnswerAgentId('builtin-quick-answer'), true)
assert.equal(isQuickAnswerAgentId(undefined), true)
assert.equal(isQuickAnswerAgentId('builtin-smart-reasoning'), false)
})
test('isAgentStreamAgentId prefers selectedAgentId for builtins', () => {
assert.equal(
isAgentStreamAgentId('builtin-quick-answer', true),
false,
'stale isAgentEnabled must not flip quick-answer into agent stream',
)
assert.equal(isAgentStreamAgentId('builtin-smart-reasoning', false), true)
assert.equal(isAgentStreamAgentId('custom-agent', true), true)
assert.equal(isAgentStreamAgentId('custom-agent', false), false)
})
test('reconcileBuiltinAgentMode repairs drifted localStorage flags', () => {
const quick = { selectedAgentId: 'builtin-quick-answer', isAgentEnabled: true }
assert.equal(reconcileBuiltinAgentMode(quick), true)
assert.equal(quick.isAgentEnabled, false)
const reasoning = { selectedAgentId: 'builtin-smart-reasoning', isAgentEnabled: false }
assert.equal(reconcileBuiltinAgentMode(reasoning), true)
assert.equal(reasoning.isAgentEnabled, true)
const custom = { selectedAgentId: 'custom-agent', isAgentEnabled: true }
assert.equal(reconcileBuiltinAgentMode(custom), false)
})
+37
View File
@@ -0,0 +1,37 @@
import {
BUILTIN_QUICK_ANSWER_ID,
BUILTIN_SMART_REASONING_ID,
} from '@/api/agent'
/** Whether the selected agent id is the builtin quick-answer (RAG) mode. */
export function isQuickAnswerAgentId(agentId: string | null | undefined): boolean {
return (agentId || BUILTIN_QUICK_ANSWER_ID) === BUILTIN_QUICK_ANSWER_ID
}
/** Whether requests should use the Agent stream pipeline (not quick-answer RAG). */
export function isAgentStreamAgentId(
agentId: string | null | undefined,
isAgentEnabled: boolean,
): boolean {
const id = agentId || BUILTIN_QUICK_ANSWER_ID
if (id === BUILTIN_QUICK_ANSWER_ID) return false
if (id === BUILTIN_SMART_REASONING_ID) return true
return isAgentEnabled
}
/** Reconcile builtin agent id with isAgentEnabled after localStorage reload. */
export function reconcileBuiltinAgentMode(settings: {
selectedAgentId?: string
isAgentEnabled: boolean
}): boolean {
const agentId = settings.selectedAgentId || BUILTIN_QUICK_ANSWER_ID
if (agentId === BUILTIN_QUICK_ANSWER_ID && settings.isAgentEnabled) {
settings.isAgentEnabled = false
return true
}
if (agentId === BUILTIN_SMART_REASONING_ID && !settings.isAgentEnabled) {
settings.isAgentEnabled = true
return true
}
return false
}
@@ -3,6 +3,7 @@ import test from 'node:test'
import {
agentHasConfiguredChatModel,
agentRequiresRerankModel,
canLocallyConfigureAgent,
getAgentNotReadyReasonKeys,
resolveAgentNotReadySection,
resolveAgentNotReadyHighlight,
@@ -100,3 +101,22 @@ test('getAgentNotReadyReasonKeys does not treat current shared context as shared
{ isAgentMode: false, isSharedAgent: false },
), ['summary_model'])
})
test('getAgentNotReadyReasonKeys treats empty allowed_tools as ready via backend defaults', () => {
assert.deepEqual(getAgentNotReadyReasonKeys(
{
model_id: 'chat-1',
rerank_model_id: 'rerank-1',
kb_selection_mode: 'all',
allowed_tools: [],
},
[{ id: 'chat-1', type: 'KnowledgeQA' }, { id: 'rerank-1', type: 'Rerank' }],
{ isAgentMode: true, isSharedAgent: false },
), [])
})
test('canLocallyConfigureAgent is false for shared agents', () => {
assert.equal(canLocallyConfigureAgent('42'), false)
assert.equal(canLocallyConfigureAgent(undefined), true)
assert.equal(canLocallyConfigureAgent(''), true)
})
+5 -4
View File
@@ -67,13 +67,14 @@ export function getAgentNotReadyReasonKeys(
}
}
if (options.isAgentMode && (!config?.allowed_tools || config.allowed_tools.length === 0)) {
reasons.push('allowed_tools')
}
return reasons
}
/** Shared agents are owned by another tenant; receivers cannot edit their config. */
export function canLocallyConfigureAgent(sourceTenantId?: string): boolean {
return !sourceTenantId
}
/** Map missing-config reasons to the agent editor section that fixes them. */
export function resolveAgentNotReadySection(reasons: AgentNotReadyReasonKey[]): string {
if (reasons.includes('summary_model') || reasons.includes('rerank_model')) {
+5 -3
View File
@@ -144,7 +144,7 @@ const isAgentStreamSession = () => {
if (props.embeddedMode) {
return !!(props.agentId && props.agentId !== 'builtin-quick-answer');
}
return useSettingsStoreInstance.isAgentEnabled;
return useSettingsStoreInstance.isAgentStreamMode;
};
const uiStore = useUIStore();
@@ -623,8 +623,10 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = []
userHasScrolledUp.value = false;
scrollToBottom(true);
// Get agent mode status from settings store
const agentEnabled = props.embeddedMode ? (props.agentId && props.agentId !== 'builtin-quick-answer') : useSettingsStoreInstance.isAgentEnabled;
// Get agent mode status from settings store (prefer selectedAgentId for builtins)
const agentEnabled = props.embeddedMode
? (props.agentId && props.agentId !== 'builtin-quick-answer')
: useSettingsStoreInstance.isAgentStreamMode;
// Get web search status from settings store
const webSearchEnabled = props.embeddedMode ? false : useSettingsStoreInstance.isWebSearchEnabled;