fix(ui): stabilize pro-multi context actions and empty state

Route message updates into injected optimizationContext and use app-level open* handlers to prevent no-op clicks.

Avoid selecting a message on initial mount so the empty hint renders until the user picks one; add a regression test.
This commit is contained in:
linshen committed 2026-01-29 21:45:00 +08:00
1 parent 89a6f0de9c
commit 11289a7b21
7 files changed
+335 -36

No files matched your search

@@ -1866,6 +1866,16 @@ const openModelManager = (tab: "text" | "image" | "function" = "text") => {
};
provide("openModelManager", openModelManager);
// 提供 openContextEditor 接口(供 Pro Multi 等工作区直接调用)
type ContextEditorOpenArg = ConversationMessage[] | "messages" | "variables" | "tools";
const openContextEditor = (
messagesOrTab?: ContextEditorOpenArg,
variables?: Record<string, string>,
) => {
void contextManagement.handleOpenContextEditor(messagesOrTab, variables);
};
provide("openContextEditor", openContextEditor);
// 模型管理器关闭回调
const handleModelManagerClosed = async () => {
try {
@@ -19,7 +19,7 @@
>
<ConversationManager
:messages="optimizationContext"
@update:messages="emit('update:optimizationContext', $event)"
@update:messages="handleConversationMessagesUpdated"
@message-change="(index, message, action) => {
// Pro Multi:新增/更新消息后自动选中最新消息,确保“优化”按钮可用
if ((action === 'add' || action === 'update') && (message.role === 'system' || message.role === 'user') && message.id) {
@@ -33,7 +33,7 @@
:optimization-mode="optimizationMode"
:tool-count="toolCount"
@open-variable-manager="emit('open-variable-manager')"
@open-context-editor="emit('open-context-editor')"
@open-context-editor="handleOpenContextEditor"
@open-tool-manager="emit('open-tool-manager')"
:enable-tool-management="true"
:collapsible="true"
@@ -64,7 +64,7 @@
:getPrimary="OptionAccessors.getPrimary"
:getSecondary="OptionAccessors.getSecondary"
:getValue="OptionAccessors.getValue"
@config="emit('config-model')"
@config="handleOpenModelManager"
/>
</NFlex>
@@ -79,7 +79,7 @@
:getPrimary="OptionAccessors.getPrimary"
:getSecondary="OptionAccessors.getSecondary"
:getValue="OptionAccessors.getValue"
@config="emit('open-template-manager')"
@config="handleOpenTemplateManager"
/>
</NFlex>
</NFlex>
@@ -119,14 +119,14 @@
:show-apply-button="displayAdapter.isInMessageOptimizationMode.value"
:optimization-mode="optimizationMode"
:advanced-mode-enabled="true"
:show-preview="true"
:show-preview="true"
@iterate="handleIterate"
@openTemplateManager="emit('open-template-manager', $event)"
@openTemplateManager="handleOpenTemplateManager"
@switchVersion="handleSwitchVersion"
@switchToV0="handleSwitchToV0"
@save-favorite="emit('save-favorite', $event)"
@open-preview="handleOpenPromptPreview"
@apply-to-conversation="handleApplyToConversation"
@switchToV0="handleSwitchToV0"
@save-favorite="emit('save-favorite', $event)"
@open-preview="handleOpenPromptPreview"
@apply-to-conversation="handleApplyToConversation"
@apply-improvement="handleApplyImprovement"
@save-local-edit="handleSaveLocalEdit"
/>
@@ -605,9 +605,58 @@ const injectedServices = inject<Ref<AppServices | null>>('services')
const servicesRef = injectedServices ?? ref<AppServices | null>(null)
const variableManager = inject<VariableManagerHooks | null>('variableManager', null)
// 注入 App 层统一的 open* 接口(避免 Pro 工作区 emit 链断导致按钮无响应)
const appOpenModelManager = inject<
((tab?: 'text' | 'image' | 'function') => void) | null
>('openModelManager', null)
const appOpenTemplateManager = inject<((type?: string) => void) | null>(
'openTemplateManager',
null,
)
type ContextEditorOpenArg = ConversationMessage[] | 'messages' | 'variables' | 'tools'
const appOpenContextEditor = inject<
((messagesOrTab?: ContextEditorOpenArg, variables?: Record<string, string>) => void) | null
>('openContextEditor', null)
// 🆕 注入优化上下文(多轮对话消息)
const optimizationContext = inject<Ref<ConversationMessage[]>>('optimizationContext', ref([]))
const handleConversationMessagesUpdated = (messages: ConversationMessage[]) => {
optimizationContext.value = messages
// 兜底:保留对外 emits,供非 PromptOptimizerApp 宿主集成使用
emit('update:optimizationContext', messages)
}
const handleOpenModelManager = () => {
if (appOpenModelManager) {
appOpenModelManager('text')
return
}
emit('config-model')
}
const handleOpenTemplateManager = (typeOrPayload?: string | Record<string, unknown>) => {
// SelectWithConfig 的 @config 可能会传入 payload(非字符串),这里统一兜底处理。
const type = typeof typeOrPayload === 'string' ? typeOrPayload : undefined
if (appOpenTemplateManager) {
appOpenTemplateManager(type || 'optimize')
return
}
emit('open-template-manager', type)
}
const handleOpenContextEditor = (
messages: ConversationMessage[],
variables: Record<string, string>,
) => {
if (appOpenContextEditor) {
appOpenContextEditor(messages, variables)
return
}
// 兜底:旧链路(如果宿主仍通过 emit 打开编辑器)
emit('open-context-editor')
}
// ✅ 优化模式:固定为 'system'(此组件专门用于系统模式优化)
const optimizationMode: OptimizationMode = 'system';
@@ -737,19 +786,15 @@ onMounted(() => {
// ✅ 刷新模型列表
modelSelection.refreshTextModels()
// Pro Multi:自动选中最新一条可优化消息(system/user),以便直接启用“优化”
const latestSelectable = [...(optimizationContext.value || [])]
.reverse()
.find((msg) => msg && (msg.role === 'system' || msg.role === 'user') && !!msg.id)
if (latestSelectable) {
void conversationOptimization.selectMessage(latestSelectable)
}
// 兜底:如果还没有消息可选,但 session store 已有选中消息(刷新/恢复场景),尝试同步一次
if (!latestSelectable && proMultiSession.selectedMessageId) {
// Pro Multi:初始态保持“未选择消息”,让用户明确选择要优化的消息。
// 仅在 session store 有选中记录时尝试恢复(刷新/恢复场景)。
if (proMultiSession.selectedMessageId) {
const restored = (optimizationContext.value || []).find((m) => m.id === proMultiSession.selectedMessageId)
if (restored) {
void conversationOptimization.selectMessage(restored)
} else {
// 防止选中 ID 指向已不存在的消息,导致 UI 误判为“已选中”。
proMultiSession.selectMessage('')
}
}
@@ -80,11 +80,11 @@
:show-preview="true"
:show-analyze-button="true"
:analyze-loading="isAnalyzing"
@submit="handleOptimize"
@analyze="handleAnalyze"
@configModel="emit('config-model')"
@open-preview="handleOpenInputPreview"
:enable-variable-extraction="true"
@submit="handleOptimize"
@analyze="handleAnalyze"
@configModel="handleOpenModelManager"
@open-preview="handleOpenInputPreview"
:enable-variable-extraction="true"
:show-extract-button="true"
:extracting="props.isExtracting"
v-bind="inputPanelVariableData || {}"
@@ -100,7 +100,7 @@
:getPrimary="OptionAccessors.getPrimary"
:getSecondary="OptionAccessors.getSecondary"
:getValue="OptionAccessors.getValue"
@config="emit('config-model')"
@config="handleOpenModelManager"
/>
</template>
@@ -112,7 +112,7 @@
:getPrimary="OptionAccessors.getPrimary"
:getSecondary="OptionAccessors.getSecondary"
:getValue="OptionAccessors.getValue"
@config="emit('open-template-manager')"
@config="handleOpenTemplateManager"
/>
</template>
@@ -170,14 +170,14 @@
"
:versions="contextUserOptimization.currentVersions"
:current-version-id="contextUserOptimization.currentVersionId"
:optimization-mode="optimizationMode"
:advanced-mode-enabled="true"
:show-preview="true"
@iterate="handleIterate"
@openTemplateManager="emit('open-template-manager', $event)"
@switchVersion="handleSwitchVersion"
@switchToV0="handleSwitchToV0"
@save-favorite="emit('save-favorite', $event)"
:optimization-mode="optimizationMode"
:advanced-mode-enabled="true"
:show-preview="true"
@iterate="handleIterate"
@openTemplateManager="handleOpenTemplateManager"
@switchVersion="handleSwitchVersion"
@switchToV0="handleSwitchToV0"
@save-favorite="emit('save-favorite', $event)"
@open-preview="handleOpenPromptPreview"
@apply-improvement="handleApplyImprovement"
@save-local-edit="handleSaveLocalEdit"
@@ -639,6 +639,33 @@ const injectedServices = inject<Ref<AppServices | null>>('services');
const servicesRef = injectedServices ?? ref<AppServices | null>(null)
const variableManager = inject<VariableManagerHooks | null>('variableManager', null);
// 注入 App 层统一的 open* 接口(与 Basic/Image 工作区保持一致)
const appOpenModelManager = inject<
((tab?: 'text' | 'image' | 'function') => void) | null
>('openModelManager', null)
const appOpenTemplateManager = inject<((type?: string) => void) | null>(
'openTemplateManager',
null,
)
const handleOpenModelManager = () => {
if (appOpenModelManager) {
appOpenModelManager('text')
return
}
emit('config-model')
}
const handleOpenTemplateManager = (typeOrPayload?: string | Record<string, unknown>) => {
// SelectWithConfig 的 @config 可能会传入 payload(非字符串),这里统一兜底处理。
const type = typeof typeOrPayload === 'string' ? typeOrPayload : undefined
if (appOpenTemplateManager) {
appOpenTemplateManager(type || 'optimize')
return
}
emit('open-template-manager', type)
}
// ========================
// 内部状态管理
// ========================
@@ -51,6 +51,7 @@
:size="buttonSize"
type="primary"
:loading="loading"
data-testid="pro-multi-open-context-editor"
>
<template #icon>
<svg
@@ -56,7 +56,7 @@
<NCard
v-if="predefinedVariables.length > 0"
size="small"
:content-style="isPredefinedExpanded ? null : 'padding: 0'"
:content-style="isPredefinedExpanded ? undefined : 'padding: 0'"
>
<template #header>
<NSpace align="center">
@@ -0,0 +1,204 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, nextTick, ref } from 'vue'
import { setActivePinia } from 'pinia'
import type { ConversationMessage } from '@prompt-optimizer/core'
import ContextSystemWorkspace from '../../src/components/context-mode/ContextSystemWorkspace.vue'
import { useSessionManager } from '../../src/stores/session/useSessionManager'
import { createTestPinia } from '../utils/pinia-test-helpers'
// Keep this test focused on UI wiring (click -> messages update -> open editor).
// Naive UI is mocked so we can trigger events deterministically in JSDOM.
vi.mock('naive-ui', async (importOriginal) => {
const actual = await importOriginal<typeof import('naive-ui')>()
const NCard = defineComponent({
name: 'NCard',
template: `
<div class="n-card" v-bind="$attrs">
<div class="n-card__header"><slot name="header" /></div>
<div class="n-card__content"><slot /></div>
<div class="n-card__footer"><slot name="footer" /></div>
</div>
`,
})
const NFlex = defineComponent({
name: 'NFlex',
template: `<div class="n-flex" v-bind="$attrs"><slot /></div>`,
})
const NButton = defineComponent({
name: 'NButton',
inheritAttrs: false,
emits: ['click'],
props: {
disabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
},
template: `
<button
class="n-button"
v-bind="$attrs"
:disabled="disabled || loading"
@click="$emit('click')"
>
<slot name="icon" />
<slot />
</button>
`,
})
const NText = defineComponent({
name: 'NText',
template: `<span class="n-text" v-bind="$attrs"><slot /></span>`,
})
const NEmpty = defineComponent({
name: 'NEmpty',
template: `
<div class="n-empty" v-bind="$attrs">
<div class="n-empty__icon"><slot name="icon" /></div>
<div class="n-empty__content"><slot /></div>
<div class="n-empty__extra"><slot name="extra" /></div>
</div>
`,
})
const NSelect = defineComponent({
name: 'NSelect',
inheritAttrs: false,
props: {
value: { type: [String, Number, Array, Object], default: undefined },
options: { type: Array, default: () => [] },
},
emits: ['update:value'],
template: `<select class="n-select" v-bind="$attrs"></select>`,
})
const NRadioGroup = defineComponent({
name: 'NRadioGroup',
inheritAttrs: false,
props: { value: { type: [String, Number], default: undefined } },
emits: ['update:value'],
template: `<div class="n-radio-group" v-bind="$attrs"><slot /></div>`,
})
const NRadioButton = defineComponent({
name: 'NRadioButton',
inheritAttrs: false,
template: `<button class="n-radio-button" v-bind="$attrs"><slot /></button>`,
})
const NTooltip = defineComponent({
name: 'NTooltip',
template: `<span class="n-tooltip" v-bind="$attrs"><slot /></span>`,
})
const NTag = defineComponent({
name: 'NTag',
template: `<span class="n-tag" v-bind="$attrs"><slot /></span>`,
})
const NSpace = defineComponent({
name: 'NSpace',
template: `<div class="n-space" v-bind="$attrs"><slot /></div>`,
})
const NScrollbar = defineComponent({
name: 'NScrollbar',
template: `<div class="n-scrollbar" v-bind="$attrs"><slot /></div>`,
})
const NList = defineComponent({
name: 'NList',
template: `<div class="n-list" v-bind="$attrs"><slot /></div>`,
})
const NListItem = defineComponent({
name: 'NListItem',
template: `<div class="n-list-item" v-bind="$attrs"><slot /></div>`,
})
const NDropdown = defineComponent({
name: 'NDropdown',
template: `<div class="n-dropdown" v-bind="$attrs"><slot /></div>`,
})
return {
...actual,
NCard,
NFlex,
NButton,
NText,
NEmpty,
NSelect,
NRadioGroup,
NRadioButton,
NTooltip,
NTag,
NSpace,
NScrollbar,
NList,
NListItem,
NDropdown,
}
})
describe('Pro Multi: context actions wiring (e2e)', () => {
it('can add first message and open context editor', async () => {
const { pinia } = createTestPinia()
setActivePinia(pinia)
// ContextSystemWorkspace touches temp variables which depend on session manager readers.
// In the app this is injected by PromptOptimizerApp; in tests we inject a stable default.
useSessionManager().injectSubModeReaders({
getFunctionMode: () => 'pro',
getBasicSubMode: () => 'system',
getProSubMode: () => 'multi',
getImageSubMode: () => 'text2image',
})
const optimizationContext = ref<ConversationMessage[]>([])
const openContextEditor = vi.fn()
const wrapper = mount(ContextSystemWorkspace, {
global: {
plugins: [pinia],
provide: {
// ContextSystemWorkspace expects a Ref<AppServices | null>.
services: ref(null),
optimizationContext,
openContextEditor,
openModelManager: vi.fn(),
openTemplateManager: vi.fn(),
},
stubs: {
// Keep the surface area minimal; we only care about ConversationManager wiring.
PromptPanelUI: true,
PromptPreviewPanel: true,
ConversationTestPanel: true,
OutputDisplay: true,
SelectWithConfig: true,
ToolCallDisplay: true,
EvaluationPanel: true,
EvaluationScoreBadge: true,
VariableAwareInput: true,
},
},
})
await wrapper.find('[data-testid="pro-multi-add-first-message"]').trigger('click')
await nextTick()
expect(optimizationContext.value).toHaveLength(1)
expect(optimizationContext.value[0]?.role).toBe('user')
await wrapper.find('[data-testid="pro-multi-open-context-editor"]').trigger('click')
expect(openContextEditor).toHaveBeenCalledTimes(1)
const [messages] = openContextEditor.mock.calls[0] ?? []
expect(messages).toHaveLength(1)
})
})
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.json",
"include": [
"../env.d.ts",
"../src/**/*",
"../src/**/*.vue",
"./**/*"
],
"compilerOptions": {
"types": ["vitest/globals", "node"]
}
}