feat: polish provider controls and themed UI

- Add provider pill selection and normalized provider display copy

- Adopt themed tooltips, confirmation dialogs, and Naive UI surface styles

- Enable Prompt Garden thumbnail previews through shared image preview components
This commit is contained in:
linshen committed 2026-05-14 21:18:43 +08:00
1 parent d08494f6b6
commit 5e942ca945
79 files changed
+1584 -710

No files matched your search

@@ -42,6 +42,7 @@ export class ModelScopeImageAdapter extends AbstractImageProviderAdapter {
requiresApiKey: true,
defaultBaseURL: 'https://api-inference.modelscope.cn/v1',
supportsDynamicModels: false,
apiKeyUrl: 'https://modelscope.cn/my/myaccesstoken',
connectionSchema: {
required: ['apiKey'],
optional: ['baseURL'],
@@ -25,6 +25,7 @@ export class OpenRouterImageAdapter extends AbstractImageProviderAdapter {
requiresApiKey: true,
defaultBaseURL: 'https://openrouter.ai/api/v1',
supportsDynamicModels: true,
apiKeyUrl: 'https://openrouter.ai/settings/keys',
connectionSchema: {
required: ['apiKey'],
optional: ['baseURL'],
@@ -31,7 +31,7 @@ const SEEDREAM_MODEL_SPECS: readonly SeedreamModelSpec[] = [
{
id: SEEDREAM_40_MODEL_ID,
name: 'Doubao Seedream 4.0',
description: 'Volcano Ark Doubao Seedream 4.0 high-quality image generation model',
description: 'Doubao Seedream 4.0 high-quality image generation model',
capabilities: {
text2image: true,
image2image: true,
@@ -80,7 +80,7 @@ const SEEDREAM_MODEL_SPECS: readonly SeedreamModelSpec[] = [
{
id: SEEDREAM_45_MODEL_ID,
name: 'Doubao Seedream 4.5',
description: 'Volcano Ark Doubao Seedream 4.5 high-quality image generation model',
description: 'Doubao Seedream 4.5 high-quality image generation model',
capabilities: {
text2image: true,
image2image: true,
@@ -129,7 +129,7 @@ const SEEDREAM_MODEL_SPECS: readonly SeedreamModelSpec[] = [
{
id: SEEDREAM_50_LITE_MODEL_ID,
name: 'Doubao Seedream 5.0 Lite',
description: 'Volcano Ark Doubao Seedream 5.0 Lite high-quality image generation model',
description: 'Doubao Seedream 5.0 Lite high-quality image generation model',
capabilities: {
text2image: true,
image2image: true,
@@ -179,11 +179,12 @@ export class SeedreamImageAdapter extends AbstractImageProviderAdapter {
getProvider(): ImageProvider {
return {
id: 'seedream',
name: 'Seedream (Volcano Ark)',
description: 'Volcano Ark Seedream image generation models',
name: 'Seedream',
description: 'Seedream image generation models',
requiresApiKey: true,
defaultBaseURL: 'https://ark.cn-beijing.volces.com/api/v3',
supportsDynamicModels: false, // 不支持动态获取
apiKeyUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey',
connectionSchema: {
required: ['apiKey'],
optional: ['baseURL'],
@@ -23,6 +23,7 @@ export class SiliconFlowImageAdapter extends AbstractImageProviderAdapter {
requiresApiKey: true,
defaultBaseURL: 'https://api.siliconflow.cn/v1',
supportsDynamicModels: false,
apiKeyUrl: 'https://cloud.siliconflow.cn/account/ak',
connectionSchema: {
required: ['apiKey'],
optional: ['baseURL'],
@@ -27,7 +27,7 @@ const CLOUDFLARE_STATIC_MODELS: ModelOverride[] = [
{
id: '@cf/qwen/qwen3-30b-a3b-fp8',
name: 'Qwen3 30B A3B FP8',
description: 'Cloudflare Workers AI 默认推荐文本模型,成本较低,支持工具调用和推理输出。',
description: 'Default recommended Cloudflare Workers AI text model with tool calling and reasoning output support',
capabilities: {
supportsTools: true,
supportsReasoning: true,
@@ -41,7 +41,7 @@ export class CloudflareAdapter extends OpenAIAdapter {
return {
id: 'cloudflare',
name: 'Cloudflare',
description: 'Cloudflare Workers AI 文本模型,支持动态检索可用模型,默认推荐 Qwen3。',
description: 'Cloudflare Workers AI text models with dynamic model discovery',
corsRestricted: true,
requiresApiKey: true,
defaultBaseURL: 'https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/v1',
@@ -16,7 +16,7 @@ const DASHSCOPE_STATIC_MODELS: ModelOverride[] = [
{
id: 'qwen3.5-27b',
name: 'Qwen3.5-27B',
description: '通义千问3.5 27B 模型,默认用于阿里百炼文本与视觉理解场景',
description: 'Qwen3.5 27B model for DashScope text and vision understanding scenarios',
capabilities: {
supportsTools: true,
supportsReasoning: true,
@@ -26,7 +26,7 @@ const DASHSCOPE_STATIC_MODELS: ModelOverride[] = [
{
id: 'qwen-plus',
name: 'Qwen Plus',
description: '通义千问高性能模型,适合复杂任务,支持超长上下文',
description: 'High-performance Qwen model for complex tasks with long-context support',
capabilities: {
supportsTools: true,
supportsReasoning: false,
@@ -36,7 +36,7 @@ const DASHSCOPE_STATIC_MODELS: ModelOverride[] = [
{
id: 'qwen-turbo',
name: 'Qwen Turbo',
description: '通义千问快速模型,支持超长上下文(1M tokens)',
description: 'Fast Qwen model with long-context support up to 1M tokens',
capabilities: {
supportsTools: true,
supportsReasoning: false,
@@ -46,7 +46,7 @@ const DASHSCOPE_STATIC_MODELS: ModelOverride[] = [
{
id: 'qwen-flash',
name: 'Qwen Flash',
description: '通义千问极速模型,响应快速,适合简单任务',
description: 'Lightweight Qwen model optimized for fast responses and simple tasks',
capabilities: {
supportsTools: true,
supportsReasoning: false,
@@ -67,7 +67,7 @@ export class DashScopeAdapter extends OpenAIAdapter {
return {
id: 'dashscope',
name: 'DashScope',
description: '阿里云百炼大模型服务平台,提供通义千问系列模型,支持 OpenAI 兼容的 Chat Completions 与 Responses 接口',
description: 'Alibaba Cloud Model Studio provider for Qwen models with OpenAI-compatible Chat Completions and Responses APIs',
requiresApiKey: true,
defaultBaseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
supportsDynamicModels: true,
@@ -17,7 +17,7 @@ const MODELSCOPE_STATIC_MODELS: ModelOverride[] = [
{
id: 'Qwen/Qwen3-Coder-480B-A35B-Instruct',
name: 'Qwen3-Coder-480B-A35B-Instruct',
description: '通义千问 Qwen/Qwen3-Coder-480B-A35B-Instruct,专为代码生成和理解优化',
description: 'Qwen3-Coder model optimized for code generation and understanding',
capabilities: {
supportsTools: false, // 未验证 ModelScope 的工具调用兼容性
supportsReasoning: false,
@@ -43,7 +43,7 @@ export class ModelScopeAdapter extends OpenAIAdapter {
return {
id: 'modelscope',
name: 'ModelScope',
description: '阿里云魔搭社区 API 推理服务,每天免费 2000 次调用',
description: 'ModelScope API-Inference service for community-hosted open models',
requiresApiKey: true,
defaultBaseURL: 'https://api-inference.modelscope.cn/v1',
supportsDynamicModels: true,
@@ -17,7 +17,7 @@ export class OpenAICompatibleAdapter extends OpenAIAdapter {
public getProvider(): TextProvider {
return {
id: 'openai-compatible',
name: 'Custom API (OpenAI Compatible)',
name: 'OpenAI Compatible (Custom)',
description: 'Custom endpoints that implement OpenAI Chat Completions or Responses APIs',
requiresApiKey: false,
defaultBaseURL: 'http://localhost:11434/v1',
@@ -13,7 +13,7 @@ const OPENROUTER_STATIC_MODELS: ModelOverride[] = [
{
id: 'google/gemma-3-27b-it:free',
name: 'Gemma 3 27B IT (Free)',
description: 'Google Gemma 3 27B 免费模型,通过 OpenRouter 访问',
description: 'Free Google Gemma 3 27B model served through OpenRouter',
capabilities: {
supportsTools: true,
supportsReasoning: false,
@@ -27,7 +27,7 @@ export class OpenRouterAdapter extends OpenAIAdapter {
return {
id: 'openrouter',
name: 'OpenRouter',
description: 'OpenRouter 聚合多种 AI 模型的 OpenAI 兼容 API',
description: 'OpenAI-compatible gateway for accessing models from many providers',
requiresApiKey: true,
defaultBaseURL: 'https://openrouter.ai/api/v1',
supportsDynamicModels: true,
@@ -283,7 +283,7 @@ function createProviderMeta(providerId: string, legacy: ModelConfig): TextProvid
} else if (providerId === 'openai-compatible') {
return {
id: 'openai-compatible',
name: 'Custom API (OpenAI Compatible)',
name: 'OpenAI Compatible (Custom)',
description: 'Custom endpoints that implement OpenAI Chat Completions or Responses APIs',
requiresApiKey: false,
defaultBaseURL: legacy.baseURL || 'http://localhost:11434/v1',
+1 -1
View File
@@ -131,7 +131,7 @@ export function getDefaultTextModels(registry?: ITextAdapterRegistry): Record<st
result.custom = {
id: 'custom',
name: 'Custom API (OpenAI Compatible)',
name: 'OpenAI Compatible (Custom)',
enabled: true,
providerMeta: openaiCompatibleAdapter.getProvider(),
modelMeta: customModelMeta,
@@ -40,7 +40,7 @@ export function generateTextModelConfig(envConfig: ValidatedCustomModelEnvConfig
// OpenAI 兼容 Provider(所有自定义模型都使用 OpenAI 兼容 API)
const customProvider: TextProvider = {
id: 'openai-compatible',
name: 'Custom API (OpenAI Compatible)',
name: 'OpenAI Compatible (Custom)',
description: 'Custom endpoint using an OpenAI-compatible API',
requiresApiKey: false,
defaultBaseURL: 'http://localhost:11434/v1',
@@ -19,6 +19,7 @@ describe('ModelScopeImageAdapter', () => {
expect(provider.requiresApiKey).toBe(true)
expect(provider.defaultBaseURL).toBe('https://api-inference.modelscope.cn/v1')
expect(provider.supportsDynamicModels).toBe(false)
expect(provider.apiKeyUrl).toBe('https://modelscope.cn/my/myaccesstoken')
expect(provider.connectionSchema?.required).toContain('apiKey')
expect(provider.connectionSchema?.optional).toEqual(expect.arrayContaining(['baseURL']))
})
@@ -24,6 +24,7 @@ describe('OpenRouterImageAdapter', () => {
expect(provider.requiresApiKey).toBe(true)
expect(provider.supportsDynamicModels).toBe(true)
expect(provider.defaultBaseURL).toBe('https://openrouter.ai/api/v1')
expect(provider.apiKeyUrl).toBe('https://openrouter.ai/settings/keys')
})
it('should have correct connection schema', () => {
@@ -24,10 +24,11 @@ describe('SeedreamImageAdapter', () => {
const provider = adapter.getProvider()
expect(provider.id).toBe('seedream')
expect(provider.name).toContain('Seedream')
expect(provider.name).toBe('Seedream')
expect(provider.requiresApiKey).toBe(true)
expect(provider.defaultBaseURL).toBe('https://ark.cn-beijing.volces.com/api/v3')
expect(provider.supportsDynamicModels).toBe(false)
expect(provider.apiKeyUrl).toBe('https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey')
expect(provider.connectionSchema?.required).toContain('apiKey')
expect(provider.connectionSchema?.optional).toEqual(expect.arrayContaining(['baseURL']))
})
@@ -24,6 +24,7 @@ describe('SiliconFlowImageAdapter', () => {
expect(provider.requiresApiKey).toBe(true)
expect(provider.defaultBaseURL).toBe('https://api.siliconflow.cn/v1')
expect(provider.supportsDynamicModels).toBe(false)
expect(provider.apiKeyUrl).toBe('https://cloud.siliconflow.cn/account/ak')
expect(provider.connectionSchema?.required).toContain('apiKey')
expect(provider.connectionSchema?.optional).toEqual(expect.arrayContaining(['baseURL']))
})
@@ -389,7 +389,7 @@ describe('OpenAIAdapter', () => {
const compatibleConfig: TextModelConfig = {
...mockConfig,
id: 'openai-compatible',
name: 'Custom API (OpenAI Compatible)',
name: 'OpenAI Compatible (Custom)',
providerMeta: openAICompatibleAdapter.getProvider(),
modelMeta: openAICompatibleAdapter.buildDefaultModel('custom-model'),
connectionConfig: {
@@ -443,7 +443,7 @@ describe('OpenAIAdapter', () => {
const compatibleConfig: TextModelConfig = {
...mockConfig,
id: 'openai-compatible',
name: 'Custom API (OpenAI Compatible)',
name: 'OpenAI Compatible (Custom)',
providerMeta: openAICompatibleAdapter.getProvider(),
modelMeta: openAICompatibleAdapter.buildDefaultModel('custom-model'),
connectionConfig: {
@@ -144,7 +144,7 @@ describe('model defaults provider env mapping', () => {
expect(models.custom).toBeDefined()
expect(models.custom.providerMeta.id).toBe('openai-compatible')
expect(models.custom.providerMeta.name).toBe('Custom API (OpenAI Compatible)')
expect(models.custom.providerMeta.name).toBe('OpenAI Compatible (Custom)')
expect(models.custom.enabled).toBe(true)
expect(models.custom.connectionConfig.apiKey).toBe('')
expect(models.custom.connectionConfig.requestStyle).toBe('chat_completions')
@@ -65,7 +65,7 @@ describe('generateTextModelConfig', () => {
const config = generateTextModelConfig(baseEnvConfig)
expect(config.providerMeta.id).toBe('openai-compatible')
expect(config.providerMeta.name).toBe('Custom API (OpenAI Compatible)')
expect(config.providerMeta.name).toBe('OpenAI Compatible (Custom)')
expect(config.connectionConfig.requestStyle).toBe('chat_completions')
expect(config.modelMeta.providerId).toBe('openai-compatible')
})
@@ -525,7 +525,8 @@ watch(() => services?.value?.favoriteManager, (favoriteManager) => {
}
.toolbar {
@apply p-4 border-b border-gray-200 dark:border-gray-700;
@apply p-4;
border-bottom: 1px solid var(--n-border-color);
}
.tree-container {
@@ -545,6 +546,6 @@ watch(() => services?.value?.favoriteManager, (favoriteManager) => {
}
:deep(.n-tree-node-content:hover) {
@apply bg-gray-50 dark:bg-gray-800;
background: var(--n-hover-color);
}
</style>
+43 -30
View File
@@ -178,10 +178,9 @@
</NButton>
</div>
<details v-if="selectedFile" class="local-import-settings">
<summary>{{ $t('dataManager.import.settingsTitle') }}</summary>
<div class="local-import-settings-body">
<NCollapse v-if="selectedFile" class="local-import-settings" :default-expanded-names="['import-settings']">
<NCollapseItem :title="$t('dataManager.import.settingsTitle')" name="import-settings">
<div class="local-import-settings-body">
<div class="local-settings-group">
<NText depth="3" class="local-settings-title">
{{ $t('dataManager.import.scopeTitle') }}
@@ -232,8 +231,9 @@
</div>
</NRadioGroup>
</div>
</div>
</details>
</div>
</NCollapseItem>
</NCollapse>
<NText depth="3" class="local-import-warning">
{{ $t('dataManager.warning') }}
@@ -376,8 +376,8 @@
'remote-step--collapsed': !isCloudflareR2StepExpanded('account'),
}"
>
<button
type="button"
<NButton
text
class="remote-step-header"
@click="toggleCloudflareR2Step('account')"
>
@@ -394,7 +394,7 @@
>
<NIcon><ChevronDown /></NIcon>
</span>
</button>
</NButton>
<div v-if="isCloudflareR2StepExpanded('account')" class="remote-step-body">
<div class="remote-field">
<NText depth="3" class="remote-field-label">{{ $t('dataManager.remote.accountId') }}</NText>
@@ -418,8 +418,8 @@
'remote-step--collapsed': !isCloudflareR2StepExpanded('bucket'),
}"
>
<button
type="button"
<NButton
text
class="remote-step-header"
@click="toggleCloudflareR2Step('bucket')"
>
@@ -436,7 +436,7 @@
>
<NIcon><ChevronDown /></NIcon>
</span>
</button>
</NButton>
<div v-if="isCloudflareR2StepExpanded('bucket')" class="remote-step-body">
<div class="remote-field">
<NText depth="3" class="remote-field-label">{{ $t('dataManager.remote.bucket') }}</NText>
@@ -477,8 +477,8 @@
'remote-step--collapsed': !isCloudflareR2StepExpanded('credentials'),
}"
>
<button
type="button"
<NButton
text
class="remote-step-header"
@click="toggleCloudflareR2Step('credentials')"
>
@@ -495,7 +495,7 @@
>
<NIcon><ChevronDown /></NIcon>
</span>
</button>
</NButton>
<div v-if="isCloudflareR2StepExpanded('credentials')" class="remote-step-body">
<div class="remote-two-column">
<div class="remote-field">
@@ -770,11 +770,13 @@ import { useI18n } from 'vue-i18n'
import {
NModal, NText, NButton, NUpload, NUploadDragger,
NIcon, NCheckbox, NRadio, NRadioGroup, NSelect, NInput, NProgress, NTag,
NCollapse, NCollapseItem,
type UploadFileInfo,
} from 'naive-ui'
import { isRunningInElectron } from '@prompt-optimizer/core'
import { ChevronDown, Clipboard, Download, ExternalLink, Folder, Refresh, Trash, Upload } from '@vicons/tabler'
import { useToast } from '../composables/ui/useToast'
import { useConfirmDialog } from '../composables/ui/useConfirmDialog'
import type { AppServices } from '../types/services'
import {
DEFAULT_DATA_MANAGER_PACKAGE_SECTIONS,
@@ -840,6 +842,7 @@ const emit = defineEmits<Emits>()
const { t } = useI18n()
const toast = useToast()
const confirmDialog = useConfirmDialog()
const isDesktopRuntime = isRunningInElectron()
const remoteRuntime: RemoteBackupRuntime = isDesktopRuntime ? 'desktop' : 'web'
const DATA_MANAGER_MODAL_MAX_WIDTH = '1200px'
@@ -1641,10 +1644,15 @@ const handleRemoteCleanup = async () => {
return
}
const confirmed = window.confirm(t('dataManager.remote.cleanupConfirm', {
count: analysis.candidates.length,
size: formatFileSize(analysis.totalCandidateBytes),
}))
const confirmed = await confirmDialog.warning({
title: t('common.warning'),
content: t('dataManager.remote.cleanupConfirm', {
count: analysis.candidates.length,
size: formatFileSize(analysis.totalCandidateBytes),
}),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
})
if (!confirmed) return
const cleanup = await cleanupRemoteSnapshotAssets(objectStore, (event) =>
@@ -2264,18 +2272,21 @@ const getStorageItemDetail = (item: Pick<StorageBreakdownItem, 'key' | 'count'>)
}
.remote-step-header {
min-width: 0;
width: 100%;
padding: 0;
color: inherit;
text-align: left;
}
.remote-step-header :deep(.n-button__content) {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) 18px;
gap: 8px;
align-items: start;
min-width: 0;
width: 100%;
padding: 0;
border: 0;
color: inherit;
min-width: 0;
text-align: left;
background: transparent;
cursor: pointer;
}
.remote-step-toggle {
@@ -2461,16 +2472,18 @@ const getStorageItemDetail = (item: Pick<StorageBreakdownItem, 'key' | 'count'>)
}
.local-import-settings {
padding: 8px 10px;
padding: 0 2px;
border-radius: 8px;
background: var(--n-color);
}
.local-import-settings > summary {
cursor: pointer;
.local-import-settings :deep(.n-collapse-item__header) {
padding: 8px 10px;
font-size: 12px;
color: var(--n-text-color-3);
user-select: none;
}
.local-import-settings :deep(.n-collapse-item__content-inner) {
padding: 0 10px 10px;
}
.local-import-settings-body {
@@ -277,6 +277,7 @@ import { useI18n } from 'vue-i18n'
import type { FavoriteCategory, FavoritePrompt } from '@prompt-optimizer/core'
import { useFavoriteInitializer } from '../composables/storage/useFavoriteInitializer'
import { useConfirmDialog } from '../composables/ui/useConfirmDialog'
import { useToast } from '../composables/ui/useToast'
import type { AppServices } from '../types/services'
import { getI18nErrorMessage } from '../utils/error'
@@ -349,6 +350,7 @@ const emit = defineEmits<{
const services = inject<Ref<AppServices | null> | null>('services', null)
const message = useToast()
const confirmDialog = useConfirmDialog()
let ensuredDefaultCategoryManager: NonNullable<AppServices['favoriteManager']> | null = null
@@ -900,28 +902,29 @@ const incrementFavoriteUseCount = async (id: string) => {
bumpUseCountLocally(id)
}
const handleDeleteFavorite = (favorite: FavoritePrompt) => {
const handleDeleteFavorite = async (favorite: FavoritePrompt) => {
const deletingOpenFavorite = selectedFavorite.value?.id === favorite.id || taskFavorite.value?.id === favorite.id
const confirmed = typeof window === 'undefined'
? true
: window.confirm(t('favorites.manager.actions.deleteConfirm', { title: favorite.title }))
const confirmed = await confirmDialog.warning({
title: t('common.warning'),
content: t('favorites.manager.actions.deleteConfirm', { title: favorite.title }),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
})
if (!confirmed) return
;(async () => {
try {
const servicesValue = services?.value
if (servicesValue?.favoriteManager) {
await servicesValue.favoriteManager.deleteFavorite(favorite.id)
message.success(t('favorites.manager.actions.deleteSuccess'))
await loadFavorites()
} else {
message.warning(t('favorites.manager.messages.unavailable'))
}
} catch (error) {
message.error(buildErrorMessage(t('favorites.manager.actions.deleteFailed'), error))
try {
const servicesValue = services?.value
if (servicesValue?.favoriteManager) {
await servicesValue.favoriteManager.deleteFavorite(favorite.id)
message.success(t('favorites.manager.actions.deleteSuccess'))
await loadFavorites()
} else {
message.warning(t('favorites.manager.messages.unavailable'))
}
})()
} catch (error) {
message.error(buildErrorMessage(t('favorites.manager.actions.deleteFailed'), error))
}
if (selectedFavorite.value?.id === favorite.id) {
selectedFavorite.value = null
@@ -965,7 +968,7 @@ const handleUseFavorite = async (
}
}
const handleActionMenuSelect = (key: string) => {
const handleActionMenuSelect = async (key: string) => {
switch (key) {
case 'manageTags':
tagManagerVisible.value = true
@@ -977,29 +980,30 @@ const handleActionMenuSelect = (key: string) => {
handleExportFavorites()
break
case 'clear': {
const confirmed = typeof window === 'undefined'
? true
: window.confirm(t('favorites.manager.actions.clearConfirm'))
const confirmed = await confirmDialog.warning({
title: t('common.warning'),
content: t('favorites.manager.actions.clearConfirm'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
})
if (!confirmed) {
break
}
;(async () => {
try {
const servicesValue = services?.value
if (servicesValue?.favoriteManager) {
const allIds = favorites.value.map((favorite) => favorite.id)
await servicesValue.favoriteManager.deleteFavorites(allIds)
message.success(t('favorites.manager.actions.clearSuccess'))
await loadFavorites()
} else {
message.warning(t('favorites.manager.messages.unavailable'))
}
} catch (error) {
message.error(buildErrorMessage(t('favorites.manager.actions.clearFailed'), error))
try {
const servicesValue = services?.value
if (servicesValue?.favoriteManager) {
const allIds = favorites.value.map((favorite) => favorite.id)
await servicesValue.favoriteManager.deleteFavorites(allIds)
message.success(t('favorites.manager.actions.clearSuccess'))
await loadFavorites()
} else {
message.warning(t('favorites.manager.messages.unavailable'))
}
})()
} catch (error) {
message.error(buildErrorMessage(t('favorites.manager.actions.clearFailed'), error))
}
break
}
}
@@ -236,7 +236,10 @@ const handleActionSelect = (key: string) => {
.favorite-list-item {
@apply transition-colors duration-200;
@apply hover:bg-gray-50 dark:hover:bg-gray-800;
}
.favorite-list-item:hover {
background: var(--n-hover-color);
}
.item-checkbox {
@@ -252,7 +255,8 @@ const handleActionSelect = (key: string) => {
}
.title-text {
@apply font-medium text-gray-900 dark:text-gray-100;
@apply font-medium;
color: var(--n-text-color);
}
.item-actions {
@@ -264,7 +268,8 @@ const handleActionSelect = (key: string) => {
}
.content-preview {
@apply text-sm text-gray-700 dark:text-gray-300 mb-1;
@apply text-sm mb-1;
color: var(--n-text-color-2);
line-height: 1.5;
max-height: 3em;
overflow: hidden;
@@ -274,7 +279,8 @@ const handleActionSelect = (key: string) => {
}
.description-text {
@apply text-xs text-gray-500 dark:text-gray-400;
@apply text-xs;
color: var(--n-text-color-3);
max-height: 2em;
overflow: hidden;
display: -webkit-box;
@@ -283,7 +289,8 @@ const handleActionSelect = (key: string) => {
}
.item-footer {
@apply flex justify-between items-center mt-3 pt-3 border-t border-gray-100 dark:border-gray-800;
@apply flex justify-between items-center mt-3 pt-3 border-t;
border-color: var(--n-border-color);
}
.footer-left {
@@ -99,6 +99,7 @@ import { NCard, NSpace, NTag, NText } from 'naive-ui'
import SelectWithConfig from './SelectWithConfig.vue'
import { useFunctionModelManager } from '../composables/model/useFunctionModelManager'
import { DataTransformer, OptionAccessors } from '../utils/data-transformer'
import { getProviderDisplayName, getTextModelConfigDisplayName } from '../utils/provider-display'
import type { AppServices } from '../types/services'
import type { ModelSelectOption } from '../types/select-options'
@@ -138,7 +139,7 @@ const findModelInfo = (modelKey: string) => {
const option = evaluationModelOptions.value.find(opt => opt.value === modelKey)
if (!option?.raw) return null
return {
provider: option.raw.providerMeta?.name || null,
provider: getProviderDisplayName(option.raw.providerMeta, t, ''),
model: option.raw.modelMeta?.id || null,
}
}
@@ -172,8 +173,10 @@ const refreshModels = async () => {
await ensureInitializedIfSupported(manager)
const enabledModels = await manager.getEnabledModels()
evaluationModelOptions.value = DataTransformer.modelsToSelectOptions(enabledModels)
imageRecognitionModelOptions.value = DataTransformer.modelsToSelectOptions(enabledModels)
const getProviderName = (model: ModelSelectOption['raw']) => getProviderDisplayName(model.providerMeta, t)
const getModelName = (model: ModelSelectOption['raw']) => getTextModelConfigDisplayName(model, t)
evaluationModelOptions.value = DataTransformer.modelsToSelectOptions(enabledModels, { getProviderName, getModelName })
imageRecognitionModelOptions.value = DataTransformer.modelsToSelectOptions(enabledModels, { getProviderName, getModelName })
} catch (error) {
console.error('[FunctionModelManager] Failed to refresh models:', error)
evaluationModelOptions.value = []
+23 -9
View File
@@ -227,6 +227,7 @@ import {
NDivider, NCollapse, NCollapseItem, NEmpty, NInput
} from 'naive-ui'
import type { PromptRecord, PromptRecordChain } from '@prompt-optimizer/core'
import { useConfirmDialog } from '../composables/ui/useConfirmDialog'
import { useToast } from '../composables/ui/useToast'
import SourceAssetBadge from './source/SourceAssetBadge.vue'
import { extractHistorySourceBinding } from '../utils/history-source-binding'
@@ -256,6 +257,7 @@ const emit = defineEmits<{
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _toast = useToast()
const confirmDialog = useConfirmDialog()
const expandedVersions = ref<Record<string, boolean>>({})
const searchQuery = ref('')
@@ -298,10 +300,16 @@ const _toggleVersion = (recordId: string) => {
// 清空历史记录
const handleClear = async () => {
if (confirm(t('history.confirmClear'))) {
emit('clear')
// 不需要强制刷新,因为现在使用props.history
}
const confirmed = await confirmDialog.warning({
title: t('common.warning'),
content: t('history.confirmClear'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
})
if (!confirmed) return
emit('clear')
// 不需要强制刷新,因为现在使用props.history
}
// 监听显示状态变化
@@ -370,11 +378,17 @@ const getFunctionModeLabel = (recordType: string) => {
}
// 添加删除单条记录的方法
const deleteChain = (chainId: string) => {
if (confirm(t('history.confirmDeleteChain'))) {
emit('deleteChain', chainId)
// 不需要强制刷新,因为现在使用props.history
}
const deleteChain = async (chainId: string) => {
const confirmed = await confirmDialog.warning({
title: t('common.warning'),
content: t('history.confirmDeleteChain'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
})
if (!confirmed) return
emit('deleteChain', chainId)
// 不需要强制刷新,因为现在使用props.history
}
</script>
@@ -25,13 +25,14 @@
<NH4 style="margin: 0 0 12px 0; font-size: 14px;">{{ t('image.provider.section') }}</NH4>
<NFormItem :label="t('image.provider.label')">
<NSelect
<ProviderPillSelect
v-model:value="configForm.providerId"
:options="providerOptions"
:placeholder="t('image.provider.placeholder')"
:loading="isLoadingProviders"
:aria-label="t('image.provider.label')"
:more-label="t('modelManager.provider.more')"
:label-overrides="providerLabelOverrides"
@update:value="onProviderChange"
required
/>
</NFormItem>
@@ -114,29 +115,30 @@
@update:value="handleModelChange"
/>
<NTooltip :disabled="canRefreshModels" :show-arrow="false">
<template #trigger>
<NButton
@click="refreshModels"
:loading="isLoadingModels"
:disabled="!canRefreshModels"
circle
secondary
type="primary"
size="small"
style="flex-shrink: 0;"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
</template>
</NButton>
</template>
{{ refreshButtonTooltip }}
</NTooltip>
<ThemedTooltip
:label="refreshButtonTooltip"
:disabled="canRefreshModels"
:show-arrow="false"
>
<NButton
@click="refreshModels"
:loading="isLoadingModels"
:disabled="!canRefreshModels"
circle
secondary
type="primary"
size="small"
style="flex-shrink: 0;"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
</template>
</NButton>
</ThemedTooltip>
</NSpace>
</NFormItem>
@@ -208,7 +210,7 @@
width="32"
height="32"
object-fit="cover"
:style="{ borderRadius: '4px', border: '1px solid #d9d9d9' }"
:style="{ borderRadius: '4px', border: '1px solid var(--n-border-color)' }"
:preview-disabled="false"
:alt="t('image.connection.testImagePreview')"
/>
@@ -237,15 +239,17 @@ import { computed, watch, nextTick, h } from 'vue'
import { useI18n } from 'vue-i18n'
import {
NModal, NSpace, NInput, NInputNumber,
NCheckbox, NSelect, NButton, NTag, NTooltip, NText,
NCheckbox, NSelect, NButton, NTag, NText,
NDivider, NH4, NForm, NFormItem, useDialog
} from 'naive-ui'
import { useImageModelManager } from '../composables/model/useImageModelManager'
import { useToast } from '../composables/ui/useToast'
import { isRunningInElectron, type ImageModelConfig } from '@prompt-optimizer/core'
import ModelAdvancedSection from './ModelAdvancedSection.vue'
import ProviderPillSelect from './ProviderPillSelect.vue'
import ExternalLinkIcon from './icons/ExternalLinkIcon.vue'
import AppPreviewImage from './media/AppPreviewImage.vue'
import ThemedTooltip from './common/ThemedTooltip.vue'
const { t } = useI18n()
@@ -344,6 +348,10 @@ const providerOptions = computed(() =>
}))
)
const providerLabelOverrides = computed(() => ({
'openai-compatible': t('modelManager.provider.openaiCompatibleCustomLabel')
}))
const modelOptions = computed(() =>
models.value.map(m => ({
label: m.id,
@@ -38,7 +38,7 @@
<!-- 标签行:Provider、Model、能力标签合并 -->
<NSpace :size="6" class="image-model-tags">
<NTag size="small" type="default" round :bordered="false">
{{ config.provider?.name || config.providerId }}
{{ providerDisplayName(config) }}
</NTag>
<NTag size="small" type="info" round :bordered="false">
{{ config.model?.name || config.modelId }}
@@ -89,7 +89,7 @@
width="24"
height="24"
object-fit="cover"
:style="{ borderRadius: '4px', border: '1px solid #d9d9d9' }"
:style="{ borderRadius: '4px', border: '1px solid var(--n-border-color)' }"
:preview-disabled="false"
:alt="t('image.connection.testImagePreview')"
/>
@@ -170,14 +170,17 @@ import {
NSpace, NCard, NText, NTag, NButton, NEmpty, useDialog
} from 'naive-ui'
import { useImageModelManager } from '../composables/model/useImageModelManager'
import { useConfirmDialog } from '../composables/ui/useConfirmDialog'
import { useToast } from '../composables/ui/useToast'
import { getI18nErrorMessage } from '../utils/error'
import { isRunningInElectron, type IImageService, type ImageModel } from '@prompt-optimizer/core'
import { getProviderDisplayName } from '../utils/provider-display'
import AppPreviewImage from './media/AppPreviewImage.vue'
const { t } = useI18n()
const toast = useToast()
const dialog = useDialog()
const confirmDialog = useConfirmDialog()
const isElectronEnv = isRunningInElectron()
// 定义事件
@@ -211,6 +214,8 @@ const testResults = ref<Record<string, {
}>>({})
const isTestingConnectionFor = (configId: string) => !!testingConnections.value[configId]
const providerDisplayName = (config: { provider?: { id?: string; name?: string }; providerId?: string }) =>
getProviderDisplayName(config.provider || { id: config.providerId }, t)
// 辅助函数:根据模型能力选择测试类型
const selectTestType = (model: ImageModel): 'text2image' | 'image2image' => {
@@ -326,7 +331,7 @@ const testConnection = async (configId: string) => {
if (config?.provider?.corsRestricted) {
dialog.warning({
title: t('modelManager.corsRestrictedTag'),
content: () => h('div', { style: 'white-space: pre-line;' }, t('modelManager.corsRestrictedConfirm', { provider: config.provider.name || config.providerId })),
content: () => h('div', { style: 'white-space: pre-line;' }, t('modelManager.corsRestrictedConfirm', { provider: providerDisplayName(config) })),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
// Don't block dialog close while the async test runs.
@@ -353,15 +358,21 @@ const toggleConfig = async (config: { id: string; enabled: boolean }) => {
}
const deleteConfig = async (configId: string) => {
if (confirm(t('modelManager.deleteConfirm'))) {
try {
await deleteConfigFromManager(configId)
await loadConfigs()
toast.success(t('modelManager.deleteSuccess'))
} catch (error) {
console.error('[ImageModelManager] Failed to delete model:', error)
toast.error(t('modelManager.deleteFailed', { error: getI18nErrorMessage(error, t('common.error')) }))
}
const confirmed = await confirmDialog.warning({
title: t('common.warning'),
content: t('modelManager.deleteConfirm'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
})
if (!confirmed) return
try {
await deleteConfigFromManager(configId)
await loadConfigs()
toast.success(t('modelManager.deleteSuccess'))
} catch (error) {
console.error('[ImageModelManager] Failed to delete model:', error)
toast.error(t('modelManager.deleteFailed', { error: getI18nErrorMessage(error, t('common.error')) }))
}
}
@@ -264,8 +264,8 @@ watch(
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: rgba(120, 113, 108, 0.1);
color: #5f5548;
background: var(--n-tag-color, var(--n-color-embedded));
color: var(--n-text-color-2);
transition:
background-color 0.15s ease,
box-shadow 0.15s ease,
@@ -285,9 +285,9 @@ watch(
.image-model-quick-switch__model--clickable:hover,
.image-model-quick-switch__model--clickable:focus-visible {
background: rgba(120, 113, 108, 0.16);
color: #3f372d;
box-shadow: inset 0 0 0 1px rgba(120, 113, 108, 0.2);
background: var(--n-hover-color);
color: var(--n-text-color);
box-shadow: inset 0 0 0 1px var(--n-border-color);
}
.image-model-quick-switch__popover {
+28 -13
View File
@@ -40,7 +40,7 @@
<!-- 提示文本 -->
<transition name="fade">
<div v-if="!isOpen && !isLoading && showHint"
class="absolute right-12 top-0 bottom-0 min-w-[120px] flex items-center text-xs text-gray-500 pointer-events-none">
class="select-hint absolute right-12 top-0 bottom-0 min-w-[120px] flex items-center pointer-events-none">
{{ hintText }}
</div>
</transition>
@@ -55,17 +55,20 @@
<NEmpty v-if="isLoading" size="small" :description="loadingText" />
<NEmpty v-else-if="filteredOptions.length === 0" size="small" :description="noOptionsText" />
<NSpace v-else vertical size="small">
<div
<NButton
v-for="option in filteredOptions"
:key="option.value"
text
block
size="small"
@click="selectOption(option)"
class="cursor-pointer px-2 py-1 rounded transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
class="select-option"
:class="{
'bg-blue-100 dark:bg-blue-900': modelValue === option.value
'select-option--selected': modelValue === option.value
}"
>
{{ option.label }}
</div>
</NButton>
</NSpace>
</NCard>
</div>
@@ -170,15 +173,8 @@ const selectOption = (option) => {
// Close dropdown when clicking outside
const handleClickOutside = (event) => {
console.log('Click detected', {
isOpen: isOpen.value,
isComponent: componentRef.value && componentRef.value.contains(event.target),
target: event.target
});
// 只有在下拉菜单打开且点击的是组件外部时才关闭下拉菜单
if (isOpen.value && componentRef.value && !componentRef.value.contains(event.target)) {
console.log('Closing dropdown');
isOpen.value = false;
searchText.value = '';
}
@@ -215,4 +211,23 @@ onUnmounted(() => {
}
/* Pure Naive UI implementation - dropdown and hover styles handled by Naive UI */
</style>
.select-hint {
font-size: 12px;
color: var(--n-text-color-3);
}
.select-option {
justify-content: flex-start;
color: var(--n-text-color-2);
}
.select-option:hover,
.select-option--selected {
background: var(--n-hover-color);
color: var(--n-text-color);
}
.select-option--selected {
box-shadow: inset 2px 0 0 var(--n-primary-color);
}
</style>
+8 -12
View File
@@ -14,8 +14,8 @@
<!-- 左侧:Logo + 标题 + 核心导航 -->
<NFlex align="center" :size="16" :wrap="false">
<!-- Logo + 标题 -->
<button
type="button"
<NButton
text
class="brand-link"
@click="openBrandWebsite"
>
@@ -35,7 +35,7 @@
<slot name="title">{{ t('common.appName') }}</slot>
</NText>
</NFlex>
</button>
</NButton>
<!-- 核心导航元素 -->
<div class="core-navigation">
@@ -72,7 +72,7 @@
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { NLayout, NLayoutHeader, NLayoutContent, NFlex, NText } from 'naive-ui'
import { NButton, NLayout, NLayoutHeader, NLayoutContent, NFlex, NText } from 'naive-ui'
import ToastUI from './Toast.vue'
import logoImage from '../assets/logo.png'
import AppPreviewImage from './media/AppPreviewImage.vue'
@@ -162,14 +162,10 @@ const openBrandWebsite = async () => {
}
.brand-link {
display: inline-flex;
align-items: center;
padding: 6px 10px 6px 6px;
border: 0;
border-radius: 12px;
background: transparent;
color: inherit;
cursor: pointer;
transition:
background-color 0.2s ease-in-out,
box-shadow 0.2s ease-in-out,
@@ -177,7 +173,7 @@ const openBrandWebsite = async () => {
}
.brand-link:hover {
background: color-mix(in srgb, var(--primary-color, #18a058) 10%, transparent);
background: color-mix(in srgb, var(--n-primary-color) 10%, transparent);
transform: translateY(-1px);
}
@@ -191,8 +187,8 @@ const openBrandWebsite = async () => {
.brand-link:focus-visible {
outline: none;
background: color-mix(in srgb, var(--primary-color, #18a058) 14%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #18a058) 28%, transparent);
background: color-mix(in srgb, var(--n-primary-color) 14%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--n-primary-color) 28%, transparent);
}
/* Logo样式优化 */
@@ -216,7 +212,7 @@ const openBrandWebsite = async () => {
align-items: center;
margin-left: 16px;
padding-left: 16px;
border-left: 1px solid var(--border-color, rgba(239, 239, 245, 0.6));
border-left: 1px solid var(--n-border-color);
min-height: 32px;
}
+14 -14
View File
@@ -364,7 +364,7 @@ onMounted(renderMarkdown);
font-weight: 600;
padding-bottom: 0.1em;
color: inherit;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
border-bottom: 1px solid var(--n-divider-color, var(--n-border-color));
}
.markdown-content h3 {
@@ -421,8 +421,8 @@ onMounted(renderMarkdown);
overflow: auto;
margin-bottom: 0.1em;
position: relative;
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.1);
background: var(--n-color-embedded, color-mix(in srgb, var(--n-color) 92%, var(--n-text-color) 8%));
border: 1px solid var(--n-border-color);
/* 添加滚动条样式 */
scrollbar-width: thin; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
@@ -434,7 +434,7 @@ onMounted(renderMarkdown);
}
.markdown-content pre::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.1);
background-color: var(--n-scrollbar-color, var(--n-border-color));
border-radius: 3px;
}
@@ -459,8 +459,8 @@ onMounted(renderMarkdown);
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
border-bottom-left-radius: 4px;
user-select: none;
background-color: #18a058;
color: white;
background-color: var(--n-primary-color);
color: var(--n-text-color-primary);
}
.markdown-content code {
@@ -471,9 +471,9 @@ onMounted(renderMarkdown);
margin: 0.3em;
border-radius: 6px;
white-space: pre;
background-color: rgba(0, 0, 0, 0.02);
background-color: var(--n-color-embedded, color-mix(in srgb, var(--n-color) 92%, var(--n-text-color) 8%));
color: inherit;
border: 1px solid rgba(0, 0, 0, 0.1);
border: 1px solid var(--n-border-color);
}
/* 引用样式 */
@@ -482,8 +482,8 @@ onMounted(renderMarkdown);
margin: var(--md-spacing-sm);
border-left-width: 0.25em;
border-left-style: solid;
border-left-color: #18a058;
background-color: rgba(0, 0, 0, 0.02);
border-left-color: var(--n-primary-color);
background-color: var(--n-color-embedded, color-mix(in srgb, var(--n-color) 92%, var(--n-text-color) 8%));
color: inherit;
}
@@ -494,19 +494,19 @@ onMounted(renderMarkdown);
margin: var(--md-spacing-sm);
overflow: auto;
font-size: 0.9em;
border: 1px solid rgba(0, 0, 0, 0.1);
border: 1px solid var(--n-border-color);
}
.markdown-content table th,
.markdown-content table td {
line-height: 1.5;
padding: 0.3em 0.5em;
border: 1px solid rgba(0, 0, 0, 0.1);
border: 1px solid var(--n-border-color);
color: inherit;
}
.markdown-content table th {
background-color: rgba(0, 0, 0, 0.02);
background-color: var(--n-color-embedded, color-mix(in srgb, var(--n-color) 92%, var(--n-text-color) 8%));
font-weight: 600;
}
@@ -544,7 +544,7 @@ onMounted(renderMarkdown);
.markdown-content a {
text-decoration: none;
transition: color 0.2s ease; /* 平滑颜色变化 */
color: #18a058;
color: var(--n-primary-color);
}
.markdown-content a:hover {
@@ -152,7 +152,7 @@
fontSize: '12px',
color: paramOverrides[entry.key] !== null && typeof paramOverrides[entry.key] === 'object'
? undefined
: 'var(--n-color-error, #d03050)'
: 'var(--n-error-color)'
}"
>
{{
@@ -308,7 +308,7 @@
fontSize: '12px',
color: paramOverrides[entry.key] !== null && typeof paramOverrides[entry.key] === 'object'
? undefined
: 'var(--n-color-error, #d03050)'
: 'var(--n-error-color)'
}"
>
{{
+34 -19
View File
@@ -169,35 +169,43 @@ defineExpose({ forceRefreshContent, forceExitEditing });
@reference "../styles/index.css";
.output-display {
@apply flex flex-col h-full border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 relative;
@apply flex flex-col h-full rounded-lg relative;
border: 1px solid var(--n-border-color);
background: var(--n-card-color, var(--n-color));
color: var(--n-text-color);
}
/* 悬浮工具栏样式 */
.floating-toolbar {
@apply absolute top-2 right-2 z-10 flex items-center gap-2 px-3 py-2 rounded-lg shadow-lg;
background: rgba(255, 255, 255, 0.8);
@apply absolute top-2 right-2 z-10 flex items-center gap-2 px-3 py-2 rounded-lg;
background: color-mix(in srgb, var(--n-popover-color, var(--n-color)) 88%, transparent);
backdrop-filter: blur(8px);
border: 1px solid rgba(0, 0, 0, 0.1);
border: 1px solid var(--n-border-color);
box-shadow: var(--n-box-shadow-2, 0 8px 20px color-mix(in srgb, var(--n-text-color) 12%, transparent));
}
/* 全屏模式下的悬浮复制按钮 */
.floating-copy-btn {
@apply absolute top-2 right-2 z-10 flex items-center gap-2 px-3 py-2 rounded-lg shadow-lg;
background: rgba(255, 255, 255, 0.8);
@apply absolute top-2 right-2 z-10 flex items-center gap-2 px-3 py-2 rounded-lg;
background: color-mix(in srgb, var(--n-popover-color, var(--n-color)) 88%, transparent);
backdrop-filter: blur(8px);
border: 1px solid rgba(0, 0, 0, 0.1);
}
.dark .floating-toolbar,
.dark .floating-copy-btn {
background: rgba(31, 41, 55, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid var(--n-border-color);
box-shadow: var(--n-box-shadow-2, 0 8px 20px color-mix(in srgb, var(--n-text-color) 12%, transparent));
}
.toolbar-btn {
@apply flex items-center gap-1 px-2 py-1 text-sm rounded transition-colors;
@apply hover:bg-gray-100 dark:hover:bg-gray-700;
@apply focus:outline-hidden focus:ring-2 focus:ring-blue-500/50;
color: var(--n-text-color-2);
}
.toolbar-btn:hover {
background: var(--n-hover-color);
color: var(--n-text-color);
}
.toolbar-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--n-primary-color-suppl);
}
/* 悬浮工具栏动画 */
@@ -225,7 +233,11 @@ defineExpose({ forceRefreshContent, forceExitEditing });
}
.reasoning-header {
@apply flex justify-between items-center cursor-pointer p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors;
@apply flex justify-between items-center cursor-pointer p-2 rounded transition-colors;
}
.reasoning-header:hover {
background: var(--n-hover-color);
}
.reasoning-toggle {
@@ -273,7 +285,8 @@ defineExpose({ forceRefreshContent, forceExitEditing });
.loading-placeholder,
.empty-placeholder {
@apply flex items-center justify-center h-full text-gray-500 text-sm italic;
@apply flex items-center justify-center h-full text-sm italic;
color: var(--n-text-color-3);
}
.edit-actions {
@@ -285,11 +298,13 @@ defineExpose({ forceRefreshContent, forceExitEditing });
}
.output-display--editable .output-display__content {
@apply border border-dashed border-gray-300 dark:border-gray-600;
@apply border border-dashed;
border-color: var(--n-border-color);
}
.output-display--editing .output-display__content {
@apply border-solid border-blue-500;
@apply border-solid;
border-color: var(--n-primary-color);
}
/* 隐藏滚动条但保持可滚动 */
+25 -20
View File
@@ -49,24 +49,21 @@
V{{ version.version }}
</NTag>
<!-- 🆕 原始版本固定放在最后 -->
<NTooltip v-if="showV0Tag" trigger="hover">
<template #trigger>
<NTag
:key="getV0TagRenderKey()"
:type="isV0Selected ? 'success' : 'default'"
size="small"
class="version-tag-clickable"
:class="getVersionSourceFeedbackClass(0)"
@click="switchToV0"
:bordered="!isV0Selected"
data-testid="prompt-panel-version-tag-v0"
:data-source-feedback-tone="getVersionSourceFeedbackTone(0) || undefined"
>
{{ t("prompt.originalVersion") }}
</NTag>
</template>
{{ t("prompt.originalVersionTooltip") }}
</NTooltip>
<ThemedTooltip v-if="showV0Tag" :label="t('prompt.originalVersionTooltip')">
<NTag
:key="getV0TagRenderKey()"
:type="isV0Selected ? 'success' : 'default'"
size="small"
class="version-tag-clickable"
:class="getVersionSourceFeedbackClass(0)"
@click="switchToV0"
:bordered="!isV0Selected"
data-testid="prompt-panel-version-tag-v0"
:data-source-feedback-tone="getVersionSourceFeedbackTone(0) || undefined"
>
{{ t("prompt.originalVersion") }}
</NTag>
</ThemedTooltip>
</NSpace>
</NSpace>
</NSpace>
@@ -304,13 +301,15 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch } from "vue";
import { useI18n } from "vue-i18n";
import { NButton, NText, NInput, NCard, NFlex, NSpace, NTag, NIcon, NTooltip } from "naive-ui";
import { NButton, NText, NInput, NCard, NFlex, NSpace, NTag, NIcon } from "naive-ui";
import { useConfirmDialog } from '../composables/ui/useConfirmDialog';
import { useToast } from '../composables/ui/useToast';
import { useEvaluationContextOptional } from '../composables/prompt/useEvaluationContext';
import { useProContextOptional } from '../composables/prompt/useProContext';
import TemplateSelect from "./TemplateSelect.vue";
import Modal from "./Modal.vue";
import OutputDisplay from "./OutputDisplay.vue";
import ThemedTooltip from './common/ThemedTooltip.vue';
import { AnalyzeActionIcon, EvaluationScoreBadge, FocusAnalyzeButton } from "./evaluation";
import type {
EvaluationContentBlock,
@@ -325,6 +324,7 @@ type SourceFeedbackTone = "change" | "error";
const { t } = useI18n();
const toast = useToast();
const confirmDialog = useConfirmDialog();
interface IteratePayload {
originalPrompt: string;
@@ -798,7 +798,12 @@ const switchVersion = async (version: PromptRecord) => {
if (version.id === props.currentVersionId && !isV0Selected.value) return;
if (showSaveChanges.value) {
const ok = window.confirm(t("prompt.unsavedChangesConfirm"));
const ok = await confirmDialog.warning({
title: t("common.warning"),
content: t("prompt.unsavedChangesConfirm"),
positiveText: t("common.confirm"),
negativeText: t("common.cancel"),
});
if (!ok) return;
}
@@ -0,0 +1,294 @@
<template>
<div
class="provider-pill-select"
role="radiogroup"
:aria-label="ariaLabel"
:aria-busy="loading ? 'true' : 'false'"
>
<template v-if="loading && normalizedOptions.length === 0">
<span
v-for="index in 4"
:key="index"
class="provider-pill-select__skeleton"
/>
</template>
<button
v-for="option in leadingOptions"
v-else
:key="option.value"
class="provider-pill-select__pill"
:class="{ 'provider-pill-select__pill--selected': option.value === value }"
type="button"
role="radio"
:aria-checked="option.value === value"
:disabled="loading || option.disabled"
:title="option.label"
@click="selectOption(option)"
>
<span class="provider-pill-select__label">{{ option.label }}</span>
</button>
<button
v-if="customOption"
:key="customOption.value"
class="provider-pill-select__pill"
:class="{
'provider-pill-select__pill--selected': customOption.value === value,
'provider-pill-select__pill--custom': true
}"
type="button"
role="radio"
:aria-checked="customOption.value === value"
:disabled="loading || customOption.disabled"
:title="customOption.label"
@click="selectOption(customOption)"
>
<span class="provider-pill-select__label">{{ customOption.label }}</span>
</button>
<button
v-if="overflowOptions.length > 0"
class="provider-pill-select__pill provider-pill-select__pill--more"
:class="{ 'provider-pill-select__pill--selected': isOverflowSelected }"
type="button"
:disabled="loading"
:title="moreLabel"
:aria-expanded="isOverflowExpanded"
@click="toggleOverflow"
>
<span class="provider-pill-select__label">{{ moreButtonText }}</span>
</button>
<button
v-for="option in visibleOverflowOptions"
:key="option.value"
class="provider-pill-select__pill provider-pill-select__pill--overflow"
:class="{ 'provider-pill-select__pill--selected': option.value === value }"
type="button"
role="radio"
:aria-checked="option.value === value"
:disabled="loading || option.disabled"
:title="option.label"
@click="selectOption(option)"
>
<span class="provider-pill-select__label">{{ option.label }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
interface ProviderOption {
label?: unknown
value?: unknown
disabled?: boolean
}
interface NormalizedProviderOption {
label: string
value: string
disabled: boolean
}
const props = defineProps<{
value: string
options: ProviderOption[]
loading?: boolean
ariaLabel?: string
moreLabel?: string
labelOverrides?: Record<string, string>
}>()
const emit = defineEmits<{
'update:value': [value: string]
}>()
const isOverflowExpanded = ref(false)
const providerOrder = [
'openai',
'deepseek',
'gemini',
'google-gemini',
'anthropic',
'dashscope',
'openrouter',
'zhipu',
'zhipuai',
'ollama',
'seedream',
'volcengine',
'volcano',
'siliconflow',
'minimax',
'modelscope',
'cloudflare',
'openai-compatible'
]
const overflowProviderIds = new Set([
'siliconflow',
'minimax',
'modelscope',
'cloudflare'
])
const normalizeProviderId = (value: string) => value.trim().toLowerCase()
const getProviderOrderIndex = (value: string) => {
const normalizedValue = normalizeProviderId(value)
const index = providerOrder.findIndex(id => id === normalizedValue)
return index === -1 ? providerOrder.length : index
}
const isCustomProvider = (value: string) => normalizeProviderId(value) === 'openai-compatible'
const shouldOverflowProvider = (value: string) => {
const normalizedValue = normalizeProviderId(value)
return !isCustomProvider(normalizedValue) && (
overflowProviderIds.has(normalizedValue) ||
getProviderOrderIndex(normalizedValue) === providerOrder.length
)
}
const normalizedOptions = computed<NormalizedProviderOption[]>(() =>
props.options
.map((option) => {
const value = String(option.value ?? '')
const rawLabel = typeof option.label === 'string' || typeof option.label === 'number'
? String(option.label)
: value
const label = props.labelOverrides?.[normalizeProviderId(value)] ?? rawLabel
return {
label,
value,
disabled: !!option.disabled || !value
}
})
.filter(option => option.value)
.sort((a, b) => {
const orderDelta = getProviderOrderIndex(a.value) - getProviderOrderIndex(b.value)
if (orderDelta !== 0) return orderDelta
return a.label.localeCompare(b.label)
})
)
const primaryOptions = computed(() =>
normalizedOptions.value.filter(option =>
!shouldOverflowProvider(option.value)
)
)
const customOption = computed(() =>
primaryOptions.value.find(option => isCustomProvider(option.value)) || null
)
const leadingOptions = computed(() =>
primaryOptions.value.filter(option => !isCustomProvider(option.value))
)
const overflowOptions = computed(() =>
normalizedOptions.value.filter(option =>
shouldOverflowProvider(option.value)
)
)
const isOverflowSelected = computed(() =>
overflowOptions.value.some(option => option.value === props.value)
)
const moreLabel = computed(() => props.moreLabel || '更多')
const moreButtonText = computed(() => {
const selected = overflowOptions.value.find(option => option.value === props.value)
return selected && !isOverflowExpanded.value ? selected.label : moreLabel.value
})
const visibleOverflowOptions = computed(() =>
isOverflowExpanded.value ? overflowOptions.value : []
)
const selectOption = (option: NormalizedProviderOption) => {
if (props.loading || option.disabled || option.value === props.value) return
emit('update:value', option.value)
}
const toggleOverflow = () => {
if (props.loading) return
isOverflowExpanded.value = !isOverflowExpanded.value
}
</script>
<style scoped>
.provider-pill-select {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.provider-pill-select__pill {
max-width: 100%;
min-height: 32px;
padding: 5px 13px;
border: 1px solid var(--n-border-color);
border-radius: 999px;
background: var(--n-color);
color: var(--n-text-color);
cursor: pointer;
font: inherit;
line-height: 1.4;
transition:
color 0.2s ease,
background-color 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.provider-pill-select__pill:hover:not(:disabled) {
border-color: var(--n-border-hover-color);
color: var(--n-text-color-hover);
}
.provider-pill-select__pill:focus-visible {
outline: none;
box-shadow: var(--n-box-shadow-focus);
}
.provider-pill-select__pill:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.provider-pill-select__pill--selected {
border-color: var(--n-border-pressed-color);
background: var(--n-color-pressed);
color: var(--n-text-color-pressed);
}
.provider-pill-select__pill--more {
border-style: dashed;
}
.provider-pill-select__pill--overflow {
background: transparent;
}
.provider-pill-select__pill--custom .provider-pill-select__label {
max-width: none;
}
.provider-pill-select__label {
display: block;
overflow: hidden;
max-width: 180px;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-pill-select__skeleton {
width: 92px;
height: 32px;
border-radius: 999px;
background: var(--n-color-disabled);
}
</style>
@@ -1,28 +1,25 @@
<template>
<NTooltip trigger="hover">
<template #trigger>
<NButton
size="small"
quaternary
circle
:disabled="disabled"
:data-testid="testId"
@click="handleSaveExample"
>
<template #icon>
<NIcon>
<Star />
</NIcon>
</template>
</NButton>
</template>
{{ t('favorites.dialog.reproducibility.saveTestResultExample') }}
</NTooltip>
<ThemedTooltip :label="t('favorites.dialog.reproducibility.saveTestResultExample')">
<NButton
size="small"
quaternary
circle
:disabled="disabled"
:data-testid="testId"
@click="handleSaveExample"
>
<template #icon>
<NIcon>
<Star />
</NIcon>
</template>
</NButton>
</ThemedTooltip>
</template>
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { NButton, NIcon, NTooltip } from 'naive-ui'
import { NButton, NIcon } from 'naive-ui'
import { Star } from '@vicons/tabler'
import { useI18n } from 'vue-i18n'
import {
@@ -32,6 +29,7 @@ import {
} from '@prompt-optimizer/core'
import { useToast } from '../composables/ui/useToast'
import ThemedTooltip from './common/ThemedTooltip.vue'
import { useSessionManager, type SubModeKey } from '../stores/session/useSessionManager'
import type { SaveFavoritePayload } from '../types/workspace'
import {
+7 -6
View File
@@ -121,7 +121,6 @@ import {
NText,
NPopconfirm,
NSelect,
NTooltip,
type DataTableColumns
} from 'naive-ui';
import { Search, Edit, Trash, GitMerge } from '@vicons/tabler';
@@ -130,6 +129,7 @@ import { useToast } from '../composables/ui/useToast';
import type { AppServices } from '../types/services';
import { TagTypeConverter, type TagStatistics } from '@prompt-optimizer/core';
import { getI18nErrorMessage } from '../utils/error';
import ThemedTooltip from './common/ThemedTooltip.vue';
const { t } = useI18n();
@@ -231,10 +231,12 @@ const columns = computed<DataTableColumns<TagStatistics>>(() => [
}
),
h(
NTooltip,
{},
ThemedTooltip,
{
trigger: () => h(
label: t('favorites.manager.tagManager.mergeTooltip')
},
{
default: () => h(
NButton,
{
size: 'small',
@@ -245,8 +247,7 @@ const columns = computed<DataTableColumns<TagStatistics>>(() => [
icon: () => h(NIcon, null, { default: () => h(GitMerge) }),
default: () => t('favorites.manager.tagManager.merge')
}
),
default: () => t('favorites.manager.tagManager.mergeTooltip')
)
}
),
h(
+17 -9
View File
@@ -643,6 +643,7 @@ import {
NGrid, NGridItem, NEl
} from 'naive-ui'
import { TemplateProcessor, type Template, type MessageTemplate, type ITemplateManager, TemplateLanguageService } from '@prompt-optimizer/core'
import { useConfirmDialog } from '../composables/ui/useConfirmDialog'
import { useToast } from '../composables/ui/useToast'
import MarkdownRenderer from './MarkdownRenderer.vue'
import BuiltinTemplateLanguageSwitch from './BuiltinTemplateLanguageSwitch.vue'
@@ -657,6 +658,7 @@ import { useImageImage2ImageSession } from '../stores/session/useImageImage2Imag
import { useImageMultiImageSession } from '../stores/session/useImageMultiImageSession'
const { t } = useI18n()
const confirmDialog = useConfirmDialog()
interface Services {
templateManager: ITemplateManager;
@@ -1187,16 +1189,22 @@ const handleSubmit = async () => {
// 确认删除
const confirmDelete = async (templateId: string) => {
if (confirm(t('template.deleteConfirm'))) {
try {
await getTemplateManager.value.deleteTemplate(templateId)
await loadTemplates()
const confirmed = await confirmDialog.warning({
title: t('common.warning'),
content: t('template.deleteConfirm'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
})
if (!confirmed) return
toast.success(t('template.success.deleted'))
} catch (error) {
console.error('Failed to delete template:', error)
toast.error(t('template.error.deleteFailed'))
}
try {
await getTemplateManager.value.deleteTemplate(templateId)
await loadTemplates()
toast.success(t('template.success.deleted'))
} catch (error) {
console.error('Failed to delete template:', error)
toast.error(t('template.error.deleteFailed'))
}
}
+6 -13
View File
@@ -26,11 +26,8 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { NTag, NCard, NFlex, NScrollbar } from 'naive-ui'
import type { CompareResult, ChangeType } from '@prompt-optimizer/core'
import { useNaiveTheme } from '../composables/ui/useNaiveTheme'
interface Props {
/** 原始文本 */
@@ -43,10 +40,6 @@ import { useNaiveTheme } from '../composables/ui/useNaiveTheme'
defineProps<Props>()
// 获取当前主题配置
const { themeOverrides } = useNaiveTheme()
const theme = computed(() => themeOverrides.value)
const getFragmentClass = (type: ChangeType): string => {
switch (type) {
case 'added':
@@ -85,18 +78,18 @@ const getFragmentClass = (type: ChangeType): string => {
}
.diff-added {
background-color: v-bind('theme.common?.successColorSuppl || "rgba(34, 197, 94, 0.15)"');
color: v-bind('theme.common?.successColor || "#16a34a"');
background-color: var(--n-success-color-suppl);
color: var(--n-success-color);
}
.diff-removed {
background-color: v-bind('theme.common?.errorColorSuppl || "rgba(239, 68, 68, 0.15)"');
color: v-bind('theme.common?.errorColor || "#dc2626"');
background-color: var(--n-error-color-suppl);
color: var(--n-error-color);
text-decoration: line-through;
}
.diff-unchanged {
color: v-bind('theme.common?.textColor3 || "#6b7280"');
color: var(--n-text-color-3);
}
/* 响应式设计 */
@@ -106,4 +99,4 @@ const getFragmentClass = (type: ChangeType): string => {
font-size: 12px;
}
}
</style>
</style>
@@ -27,13 +27,14 @@
<NH4 style="margin: 0 0 12px 0; font-size: 14px;">{{ t('modelManager.provider.section') }}</NH4>
<NFormItem :label="t('modelManager.provider.label')">
<NSelect
<ProviderPillSelect
v-model:value="form.providerId"
:options="providerOptions"
:loading="isLoadingProviders"
:placeholder="t('modelManager.provider.placeholder')"
:aria-label="t('modelManager.provider.label')"
:more-label="t('modelManager.provider.more')"
:label-overrides="providerLabelOverrides"
@update:value="onProviderChange"
required
/>
</NFormItem>
@@ -49,20 +50,20 @@
<template v-if="field.name === 'baseURL'" #label>
<NSpace align="center" :size="4">
<span>{{ t('modelManager.apiUrl') }}</span>
<NTooltip :show-arrow="false" placement="top">
<template #trigger>
<NButton
class="api-url-help-button"
quaternary
circle
size="tiny"
:aria-label="t('modelManager.apiUrlHintAriaLabel')"
>
?
</NButton>
</template>
<ThemedTooltip :show-arrow="false" placement="top">
<NButton
class="api-url-help-button"
quaternary
circle
size="tiny"
:aria-label="t('modelManager.apiUrlHintAriaLabel')"
>
?
</NButton>
<template #content>
<span class="api-url-help-text">{{ t('modelManager.apiUrlHint') }}</span>
</NTooltip>
</template>
</ThemedTooltip>
</NSpace>
</template>
@@ -181,29 +182,30 @@
@update:value="handleModelChange"
/>
<NTooltip :disabled="!canRefreshModelOptions" :show-arrow="false">
<template #trigger>
<NButton
@click="refreshModelOptions()"
:loading="isLoadingModelOptions"
:disabled="!canRefreshModelOptions"
circle
secondary
type="primary"
size="small"
style="flex-shrink: 0;"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
</template>
</NButton>
</template>
{{ t('modelManager.clickToFetchModels') }}
</NTooltip>
<ThemedTooltip
:label="t('modelManager.clickToFetchModels')"
:disabled="!canRefreshModelOptions"
:show-arrow="false"
>
<NButton
@click="refreshModelOptions()"
:loading="isLoadingModelOptions"
:disabled="!canRefreshModelOptions"
circle
secondary
type="primary"
size="small"
style="flex-shrink: 0;"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
</template>
</NButton>
</ThemedTooltip>
</NSpace>
</NFormItem>
</NForm>
@@ -278,14 +280,16 @@ import {
NDivider,
NText,
NTag,
NTooltip,
NSpin,
useDialog
} from 'naive-ui'
import ModelAdvancedSection from './ModelAdvancedSection.vue'
import ProviderPillSelect from './ProviderPillSelect.vue'
import ExternalLinkIcon from './icons/ExternalLinkIcon.vue'
import ThemedTooltip from './common/ThemedTooltip.vue'
import type { TextModelManager } from '../composables/model/useTextModelManager'
import { resolveTextConnectionFieldLabel } from '../utils/model-connection-label'
import { getProviderDisplayName } from '../utils/provider-display'
const { show } = defineProps({
show: {
@@ -326,6 +330,10 @@ const isSaving = manager.isSaving
const isEditing = computed(() => !!manager.editingModelId.value)
const providerLabelOverrides = computed(() => ({
'openai-compatible': t('modelManager.provider.openaiCompatibleCustomLabel')
}))
interface CustomHeaderRow {
key: string
value: string
@@ -431,7 +439,7 @@ const handleTestFormConnection = async () => {
if (!isRunningInElectron()) {
const provider = manager.selectedProvider.value
if (provider?.corsRestricted) {
const providerName = provider.name || provider.id || 'Unknown Provider'
const providerName = getProviderDisplayName(provider, t, 'Unknown Provider')
dialog.warning({
title: t('modelManager.corsRestrictedTag'),
content: () => h('div', { style: 'white-space: pre-line;' }, t('modelManager.corsRestrictedConfirm', { provider: providerName })),
+5 -2
View File
@@ -12,7 +12,7 @@
<NSpace justify="space-between" align="center">
<NSpace vertical :size="4" class="model-card-heading">
<NSpace align="center" :size="8">
<NText strong>{{ model.name }}</NText>
<NText strong>{{ modelDisplayName(model) }}</NText>
<NTag v-if="!model.enabled" type="warning" size="small" round :bordered="false">
{{ t('modelManager.disabled') }}
</NTag>
@@ -20,7 +20,7 @@
<NSpace :size="6" class="model-card-tags">
<NTag size="small" type="default" round :bordered="false">
{{ model.providerMeta?.name || model.providerMeta?.id }}
{{ providerDisplayName(model) }}
</NTag>
<NTag size="small" type="info" round :bordered="false">
{{ model.modelMeta?.name || model.modelMeta?.id }}
@@ -202,6 +202,7 @@ import { type PropType } from 'vue'
import { useI18n } from 'vue-i18n'
import { NButton, NCard, NTag, NText, NSpace } from 'naive-ui'
import { isRunningInElectron, type TextModelConfig } from '@prompt-optimizer/core'
import { getProviderDisplayName, getTextModelConfigDisplayName } from '../utils/provider-display'
const { models, isTestingConnectionFor, isDefaultModel } = defineProps({
models: {
@@ -223,6 +224,8 @@ const emit = defineEmits(['test', 'edit', 'clone', 'enable', 'disable', 'delete'
const { t } = useI18n()
const isElectronEnv = isRunningInElectron()
const modelDisplayName = (model: TextModelConfig) => getTextModelConfigDisplayName(model, t)
const providerDisplayName = (model: TextModelConfig) => getProviderDisplayName(model.providerMeta, t)
</script>
<style scoped>
@@ -29,10 +29,13 @@ import { useTextModelManager } from '../composables/model/useTextModelManager'
import TextModelList from './TextModelList.vue'
import TextModelEditModal from './TextModelEditModal.vue'
import { useDialog } from 'naive-ui'
import { useConfirmDialog } from '../composables/ui/useConfirmDialog'
import { getProviderDisplayName } from '../utils/provider-display'
const emit = defineEmits(['modelsUpdated'])
const { t } = useI18n()
const dialog = useDialog()
const confirmDialog = useConfirmDialog()
const manager = useTextModelManager()
provide('textModelManager', manager)
@@ -62,7 +65,7 @@ const handleTestConnection = async (id: string) => {
if (model) {
const isCorsRestricted = !!model.providerMeta?.corsRestricted
if (isCorsRestricted) {
const providerName = model.providerMeta?.name || model.providerMeta?.id || 'Unknown Provider'
const providerName = getProviderDisplayName(model.providerMeta, t, 'Unknown Provider')
dialog.warning({
title: t('modelManager.corsRestrictedTag'),
content: () => h('div', { style: 'white-space: pre-line;' }, t('modelManager.corsRestrictedConfirm', { provider: providerName })),
@@ -126,12 +129,18 @@ const handleDisableModel = async (id: string) => {
}
const handleDeleteModel = async (id: string) => {
if (confirm(t('modelManager.deleteConfirm'))) {
await manager.deleteModel(id)
const firstId = manager.models.value[0]?.id
if (firstId) {
emit('modelsUpdated', firstId)
}
const confirmed = await confirmDialog.warning({
title: t('common.warning'),
content: t('modelManager.deleteConfirm'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
})
if (!confirmed) return
await manager.deleteModel(id)
const firstId = manager.models.value[0]?.id
if (firstId) {
emit('modelsUpdated', firstId)
}
}
@@ -68,6 +68,7 @@ import {
} from '@prompt-optimizer/core'
import { useToast } from '../composables/ui/useToast'
import { getProviderDisplayName } from '../utils/provider-display'
import type { ModelSelectOption } from '../types/select-options'
import type { AppServices } from '../types/services'
@@ -100,7 +101,7 @@ const selectedConfig = computed(() =>
const providerId = computed(() => selectedConfig.value?.providerMeta?.id ?? '')
const selectedModelId = computed(() => selectedConfig.value?.modelMeta?.id ?? '')
const providerLabel = computed(() =>
selectedConfig.value?.providerMeta?.name || selectedConfig.value?.providerMeta?.id || ''
selectedConfig.value ? getProviderDisplayName(selectedConfig.value.providerMeta, t, '') : ''
)
const modelLabel = computed(() =>
selectedConfig.value?.modelMeta?.name || selectedConfig.value?.modelMeta?.id || ''
@@ -260,8 +261,8 @@ watch(
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: rgba(120, 113, 108, 0.1);
color: #5f5548;
background: var(--n-tag-color, var(--n-color-embedded));
color: var(--n-text-color-2);
transition:
background-color 0.15s ease,
box-shadow 0.15s ease,
@@ -281,9 +282,9 @@ watch(
.text-model-quick-switch__model--clickable:hover,
.text-model-quick-switch__model--clickable:focus-visible {
background: rgba(120, 113, 108, 0.16);
color: #3f372d;
box-shadow: inset 0 0 0 1px rgba(120, 113, 108, 0.2);
background: var(--n-hover-color);
color: var(--n-text-color);
box-shadow: inset 0 0 0 1px var(--n-border-color);
}
.text-model-quick-switch__popover {
+20 -8
View File
@@ -14,24 +14,36 @@
<script setup lang="ts">
import { onMounted, defineComponent, h } from "vue";
import { NMessageProvider, NDialogProvider, useMessage } from "naive-ui";
import { NMessageProvider, NDialogProvider, useMessage, useDialog } from "naive-ui";
import { setGlobalMessageApi } from '../composables/ui/useToast';
import { setGlobalDialogApi } from '../composables/ui/useConfirmDialog';
// 内部组件用于在正确的上下文中初始化消息API
const MessageApiInitializer = defineComponent({
name: "MessageApiInitializer",
setup() {
let messageApi: ReturnType<typeof useMessage> | null = null;
let dialogApi: ReturnType<typeof useDialog> | null = null;
try {
messageApi = useMessage();
dialogApi = useDialog();
} catch (error) {
console.warn(
"[Toast] Naive UI API initialization failed (this is normal during SSR or when provider is not ready):",
error,
);
}
onMounted(() => {
try {
const messageApi = useMessage();
if (messageApi) {
setGlobalMessageApi(messageApi);
console.log("[Toast] Message API initialized successfully");
} catch (error) {
console.warn(
"[Toast] Message API initialization failed (this is normal during SSR or when provider is not ready):",
error,
);
}
if (dialogApi) {
setGlobalDialogApi(dialogApi);
console.log("[Toast] Dialog API initialized successfully");
}
});
return () => h("div", { style: { display: "none" } });
@@ -304,11 +304,11 @@ const handleOpenDocs = () => {
.page-destination-group {
padding-right: 8px;
border-right: 1px solid var(--border-color, rgba(239, 239, 245, 0.72));
border-right: 1px solid var(--n-border-color);
}
.page-destination-active {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #18a058) 18%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--n-primary-color) 18%, transparent);
}
.about-panel {
@@ -345,14 +345,14 @@ const handleOpenDocs = () => {
.about-link-label {
flex-shrink: 0;
font-size: 11px;
color: var(--text-color-3);
color: var(--n-text-color-3);
}
.about-link-value {
min-width: 0;
font-size: 12px;
font-weight: 600;
color: var(--text-color-2);
color: var(--n-text-color-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -362,6 +362,6 @@ const handleOpenDocs = () => {
width: 12px;
height: 12px;
flex-shrink: 0;
color: var(--text-color-3);
color: var(--n-text-color-3);
}
</style>
@@ -17,15 +17,15 @@
:hljs="hljsInstance"
>
<div v-if="isInitializing" class="loading-container">
<div class="spinner"></div>
<p>{{ t("log.info.initializing") }}</p>
<NSpin size="medium" />
<NText depth="2">{{ t("log.info.initializing") }}</NText>
</div>
<div v-else-if="!services" class="loading-container error">
<p>{{ t("toast.error.appInitFailed") }}</p>
<NResult status="error" :title="t('toast.error.appInitFailed')" />
</div>
<div v-else-if="!isReady" class="loading-container">
<div class="spinner"></div>
<p>{{ t("log.info.initializing") }}</p>
<NSpin size="medium" />
<NText depth="2">{{ t("log.info.initializing") }}</NText>
</div>
<template v-else>
<MainLayoutUI>
@@ -253,6 +253,9 @@ import { useI18n } from "vue-i18n";
import {
NConfigProvider,
NGlobalStyle,
NResult,
NSpin,
NText,
} from "naive-ui";
import hljs from "highlight.js/lib/core";
import jsonLang from "highlight.js/lib/languages/json";
@@ -302,6 +305,7 @@ import {
useTemporaryVariables,
// UI 相关
useToast,
useConfirmDialog,
useNaiveTheme,
// 系统相关
useAppInitializer,
@@ -333,6 +337,7 @@ import type { TemplateManagerTemplateType } from '../../composables/prompt/useTe
// Data Transformation
import { DataTransformer } from '../../utils/data-transformer'
import { getProviderDisplayName, getTextModelConfigDisplayName } from '../../utils/provider-display'
import {
DATA_BACKUP_STATUS_EVENT,
isDataBackupReminderDue,
@@ -348,6 +353,7 @@ const i18n = useI18n();
const t = i18n.t; // 在模板中使用
const toast = useToast();
const confirmDialog = useConfirmDialog();
// ========= Chunk-load failure recovery =========
// A long-lived tab can keep running an old main bundle after a new deployment.
@@ -383,7 +389,12 @@ const promptRefreshForNewDeploy = async (reason: unknown) => {
}
window.sessionStorage.setItem(CHUNK_LOAD_REFRESH_GUARD_KEY, '1');
const ok = window.confirm(t('toast.warning.chunkLoadRefreshConfirm'));
const ok = await confirmDialog.warning({
title: t('common.warning'),
content: t('toast.warning.chunkLoadRefreshConfirm'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
});
if (!ok) {
toast.warning(t('toast.warning.chunkLoadRefreshDeclined'), 8000);
return;
@@ -1873,7 +1884,10 @@ const refreshTextModels = async () => {
await m.ensureInitialized();
}
const enabledModels = await manager.getEnabledModels();
textModelOptions.value = DataTransformer.modelsToSelectOptions(enabledModels);
textModelOptions.value = DataTransformer.modelsToSelectOptions(enabledModels, {
getProviderName: (model) => getProviderDisplayName(model.providerMeta, t),
getModelName: (model) => getTextModelConfigDisplayName(model, t)
});
const availableKeys = new Set(textModelOptions.value.map((opt) => opt.value));
const fallbackValue = textModelOptions.value[0]?.value || "";
@@ -2413,14 +2427,14 @@ onBeforeUnmount(async () => {
<style scoped>
.active-button {
background-color: var(--primary-color, #3b82f6) !important;
color: white !important;
border-color: var(--primary-color, #3b82f6) !important;
background-color: var(--n-primary-color) !important;
color: var(--n-text-color-primary) !important;
border-color: var(--n-primary-color) !important;
}
.active-button:hover {
background-color: var(--primary-hover-color, #2563eb) !important;
border-color: var(--primary-hover-color, #2563eb) !important;
background-color: var(--n-primary-color-hover) !important;
border-color: var(--n-primary-color-hover) !important;
}
.loading-container {
@@ -2428,32 +2442,10 @@ onBeforeUnmount(async () => {
flex-direction: column;
justify-content: center;
align-items: center;
gap: 12px;
height: 100vh;
font-size: 1.2rem;
color: var(--text-color);
background-color: var(--background-color);
}
.loading-container.error {
color: #f56c6c;
}
.spinner {
border: 4px solid rgba(128, 128, 128, 0.2);
width: 36px;
height: 36px;
border-radius: 50%;
border-left-color: var(--primary-color);
animation: spin 1s ease infinite;
margin-bottom: 20px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
color: var(--n-text-color);
background-color: var(--n-body-color, var(--n-color));
}
</style>
@@ -343,26 +343,23 @@
</div>
<div class="variant-cell__run">
<NTooltip trigger="hover">
<template #trigger>
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="isAnyVariantRunning && !variantRunning[id]"
@click="() => runVariant(id)"
:data-testid="getVariantRunTestId(id)"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M8 5v14l11-7z" />
</svg>
</template>
</NButton>
</template>
{{ t('test.layout.runThisColumn') }}
</NTooltip>
<ThemedTooltip :label="t('test.layout.runThisColumn')">
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="isAnyVariantRunning && !variantRunning[id]"
@click="() => runVariant(id)"
:data-testid="getVariantRunTestId(id)"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M8 5v14l11-7z" />
</svg>
</template>
</NButton>
</ThemedTooltip>
</div>
</div>
</div>
@@ -513,10 +510,11 @@ import { useWorkspaceTemplateSelection } from '../../composables/workspaces/useW
import { useEvaluationHandler } from '../../composables/prompt/useEvaluationHandler'
import { buildCompareEvaluationPayload, useCompareRoleConfig, useTestSourceAreaFeedback, useTestVariantSourceFeedback } from '../../composables/prompt'
import { provideEvaluation } from '../../composables/prompt/useEvaluationContext'
import { NButton, NCard, NFlex, NIcon, NText, NRadioGroup, NRadioButton, NTooltip, NTag } from 'naive-ui'
import { NButton, NCard, NFlex, NIcon, NText, NRadioGroup, NRadioButton, NTag } from 'naive-ui'
import InputPanelUI from '../InputPanel.vue'
import PromptPanelUI from '../PromptPanel.vue'
import WorkspaceUtilityMenu from '../common/WorkspaceUtilityMenu.vue'
import ThemedTooltip from '../common/ThemedTooltip.vue'
import { resolveSourceAssetRef } from '../../utils/source-asset'
import TestInputSection from '../TestInputSection.vue'
import OutputDisplay from '../OutputDisplay.vue'
@@ -333,26 +333,23 @@
</div>
<div class="variant-cell__run">
<NTooltip trigger="hover">
<template #trigger>
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="isAnyVariantRunning && !variantRunning[id]"
@click="() => runVariant(id)"
:data-testid="getVariantRunTestId(id)"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M8 5v14l11-7z" />
</svg>
</template>
</NButton>
</template>
{{ t('test.layout.runThisColumn') }}
</NTooltip>
<ThemedTooltip :label="t('test.layout.runThisColumn')">
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="isAnyVariantRunning && !variantRunning[id]"
@click="() => runVariant(id)"
:data-testid="getVariantRunTestId(id)"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M8 5v14l11-7z" />
</svg>
</template>
</NButton>
</ThemedTooltip>
</div>
</div>
</div>
@@ -511,10 +508,11 @@ import { useEvaluationHandler } from '../../composables/prompt/useEvaluationHand
import { useCompareRoleConfig, useTestSourceAreaFeedback, useTestVariantSourceFeedback } from '../../composables/prompt'
import { buildCompareEvaluationPayload } from '../../composables/prompt/compareEvaluation'
import { provideEvaluation } from '../../composables/prompt/useEvaluationContext'
import { NButton, NCard, NFlex, NIcon, NText, NRadioGroup, NRadioButton, NTooltip, NTag } from 'naive-ui'
import { NButton, NCard, NFlex, NIcon, NText, NRadioGroup, NRadioButton, NTag } from 'naive-ui'
import InputPanelUI from '../InputPanel.vue'
import PromptPanelUI from '../PromptPanel.vue'
import WorkspaceUtilityMenu from '../common/WorkspaceUtilityMenu.vue'
import ThemedTooltip from '../common/ThemedTooltip.vue'
import { resolveSourceAssetRef } from '../../utils/source-asset'
import OutputDisplay from '../OutputDisplay.vue'
import SaveTestResultExampleButton from '../SaveTestResultExampleButton.vue'
@@ -77,7 +77,7 @@
<NText depth="3">{{ t('common.promptGarden.loadingSuggestions') }}</NText>
</div>
<div
<AppPreviewImageGroup
v-else-if="suggestions.length > 0"
class="prompt-garden-inspiration-list"
>
@@ -87,11 +87,15 @@
class="prompt-garden-inspiration-item"
:data-testid="`${rootTestId}-suggestion-${index}`"
>
<img
<AppPreviewImage
v-if="item.thumbnailUrl"
class="prompt-garden-inspiration-item__thumb"
:src="item.thumbnailUrl"
:preview-src="item.thumbnailUrl"
:alt="item.title"
width="44"
height="44"
object-fit="cover"
loading="lazy"
/>
<div class="prompt-garden-inspiration-item__body">
@@ -124,7 +128,7 @@
</div>
</div>
</div>
</div>
</AppPreviewImageGroup>
<div v-else class="prompt-garden-inspiration-popover__empty">
<NText depth="3">{{ t('common.promptGarden.noSuggestions') }}</NText>
@@ -174,6 +178,8 @@ import {
fetchPromptGardenSuggestions,
type PromptGardenSuggestionItem,
} from '../../utils/prompt-garden-suggestions'
import AppPreviewImage from '../media/AppPreviewImage.vue'
import AppPreviewImageGroup from '../media/AppPreviewImageGroup.vue'
type PopoverInst = {
syncPosition?: () => void
@@ -537,8 +543,15 @@ const handleOpenImport = () => {
width: 44px;
height: 44px;
border-radius: 6px;
object-fit: cover;
overflow: hidden;
background: var(--n-border-color);
cursor: zoom-in;
}
.prompt-garden-inspiration-item__thumb :deep(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
.prompt-garden-inspiration-item__body {
@@ -1,34 +1,80 @@
<template>
<NTooltip
v-bind="$attrs"
:trigger="trigger"
:placement="placement"
:disabled="disabled"
:show-arrow="showArrow"
:to="to"
:flip="flip"
:keep-alive-on-hover="keepAliveOnHover"
:theme-overrides="tooltipThemeOverrides"
:overlay-style="mergedOverlayStyle"
:content-style="mergedContentStyle"
>
<template #trigger>
<slot />
</template>
{{ label }}
<slot name="content">{{ label }}</slot>
</NTooltip>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { NTooltip } from 'naive-ui'
import { computed, type CSSProperties } from 'vue'
import { NTooltip, type TooltipProps } from 'naive-ui'
withDefaults(defineProps<{
label: string
trigger?: 'hover' | 'click' | 'focus' | 'manual'
placement?: 'top' | 'right' | 'bottom' | 'left' | 'top-start' | 'top-end' | 'right-start' | 'right-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end'
import { useTooltipTheme } from '../../composables/ui/useTooltipTheme'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<{
label?: string
trigger?: TooltipProps['trigger']
placement?: TooltipProps['placement']
disabled?: boolean
showArrow?: boolean
to?: TooltipProps['to']
flip?: boolean
keepAliveOnHover?: boolean
maxWidth?: CSSProperties['maxWidth']
maxHeight?: CSSProperties['maxHeight']
whiteSpace?: CSSProperties['whiteSpace']
wordBreak?: CSSProperties['wordBreak']
overflowWrap?: CSSProperties['overflowWrap']
padding?: CSSProperties['padding']
overflowY?: CSSProperties['overflowY']
overlayStyle?: CSSProperties
contentStyle?: CSSProperties
}>(), {
trigger: 'hover',
placement: 'top',
disabled: false,
showArrow: true,
})
const tooltipThemeOverrides = computed(() => ({
color: 'var(--n-popover-color, var(--n-color))',
textColor: 'var(--n-text-color-2)',
borderRadius: '8px',
boxShadow: '0 10px 28px rgba(15, 23, 42, 0.16)',
padding: '6px 10px',
const {
tooltipThemeOverrides,
tooltipOverlayStyle,
tooltipContentStyle,
} = useTooltipTheme({
maxWidth: props.maxWidth,
maxHeight: props.maxHeight,
whiteSpace: props.whiteSpace,
wordBreak: props.wordBreak,
overflowWrap: props.overflowWrap,
padding: props.padding,
overflowY: props.overflowY,
})
const mergedOverlayStyle = computed<CSSProperties>(() => ({
...tooltipOverlayStyle.value,
...props.overlayStyle,
}))
const mergedContentStyle = computed<CSSProperties>(() => ({
...tooltipContentStyle.value,
...props.contentStyle,
}))
</script>
@@ -331,26 +331,23 @@
</div>
<div class="variant-cell__run">
<NTooltip trigger="hover">
<template #trigger>
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="isAnyVariantRunning && !variantRunning[id]"
@click="() => runVariant(id)"
:data-testid="getVariantRunTestId(id)"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M8 5v14l11-7z" />
</svg>
</template>
</NButton>
</template>
{{ t('test.layout.runThisColumn') }}
</NTooltip>
<ThemedTooltip :label="t('test.layout.runThisColumn')">
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="isAnyVariantRunning && !variantRunning[id]"
@click="() => runVariant(id)"
:data-testid="getVariantRunTestId(id)"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M8 5v14l11-7z" />
</svg>
</template>
</NButton>
</ThemedTooltip>
</div>
</div>
</div>
@@ -525,7 +522,6 @@ import {
NEmpty,
NRadioGroup,
NRadioButton,
NTooltip,
NTag,
} from "naive-ui";
import PromptPanelUI from "../PromptPanel.vue";
@@ -551,6 +547,7 @@ import {
} from '../evaluation'
import { buildCompareToolbarStatus } from '../evaluation/compare-ui'
import WorkspaceUtilityMenu from '../common/WorkspaceUtilityMenu.vue'
import ThemedTooltip from '../common/ThemedTooltip.vue'
import { resolveSourceAssetRef } from '../../utils/source-asset'
import { useConversationOptimization } from '../../composables/prompt/useConversationOptimization'
import { usePromptDisplayAdapter } from '../../composables/prompt/usePromptDisplayAdapter'
@@ -381,26 +381,23 @@
</div>
<div class="variant-cell__run">
<NTooltip trigger="hover">
<template #trigger>
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="isAnyVariantRunning && !variantRunning[id]"
@click="() => runVariant(id)"
:data-testid="getVariantRunTestId(id)"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M8 5v14l11-7z" />
</svg>
</template>
</NButton>
</template>
{{ t('test.layout.runThisColumn') }}
</NTooltip>
<ThemedTooltip :label="t('test.layout.runThisColumn')">
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="isAnyVariantRunning && !variantRunning[id]"
@click="() => runVariant(id)"
:data-testid="getVariantRunTestId(id)"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M8 5v14l11-7z" />
</svg>
</template>
</NButton>
</ThemedTooltip>
</div>
</div>
</div>
@@ -563,7 +560,7 @@ import { ref, reactive, computed, inject, nextTick, watch, onMounted, onUnmounte
import { storeToRefs } from 'pinia'
import { useI18n } from "vue-i18n";
import { NCard, NFlex, NText, NIcon, NButton, NRadioGroup, NRadioButton, NTooltip, NTag } from "naive-ui";
import { NCard, NFlex, NText, NIcon, NButton, NRadioGroup, NRadioButton, NTag } from "naive-ui";
import { useToast } from "../../composables/ui/useToast";
import InputPanelUI from "../InputPanel.vue";
import PromptPanelUI from "../PromptPanel.vue";
@@ -587,6 +584,7 @@ import {
} from '../evaluation'
import { buildCompareToolbarStatus } from '../evaluation/compare-ui'
import WorkspaceUtilityMenu from '../common/WorkspaceUtilityMenu.vue'
import ThemedTooltip from '../common/ThemedTooltip.vue'
import { resolveSourceAssetRef } from '../../utils/source-asset'
import {
applyPatchOperationsToText,
@@ -7,8 +7,8 @@
class="compare-role-badge__popover"
>
<template #trigger>
<button
type="button"
<NButton
text
class="compare-role-badge"
:class="{ 'compare-role-badge--clickable': clickable }"
@click="handleClick"
@@ -28,7 +28,7 @@
>
!
</span>
</button>
</NButton>
</template>
<div class="compare-role-badge__content">
@@ -44,7 +44,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { NPopover, NTag, NText } from 'naive-ui'
import { NButton, NPopover, NTag, NText } from 'naive-ui'
import {
buildCompareRoleTooltipCopy,
getCompareRoleTagType,
@@ -78,16 +78,14 @@ function handleClick() {
<style scoped>
.compare-role-badge {
appearance: none;
border: 0;
background: transparent;
padding: 0;
margin: 0;
position: relative;
cursor: default;
}
.compare-role-badge :deep(.n-button__content) {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: default;
}
.compare-role-badge--clickable {
@@ -95,7 +93,7 @@ function handleClick() {
}
.compare-role-badge--clickable:focus-visible {
outline: 2px solid rgba(59, 130, 246, 0.28);
outline: 2px solid var(--n-primary-color-suppl);
outline-offset: 3px;
border-radius: 999px;
}
@@ -115,8 +113,8 @@ function handleClick() {
width: 14px;
height: 14px;
border-radius: 999px;
background: rgba(240, 160, 32, 0.16);
color: #d97706;
background: var(--n-warning-color-suppl);
color: var(--n-warning-color);
font-size: 10px;
font-weight: 700;
line-height: 1;
@@ -702,8 +702,8 @@ function handleConfirm() {
.compare-role-config__summary-card,
.compare-role-config__slot-card {
border: 1px solid rgba(148, 163, 184, 0.22);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
border: 1px solid var(--n-border-color);
box-shadow: var(--n-box-shadow-1);
}
.compare-role-config__section-copy {
@@ -771,25 +771,25 @@ function handleConfirm() {
gap: 6px;
padding: 9px 11px;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(248, 250, 252, 0.92);
border: 1px solid var(--n-border-color);
background: var(--n-color-embedded);
}
.compare-role-config__slot-summary--target {
border-left: 3px solid rgba(16, 185, 129, 0.9);
border-left: 3px solid var(--n-success-color);
}
.compare-role-config__slot-summary--baseline {
border-left: 3px solid rgba(245, 158, 11, 0.9);
border-left: 3px solid var(--n-warning-color);
}
.compare-role-config__slot-summary--reference {
border-left: 3px solid rgba(59, 130, 246, 0.9);
border-left: 3px solid var(--n-info-color);
}
.compare-role-config__slot-summary--referenceBaseline,
.compare-role-config__slot-summary--replica {
border-left: 3px solid rgba(99, 102, 241, 0.75);
border-left: 3px solid var(--n-primary-color);
}
.compare-role-config__slot-summary-tags {
@@ -805,7 +805,7 @@ function handleConfirm() {
.compare-role-config__slot-hint {
line-height: 1.45;
min-width: 0;
color: rgba(71, 85, 105, 0.96);
color: var(--n-text-color-3);
font-size: 12px;
}
@@ -824,9 +824,9 @@ function handleConfirm() {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
padding: 3px;
border: 1px solid rgba(148, 163, 184, 0.18);
border: 1px solid var(--n-border-color);
border-radius: 12px;
background: rgba(248, 250, 252, 0.9);
background: var(--n-color-embedded);
}
.compare-role-config__role-segment-button {
@@ -1289,23 +1289,23 @@ watch(() => props.show, (visible) => {
/* 评分等级颜色 */
.score-excellent {
color: #18a058;
color: var(--n-success-color);
}
.score-good {
color: #2080f0;
color: var(--n-info-color);
}
.score-acceptable {
color: #f0a020;
color: var(--n-warning-color);
}
.score-poor {
color: #d03050;
color: var(--n-error-color);
}
.score-very-poor {
color: #d03050;
color: var(--n-error-color);
}
.dimension-item {
@@ -1323,7 +1323,7 @@ watch(() => props.show, (visible) => {
flex-direction: column;
gap: 6px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(128, 128, 128, 0.16);
border-bottom: 1px solid var(--n-border-color);
}
.compare-judgement-item:last-child {
@@ -1362,7 +1362,7 @@ watch(() => props.show, (visible) => {
padding: 8px 10px;
border-radius: 8px;
background: var(--n-color-embedded);
border: 1px solid rgba(128, 128, 128, 0.12);
border: 1px solid var(--n-border-color);
}
.compare-role-inline-list {
@@ -1377,11 +1377,11 @@ watch(() => props.show, (visible) => {
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(128, 128, 128, 0.08);
background: var(--n-hover-color);
}
.compare-role-inline-item--target {
background: rgba(24, 160, 88, 0.12);
background: var(--n-success-color-suppl);
}
.compare-role-inline-label {
@@ -1412,7 +1412,7 @@ watch(() => props.show, (visible) => {
.compare-focus-panel {
padding: 10px 12px;
border: 1px solid rgba(128, 128, 128, 0.16);
border: 1px solid var(--n-border-color);
border-radius: 8px;
background: var(--n-color-embedded);
}
@@ -1,28 +1,25 @@
<template>
<div class="feedback-analyze">
<NTooltip v-if="compact" trigger="hover">
<template #trigger>
<NButton
:size="size"
:quaternary="true"
:circle="true"
:disabled="disabled || loading"
:loading="loading"
class="feedback-trigger"
:aria-label="t('evaluation.feedbackAnalyze')"
:title="t('evaluation.feedbackAnalyze')"
data-testid="feedback-analyze-trigger"
@click="handleOpen"
>
<template #icon>
<NIcon :size="14" aria-hidden="true">
<Focus2 />
</NIcon>
</template>
</NButton>
</template>
{{ t('evaluation.feedbackAnalyze') }}
</NTooltip>
<ThemedTooltip v-if="compact" :label="t('evaluation.feedbackAnalyze')">
<NButton
:size="size"
:quaternary="true"
:circle="true"
:disabled="disabled || loading"
:loading="loading"
class="feedback-trigger"
:aria-label="t('evaluation.feedbackAnalyze')"
:title="t('evaluation.feedbackAnalyze')"
data-testid="feedback-analyze-trigger"
@click="handleOpen"
>
<template #icon>
<NIcon :size="14" aria-hidden="true">
<Focus2 />
</NIcon>
</template>
</NButton>
</ThemedTooltip>
<NButton
v-else
@@ -61,10 +58,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { NButton, NCard, NDivider, NIcon, NTooltip } from 'naive-ui'
import { NButton, NCard, NDivider, NIcon } from 'naive-ui'
import type { EvaluationType } from '@prompt-optimizer/core'
import { Focus2 } from '@vicons/tabler'
import FeedbackEditor from './FeedbackEditor.vue'
import ThemedTooltip from '../common/ThemedTooltip.vue'
const props = withDefaults(
defineProps<{
@@ -1,35 +1,31 @@
<template>
<NTooltip trigger="hover" :disabled="!disabledTooltip">
<template #trigger>
<span
class="focus-analyze-tooltip-trigger"
:title="disabledTooltip || undefined"
>
<ThemedTooltip :label="disabledTooltip" :disabled="!disabledTooltip">
<span
class="focus-analyze-tooltip-trigger"
:title="disabledTooltip || undefined"
>
<NButtonGroup
class="focus-analyze-group"
:class="{ 'focus-analyze-group--toolbar': isToolbarVariant }"
:data-evaluation-type="type"
>
<NTooltip v-if="isToolbarVariant" trigger="hover" :disabled="!!disabledTooltip">
<template #trigger>
<NButton
v-bind="buttonPropsMerged"
:disabled="isDisabled"
:loading="loading"
class="focus-analyze-main focus-analyze-main--toolbar"
:aria-label="label"
:title="label"
data-testid="focus-analyze-main"
@click="handleEvaluate"
>
<template #icon>
<slot v-if="$slots.icon" name="icon" />
<AnalyzeActionIcon v-else />
</template>
</NButton>
</template>
{{ label }}
</NTooltip>
<ThemedTooltip v-if="isToolbarVariant" :label="label" :disabled="!!disabledTooltip">
<NButton
v-bind="buttonPropsMerged"
:disabled="isDisabled"
:loading="loading"
class="focus-analyze-main focus-analyze-main--toolbar"
:aria-label="label"
:title="label"
data-testid="focus-analyze-main"
@click="handleEvaluate"
>
<template #icon>
<slot v-if="$slots.icon" name="icon" />
<AnalyzeActionIcon v-else />
</template>
</NButton>
</ThemedTooltip>
<NButton
v-else
v-bind="buttonPropsMerged"
@@ -58,7 +54,13 @@
@clickoutside="handleClickOutside"
>
<template #trigger>
<NTooltip trigger="hover" :disabled="!!disabledTooltip">
<NTooltip
trigger="hover"
:disabled="!!disabledTooltip"
:theme-overrides="tooltipThemeOverrides"
:overlay-style="tooltipOverlayStyle"
:content-style="tooltipContentStyle"
>
<template #trigger>
<NButton
v-bind="buttonPropsMerged"
@@ -120,9 +122,7 @@
</NPopover>
</NButtonGroup>
</span>
</template>
{{ disabledTooltip }}
</NTooltip>
</ThemedTooltip>
</template>
<script setup lang="ts">
@@ -143,6 +143,8 @@ import type { EvaluationType } from '@prompt-optimizer/core'
import { Focus2 } from '@vicons/tabler'
import FeedbackEditor from './FeedbackEditor.vue'
import AnalyzeActionIcon from './AnalyzeActionIcon.vue'
import ThemedTooltip from '../common/ThemedTooltip.vue'
import { useTooltipTheme } from '../../composables/ui/useTooltipTheme'
const props = withDefaults(
defineProps<{
@@ -173,6 +175,11 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const {
tooltipThemeOverrides,
tooltipOverlayStyle,
tooltipContentStyle,
} = useTooltipTheme()
const focusVisible = ref(false)
const focusDraft = ref('')
@@ -17,21 +17,12 @@
import { computed, inject, ref, type Ref } from 'vue'
import { createCompareService, type ChangeType, type TextFragment, type ICompareService } from '@prompt-optimizer/core'
import type { AppServices } from '../../types/services'
import { useNaiveTheme } from '../../composables/ui/useNaiveTheme'
const props = defineProps<{
oldText: string
newText: string
}>()
// 获取主题配置并计算颜色变量
const { themeOverrides } = useNaiveTheme()
const successBg = computed(() => themeOverrides.value?.common?.successColorSuppl || 'rgba(34, 197, 94, 0.15)')
const successColor = computed(() => themeOverrides.value?.common?.successColor || '#16a34a')
const errorBg = computed(() => themeOverrides.value?.common?.errorColorSuppl || 'rgba(239, 68, 68, 0.15)')
const errorColor = computed(() => themeOverrides.value?.common?.errorColor || '#dc2626')
const textColor3 = computed(() => themeOverrides.value?.common?.textColor3 || '#6b7280')
// 长度阈值:超过此值降级显示
// 图像模式的 JSON 提示词可能很长;为保持与其它模式一致,这里不再做长度限制
const MAX_LENGTH = Number.POSITIVE_INFINITY
@@ -111,21 +102,21 @@ const getFragmentClass = (type: ChangeType): string => {
}
.diff-added {
background-color: v-bind(successBg);
color: v-bind(successColor);
background-color: var(--n-success-color-suppl);
color: var(--n-success-color);
border-radius: 2px;
padding: 0 2px;
}
.diff-removed {
background-color: v-bind(errorBg);
color: v-bind(errorColor);
background-color: var(--n-error-color-suppl);
color: var(--n-error-color);
text-decoration: line-through;
border-radius: 2px;
padding: 0 2px;
}
.diff-unchanged {
color: v-bind(textColor3);
color: var(--n-text-color-3);
}
</style>
@@ -551,26 +551,23 @@
</div>
<div class="variant-cell__run">
<NTooltip trigger="hover">
<template #trigger>
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="variantRunning[id]"
@click="() => runVariant(id)"
:data-testid="getVariantRunTestId(id)"
>
<ThemedTooltip :label="t('test.layout.runThisColumn')">
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="variantRunning[id]"
@click="() => runVariant(id)"
:data-testid="getVariantRunTestId(id)"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M8 5v14l11-7z" />
</svg>
</template>
</NButton>
</template>
{{ t('test.layout.runThisColumn') }}
</NTooltip>
</ThemedTooltip>
</div>
</div>
</div>
@@ -839,12 +836,12 @@ import {
NIcon,
NRadioGroup,
NRadioButton,
NTooltip,
type UploadFileInfo,
} from "naive-ui";
import { useI18n } from "vue-i18n";
import PromptPanelUI from "../PromptPanel.vue";
import WorkspaceUtilityMenu from '../common/WorkspaceUtilityMenu.vue'
import ThemedTooltip from '../common/ThemedTooltip.vue'
import PromptGardenInspirationPopover from '../common/PromptGardenInspirationPopover.vue'
import PromptGardenImportDialog from '../common/PromptGardenImportDialog.vue'
import PromptPreviewPanel from "../PromptPreviewPanel.vue";
@@ -450,28 +450,25 @@
</div>
<div class="variant-cell__run">
<NTooltip trigger="hover">
<template #trigger>
<span class="variant-cell__run-trigger">
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="variantRunning[id] || isVariantModelUnsupported(id)"
:data-testid="getVariantRunTestId(id)"
@click="runVariant(id)"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M8 5v14l11-7z" />
</svg>
</template>
</NButton>
</span>
</template>
{{ getVariantRunTooltip(id) }}
</NTooltip>
<ThemedTooltip :label="getVariantRunTooltip(id)">
<span class="variant-cell__run-trigger">
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="variantRunning[id] || isVariantModelUnsupported(id)"
:data-testid="getVariantRunTestId(id)"
@click="runVariant(id)"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M8 5v14l11-7z" />
</svg>
</template>
</NButton>
</span>
</ThemedTooltip>
</div>
</div>
</div>
@@ -621,7 +618,7 @@
<script setup lang="ts">
import { computed, inject, nextTick, onMounted, onUnmounted, reactive, ref, watch, toRef, type Ref } from 'vue'
import { NButton, NCard, NEmpty, NFlex, NGrid, NGridItem, NIcon, NInput, NRadioButton, NRadioGroup, NSpace, NText, NTooltip } from 'naive-ui'
import { NButton, NCard, NEmpty, NFlex, NGrid, NGridItem, NIcon, NInput, NRadioButton, NRadioGroup, NSpace, NText } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import {
applyPatchOperationsToText,
@@ -678,6 +675,7 @@ import AppPreviewImage from '../media/AppPreviewImage.vue'
import { VariableAwareInput } from '../variable-extraction'
import TemporaryVariablesPanel from '../variable/TemporaryVariablesPanel.vue'
import WorkspaceUtilityMenu from '../common/WorkspaceUtilityMenu.vue'
import ThemedTooltip from '../common/ThemedTooltip.vue'
import VariableValuePreviewDialog from '../variable/VariableValuePreviewDialog.vue'
import TestPanelVersionSelect from '../TestPanelVersionSelect.vue'
import ImageTokenUsage from './ImageTokenUsage.vue'
@@ -678,26 +678,23 @@
</div>
<div class="variant-cell__run">
<NTooltip trigger="hover">
<template #trigger>
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="variantRunning[id]"
@click="() => runVariant(id)"
:data-testid="getVariantRunTestId(id)"
>
<ThemedTooltip :label="t('test.layout.runThisColumn')">
<NButton
type="primary"
size="small"
circle
:loading="variantRunning[id]"
:disabled="variantRunning[id]"
@click="() => runVariant(id)"
:data-testid="getVariantRunTestId(id)"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
<path d="M8 5v14l11-7z" />
</svg>
</template>
</NButton>
</template>
{{ t('test.layout.runThisColumn') }}
</NTooltip>
</ThemedTooltip>
</div>
</div>
</div>
@@ -947,6 +944,7 @@ import {
import { useI18n } from "vue-i18n";
import PromptPanelUI from "../PromptPanel.vue";
import WorkspaceUtilityMenu from '../common/WorkspaceUtilityMenu.vue'
import ThemedTooltip from '../common/ThemedTooltip.vue'
import PromptGardenInspirationPopover from '../common/PromptGardenInspirationPopover.vue'
import PromptGardenImportDialog from '../common/PromptGardenImportDialog.vue'
import PromptPreviewPanel from "../PromptPreviewPanel.vue";
@@ -1,8 +1,8 @@
import { Fragment, h, type Ref, type VNode, type VNodeChild } from 'vue'
import { NTooltip } from 'naive-ui'
import type { ImageRenderToolbar, ImageRenderToolbarProps } from 'naive-ui/es/image/src/public-types'
import { downloadImageSource } from '../../utils/image-download'
import ThemedTooltip from '../common/ThemedTooltip.vue'
type SafePreviewToolbarOptions = {
clsPrefixRef: Ref<string>
@@ -96,14 +96,14 @@ export const createSafeImageToolbarRenderer = (
}
return h(
NTooltip,
ThemedTooltip,
{
label: options.downloadLabel,
to: false,
keepAliveOnHover: false,
},
{
default: () => options.downloadLabel,
trigger: () => iconNode,
default: () => iconNode,
},
)
}
@@ -277,8 +277,10 @@ import type { ToolDefinition } from '@prompt-optimizer/core'
import type {
ToolManagerModalProps,
} from '../../types/components'
import { useConfirmDialog } from '../../composables/ui/useConfirmDialog'
const { t } = useI18n()
const confirmDialog = useConfirmDialog()
const props = withDefaults(defineProps<ToolManagerModalProps>(), {
disabled: false,
@@ -444,13 +446,16 @@ const editTool = (index: number) => {
showEditor.value = true
}
const deleteTool = (index: number) => {
const deleteTool = async (index: number) => {
const tool = localTools.value[index]
const confirmed = confirm(
t('contextEditor.deleteToolConfirm', {
const confirmed = await confirmDialog.warning({
title: t('common.warning'),
content: t('contextEditor.deleteToolConfirm', {
name: tool?.function?.name || '',
})
)
}),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
})
if (!confirmed) return
localTools.value.splice(index, 1)
emit('toolChange', [...localTools.value], 'delete', index)
@@ -365,6 +365,7 @@ import {
} from "naive-ui";
import { useResponsive } from "../../composables/ui/useResponsive";
import { useClipboard } from "../../composables/ui/useClipboard";
import { useConfirmDialog } from "../../composables/ui/useConfirmDialog";
import type {
VariableManagerModalProps,
} from "../../types/components";
@@ -376,6 +377,7 @@ import VariableImporter from "./VariableImporter.vue";
const { t } = useI18n();
const { copyText } = useClipboard();
const confirmDialog = useConfirmDialog();
interface VariableRow {
name: string;
@@ -876,22 +878,28 @@ const deleteVariable = async (name: string) => {
if (!props.variableManager?.variableManager.value) return;
if (props.readonly) return;
if (confirm(t("variables.management.deleteConfirm", { name }))) {
try {
loading.value = true;
props.variableManager.deleteVariable(name);
const confirmed = await confirmDialog.warning({
title: t("common.warning"),
content: t("variables.management.deleteConfirm", { name }),
positiveText: t("common.confirm"),
negativeText: t("common.cancel"),
});
if (!confirmed) return;
// 发送删除事件
handleVariableChange(name, "", "delete");
} catch (error: unknown) {
console.error(
"[VariableManagerModal] Failed to delete variable:",
error,
);
emit("error", error instanceof Error ? error : new Error(String(error)));
} finally {
loading.value = false;
}
try {
loading.value = true;
props.variableManager.deleteVariable(name);
// 发送删除事件
handleVariableChange(name, "", "delete");
} catch (error: unknown) {
console.error(
"[VariableManagerModal] Failed to delete variable:",
error,
);
emit("error", error instanceof Error ? error : new Error(String(error)));
} finally {
loading.value = false;
}
};
+63 -25
View File
@@ -1,7 +1,18 @@
<template>
<template v-if="node.kind === 'element'">
<details v-if="hasChildren" class="xml-details" :open="isOpenByDefault">
<summary class="xml-summary" :style="lineStyle">
<div v-if="hasChildren" class="xml-details">
<NButton
text
class="xml-summary"
:style="lineStyle"
:aria-expanded="isExpanded"
@click="toggleExpanded"
>
<template #icon>
<NIcon class="xml-summary-icon" :class="{ 'xml-summary-icon--expanded': isExpanded }">
<ChevronRight />
</NIcon>
</template>
<span class="xml-punct">&lt;</span>
<span class="xml-tag">{{ node.name }}</span>
<template v-for="attribute in node.attributes" :key="`${node.name || 'node'}:${attribute.name}`">
@@ -11,22 +22,24 @@
<span class="xml-attr-value">"{{ attribute.value }}"</span>
</template>
<span class="xml-punct">&gt;</span>
</summary>
</NButton>
<XmlTreeNode
v-for="(child, childIndex) in node.children"
:key="buildChildKey(child, childIndex)"
:node="child"
:depth="depth + 1"
:default-expanded-depth="defaultExpandedDepth"
/>
<template v-if="isExpanded">
<XmlTreeNode
v-for="(child, childIndex) in node.children"
:key="buildChildKey(child, childIndex)"
:node="child"
:depth="depth + 1"
:default-expanded-depth="defaultExpandedDepth"
/>
<div class="xml-line xml-closing" :style="lineStyle">
<span class="xml-punct">&lt;/</span>
<span class="xml-tag">{{ node.name }}</span>
<span class="xml-punct">&gt;</span>
</div>
</details>
<div class="xml-line xml-closing" :style="lineStyle">
<span class="xml-punct">&lt;/</span>
<span class="xml-tag">{{ node.name }}</span>
<span class="xml-punct">&gt;</span>
</div>
</template>
</div>
<div v-else class="xml-line" :style="lineStyle">
<span class="xml-punct">&lt;</span>
@@ -59,7 +72,9 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { NButton, NIcon } from 'naive-ui'
import { ChevronRight } from '@vicons/tabler'
import type { XmlNodeModel } from '../../types/xml-renderer'
defineOptions({
@@ -82,6 +97,7 @@ const hasChildren = computed(
)
const isOpenByDefault = computed(() => props.depth < props.defaultExpandedDepth)
const isExpanded = ref(isOpenByDefault.value)
const lineStyle = computed(() => ({
paddingLeft: `${props.depth * 16}px`,
@@ -96,6 +112,10 @@ const buildChildKey = (child: XmlNodeModel, index: number): string => {
}
return `${child.kind}:${index}`
}
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value
}
</script>
<style scoped>
@@ -117,40 +137,58 @@ const buildChildKey = (child: XmlNodeModel, index: number): string => {
.xml-summary {
cursor: pointer;
user-select: none;
width: 100%;
justify-content: flex-start;
}
.xml-summary:hover {
background-color: rgba(24, 160, 88, 0.08);
background-color: var(--n-hover-color);
}
.xml-summary :deep(.n-button__content) {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
min-width: 0;
}
.xml-summary-icon {
color: var(--n-text-color-3);
transition: transform 0.15s ease;
}
.xml-summary-icon--expanded {
transform: rotate(90deg);
}
.xml-punct {
color: rgba(100, 116, 139, 0.95);
color: var(--n-text-color-3);
}
.xml-tag {
color: #0f766e;
color: var(--n-primary-color);
font-weight: 600;
}
.xml-attr-name {
color: #2563eb;
color: var(--n-info-color);
}
.xml-attr-value {
color: #9333ea;
color: var(--n-warning-color);
}
.xml-text-node {
color: #334155;
color: var(--n-text-color);
}
.xml-comment {
color: #64748b;
color: var(--n-text-color-3);
font-style: italic;
}
.xml-cdata,
.xml-processing {
color: #475569;
color: var(--n-text-color-2);
}
</style>
+1
View File
@@ -1,6 +1,7 @@
// UI 交互相关 composables
export * from './useModals'
export * from './useToast'
export * from './useConfirmDialog'
export * from './useNaiveTheme'
export * from './useTooltipTheme'
export * from './useFullscreen'
@@ -0,0 +1,78 @@
import {
createDiscreteApi,
useDialog,
type DialogApi,
type DialogOptions,
} from 'naive-ui'
type ConfirmDialogType = 'warning' | 'error' | 'info' | 'success'
export interface ConfirmDialogOptions {
title: DialogOptions['title']
content: DialogOptions['content']
positiveText: string
negativeText: string
type?: ConfirmDialogType
}
let globalDialogApi: DialogApi | null = null
let fallbackDialogApi: DialogApi | null = null
export function setGlobalDialogApi(api: DialogApi) {
globalDialogApi = api
}
const getFallbackDialogApi = (): DialogApi => {
if (!fallbackDialogApi) {
fallbackDialogApi = createDiscreteApi(['dialog']).dialog
}
return fallbackDialogApi
}
const resolveDialogApi = (): DialogApi | null => {
try {
return useDialog()
} catch {
return null
}
}
export function useConfirmDialog() {
const contextDialog = resolveDialogApi()
const getDialogApi = () => contextDialog ?? globalDialogApi ?? getFallbackDialogApi()
const confirm = (options: ConfirmDialogOptions): Promise<boolean> => {
return new Promise((resolve) => {
let settled = false
const settle = (value: boolean) => {
if (settled) return
settled = true
resolve(value)
}
const dialog = getDialogApi()
const createDialog = dialog[options.type ?? 'warning']
createDialog({
title: options.title,
content: options.content,
positiveText: options.positiveText,
negativeText: options.negativeText,
onPositiveClick: () => settle(true),
onNegativeClick: () => settle(false),
onClose: () => settle(false),
onEsc: () => settle(false),
onMaskClick: () => settle(false),
})
})
}
const warning = (options: Omit<ConfirmDialogOptions, 'type'>) =>
confirm({ ...options, type: 'warning' })
return {
confirm,
warning,
}
}
@@ -12,9 +12,11 @@
* @param sessionStore - Session store 实例(ProMultiMessageSession 或 ProVariableSession)
*/
import { computed, ref, watch, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AppServices } from '../../types/services'
import type { ModelSelectOption } from '../../types/select-options'
import { DataTransformer } from '../../utils/data-transformer'
import { getProviderDisplayName, getTextModelConfigDisplayName } from '../../utils/provider-display'
type WorkspaceModelSessionStore = {
selectedOptimizeModelKey: string
@@ -27,6 +29,7 @@ export function useWorkspaceModelSelection<T extends WorkspaceModelSessionStore>
services: Ref<AppServices | null>,
sessionStore: T
) {
const { t } = useI18n()
const textModelOptions = ref<ModelSelectOption[]>([])
// 优化模型(双向绑定)
@@ -50,7 +53,7 @@ export function useWorkspaceModelSelection<T extends WorkspaceModelSessionStore>
const key = selectedOptimizeModelKey.value
const option = textModelOptions.value.find(opt => opt.value === key)
return {
provider: option?.raw?.providerMeta?.name || null,
provider: option?.raw ? getProviderDisplayName(option.raw.providerMeta, t, '') : null,
model: option?.raw?.modelMeta?.name || null
}
})
@@ -60,7 +63,7 @@ export function useWorkspaceModelSelection<T extends WorkspaceModelSessionStore>
const key = selectedTestModelKey.value
const option = textModelOptions.value.find(opt => opt.value === key)
return {
provider: option?.raw?.providerMeta?.name || null,
provider: option?.raw ? getProviderDisplayName(option.raw.providerMeta, t, '') : null,
model: option?.raw?.modelMeta?.name || null
}
})
@@ -89,7 +92,10 @@ export function useWorkspaceModelSelection<T extends WorkspaceModelSessionStore>
const enabledModels = await mgr.getEnabledModels()
if (token !== refreshModelToken) return
textModelOptions.value = DataTransformer.modelsToSelectOptions(enabledModels)
textModelOptions.value = DataTransformer.modelsToSelectOptions(enabledModels, {
getProviderName: (model) => getProviderDisplayName(model.providerMeta, t),
getModelName: (model) => getTextModelConfigDisplayName(model, t)
})
// 自动 fallback:如果当前选中模型不在列表中,使用第一个
const fallback = textModelOptions.value[0]?.value || ''
@@ -11,9 +11,11 @@
* @param sessionStore - Session store 实例(ImageText2ImageSession / ImageImage2ImageSession)
*/
import { computed, ref, watch, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AppServices } from '../../types/services'
import type { ModelSelectOption } from '../../types/select-options'
import { DataTransformer } from '../../utils/data-transformer'
import { getProviderDisplayName, getTextModelConfigDisplayName } from '../../utils/provider-display'
type WorkspaceTextModelSessionStore = {
selectedTextModelKey: string
@@ -24,6 +26,7 @@ export function useWorkspaceTextModelSelection<T extends WorkspaceTextModelSessi
services: Ref<AppServices | null>,
sessionStore: T
) {
const { t } = useI18n()
const textModelOptions = ref<ModelSelectOption[]>([])
const selectedTextModelKey = computed<string>({
@@ -54,7 +57,10 @@ export function useWorkspaceTextModelSelection<T extends WorkspaceTextModelSessi
const enabledModels = await mgr.getEnabledModels()
if (token !== refreshToken) return
textModelOptions.value = DataTransformer.modelsToSelectOptions(enabledModels)
textModelOptions.value = DataTransformer.modelsToSelectOptions(enabledModels, {
getProviderName: (model) => getProviderDisplayName(model.providerMeta, t),
getModelName: (model) => getTextModelConfigDisplayName(model, t)
})
const fallback = textModelOptions.value[0]?.value || ''
const keys = new Set(textModelOptions.value.map(opt => opt.value))
+3 -1
View File
@@ -25,7 +25,9 @@ const messages = {
"section": "Provider Configuration",
"label": "Provider",
"placeholder": "Select Provider",
"openaiHint": "This is the official OpenAI API. If you want a custom base URL, a third-party compatible service, or a local model, choose \"Custom API (OpenAI Compatible)\" instead.",
"more": "More",
"openaiCompatibleCustomLabel": "OpenAI Compatible (Custom)",
"openaiHint": "This is the official OpenAI API. If you want a custom base URL, a third-party compatible service, or a local model, choose \"OpenAI Compatible (Custom)\" instead.",
"customApiHint": "Use this for custom OpenAI-compatible endpoints. You can configure the base URL, use your own model name, and choose Chat Completions or Responses as the request style.",
"dashscopeHint": "DashScope supports both OpenAI-compatible Chat Completions and Responses APIs. You can switch the request style here directly.",
"minimaxHint": "The default endpoint is the global MiniMax OpenAI-compatible API. Mainland China users should set API URL to https://api.minimaxi.com/v1. Do not use Anthropic-format MiniMax endpoints here."
+3 -1
View File
@@ -26,7 +26,9 @@ const messages = {
"section": "提供商配置",
"label": "提供商",
"placeholder": "选择提供商",
"openaiHint": "官方 OpenAI API 入口。如果你想接入自定义 Base URL、第三方兼容服务或本地模型,请选择“Custom API (OpenAI Compatible)”。",
"more": "更多",
"openaiCompatibleCustomLabel": "OpenAI 兼容(自定义)",
"openaiHint": "官方 OpenAI API 入口。如果你想接入自定义 Base URL、第三方兼容服务或本地模型,请选择“OpenAI 兼容(自定义)”。",
"customApiHint": "用于接入自定义 OpenAI 兼容接口。你可以配置 Base URL、自定义模型名,并选择 Chat Completions 或 Responses 请求风格。",
"dashscopeHint": "阿里百炼已支持 OpenAI 兼容的 Chat Completions 与 Responses 接口。当前可直接在这里切换请求风格进行使用。",
"minimaxHint": "默认地址是 MiniMax 海外 OpenAI 兼容接口。国内用户请将 API 地址改为 https://api.minimaxi.com/v1;这里不要使用 MiniMax 的 Anthropic 格式地址。"
+3 -1
View File
@@ -25,7 +25,9 @@ const messages = {
"section": "提供商配置",
"label": "提供商",
"placeholder": "選擇提供商",
"openaiHint": "這是官方 OpenAI API 入口。如果你想接入自訂 Base URL、第三方相容服務或本地模型,請選擇「Custom API (OpenAI Compatible)」。",
"more": "更多",
"openaiCompatibleCustomLabel": "OpenAI 相容(自訂)",
"openaiHint": "這是官方 OpenAI API 入口。如果你想接入自訂 Base URL、第三方相容服務或本地模型,請選擇「OpenAI 相容(自訂)」。",
"customApiHint": "用於接入自訂 OpenAI 相容接口。你可以配置 Base URL、自訂模型名稱,並選擇 Chat Completions 或 Responses 請求風格。",
"dashscopeHint": "阿里百鍊已支援 OpenAI 相容的 Chat Completions 與 Responses 接口,目前可直接在這裡切換請求風格使用。",
"minimaxHint": "預設位址是 MiniMax 海外 OpenAI 相容接口。中國大陸用戶請將 API 位址改為 https://api.minimaxi.com/v1;這裡不要使用 MiniMax 的 Anthropic 格式位址。"
+18 -10
View File
@@ -1,6 +1,11 @@
import type { TextModelConfig, Template } from '@prompt-optimizer/core'
import type { ModelSelectOption, TemplateSelectOption, SelectOption } from '../types/select-options'
interface ModelSelectTransformOptions {
getProviderName?: (model: TextModelConfig) => string
getModelName?: (model: TextModelConfig) => string
}
/**
* 数据转换工具类
* 负责将原始数据转换为SelectWithConfig组件所需的标准化格式
@@ -11,15 +16,19 @@ export class DataTransformer {
* @param models 模型配置数组
* @returns 标准化的模型选择选项
*/
static modelsToSelectOptions(models: TextModelConfig[]): ModelSelectOption[] {
return models.map(model => ({
primary: model.name,
secondary: model.providerMeta?.name ?? model.providerMeta?.id ?? 'Unknown',
value: model.id,
raw: model,
// 保持向后兼容性
label: `${model.name} (${model.providerMeta?.name ?? model.providerMeta?.id ?? 'Unknown'})`
}))
static modelsToSelectOptions(models: TextModelConfig[], options: ModelSelectTransformOptions = {}): ModelSelectOption[] {
return models.map(model => {
const providerName = options.getProviderName?.(model) ?? model.providerMeta?.name ?? model.providerMeta?.id ?? 'Unknown'
const modelName = options.getModelName?.(model) ?? model.name
return {
primary: modelName,
secondary: providerName,
value: model.id,
raw: model,
// 保持向后兼容性
label: `${modelName} (${providerName})`
}
})
}
/**
@@ -87,4 +96,3 @@ export const OptionAccessors = {
*/
getValue: (opt: SelectOption): string => opt.value
}
+54
View File
@@ -0,0 +1,54 @@
type Translate = (key: string) => string
interface ProviderDisplaySource {
id?: string | null
name?: string | null
}
interface TextModelConfigDisplaySource {
id?: string | null
name?: string | null
providerMeta?: ProviderDisplaySource | null
}
const normalizeProviderText = (value?: string | null) => (value || '').trim().toLowerCase()
export const getProviderDisplayName = (
provider: ProviderDisplaySource | undefined | null,
t: Translate,
fallback = 'Unknown'
) => {
const id = normalizeProviderText(provider?.id)
const name = provider?.name?.trim() || ''
const normalizedName = normalizeProviderText(name)
if (id === 'openai-compatible' || normalizedName === 'custom api (openai compatible)') {
return t('modelManager.provider.openaiCompatibleCustomLabel')
}
if (
id === 'seedream' ||
normalizedName === 'seedream (volcano ark)' ||
(normalizedName.includes('seedream') && normalizedName.includes('volcano ark'))
) {
return 'Seedream'
}
return name || provider?.id || fallback
}
export const getTextModelConfigDisplayName = (
model: TextModelConfigDisplaySource | undefined | null,
t: Translate,
fallback = ''
) => {
const name = model?.name?.trim() || ''
const normalizedName = normalizeProviderText(name)
const providerId = normalizeProviderText(model?.providerMeta?.id)
if (providerId === 'openai-compatible' && normalizedName === 'custom api (openai compatible)') {
return t('modelManager.provider.openaiCompatibleCustomLabel')
}
return name || model?.id || fallback
}
@@ -42,6 +42,51 @@ vi.mock('../../../src/utils/prompt-garden-suggestions', () => ({
fetchPromptGardenSuggestions: fetchSuggestionsMock,
}))
vi.mock('../../../src/components/media/AppPreviewImage.vue', () => ({
default: defineComponent({
name: 'AppPreviewImage',
props: {
src: String,
previewSrc: String,
alt: String,
},
setup(props, { attrs }) {
return () => {
const { class: className, ...restAttrs } = attrs
return h(
'button',
{
...restAttrs,
type: 'button',
class: ['app-preview-image-stub', className],
'data-preview-src': props.previewSrc,
},
h('img', { src: props.src, alt: props.alt }),
)
}
},
}),
}))
vi.mock('../../../src/components/media/AppPreviewImageGroup.vue', () => ({
default: defineComponent({
name: 'AppPreviewImageGroup',
setup(_, { attrs, slots }) {
return () => {
const { class: className, ...restAttrs } = attrs
return h(
'div',
{
...restAttrs,
class: ['app-preview-image-group-stub', className],
},
slots.default?.(),
)
}
},
}),
}))
vi.mock('naive-ui', () => ({
NButton: defineComponent({
name: 'NButton',
@@ -197,4 +242,45 @@ describe('PromptGardenInspirationPopover', () => {
{ logPrefix: 'PromptGarden' },
)
})
it('renders suggestion thumbnails through the preview image component', async () => {
fetchSuggestionsMock.mockResolvedValue({
items: [
{
id: 'prompt-1',
title: 'Cinematic portrait',
summary: 'Keep the subject and improve lighting.',
importCode: 'NB-001',
tags: [],
mode: 'image-text2image',
thumbnailUrl: 'https://garden.always200.com/prompt-assets/thumb.webp',
updatedAt: null,
source: 'featured',
},
],
browseUrl: 'https://garden.always200.com/prompts?mode=image-text2image',
nextExclude: ['NB-001'],
ttlSeconds: 300,
})
const wrapper = mount(PromptGardenInspirationPopover, {
props: {
mode: 'image-text2image',
testId: 'garden-test',
},
})
await wrapper.find('[data-testid="garden-test-trigger"]').trigger('click')
await Promise.resolve()
await Promise.resolve()
const previewImage = wrapper.find('.app-preview-image-stub')
expect(previewImage.exists()).toBe(true)
expect(previewImage.attributes('data-preview-src')).toBe(
'https://garden.always200.com/prompt-assets/thumb.webp',
)
expect(previewImage.find('img').attributes('src')).toBe(
'https://garden.always200.com/prompt-assets/thumb.webp',
)
})
})
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import ProviderPillSelect from '../../../src/components/ProviderPillSelect.vue'
describe('ProviderPillSelect', () => {
const options = [
{ label: 'OpenAI', value: 'openai' },
{ label: 'Custom API', value: 'openai-compatible' },
{ label: 'SiliconFlow', value: 'siliconflow' },
{ label: 'MiniMax', value: 'minimax' },
{ label: 'Ollama', value: 'ollama' },
{ label: 'Disabled', value: 'disabled', disabled: true }
]
const mountComponent = (props: Record<string, unknown>) =>
mount(ProviderPillSelect, {
props
})
it('renders provider options as selectable pills', () => {
const wrapper = mountComponent({
value: 'openai',
options,
ariaLabel: 'Provider'
})
const pills = wrapper.findAll('button[role="radio"]')
expect(pills).toHaveLength(3)
expect(pills[0].attributes('aria-checked')).toBe('true')
expect(pills[0].classes()).toContain('provider-pill-select__pill--selected')
expect(wrapper.find('[role="radiogroup"]').attributes('aria-label')).toBe('Provider')
})
it('orders common providers first, groups niche providers, and keeps custom last', async () => {
const wrapper = mountComponent({
value: 'openai-compatible',
options,
moreLabel: '更多',
labelOverrides: {
'openai-compatible': 'OpenAI 兼容(自定义)'
}
})
const buttons = wrapper.findAll('.provider-pill-select > button')
expect(buttons.map(button => button.text())).toEqual([
'OpenAI',
'Ollama',
'OpenAI 兼容(自定义)',
'更多'
])
expect(buttons.at(2)?.attributes('aria-checked')).toBe('true')
expect(buttons.at(2)?.classes()).toContain('provider-pill-select__pill--custom')
expect(buttons.at(3)?.attributes('aria-expanded')).toBe('false')
await buttons[3].trigger('click')
expect(wrapper.findAll('.provider-pill-select > button').map(button => button.text())).toEqual([
'OpenAI',
'Ollama',
'OpenAI 兼容(自定义)',
'更多',
'SiliconFlow',
'MiniMax',
'Disabled'
])
})
it('emits updates only for enabled non-current options', async () => {
const wrapper = mountComponent({
value: 'openai',
options
})
const pills = wrapper.findAll('button[role="radio"]')
await pills[0].trigger('click')
await pills[2].trigger('click')
expect(wrapper.emitted('update:value')).toEqual([['openai-compatible']])
})
it('emits updates when selecting an expanded overflow provider', async () => {
const wrapper = mountComponent({
value: 'openai',
options
})
await wrapper.find('.provider-pill-select__pill--more').trigger('click')
await wrapper.findAll('.provider-pill-select__pill--overflow')[0].trigger('click')
expect(wrapper.emitted('update:value')).toEqual([['siliconflow']])
})
})
@@ -141,7 +141,7 @@ describe('ui runtime english guards', () => {
it('keeps text model API URL and MiniMax guidance locale-backed', () => {
const source = readUiSource('src/components/TextModelEditModal.vue')
expect(source).toMatch(/NTooltip/)
expect(source).toMatch(/ThemedTooltip/)
expect(source).toMatch(/modelManager\.apiUrlHintAriaLabel/)
expect(source).toMatch(/modelManager\.provider\.minimaxHint/)
expect(source).not.toMatch(/:title="t\('modelManager\.apiUrlHint'\)"/)
@@ -13,6 +13,7 @@ describe('app preview image adoption guards', () => {
'src/components/ImageModelEditModal.vue',
'src/components/ImageModelManager.vue',
'src/components/MainLayout.vue',
'src/components/common/PromptGardenInspirationPopover.vue',
'src/components/FavoriteEditorForm.vue',
'src/components/image-mode/ImageImage2ImageWorkspace.vue',
'src/components/image-mode/ImageText2ImageWorkspace.vue',
@@ -30,6 +31,7 @@ describe('app preview image adoption guards', () => {
'src/components/FavoriteMediaPreviewPanel.vue',
'src/components/GardenSnapshotPreview.vue',
'src/components/FavoriteEditorForm.vue',
'src/components/common/PromptGardenInspirationPopover.vue',
]
for (const file of files) {
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { getProviderDisplayName, getTextModelConfigDisplayName } from '../../../src/utils/provider-display'
const t = (key: string) => {
if (key === 'modelManager.provider.openaiCompatibleCustomLabel') {
return 'OpenAI 兼容(自定义)'
}
return key
}
describe('getProviderDisplayName', () => {
it('localizes OpenAI-compatible legacy provider names', () => {
expect(getProviderDisplayName({ id: 'openai-compatible', name: 'Custom API (OpenAI Compatible)' }, t)).toBe('OpenAI 兼容(自定义)')
})
it('keeps Seedream concise for legacy Volcano Ark labels', () => {
expect(getProviderDisplayName({ id: 'seedream', name: 'Seedream (Volcano Ark)' }, t)).toBe('Seedream')
})
it('localizes the legacy default custom text model name', () => {
expect(getTextModelConfigDisplayName({
id: 'custom',
name: 'Custom API (OpenAI Compatible)',
providerMeta: { id: 'openai-compatible', name: 'Custom API (OpenAI Compatible)' },
}, t)).toBe('OpenAI 兼容(自定义)')
})
})