- Clear stale history chain metadata when workspace state changes - Centralize external URL opening behind a shared helper - Add copy-and-open AI platform actions with localized labels - Cover workspace and output action behavior with unit tests
754 lines
24 KiB
Vue
754 lines
24 KiB
Vue
<template>
|
||
<NCard
|
||
:bordered="false"
|
||
class="output-display-core h-full max-height: 100% "
|
||
content-style="padding: 0; height: 100%; max-height: 100%; display: flex; flex-direction: column; overflow: hidden;"
|
||
:data-testid="testId"
|
||
>
|
||
<NFlex vertical style="height: 100%; min-height: 0; overflow: hidden;">
|
||
<!-- 统一顶层工具栏 -->
|
||
<NFlex v-if="hasToolbar" justify="space-between" align="center" style="flex: 0 0 auto;">
|
||
<!-- 左侧:视图控制按钮组 -->
|
||
<NButtonGroup>
|
||
<NButton
|
||
@click="internalViewMode = 'render'"
|
||
:disabled="internalViewMode === 'render'"
|
||
size="small"
|
||
:type="internalViewMode === 'render' ? 'primary' : 'default'"
|
||
>
|
||
{{ t('common.render') }}
|
||
</NButton>
|
||
<NButton
|
||
@click="internalViewMode = 'source'"
|
||
:disabled="internalViewMode === 'source'"
|
||
size="small"
|
||
:type="internalViewMode === 'source' ? 'primary' : 'default'"
|
||
>
|
||
{{ t('common.source') }}
|
||
</NButton>
|
||
<NButton
|
||
v-if="isActionEnabled('diff') && originalContent"
|
||
@click="internalViewMode = 'diff'"
|
||
:disabled="internalViewMode === 'diff' || !originalContent"
|
||
size="small"
|
||
:type="internalViewMode === 'diff' ? 'primary' : 'default'"
|
||
>
|
||
{{ t('common.compare') }}
|
||
</NButton>
|
||
</NButtonGroup>
|
||
|
||
<!-- 右侧:操作按钮 -->
|
||
<NFlex align="center" :size="6" :wrap="false" class="output-toolbar-actions">
|
||
<slot name="toolbar-right-extra"></slot>
|
||
<NButton
|
||
v-if="isActionEnabled('favorite')"
|
||
:data-testid="testId ? `${testId}-favorite` : 'output-favorite'"
|
||
@click="handleFavorite"
|
||
size="small"
|
||
quaternary
|
||
circle
|
||
>
|
||
<template #icon>
|
||
<NIcon>
|
||
<Star />
|
||
</NIcon>
|
||
</template>
|
||
</NButton>
|
||
<div
|
||
v-if="isActionEnabled('copy')"
|
||
class="output-copy-split-button"
|
||
:class="{ 'is-disabled': !hasContent }"
|
||
role="group"
|
||
:aria-label="activeCopyActionTitle"
|
||
>
|
||
<NButton
|
||
:data-testid="testId ? `${testId}-copy-action` : 'output-copy-action'"
|
||
class="output-copy-split-primary"
|
||
@click="handlePrimaryCopyAction"
|
||
size="small"
|
||
quaternary
|
||
:disabled="!hasContent"
|
||
:title="activeCopyActionTitle"
|
||
:aria-label="activeCopyActionTitle"
|
||
>
|
||
<template #icon>
|
||
<NIcon>
|
||
<component :is="activeCopyActionIcon" />
|
||
</NIcon>
|
||
</template>
|
||
</NButton>
|
||
<NDropdown
|
||
trigger="click"
|
||
:options="copyActionOptions"
|
||
@select="handleCopyActionSelect"
|
||
>
|
||
<NButton
|
||
:data-testid="testId ? `${testId}-copy-action-menu` : 'output-copy-action-menu'"
|
||
class="output-copy-split-menu"
|
||
size="small"
|
||
quaternary
|
||
:disabled="!hasContent"
|
||
:title="t('copyOpen.selectAction')"
|
||
:aria-label="t('copyOpen.selectAction')"
|
||
>
|
||
<template #icon>
|
||
<NIcon>
|
||
<ChevronDown />
|
||
</NIcon>
|
||
</template>
|
||
</NButton>
|
||
</NDropdown>
|
||
</div>
|
||
<NButton
|
||
v-if="isActionEnabled('fullscreen')"
|
||
@click="handleFullscreen"
|
||
size="small"
|
||
quaternary
|
||
circle
|
||
>
|
||
<template #icon>
|
||
<NIcon>
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||
</svg>
|
||
</NIcon>
|
||
</template>
|
||
</NButton>
|
||
</NFlex>
|
||
</NFlex>
|
||
|
||
<!-- 推理内容区域 -->
|
||
<NFlex v-if="shouldShowReasoning" style="flex: 0 0 auto;">
|
||
<NCollapse v-model:expanded-names="reasoningExpandedNames" style="width: 100%;">
|
||
<NCollapseItem name="reasoning">
|
||
<template #header>
|
||
<NFlex justify="space-between" align="center" style="width: 100%;">
|
||
<NText class="text-sm font-medium">
|
||
{{ t('common.reasoning') }}
|
||
</NText>
|
||
<NFlex v-if="isReasoningStreaming" align="center" :size="4">
|
||
<NSpin :size="12" />
|
||
<NText class="text-xs">{{ t('common.generating') }}</NText>
|
||
</NFlex>
|
||
</NFlex>
|
||
</template>
|
||
|
||
<NScrollbar class="reasoning-content" ref="reasoningContentRef" style="max-height: clamp(160px, 28vh, 360px); overflow: auto;">
|
||
<MarkdownRenderer
|
||
v-if="displayReasoning"
|
||
:content="displayReasoning"
|
||
:streaming="streaming"
|
||
:disableInternalScroll="true"
|
||
class="prose-sm max-w-none px-3 py-2"
|
||
/>
|
||
<NSpace v-else-if="streaming" class="text-gray-500 text-sm italic px-3 py-2">
|
||
<NText>{{ t('common.generatingReasoning') }}</NText>
|
||
</NSpace>
|
||
</NScrollbar>
|
||
</NCollapseItem>
|
||
</NCollapse>
|
||
</NFlex>
|
||
<!-- 主要内容区域 -->
|
||
<NFlex vertical style="flex: 1; min-height: 0; max-height: 100%; overflow: hidden;">
|
||
<!-- 对比模式 -->
|
||
<TextDiffUI v-if="internalViewMode === 'diff' && content && originalContent"
|
||
:originalText="originalContent"
|
||
:optimizedText="content"
|
||
:compareResult="compareResult"
|
||
class="w-full"
|
||
style="height: 100%; min-height: 0; overflow: auto;"
|
||
/>
|
||
|
||
<!-- 原文模式 -->
|
||
<template v-if="internalViewMode === 'source'">
|
||
<!-- 🆕 Pro 模式:使用变量感知输入框 -->
|
||
<VariableAwareInput
|
||
v-if="shouldEnableVariables && variableData"
|
||
:model-value="content"
|
||
@update:model-value="handleSourceInput"
|
||
:readonly="mode !== 'editable' || streaming"
|
||
:placeholder="placeholder"
|
||
:autosize="true"
|
||
v-bind="variableData"
|
||
@variable-extracted="handleVariableExtracted"
|
||
@add-missing-variable="handleAddMissingVariable"
|
||
style="height: 100%; min-height: 0;"
|
||
/>
|
||
|
||
<!-- Basic/Image 模式:使用普通输入框 -->
|
||
<NInput
|
||
v-else
|
||
:value="content"
|
||
@input="handleSourceInput"
|
||
:readonly="mode !== 'editable' || streaming"
|
||
type="textarea"
|
||
:placeholder="placeholder"
|
||
:autosize="{ minRows: 10 }"
|
||
style="height: 100%; min-height: 0;"
|
||
/>
|
||
</template>
|
||
|
||
<!-- 渲染模式(默认) -->
|
||
<NFlex v-else
|
||
vertical
|
||
:align="displayContent ? 'stretch' : 'center'"
|
||
:justify="displayContent ? 'start' : 'center'"
|
||
style="flex: 1; min-height: 0; overflow: hidden;"
|
||
>
|
||
<XmlRenderer
|
||
v-if="displayContent && renderContentType === 'xml'"
|
||
:content="displayContent"
|
||
style="flex: 1; min-height: 0; overflow: auto;"
|
||
/>
|
||
<MarkdownRenderer
|
||
v-else-if="displayContent"
|
||
:content="displayContent"
|
||
:streaming="streaming"
|
||
style="flex: 1; min-height: 0; overflow: auto;"
|
||
/>
|
||
<NEmpty
|
||
v-else-if="!loading && !streaming"
|
||
:description="placeholder || t('common.noContent')"
|
||
class="flex items-center justify-center"
|
||
style="height: 100%;"
|
||
/>
|
||
<NText v-else class="ml-2">{{ placeholder || t('common.loading') }}</NText>
|
||
</NFlex>
|
||
</NFlex>
|
||
|
||
</NFlex>
|
||
</NCard>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, watch, nextTick, onMounted, inject, h, type Ref } from 'vue'
|
||
|
||
import { useI18n } from 'vue-i18n'
|
||
import {
|
||
NCard, NButton, NButtonGroup, NIcon, NCollapse, NCollapseItem,
|
||
NInput, NEmpty, NSpin, NScrollbar, NFlex, NText, NSpace, NDropdown,
|
||
type DropdownOption
|
||
} from 'naive-ui'
|
||
import { useToast } from '../composables/ui/useToast'
|
||
import {
|
||
ChevronDown,
|
||
Star,
|
||
} from '@vicons/tabler'
|
||
import { useClipboard } from '../composables/ui/useClipboard'
|
||
import MarkdownRenderer from './MarkdownRenderer.vue'
|
||
import XmlRenderer from './XmlRenderer.vue'
|
||
import TextDiffUI from './TextDiff.vue'
|
||
import type { CompareResult, ICompareService } from '@prompt-optimizer/core'
|
||
import { VariableAwareInput } from './variable-extraction'
|
||
import { useTemporaryVariables } from '../composables/variable/useTemporaryVariables'
|
||
import { useVariableAwareInputBridge } from '../composables/variable/useVariableAwareInputBridge'
|
||
import { useVariableManager } from '../composables/prompt/useVariableManager'
|
||
import type { AppServices } from '../types/services'
|
||
import { router as routerInstance } from '../router'
|
||
import { isValidXmlContent } from '../utils/xml-renderer'
|
||
import {
|
||
buildCopyOpenActionUrl,
|
||
COPY_OPEN_ACTIONS,
|
||
DEFAULT_COPY_OPEN_ACTION_ID,
|
||
getCopyOpenAction,
|
||
readCopyOpenActionFromSession,
|
||
writeCopyOpenActionToSession,
|
||
type CopyOpenActionId,
|
||
} from '../utils/copy-open-action'
|
||
import { copyOpenActionIconMap } from '../utils/copy-open-icons'
|
||
import { openExternalUrl } from '../utils/open-external-url'
|
||
|
||
type ActionName = 'fullscreen' | 'diff' | 'copy' | 'edit' | 'reasoning' | 'favorite'
|
||
|
||
const { t } = useI18n()
|
||
const { copyText } = useClipboard()
|
||
|
||
const message = useToast()
|
||
|
||
// 🆕 注入 services(用于变量管理)
|
||
const services = inject<Ref<AppServices | null>>('services') ?? ref<AppServices | null>(null)
|
||
|
||
// 移除收藏状态管理(改由父组件处理)
|
||
|
||
// 组件 Props
|
||
interface Props {
|
||
// 内容相关
|
||
content?: string
|
||
originalContent?: string
|
||
reasoning?: string
|
||
|
||
/** E2E/测试定位用的 data-testid(挂在组件根节点) */
|
||
testId?: string
|
||
|
||
// 显示模式
|
||
mode: 'readonly' | 'editable'
|
||
reasoningMode?: 'show' | 'hide' | 'auto'
|
||
|
||
// 功能开关
|
||
enabledActions?: ActionName[]
|
||
|
||
// 样式配置
|
||
height?: string | number
|
||
placeholder?: string
|
||
|
||
// 状态
|
||
loading?: boolean
|
||
streaming?: boolean
|
||
|
||
// 服务
|
||
compareService?: ICompareService
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
content: '',
|
||
originalContent: '',
|
||
reasoning: '',
|
||
testId: undefined,
|
||
mode: 'readonly',
|
||
reasoningMode: 'auto',
|
||
enabledActions: () => ['fullscreen', 'diff', 'copy', 'edit', 'reasoning', 'favorite'],
|
||
height: '100%',
|
||
placeholder: ''
|
||
})
|
||
|
||
const testId = computed(() => props.testId || undefined)
|
||
|
||
// 事件定义
|
||
const emit = defineEmits<{
|
||
'update:content': [content: string]
|
||
'update:reasoning': [reasoning: string]
|
||
'copy': [content: string, type: 'content' | 'reasoning' | 'all']
|
||
'fullscreen': []
|
||
'edit-start': []
|
||
'edit-end': []
|
||
'reasoning-toggle': [expanded: boolean]
|
||
'view-change': [mode: 'base' | 'diff']
|
||
'save-favorite': [data: { content: string; originalContent?: string }]
|
||
}>()
|
||
|
||
// 🆕 变量管理功能(Pro / Image 模式)
|
||
// 当前架构以路由为单一真源;不要依赖 legacy 的 Preference-based functionMode。
|
||
const routeFunctionMode = computed<'basic' | 'pro' | 'image'>(() => {
|
||
const path = routerInstance.currentRoute.value.path || ''
|
||
if (path.startsWith('/pro')) return 'pro'
|
||
if (path.startsWith('/image')) return 'image'
|
||
return 'basic'
|
||
})
|
||
|
||
const shouldEnableVariables = computed(() => routeFunctionMode.value === 'pro' || routeFunctionMode.value === 'image')
|
||
|
||
// ==================== 变量管理 Composables ====================
|
||
// 临时变量管理器(全局单例)
|
||
const tempVars = useTemporaryVariables()
|
||
|
||
// ✅ 无条件调用,composable 内部会等待 services.preferenceService 准备就绪
|
||
const globalVarsManager = useVariableManager(services)
|
||
|
||
const {
|
||
variableInputData: variableData,
|
||
handleVariableExtracted,
|
||
handleAddMissingVariable,
|
||
} = useVariableAwareInputBridge({
|
||
enabled: shouldEnableVariables,
|
||
isReady: globalVarsManager.isReady,
|
||
globalVariables: globalVarsManager.customVariables,
|
||
temporaryVariables: tempVars.temporaryVariables,
|
||
allVariables: globalVarsManager.allVariables,
|
||
saveGlobalVariable: (name, value) => globalVarsManager.addVariable(name, value),
|
||
saveTemporaryVariable: (name, value) => tempVars.setVariable(name, value),
|
||
logPrefix: 'OutputDisplayCore',
|
||
})
|
||
|
||
// 内部状态
|
||
type ScrollbarLike = {
|
||
scrollTo: (options: { top: number; behavior?: ScrollBehavior }) => void
|
||
}
|
||
|
||
const reasoningContentRef = ref<ScrollbarLike | null>(null)
|
||
const userHasManuallyToggledReasoning = ref(false)
|
||
|
||
// 新的视图状态机
|
||
const internalViewMode = ref<'render' | 'source' | 'diff'>('render')
|
||
const EMPTY_COMPARE_RESULT: CompareResult = {
|
||
fragments: [],
|
||
summary: { additions: 0, deletions: 0, unchanged: 0 },
|
||
}
|
||
const compareResult = ref<CompareResult>(EMPTY_COMPARE_RESULT)
|
||
|
||
// 推理折叠面板状态
|
||
const reasoningExpandedNames = ref<string[]>([])
|
||
|
||
const isActionEnabled = (action: ActionName) => props.enabledActions.includes(action)
|
||
|
||
const hasToolbar = computed(() =>
|
||
['diff', 'copy', 'fullscreen', 'edit'].some(action => isActionEnabled(action as ActionName))
|
||
)
|
||
|
||
// 计算属性
|
||
const displayContent = computed(() => (props.content || '').trim())
|
||
const displayReasoning = computed(() => (props.reasoning || '').trim())
|
||
const copyActionWorkspacePath = computed(() => routerInstance.currentRoute.value.path || '/')
|
||
const activeCopyActionId = ref<CopyOpenActionId>(
|
||
readCopyOpenActionFromSession(copyActionWorkspacePath.value),
|
||
)
|
||
|
||
const renderContentType = computed<'markdown' | 'xml'>(() => {
|
||
if (!displayContent.value) return 'markdown'
|
||
|
||
// Avoid format jumps while text is still being streamed.
|
||
if (props.streaming) return 'markdown'
|
||
|
||
return isValidXmlContent(displayContent.value) ? 'xml' : 'markdown'
|
||
})
|
||
|
||
const hasContent = computed(() => !!displayContent.value)
|
||
const hasReasoning = computed(() => !!displayReasoning.value)
|
||
|
||
const isReasoningStreaming = computed(() => {
|
||
return props.streaming && hasReasoning.value && !hasContent.value
|
||
})
|
||
|
||
const shouldShowReasoning = computed(() => {
|
||
if (!isActionEnabled('reasoning')) return false
|
||
if (props.reasoningMode === 'hide') return false
|
||
if (props.reasoningMode === 'show') return true
|
||
return hasReasoning.value
|
||
})
|
||
|
||
const getCopyActionPlatformLabel = (actionId: CopyOpenActionId): string => {
|
||
const platform = getCopyOpenAction(actionId).platform
|
||
return platform ? t(`copyOpen.platforms.${actionId}`) : ''
|
||
}
|
||
|
||
const activeCopyActionIcon = computed(() => copyOpenActionIconMap[activeCopyActionId.value])
|
||
|
||
const activeCopyActionTitle = computed(() => {
|
||
if (activeCopyActionId.value === DEFAULT_COPY_OPEN_ACTION_ID) {
|
||
return t('common.copy')
|
||
}
|
||
|
||
return t('copyOpen.copyAndOpen', {
|
||
platform: getCopyActionPlatformLabel(activeCopyActionId.value),
|
||
})
|
||
})
|
||
|
||
const copyActionOptions = computed<DropdownOption[]>(() =>
|
||
COPY_OPEN_ACTIONS.map((action) => ({
|
||
key: action.id,
|
||
label: action.id === 'copy'
|
||
? t('copyOpen.copyOnly')
|
||
: t('copyOpen.copyAndOpen', { platform: getCopyActionPlatformLabel(action.id) }),
|
||
icon: () => h(NIcon, null, { default: () => h(copyOpenActionIconMap[action.id]) }),
|
||
})),
|
||
)
|
||
|
||
// 推理展开/折叠状态的计算属性
|
||
const isReasoningExpanded = computed({
|
||
get: () => reasoningExpandedNames.value.includes('reasoning'),
|
||
set: (expanded: boolean) => {
|
||
if (expanded) {
|
||
reasoningExpandedNames.value = ['reasoning']
|
||
} else {
|
||
reasoningExpandedNames.value = []
|
||
}
|
||
emit('reasoning-toggle', expanded)
|
||
}
|
||
})
|
||
|
||
// 处理原文模式输入
|
||
const handleSourceInput = (value: string) => {
|
||
emit('update:content', value)
|
||
}
|
||
|
||
const copyDisplayText = async (
|
||
textToCopy: string,
|
||
emitType: 'content' | 'reasoning' | 'all',
|
||
): Promise<boolean> => {
|
||
if (!textToCopy) return false
|
||
|
||
try {
|
||
await copyText(textToCopy)
|
||
emit('copy', textToCopy, emitType)
|
||
return true
|
||
} catch (error) {
|
||
console.error('[OutputDisplayCore] Failed to copy content:', error)
|
||
message.error(t('common.copyFailed'))
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 复制功能
|
||
const handleCopy = async (type: 'content' | 'reasoning' | 'all') => {
|
||
let textToCopy = ''
|
||
const emitType: 'content' | 'reasoning' | 'all' = type
|
||
|
||
switch (type) {
|
||
case 'content':
|
||
textToCopy = displayContent.value
|
||
break
|
||
case 'reasoning':
|
||
textToCopy = displayReasoning.value
|
||
break
|
||
case 'all':
|
||
textToCopy = [
|
||
displayReasoning.value && `${t('common.reasoning')}:\n${displayReasoning.value}`,
|
||
`${t('common.content')}:\n${displayContent.value}`
|
||
].filter(Boolean).join('\n\n')
|
||
break
|
||
}
|
||
|
||
await copyDisplayText(textToCopy, emitType)
|
||
}
|
||
|
||
const handlePrimaryCopyAction = async () => {
|
||
const textToCopy = displayContent.value
|
||
const copied = await copyDisplayText(textToCopy, 'content')
|
||
if (!copied || activeCopyActionId.value === DEFAULT_COPY_OPEN_ACTION_ID) return
|
||
|
||
const url = buildCopyOpenActionUrl(activeCopyActionId.value)
|
||
if (!url) return
|
||
|
||
try {
|
||
await openExternalUrl(url, { logPrefix: 'OutputDisplayCore' })
|
||
} catch (error) {
|
||
console.error('[OutputDisplayCore] Failed to open external AI platform:', error)
|
||
message.error(t('copyOpen.openFailed'))
|
||
}
|
||
}
|
||
|
||
const handleCopyActionSelect = async (key: string | number) => {
|
||
const selected = COPY_OPEN_ACTIONS.find((action) => action.id === key)
|
||
if (!selected) return
|
||
|
||
activeCopyActionId.value = selected.id
|
||
writeCopyOpenActionToSession(copyActionWorkspacePath.value, selected.id)
|
||
await handlePrimaryCopyAction()
|
||
}
|
||
|
||
// 全屏功能
|
||
const handleFullscreen = () => {
|
||
emit('fullscreen')
|
||
}
|
||
|
||
const scrollReasoningToBottom = () => {
|
||
if (reasoningContentRef.value) {
|
||
nextTick(() => {
|
||
if (reasoningContentRef.value) {
|
||
reasoningContentRef.value.scrollTo({
|
||
top: 999999, // 滚动到底部
|
||
behavior: 'smooth'
|
||
})
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 对比功能
|
||
const updateCompareResult = async () => {
|
||
if (internalViewMode.value === 'diff' && props.originalContent && props.content) {
|
||
try {
|
||
const compareService = props.compareService ?? services.value?.compareService
|
||
if (!compareService) throw new Error('CompareService not available')
|
||
|
||
compareResult.value = await compareService.compareTexts(
|
||
props.originalContent,
|
||
props.content
|
||
)
|
||
} catch (error) {
|
||
console.error('[OutputDisplayCore] Error calculating diff:', error)
|
||
message.warning(t('toast.warning.compareFailed'))
|
||
compareResult.value = EMPTY_COMPARE_RESULT
|
||
}
|
||
} else {
|
||
compareResult.value = EMPTY_COMPARE_RESULT
|
||
}
|
||
}
|
||
|
||
// 智能自动切换逻辑
|
||
const previousViewMode = ref<'render' | 'source' | 'diff' | null>(null)
|
||
|
||
watch(() => props.streaming, (isStreaming, wasStreaming) => {
|
||
if (isStreaming && !wasStreaming) {
|
||
// 新任务开始,重置用户记忆
|
||
userHasManuallyToggledReasoning.value = false
|
||
} else if (!isStreaming && wasStreaming) {
|
||
// 任务结束,如果用户未干预且思考区域仍然展开,自动折叠
|
||
if (!userHasManuallyToggledReasoning.value && isReasoningExpanded.value) {
|
||
isReasoningExpanded.value = false
|
||
}
|
||
}
|
||
|
||
if (isStreaming) {
|
||
// 记住当前模式,并强制切换到原文模式
|
||
if (internalViewMode.value !== 'source') {
|
||
previousViewMode.value = internalViewMode.value
|
||
internalViewMode.value = 'source'
|
||
}
|
||
} else {
|
||
// 流式结束后,恢复之前的模式
|
||
if (previousViewMode.value) {
|
||
internalViewMode.value = previousViewMode.value
|
||
previousViewMode.value = null
|
||
}
|
||
}
|
||
})
|
||
|
||
watch(internalViewMode, updateCompareResult, { immediate: true })
|
||
watch(() => [props.content, props.originalContent], () => {
|
||
if (internalViewMode.value === 'diff') {
|
||
updateCompareResult()
|
||
}
|
||
})
|
||
|
||
watch(() => props.reasoning, (newReasoning, oldReasoning) => {
|
||
// 当推理内容从无到有,且用户未手动干预时,自动展开
|
||
if (newReasoning && !oldReasoning && !userHasManuallyToggledReasoning.value) {
|
||
isReasoningExpanded.value = true
|
||
}
|
||
|
||
// 如果思考过程已展开且有新内容,滚动到底部
|
||
if (isReasoningExpanded.value && newReasoning) {
|
||
scrollReasoningToBottom()
|
||
}
|
||
}, { flush: 'post' })
|
||
|
||
watch(() => props.content, (newContent, oldContent) => {
|
||
// 当主要内容开始流式输出时,如果用户未干预,自动折叠思考过程
|
||
const mainContentJustStarted = newContent && !oldContent
|
||
if (props.streaming && mainContentJustStarted && !userHasManuallyToggledReasoning.value) {
|
||
isReasoningExpanded.value = false
|
||
}
|
||
})
|
||
|
||
watch(copyActionWorkspacePath, (workspacePath) => {
|
||
activeCopyActionId.value = readCopyOpenActionFromSession(workspacePath)
|
||
})
|
||
|
||
// 监听推理折叠状态变化
|
||
watch(reasoningExpandedNames, (newNames) => {
|
||
const expanded = newNames.includes('reasoning')
|
||
if (expanded !== isReasoningExpanded.value) {
|
||
userHasManuallyToggledReasoning.value = true
|
||
}
|
||
})
|
||
|
||
// 暴露方法给父组件
|
||
const resetReasoningState = (initialState: boolean) => {
|
||
isReasoningExpanded.value = initialState
|
||
userHasManuallyToggledReasoning.value = false
|
||
}
|
||
|
||
const forceExitEditing = () => {
|
||
// In Pro/Image (variable-enabled) workspaces, keep source view as the default
|
||
// to preserve variable highlighting instead of flipping back to Markdown.
|
||
if (shouldEnableVariables.value) return
|
||
|
||
internalViewMode.value = 'render'
|
||
}
|
||
|
||
const forceRefreshContent = () => {
|
||
// V2版本中这个方法不再需要,但保留以确保向后兼容
|
||
}
|
||
|
||
// 收藏相关方法 - 触发保存对话框而不是直接保存
|
||
const handleFavorite = () => {
|
||
if (!props.content) {
|
||
message.warning(t('toast.error.noContentToSave'));
|
||
return;
|
||
}
|
||
|
||
// 触发保存收藏事件,由父组件打开保存对话框
|
||
emit('save-favorite', {
|
||
content: props.content,
|
||
originalContent: props.originalContent
|
||
});
|
||
};
|
||
|
||
// 组件挂载时设置初始视图模式
|
||
onMounted(() => {
|
||
// ⚠️ 不在此处初始化 functionMode
|
||
// 原因:useFunctionMode 是全局单例,不应由单个组件控制初始化时机
|
||
// - 如果 services 未就绪,初始化会失败但仍标记为已完成,导致永久卡在 'basic'
|
||
// - 应该在应用级别统一初始化(如 App.vue)
|
||
// - functionMode 有默认值 'basic',可以正常工作
|
||
|
||
// 如果是可编辑模式,默认显示原文
|
||
if (props.mode === 'editable') {
|
||
internalViewMode.value = 'source';
|
||
}
|
||
});
|
||
|
||
// 监听 mode 变化,自动切换视图模式
|
||
watch(() => props.mode, (newMode) => {
|
||
if (newMode === 'editable' && internalViewMode.value === 'render') {
|
||
internalViewMode.value = 'source';
|
||
} else if (newMode === 'readonly' && internalViewMode.value === 'source') {
|
||
internalViewMode.value = 'render';
|
||
}
|
||
});
|
||
|
||
defineExpose({ resetReasoningState, forceRefreshContent, forceExitEditing })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.output-toolbar-actions {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.output-copy-split-button {
|
||
flex-shrink: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
height: 28px;
|
||
overflow: hidden;
|
||
color: inherit;
|
||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
}
|
||
|
||
.output-copy-split-button:not(.is-disabled):hover {
|
||
border-color: color-mix(in srgb, currentColor 28%, transparent);
|
||
background: color-mix(in srgb, currentColor 6%, transparent);
|
||
}
|
||
|
||
.output-copy-split-button.is-disabled {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.output-copy-split-button :deep(.n-button.output-copy-split-primary),
|
||
.output-copy-split-button :deep(.n-button.output-copy-split-menu) {
|
||
height: 26px;
|
||
border: 0 !important;
|
||
border-radius: 0 !important;
|
||
background: transparent !important;
|
||
}
|
||
|
||
.output-copy-split-button :deep(.n-button.output-copy-split-primary) {
|
||
min-width: 29px;
|
||
width: 29px;
|
||
padding: 0 6px;
|
||
}
|
||
|
||
.output-copy-split-button :deep(.n-button.output-copy-split-menu) {
|
||
position: relative;
|
||
min-width: 21px;
|
||
width: 21px;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
.output-copy-split-button :deep(.n-button.output-copy-split-menu)::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 6px;
|
||
bottom: 6px;
|
||
width: 1px;
|
||
background: color-mix(in srgb, currentColor 18%, transparent);
|
||
}
|
||
|
||
.output-copy-split-button:not(.is-disabled) :deep(.n-button:hover) {
|
||
background: color-mix(in srgb, currentColor 8%, transparent) !important;
|
||
}
|
||
</style>
|