feat(ui): warn about CORS-restricted providers
Expose optional corsRestricted provider metadata and show tags/dialogs on connection tests in web. Also hide the warning tag in desktop where IPC bypasses CORS.
This commit is contained in:
1 parent
3115540cac
commit
e3f428013e
15 files changed
+209
-59
No files matched your search
@@ -350,8 +350,25 @@ export class ImageModelManager implements IImageModelManager {
|
||||
|
||||
// 确保配置是自包含的(包含完整的provider和model信息)
|
||||
private ensureSelfContained(config: ImageModelConfig): ImageModelConfig {
|
||||
// 如果已经有完整的自包含字段,直接返回
|
||||
// 如果已经有完整的自包含字段,尽量补齐新增的 provider 字段(保持向后兼容)
|
||||
if (config.provider && config.model) {
|
||||
// 旧存储数据里 provider 可能缺少新字段;用当前 adapter 的 provider 元数据补齐。
|
||||
if (config.provider.corsRestricted === undefined) {
|
||||
try {
|
||||
const latestProvider = this.registry.getAdapter(config.providerId).getProvider()
|
||||
if (latestProvider.corsRestricted !== undefined) {
|
||||
return {
|
||||
...config,
|
||||
provider: {
|
||||
...config.provider,
|
||||
corsRestricted: latestProvider.corsRestricted
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore - unknown provider or adapter failure
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export class DashScopeImageAdapter extends AbstractImageProviderAdapter {
|
||||
id: 'dashscope',
|
||||
name: '阿里百炼',
|
||||
description: '阿里云百炼图像生成服务,支持通义千问图像模型(文生图/图生图)',
|
||||
corsRestricted: true,
|
||||
requiresApiKey: true,
|
||||
defaultBaseURL: 'https://dashscope.aliyuncs.com',
|
||||
supportsDynamicModels: false,
|
||||
|
||||
@@ -38,6 +38,7 @@ export class ModelScopeImageAdapter extends AbstractImageProviderAdapter {
|
||||
id: 'modelscope',
|
||||
name: 'ModelScope',
|
||||
description: 'ModelScope 魔搭社区图像生成服务,每天免费 2000 次调用',
|
||||
corsRestricted: true,
|
||||
requiresApiKey: true,
|
||||
defaultBaseURL: 'https://api-inference.modelscope.cn/v1',
|
||||
supportsDynamicModels: false,
|
||||
|
||||
@@ -21,6 +21,7 @@ export class SeedreamImageAdapter extends AbstractImageProviderAdapter {
|
||||
id: 'seedream',
|
||||
name: 'Seedream (火山方舟)',
|
||||
description: '火山方舟 Seedream 图像生成模型',
|
||||
corsRestricted: true,
|
||||
requiresApiKey: true,
|
||||
defaultBaseURL: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
supportsDynamicModels: false, // 不支持动态获取
|
||||
|
||||
@@ -27,6 +27,12 @@ export interface TextProvider {
|
||||
readonly name: string
|
||||
/** 描述信息 */
|
||||
readonly description?: string
|
||||
/**
|
||||
* 浏览器环境是否会被 CORS 限制(无法直接请求该 API)。
|
||||
* - true: Web 端可能因 CORS 被浏览器拦截,建议使用 Desktop 或自行配置代理
|
||||
* - false/undefined: 未标记为 CORS 限制(不代表一定可用,仍可能受网络/鉴权等影响)
|
||||
*/
|
||||
readonly corsRestricted?: boolean
|
||||
/** 是否必须提供 API Key */
|
||||
readonly requiresApiKey: boolean
|
||||
/** 默认 API 地址 */
|
||||
|
||||
@@ -27,6 +27,12 @@ export interface BaseProvider {
|
||||
readonly name: string;
|
||||
/** 描述信息 */
|
||||
readonly description?: string;
|
||||
/**
|
||||
* 浏览器环境是否会被 CORS 限制(无法直接请求该 API)。
|
||||
* - true: Web 端可能因 CORS 被浏览器拦截,建议使用 Desktop 或自行配置代理
|
||||
* - false/undefined: 未标记为 CORS 限制(不代表一定可用,仍可能受网络/鉴权等影响)
|
||||
*/
|
||||
readonly corsRestricted?: boolean;
|
||||
/** 是否必须提供 API Key */
|
||||
readonly requiresApiKey: boolean;
|
||||
/** 默认 API 地址 */
|
||||
|
||||
@@ -232,23 +232,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, nextTick } from 'vue'
|
||||
import { computed, watch, nextTick, h } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
NModal, NSpace, NInput, NInputNumber,
|
||||
NCheckbox, NSelect, NButton, NTag, NTooltip, NText,
|
||||
NDivider, NH4, NForm, NFormItem, NImage
|
||||
NDivider, NH4, NForm, NFormItem, NImage, useDialog
|
||||
} from 'naive-ui'
|
||||
import { useImageModelManager } from '../composables/model/useImageModelManager'
|
||||
import { useToast } from '../composables/ui/useToast'
|
||||
import type { ImageModelConfig } from '@prompt-optimizer/core'
|
||||
import { isRunningInElectron, type ImageModelConfig } from '@prompt-optimizer/core'
|
||||
import ModelAdvancedSection from './ModelAdvancedSection.vue'
|
||||
import ExternalLinkIcon from './icons/ExternalLinkIcon.vue'
|
||||
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
@@ -293,7 +294,7 @@ const {
|
||||
onProviderChange: handleProviderChange,
|
||||
onConnectionConfigChange,
|
||||
onModelChange,
|
||||
testConnection: handleTestConnection,
|
||||
testConnection: performTestConnection,
|
||||
refreshModels: handleRefreshModels,
|
||||
updateParamOverrides,
|
||||
saveConfig,
|
||||
@@ -309,6 +310,27 @@ const currentProviderApiKeyUrl = computed(() => {
|
||||
return selectedProvider.value?.apiKeyUrl || null
|
||||
})
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
const runTest = async () => {
|
||||
await performTestConnection()
|
||||
}
|
||||
|
||||
if (!isRunningInElectron()) {
|
||||
if (selectedProvider.value?.corsRestricted) {
|
||||
const providerName = selectedProvider.value.name || selectedProvider.value.id || 'Unknown'
|
||||
dialog.warning({
|
||||
title: t('modelManager.corsRestrictedTag'),
|
||||
content: () => h('div', { style: 'white-space: pre-line;' }, t('modelManager.corsRestrictedConfirm', { provider: providerName })),
|
||||
positiveText: t('common.confirm'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: runTest
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
await runTest()
|
||||
}
|
||||
|
||||
const providerOptions = computed(() =>
|
||||
providers.value.map(p => ({
|
||||
label: p.name,
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
<NTag size="small" type="primary" :bordered="false">
|
||||
{{ config.model?.name || config.modelId }}
|
||||
</NTag>
|
||||
<NTag
|
||||
v-if="config.provider?.corsRestricted && !isElectronEnv"
|
||||
size="small"
|
||||
type="error"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ t('modelManager.corsRestrictedTag') }}
|
||||
</NTag>
|
||||
<!-- 能力标签移到这里 -->
|
||||
<NTag v-if="config.model?.capabilities?.text2image" size="small" type="success" :bordered="false">
|
||||
{{ t('image.capability.text2image') }}
|
||||
@@ -136,19 +144,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, inject } from 'vue'
|
||||
import { ref, onMounted, inject, h } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
NSpace, NCard, NText, NTag, NButton, NEmpty, NImage
|
||||
NSpace, NCard, NText, NTag, NButton, NEmpty, NImage, useDialog
|
||||
} from 'naive-ui'
|
||||
import { useImageModelManager } from '../composables/model/useImageModelManager'
|
||||
import { useToast } from '../composables/ui/useToast'
|
||||
import { getI18nErrorMessage } from '../utils/error'
|
||||
import type { IImageService, ImageModel } from '@prompt-optimizer/core'
|
||||
import { isRunningInElectron, type IImageService, type ImageModel } from '@prompt-optimizer/core'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const isElectronEnv = isRunningInElectron()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['add', 'edit'])
|
||||
@@ -223,53 +233,72 @@ const editConfig = (configId: string) => {
|
||||
const testConnection = async (configId: string) => {
|
||||
if (isTestingConnectionFor(configId)) return
|
||||
|
||||
try {
|
||||
testingConnections.value[configId] = true
|
||||
const config = configs.value.find(c => c.id === configId)
|
||||
if (!config) return
|
||||
|
||||
// 清除之前的测试结果
|
||||
delete testResults.value[configId]
|
||||
const runTest = async () => {
|
||||
try {
|
||||
testingConnections.value[configId] = true
|
||||
|
||||
const config = configs.value.find(c => c.id === configId)
|
||||
if (!config) throw new Error('Config not found')
|
||||
// 清除之前的测试结果
|
||||
delete testResults.value[configId]
|
||||
|
||||
// 获取选中的模型信息
|
||||
if (!config.model) {
|
||||
throw new Error('选中的模型未找到')
|
||||
if (!config) throw new Error('Config not found')
|
||||
|
||||
// 获取选中的模型信息
|
||||
if (!config.model) {
|
||||
throw new Error('选中的模型未找到')
|
||||
}
|
||||
|
||||
// 根据模型能力确定测试类型
|
||||
const testType = selectTestType(config.model)
|
||||
|
||||
// 通过统一服务执行测试(Electron 下经 IPC 走主进程;Web 下本地执行)
|
||||
const result = await imageService.testConnection(config)
|
||||
|
||||
// 测试成功
|
||||
testResults.value[configId] = {
|
||||
success: true,
|
||||
image: result.images[0],
|
||||
testType
|
||||
}
|
||||
|
||||
toast.success(t('image.connection.testSuccess'))
|
||||
|
||||
} catch (error) {
|
||||
console.error('Connection test failed:', error)
|
||||
|
||||
// 记录失败结果
|
||||
testResults.value[configId] = {
|
||||
success: false,
|
||||
testType: 'text2image' // 默认值
|
||||
}
|
||||
|
||||
const detail = getI18nErrorMessage(error, t('image.connection.testError'))
|
||||
if (detail === t('image.connection.testError')) {
|
||||
toast.error(detail)
|
||||
} else {
|
||||
toast.error(`${t('image.connection.testError')}: ${detail}`)
|
||||
}
|
||||
} finally {
|
||||
delete testingConnections.value[configId]
|
||||
}
|
||||
|
||||
// 根据模型能力确定测试类型
|
||||
const testType = selectTestType(config.model)
|
||||
|
||||
// 通过统一服务执行测试(Electron 下经 IPC 走主进程;Web 下本地执行)
|
||||
const result = await imageService.testConnection(config)
|
||||
|
||||
// 测试成功
|
||||
testResults.value[configId] = {
|
||||
success: true,
|
||||
image: result.images[0],
|
||||
testType
|
||||
}
|
||||
|
||||
toast.success(t('image.connection.testSuccess'))
|
||||
|
||||
} catch (error) {
|
||||
console.error('Connection test failed:', error)
|
||||
|
||||
// 记录失败结果
|
||||
testResults.value[configId] = {
|
||||
success: false,
|
||||
testType: 'text2image' // 默认值
|
||||
}
|
||||
|
||||
const detail = getI18nErrorMessage(error, t('image.connection.testError'))
|
||||
if (detail === t('image.connection.testError')) {
|
||||
toast.error(detail)
|
||||
} else {
|
||||
toast.error(`${t('image.connection.testError')}: ${detail}`)
|
||||
}
|
||||
} finally {
|
||||
delete testingConnections.value[configId]
|
||||
}
|
||||
|
||||
if (!isRunningInElectron()) {
|
||||
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 })),
|
||||
positiveText: t('common.confirm'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: runTest
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await runTest()
|
||||
}
|
||||
|
||||
const toggleConfig = async (config: { id: string; enabled: boolean }) => {
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
<NSpace justify="space-between" align="center" style="width: 100%;">
|
||||
<NSpace align="center">
|
||||
<NButton
|
||||
@click="testFormConnection"
|
||||
@click="handleTestFormConnection"
|
||||
:loading="isTestingFormConnection"
|
||||
:disabled="!canTestFormConnection"
|
||||
secondary
|
||||
@@ -202,10 +202,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, nextTick } from 'vue'
|
||||
import { computed, inject, nextTick, h } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToast } from '../composables/ui/useToast'
|
||||
import { isRunningInElectron } from '@prompt-optimizer/core'
|
||||
import {
|
||||
NModal,
|
||||
NForm,
|
||||
@@ -222,7 +223,8 @@ import {
|
||||
NText,
|
||||
NTag,
|
||||
NTooltip,
|
||||
NSpin
|
||||
NSpin,
|
||||
useDialog
|
||||
} from 'naive-ui'
|
||||
import ModelAdvancedSection from './ModelAdvancedSection.vue'
|
||||
import ExternalLinkIcon from './icons/ExternalLinkIcon.vue'
|
||||
@@ -239,6 +241,7 @@ const emit = defineEmits(['update:show', 'saved'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const manager = inject<TextModelManager>('textModelManager')
|
||||
if (!manager) {
|
||||
throw new Error('Text model manager not provided')
|
||||
@@ -271,6 +274,28 @@ const currentProviderApiKeyUrl = computed(() => {
|
||||
return manager.selectedProvider.value?.apiKeyUrl || null
|
||||
})
|
||||
|
||||
const handleTestFormConnection = async () => {
|
||||
const runTest = async () => {
|
||||
await testFormConnection()
|
||||
}
|
||||
|
||||
if (!isRunningInElectron()) {
|
||||
const provider = manager.selectedProvider.value
|
||||
if (provider?.corsRestricted) {
|
||||
const providerName = provider.name || provider.id || 'Unknown Provider'
|
||||
dialog.warning({
|
||||
title: t('modelManager.corsRestrictedTag'),
|
||||
content: () => h('div', { style: 'white-space: pre-line;' }, t('modelManager.corsRestrictedConfirm', { provider: providerName })),
|
||||
positiveText: t('common.confirm'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: runTest
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
await runTest()
|
||||
}
|
||||
|
||||
const handleUpdateShow = async (value: boolean) => {
|
||||
emit('update:show', value)
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@
|
||||
<NTag size="small" type="primary" :bordered="false">
|
||||
{{ model.modelMeta?.name || model.modelMeta?.id }}
|
||||
</NTag>
|
||||
<NTag
|
||||
v-if="model.providerMeta?.corsRestricted && !isElectronEnv"
|
||||
size="small"
|
||||
type="error"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ t('modelManager.corsRestrictedTag') }}
|
||||
</NTag>
|
||||
<NTag
|
||||
v-if="model.modelMeta?.capabilities?.supportsTools"
|
||||
size="small"
|
||||
@@ -172,7 +180,7 @@ import { type PropType } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NButton, NCard, NTag, NText, NSpace } from 'naive-ui'
|
||||
import type { TextModelConfig } from '@prompt-optimizer/core'
|
||||
import { isRunningInElectron, type TextModelConfig } from '@prompt-optimizer/core'
|
||||
|
||||
const { models, isTestingConnectionFor, isDefaultModel } = defineProps({
|
||||
models: {
|
||||
@@ -192,4 +200,6 @@ const { models, isTestingConnectionFor, isDefaultModel } = defineProps({
|
||||
const emit = defineEmits(['test', 'edit', 'enable', 'disable', 'delete'])
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isElectronEnv = isRunningInElectron()
|
||||
</script>
|
||||
@@ -20,15 +20,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, ref } from 'vue'
|
||||
import { onMounted, provide, ref, h } from 'vue'
|
||||
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { isRunningInElectron } from '@prompt-optimizer/core'
|
||||
import { useTextModelManager } from '../composables/model/useTextModelManager'
|
||||
import TextModelList from './TextModelList.vue'
|
||||
import TextModelEditModal from './TextModelEditModal.vue'
|
||||
import { useDialog } from 'naive-ui'
|
||||
|
||||
const emit = defineEmits(['modelsUpdated'])
|
||||
const { t } = useI18n()
|
||||
const dialog = useDialog()
|
||||
const manager = useTextModelManager()
|
||||
provide('textModelManager', manager)
|
||||
|
||||
@@ -49,7 +52,28 @@ const handleModelUpdated = async (id?: string) => {
|
||||
}
|
||||
|
||||
const handleTestConnection = async (id: string) => {
|
||||
await manager.testConfigConnection(id)
|
||||
const runTest = async () => {
|
||||
await manager.testConfigConnection(id)
|
||||
}
|
||||
|
||||
if (!isRunningInElectron()) {
|
||||
const model = manager.models.value.find(m => m.id === id)
|
||||
if (model) {
|
||||
const isCorsRestricted = !!model.providerMeta?.corsRestricted
|
||||
if (isCorsRestricted) {
|
||||
const providerName = model.providerMeta?.name || model.providerMeta?.id || 'Unknown Provider'
|
||||
dialog.warning({
|
||||
title: t('modelManager.corsRestrictedTag'),
|
||||
content: () => h('div', { style: 'white-space: pre-line;' }, t('modelManager.corsRestrictedConfirm', { provider: providerName })),
|
||||
positiveText: t('common.confirm'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: runTest
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
await runTest()
|
||||
}
|
||||
|
||||
const handleEditModel = async (id: string) => {
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
placement="top-right"
|
||||
container-style="position: fixed; top: 20px; right: 20px;"
|
||||
>
|
||||
<MessageApiInitializer />
|
||||
<slot />
|
||||
<NDialogProvider>
|
||||
<MessageApiInitializer />
|
||||
<slot />
|
||||
</NDialogProvider>
|
||||
</NMessageProvider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, defineComponent, h } from "vue";
|
||||
import { NMessageProvider, useMessage } from "naive-ui";
|
||||
import { NMessageProvider, NDialogProvider, useMessage } from "naive-ui";
|
||||
|
||||
import { setGlobalMessageApi } from '../composables/ui/useToast';
|
||||
|
||||
|
||||
@@ -833,6 +833,8 @@ export default {
|
||||
fetchModelsFailed: "Failed to fetch models: {error}",
|
||||
needApiKeyAndBaseUrl: "Please fill API key and base URL first",
|
||||
needBaseUrl: "Please fill in API URL first",
|
||||
corsRestrictedTag: "CORS Restricted",
|
||||
corsRestrictedConfirm: "{provider} has browser CORS restrictions, connection test may fail in Web.\n\nThis does not mean your API Key is invalid, but browser security policy blocked the request.\nRecommend using Desktop App, or ensure this provider supports direct browser access.\n\nContinue testing?",
|
||||
|
||||
// Error handling for model fetching
|
||||
errors: {
|
||||
|
||||
@@ -810,6 +810,8 @@ export default {
|
||||
fetchModelsFailed: "获取模型列表失败:{error}",
|
||||
needApiKeyAndBaseUrl: "请先填写API地址和密钥",
|
||||
needBaseUrl: "请先填写API地址",
|
||||
corsRestrictedTag: "CORS受限",
|
||||
corsRestrictedConfirm: "{provider} 存在浏览器CORS跨域限制,Web端连接测试可能会失败。\n\n这不代表API Key有问题,而是浏览器安全策略阻止了请求。\n建议下载桌面版APP使用,或确保该提供商支持浏览器直连。\n\n是否继续测试?",
|
||||
|
||||
// 模型获取错误处理
|
||||
errors: {
|
||||
|
||||
@@ -806,6 +806,8 @@ export default {
|
||||
fetchModelsFailed: "取得模型清單失敗:{error}",
|
||||
needApiKeyAndBaseUrl: "請先填寫API位址和金鑰",
|
||||
needBaseUrl: "請先填寫API位址",
|
||||
corsRestrictedTag: "CORS受限",
|
||||
corsRestrictedConfirm: "{provider} 存在瀏覽器CORS跨域限制,Web端連線測試可能會失敗。\n\n這不代表API Key有問題,而是瀏覽器安全策略阻止了請求。\n建議下載桌面版APP使用,或確保該提供商支援瀏覽器直連。\n\n是否繼續測試?",
|
||||
|
||||
// 模型取得錯誤處理
|
||||
errors: {
|
||||
|
||||
Reference in new issue
Block a user