Skip to content

Commit f82eb4c

Browse files
committed
feat(ui): 集成变量感知输入功能并支持只读模式
- OutputDisplayCore 在 Pro 模式下集成 VariableAwareInput - 添加变量提取、保存、缺失变量检测功能 - VariableAwareInput 支持只读模式,禁用编辑和变量提取 - 优化 useFunctionMode 初始化逻辑,失败时允许重试 - 增强 useToast API,支持传入 MessageOptions 对象 - 新增 platform.ts 工具,提供跨平台快捷键检测 - 完善中英繁三语言国际化文案
1 parent 01d1246 commit f82eb4c

File tree

8 files changed

+281
-32
lines changed

8 files changed

+281
-32
lines changed

packages/ui/src/components/OutputDisplayCore.vue

Lines changed: 169 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -127,15 +127,32 @@
127127
/>
128128

129129
<!-- 原文模式 -->
130-
<NInput v-else-if="internalViewMode === 'source'"
131-
:value="content"
132-
@input="handleSourceInput"
133-
:readonly="mode !== 'editable' || streaming"
134-
type="textarea"
135-
:placeholder="placeholder"
136-
:autosize="{ minRows: 10 }"
137-
style="height: 100%; min-height: 0;"
138-
/>
130+
<template v-if="internalViewMode === 'source'">
131+
<!-- 🆕 Pro 模式:使用变量感知输入框 -->
132+
<VariableAwareInput
133+
v-if="shouldEnableVariables && variableData"
134+
:model-value="content"
135+
@update:model-value="handleSourceInput"
136+
:readonly="mode !== 'editable' || streaming"
137+
:placeholder="placeholder"
138+
:autosize="{ minRows: 10, maxRows: 20 }"
139+
v-bind="variableData"
140+
@variable-extracted="handleVariableExtracted"
141+
@add-missing-variable="handleAddMissingVariable"
142+
/>
143+
144+
<!-- Basic/Image 模式:使用普通输入框 -->
145+
<NInput
146+
v-else
147+
:value="content"
148+
@input="handleSourceInput"
149+
:readonly="mode !== 'editable' || streaming"
150+
type="textarea"
151+
:placeholder="placeholder"
152+
:autosize="{ minRows: 10 }"
153+
style="height: 100%; min-height: 0;"
154+
/>
155+
</template>
139156

140157
<!-- 渲染模式(默认) -->
141158
<NFlex v-else
@@ -165,7 +182,7 @@
165182
</template>
166183

