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:
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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)'
|
||||
}"
|
||||
>
|
||||
{{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保持可滚动 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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"><</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">></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"></</span>
|
||||
<span class="xml-tag">{{ node.name }}</span>
|
||||
<span class="xml-punct">></span>
|
||||
</div>
|
||||
</details>
|
||||
<div class="xml-line xml-closing" :style="lineStyle">
|
||||
<span class="xml-punct"></</span>
|
||||
<span class="xml-tag">{{ node.name }}</span>
|
||||
<span class="xml-punct">></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else class="xml-line" :style="lineStyle">
|
||||
<span class="xml-punct"><</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,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))
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 格式地址。"
|
||||
|
||||
@@ -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 格式位址。"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 兼容(自定义)')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user