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:
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": [
|
||||
"../env.d.ts",
|
||||
"../src/**/*",
|
||||
"../src/**/*.vue",
|
||||
"./**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals", "node"]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user