167184
<script setup lang="ts">
168-
import { computed, ref, watch, nextTick, onMounted } from 'vue'
185+
import { computed, ref, watch, nextTick, onMounted, inject, type Ref } from 'vue'
169186
170187
import { useI18n } from 'vue-i18n'
171188
import {
@@ -178,6 +195,12 @@ import { useClipboard } from '../composables/ui/useClipboard'
178195
import MarkdownRenderer from './MarkdownRenderer.vue'
179196
import TextDiffUI from './TextDiff.vue'
180197
import type { CompareResult } from '@prompt-optimizer/core'
198+
import { VariableAwareInput } from './variable-extraction'
199+
import { useFunctionMode } from '../composables/mode/useFunctionMode'
200+
import { useTemporaryVariables } from '../composables/variable/useTemporaryVariables'
201+
import { useVariableManager } from '../composables/prompt/useVariableManager'
202+
import type { AppServices } from '../types/services'
203+
import { platform } from '../utils/platform'
181204
182205
type ActionName = 'fullscreen' | 'diff' | 'copy' | 'edit' | 'reasoning' | 'favorite'
183206
@@ -186,8 +209,8 @@ const { copyText } = useClipboard()
186209
187210
const message = useToast()
188211
189-
// 服务注入(当前未使用,保留用于未来扩展
190-
// const services = inject<Ref<AppServices | null> | null>('services', null)
212+
// 🆕 注入 services(用于变量管理
213+
const services = inject<Ref<AppServices | null>>('services', ref(null))
191214
192215
// 移除收藏状态管理(改由父组件处理)
193216
@@ -241,6 +264,134 @@ const emit = defineEmits<{
241264
'save-favorite': [data: { content: string; originalContent?: string }]
242265
}>()
243266
267+
// 🆕 变量管理功能(仅 Pro 模式)
268+
// ==================== 功能模式判断 ====================
269+
// ✅ 无条件调用,使用全局单例的 functionMode
270+
// ⚠️ 不主动初始化,避免在 services 未就绪时污染全局单例
271+
const { functionMode } = useFunctionMode(services)
272+
273+
// 判断是否启用变量功能(仅 Pro 模式)
274+
const shouldEnableVariables = computed(() => functionMode.value === 'pro')
275+
276+
// ==================== 变量管理 Composables ====================
277+
// 临时变量管理器(全局单例)
278+
const tempVars = useTemporaryVariables()
279+
280+
// ✅ 无条件调用,composable 内部会等待 services.preferenceService 准备就绪
281+
const globalVarsManager = useVariableManager(services)
282+
283+
// ==================== 变量数据计算 ====================
284+
/**
285+
* 计算纯预定义变量
286+
* allVariables = 预定义变量 + 自定义全局变量
287+
* 因此:预定义变量 = allVariables - customVariables
288+
*/
289+
const purePredefinedVariables = computed(() => {
290+
const all = globalVarsManager.allVariables.value || {}
291+
const custom = globalVarsManager.customVariables.value || {}
292+
293+
const predefined: Record<string, string> = {}
294+
for (const [key, value] of Object.entries(all)) {
295+
// 只保留不在 customVariables 中的变量
296+
if (!(key in custom)) {
297+
predefined[key] = value
298+
}
299+
}
300+
301+
return predefined
302+
})
303+
304+
const variableData = computed(() => {
305+
// 只在 Pro 模式下提供变量数据
306+
if (!shouldEnableVariables.value) return null
307+
308+
// 🔒 如果全局变量管理器未就绪,返回 null 以禁用变量功能
309+
// 这样可以避免文本被替换但变量未保存的不一致状态
310+
if (!globalVarsManager.isReady.value) return null
311+
312+
return {
313+
existingGlobalVariables: Object.keys(globalVarsManager.customVariables.value || {}),
314+
existingTemporaryVariables: Object.keys(tempVars.temporaryVariables.value || {}),
315+
predefinedVariables: Object.keys(purePredefinedVariables.value),
316+
globalVariableValues: globalVarsManager.customVariables.value || {},
317+
temporaryVariableValues: tempVars.temporaryVariables.value || {},
318+
predefinedVariableValues: purePredefinedVariables.value
319+
}
320+
})
321+
322+
// ==================== 变量事件处理 ====================
323+
/**
324+
* 处理变量提取事件
325+
* 在 Pro 模式的原文编辑模式下,用户选中文本提取变量时触发
326+
*
327+
* ⚠️ 注意:此函数只会在 variableData 不为 null 时被调用
328+
* (即管理器已就绪且为 Pro 模式),因此不需要额外检查
329+
*
330+
* ⚠️ 数据一致性问题:
331+
* VariableAwareInput 在触发此事件前已完成文本替换({{varName}})
332+
* 如果保存失败,文本已被修改但变量未保存,需提示用户撤销操作
333+
*/
334+
const handleVariableExtracted = (data: {
335+
variableName: string
336+
variableValue: string
337+
variableType: 'global' | 'temporary'
338+
}) => {
339+
if (data.variableType === 'global') {
340+
try {
341+
// 保存到全局变量
342+
globalVarsManager.addVariable(data.variableName, data.variableValue)
343+
message.success(
344+
t('variableExtraction.savedToGlobal', { name: data.variableName })
345+
)
346+
} catch (error) {
347+
console.error('[OutputDisplayCore] Failed to save global variable:', error)
348+
// ⚠️ 保存失败但文本已被替换,提示用户需要撤销
349+
message.error(
350+
t('variableExtraction.saveFailedWithUndo', {
351+
name: data.variableName,
352+
undo: platform.getUndoKey()
353+
}),
354+
{
355+
duration: 8000, // 延长显示时间,确保用户看到
356+
closable: true
357+
}
358+
)
359+
}
360+
} else {
361+
// 保存到临时变量(临时变量管理器是全局单例,始终可用)
362+
try {
363+
tempVars.setVariable(data.variableName, data.variableValue)
364+
message.success(
365+
t('variableExtraction.savedToTemporary', { name: data.variableName })
366+
)
367+
} catch (error) {
368+
console.error('[OutputDisplayCore] Failed to save temporary variable:', error)
369+
// 临时变量保存失败的可能性极低,但仍需处理
370+
message.error(
371+
t('variableExtraction.saveFailedWithUndo', {
372+
name: data.variableName,
373+
undo: platform.getUndoKey()
374+
}),
375+
{
376+
duration: 8000,
377+
closable: true
378+
}
379+
)
380+
}
381+
}
382+
}
383+
384+
/**
385+
* 处理添加缺失变量事件
386+
* 当用户悬停在缺失变量上并点击快速添加时触发
387+
*/
388+
const handleAddMissingVariable = (varName: string) => {
389+
tempVars.setVariable(varName, '')
390+
message.success(
391+
t('variableDetection.addSuccess', { name: varName })
392+
)
393+
}
394+
244395
// 内部状态
245396
const reasoningContentRef = ref<HTMLDivElement | null>(null)
246397
const userHasManuallyToggledReasoning = ref(false)
@@ -462,6 +613,12 @@ const handleFavorite = () => {
462613
463614
// 组件挂载时设置初始视图模式
464615
onMounted(() => {
616+
// ⚠️ 不在此处初始化 functionMode
617+
// 原因:useFunctionMode 是全局单例,不应由单个组件控制初始化时机
618+
// - 如果 services 未就绪,初始化会失败但仍标记为已完成,导致永久卡在 'basic'
619+
// - 应该在应用级别统一初始化(如 App.vue)
620+
// - functionMode 有默认值 'basic',可以正常工作
621+
465622
// 如果是可编辑模式,默认显示原文
466623
if (props.mode === 'editable') {
467624
internalViewMode.value = 'source';

packages/ui/src/components/variable-extraction/VariableAwareInput.vue

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { EditorView, basicSetup } from "codemirror";
5454
import { EditorState, Compartment } from "@codemirror/state";
5555
import { NPopover, NButton, useThemeVars } from "naive-ui";
5656
import { useI18n } from "vue-i18n";
57+
import { useToast } from "../../composables/ui/useToast";
5758
import { useVariableDetection } from "./useVariableDetection";
5859
import VariableExtractionDialog from "./VariableExtractionDialog.vue";
5960
import {
@@ -81,6 +82,8 @@ interface Props {
8182
modelValue: string;
8283
/** 占位符文本 */
8384
placeholder?: string;
85+
/** 🆕 是否只读 */
86+
readonly?: boolean;
8487
/** 自动调整高度 */
8588
autosize?: boolean | { minRows?: number; maxRows?: number };
8689
/** 已存在的全局变量名列表 */
@@ -99,6 +102,7 @@ interface Props {
99102
100103
const props = withDefaults(defineProps<Props>(), {
101104
placeholder: "",
105+
readonly: false,
102106
autosize: () => ({ minRows: 4, maxRows: 12 }),
103107
existingGlobalVariables: () => [],
104108
existingTemporaryVariables: () => [],
@@ -128,6 +132,7 @@ interface Emits {
128132
const emit = defineEmits<Emits>();
129133
130134
const { t } = useI18n();
135+
const message = useToast();
131136
const themeVars = useThemeVars();
132137
const completionColorVars = computed(() => ({
133138
"--variable-completion-temporary-color":
@@ -151,6 +156,7 @@ const missingVariableTooltipCompartment = new Compartment();
151156
const existingVariableTooltipCompartment = new Compartment();
152157
const placeholderCompartment = new Compartment();
153158
const themeCompartment = new Compartment();
159+
const readOnlyCompartment = new Compartment();
154160
155161
const buildVariableMap = (
156162
names: string[] | undefined,
@@ -357,6 +363,12 @@ const editorHeight = computed(() => {
357363
const checkSelection = () => {
358364
if (!editorView) return;
359365
366+
// 🔒 只读模式下禁用变量提取功能
367+
if (props.readonly) {
368+
showExtractionButton.value = false;
369+
return;
370+
}
371+
360372
const { from, to } = editorView.state.selection.main;
361373
const selectedText = editorView.state.sliceDoc(from, to);
362374
@@ -423,6 +435,13 @@ const handleExtractionConfirm = (data: {
423435
}) => {
424436
if (!editorView) return;
425437
438+
// 🔒 只读模式下禁止修改文本(双重防护)
439+
if (props.readonly) {
440+
message.warning(t("variableExtraction.readonlyWarning"));
441+
showExtractionDialog.value = false;
442+
return;
443+
}
444+
426445
const placeholder = `{{${data.variableName}}}`;
427446
const text = editorView.state.doc.toString();
428447
let newValue = text;
@@ -539,6 +558,8 @@ onMounted(() => {
539558
),
540559
// 主题适配
541560
themeCompartment.of(createThemeExtension(themeVars.value)),
561+
// 🆕 只读状态
562+
readOnlyCompartment.of(EditorState.readOnly.of(props.readonly)),
542563
// 监听文档变化
543564
EditorView.updateListener.of((update) => {
544565
if (update.docChanged) {
@@ -659,6 +680,20 @@ watch(
659680
},
660681
);
661682
683+
// 🆕 监听 readonly 变化,动态更新编辑器只读状态
684+
watch(
685+
() => props.readonly,
686+
(readonly) => {
687+
if (!editorView) return;
688+
689+
editorView.dispatch({
690+
effects: [
691+
readOnlyCompartment.reconfigure(EditorState.readOnly.of(readonly)),
692+
],
693+
});
694+
},
695+
);
696+
662697
// 监听主题变化,动态更新 CodeMirror 主题
663698
watch(
664699
themeVars,

packages/ui/src/composables/mode/useFunctionMode.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,14 @@ export function useFunctionMode(services: Ref<AppServices | null>): UseFunctionM
4848
if (saved !== 'pro' && saved !== 'basic' && saved !== 'image') {
4949
await setPreference(UI_SETTINGS_KEYS.FUNCTION_MODE, 'basic')
5050
}
51+
// ✅ 只在成功时标记为已初始化
52+
singleton!.initialized = true
5153
} catch (e) {
52-
// 读取失败则保持默认 'basic',并尝试持久化
53-
try {
54-
await setPreference(UI_SETTINGS_KEYS.FUNCTION_MODE, 'basic')
55-
} catch {
56-
// 忽略设置失败错误
57-
}
54+
// ⚠️ 初始化失败,保持 initialized = false,允许后续重试
55+
console.warn('[useFunctionMode] Initialization failed, will retry on next call:', e)
56+
// 保持默认 'basic' 模式,但不标记为已初始化
5857
} finally {
59-
singleton!.initialized = true
58+
// 清理初始化锁,无论成败
6059
singleton!.initializing = null
6160
}
6261
})()

0 commit comments

Comments
 (0)