1 line
25 KiB
JSON
1 line
25 KiB
JSON
{"repo": "1600822305/aetherlink", "files": {"src/pages/Settings/AssistantModelSettings/VisionRecognitionTab.tsx": {"1b56c288492e5c5f51c6a29046220ff653ab983c": {"t": 1785595149.0954847, "n": 11590, "h": "e007c5f9362877b0", "c": "import React, { useState, useMemo } from 'react';\nimport {\n Box,\n Typography,\n TextField,\n Button,\n Alert,\n InputAdornment,\n IconButton,\n CircularProgress,\n FormControl,\n Select,\n MenuItem,\n} from '@mui/material';\nimport type { SelectChangeEvent } from '@mui/material';\nimport { Eye, EyeOff, CheckCircle2, XCircle, RefreshCw, RotateCcw } from 'lucide-react';\nimport { useSelector, useDispatch } from 'react-redux';\nimport { SettingGroup, Row, YStack } from '../../../components/settings/SettingComponents';\nimport CustomSwitch from '../../../components/CustomSwitch';\nimport type { RootState, AppDispatch } from '../../../shared/store';\nimport {\n setVisionEnabled,\n setVisionModelSource,\n setVisionPresetModelRef,\n updateVisionCustomConfig,\n setVisionPrompt,\n setVisionFailureStrategy,\n setVisionTimeoutMs,\n DEFAULT_VISION_PROMPT,\n type VisionModelSource,\n type VisionFailureStrategy,\n} from '../../../shared/store/slices/visionRecognitionSlice';\nimport { testVisionModelConnection, type VisionTestResult } from '../../../shared/services/vision/VisionRecognitionService';\nimport ModelPickerRow from './ModelPickerRow';\nimport { useAllModels, type ModelWithProvider } from './useAllModels';\n\n/** API 厂商预设(点击快捷填充 Base URL) */\nconst PROVIDER_PRESETS: Array<{ name: string; baseUrl: string }> = [\n { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1' },\n { name: '小米 MiMo', baseUrl: 'https://api.xiaomimimo.com/v1' },\n { name: 'Kimi (Moonshot)', baseUrl: 'https://api.moonshot.cn/v1' },\n { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1' },\n { name: '硅基流动', baseUrl: 'https://api.siliconflow.cn/v1' },\n { name: '智谱 GLM', baseUrl: 'https://open.bigmodel.cn/api/paas/v4' },\n { name: '阿里云百炼', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },\n];\n\nconst VisionRecognitionTab: React.FC = () => {\n const dispatch = useDispatch<AppDispatch>();\n\n const visionState = useSelector((state: RootState) => state.visionRecognition);\n const allModels = useAllModels();\n\n const [showApiKey, setShowApiKey] = useState(false);\n const [isTesting, setIsTesting] = useState(false);\n const [testResult, setTestResult] = useState<VisionTestResult | null>(null);\n\n const selectedPresetModel = useMemo(() => {\n const ref = visionState.presetModelRef;\n if (!ref) return null;\n return allModels.find((model) => model.providerId === ref.providerId && model.id === ref.modelId) || null;\n }, [allModels, visionState.presetModelRef]);\n\n const handleModelSourceChange = (event: SelectChangeEvent) => {\n dispatch(setVisionModelSource(event.target.value as VisionModelSource));\n setTestResult(null);\n };\n\n const handlePresetModelSelect = (model: ModelWithProvider) => {\n dispatch(\n setVisionPresetModelRef({\n providerId: model.providerId || (model as any).provider,\n modelId: model.id,\n })\n );\n setTestResult(null);\n };\n\n const handleProviderPreset = (event: SelectChangeEvent) => {\n const preset = PROVIDER_PRESETS.find((p) => p.name === event.target.value);\n if (preset) {\n dispatch(updateVisionCustomConfig({ baseUrl: preset.baseUrl }));\n setTestResult(null);\n }\n };\n\n const handleCustomChange = (field: 'modelName' | 'baseUrl' | 'apiKey') =>\n (e: React.ChangeEvent<HTMLInputElement>) => {\n dispatch(updateVisionCustomConfig({ [field]: e.target.value }));\n setTestResult(null);\n };\n\n const handleTestConnection = async () => {\n setIsTesting(true);\n setTestResult(null);\n try {\n const result = await testVisionModelConnection(visionState);\n setTestResult(result);\n } finally {\n setIsTesting(false);\n }\n };\n\n const handleTimeoutChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n const value = e.target.value;\n if (value === '') return;\n const seconds = parseInt(value, 10);\n if (!isNaN(seconds) && seconds >= 5 && seconds <= 600) {\n dispatch(setVisionTimeoutMs(seconds * 1000));\n }\n };\n\n const isCustom = visionState.modelSource === 'custom';\n const canTest = isCustom\n ? !!(visionState.custom.modelName && visionState.custom.baseUrl && visionState.custom.apiKey)\n : !!visionState.presetModelRef;\n\n return (\n <YStack sx={{ gap: 3 }}>\n\n {/* ==================== 总开关 ==================== */}\n <SettingGroup title=\"视觉识别\">\n <Row>\n <Box sx={{ flex: 1 }}>\n <Typography>启用视觉识别</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n 发送图片给不支持视觉的模型时,自动用视觉模型分析图片内容并提供给当前模型\n </Typography>\n </Box>\n <CustomSwitch\n checked={visionState.enabled}\n onChange={() => dispatch(setVisionEnabled(!visionState.enabled))}\n />\n </Row>\n </SettingGroup>\n\n {/* ==================== 模型来源 ==================== */}\n <SettingGroup title=\"视觉模型\">\n <Row>\n <Typography sx={{ flex: 1 }}>模型来源</Typography>\n <FormControl size=\"small\" sx={{ minWidth: 180 }}>\n <Select value={visionState.modelSource} onChange={handleModelSourceChange}>\n <MenuItem value=\"preset\">使用已配置的模型</MenuItem>\n <MenuItem value=\"custom\">自定义API配置</MenuItem>\n </Select>\n </FormControl>\n </Row>\n\n {!isCustom && (\n <ModelPickerRow\n label=\"视觉模型\"\n selectedModel={selectedPresetModel}\n availableModels={allModels}\n onSelect={handlePresetModelSelect}\n />\n )}\n\n {isCustom && (\n <>\n <Row>\n <Typography sx={{ minWidth: 80 }}>厂商预设</Typography>\n <FormControl size=\"small\" sx={{ flex: 1 }}>\n <Select value=\"\" onChange={handleProviderPreset} displayEmpty>\n <MenuItem value=\"\">\n <em>选择厂商快捷填充 Base URL</em>\n </MenuItem>\n {PROVIDER_PRESETS.map((preset) => (\n <MenuItem key={preset.name} value={preset.name}>\n {preset.name}\n </MenuItem>\n ))}\n </Select>\n </FormControl>\n </Row>\n\n <Row>\n <Typography sx={{ minWidth: 80 }}>模型名称</Typography>\n <TextField\n size=\"small\"\n placeholder=\"如 gpt-4o-mini、moonshot-v1-8k-vision-preview\"\n value={visionState.custom.modelName}\n onChange={handleCustomChange('modelName')}\n sx={{ flex: 1 }}\n />\n </Row>\n\n <Row>\n <Typography sx={{ minWidth: 80 }}>Base URL</Typography>\n <TextField\n size=\"small\"\n placeholder=\"https://api.openai.com/v1\"\n value={visionState.custom.baseUrl}\n onChange={handleCustomChange('baseUrl')}\n sx={{ flex: 1 }}\n />\n </Row>\n\n <Row>\n <Typography sx={{ minWidth: 80 }}>API 密钥</Typography>\n <TextField\n size=\"small\"\n type={showApiKey ? 'text' : 'password'}\n placeholder=\"sk-...\"\n value={visionState.custom.apiKey}\n onChange={handleCustomChange('apiKey')}\n sx={{ flex: 1 }}\n InputProps={{\n endAdornment: (\n <InputAdornment position=\"end\">\n <IconButton size=\"small\" onClick={() => setShowApiKey(!showApiKey)} edge=\"end\">\n {showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}\n </IconButton>\n </InputAdornment>\n ),\n }}\n />\n </Row>\n </>\n )}\n\n <Box sx={{ px: 2, pb: 2 }}>\n <Button\n fullWidth\n variant=\"outlined\"\n size=\"small\"\n onClick={handleTestConnection}\n disabled={isTesting || !canTest}\n startIcon={isTesting ? <CircularProgress size={16} /> : <RefreshCw size={16} />}\n sx={{ textTransform: 'none' }}\n >\n {isTesting ? '测试中...' : '测试 API 连接'}\n </Button>\n\n {testResult && (\n <Alert\n severity={testResult.success ? 'success' : 'error'}\n icon={testResult.success ? <CheckCircle2 size={18} /> : <XCircle size={18} />}\n sx={{ mt: 1.5, borderRadius: 2 }}\n >\n <Typography variant=\"body2\">{testResult.message}</Typography>\n {testResult.success && testResult.latencyMs !== undefined && (\n <Typography variant=\"caption\" color=\"text.secondary\">\n 响应时间: {testResult.latencyMs}ms\n </Typography>\n )}\n </Alert>\n )}\n </Box>\n </SettingGroup>\n\n {/* ==================== 高级设置 ==================== */}\n <SettingGroup title=\"高级设置\">\n <Row sx={{ alignItems: 'flex-start', flexDirection: 'column', gap: 1 }}>\n <Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>\n <Box sx={{ flex: 1 }}>\n <Typography>分析提示词</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n 指导视觉模型如何描述图片\n </Typography>\n </Box>\n <IconButton\n size=\"small\"\n onClick={() => dispatch(setVisionPrompt(DEFAULT_VISION_PROMPT))}\n title=\"恢复默认\"\n >\n <RotateCcw size={16} />\n </IconButton>\n </Box>\n <TextField\n fullWidth\n multiline\n minRows={3}\n maxRows={8}\n size=\"small\"\n value={visionState.prompt}\n onChange={(e) => dispatch(setVisionPrompt(e.target.value))}\n />\n </Row>\n\n <Row>\n <Box sx={{ flex: 1 }}>\n <Typography>分析失败时</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n 视觉模型分析失败后的处理方式\n </Typography>\n </Box>\n <FormControl size=\"small\" sx={{ minWidth: 160 }}>\n <Select\n value={visionState.onFailure}\n onChange={(e) => dispatch(setVisionFailureStrategy(e.target.value as VisionFailureStrategy))}\n >\n <MenuItem value=\"abort\">中止并报错</MenuItem>\n <MenuItem value=\"continueWithoutImage\">移除图片继续发送</MenuItem>\n </Select>\n </FormControl>\n </Row>\n\n <Row>\n <Typography sx={{ flex: 1 }}>分析超时(秒)</Typography>\n <TextField\n size=\"small\"\n value={Math.round(visionState.timeoutMs / 1000)}\n onChange={handleTimeoutChange}\n inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }}\n sx={{ width: 100 }}\n />\n </Row>\n </SettingGroup>\n\n {/* ==================== 配置说明 ==================== */}\n <Box sx={{ px: 1.5 }}>\n <YStack sx={{ gap: 0.75 }}>\n {[\n { label: '使用已配置的模型', desc: '从现有供应商中选择一个支持视觉的模型,复用其密钥与地址' },\n { label: '自定义API配置', desc: '独立填写模型名、Base URL 与密钥(OpenAI 兼容协议),与主API互不影响' },\n { label: '工作方式', desc: '仅当当前对话模型不支持图片时触发;分析结果只注入本次请求,聊天记录仍保留原图' },\n ].map((item) => (\n <Typography key={item.label} variant=\"caption\" color=\"text.secondary\">\n <strong>{item.label}</strong> — {item.desc}\n </Typography>\n ))}\n </YStack>\n </Box>\n\n </YStack>\n );\n};\n\nexport default VisionRecognitionTab;\n"}, "2fc9a7e75431a6871ebb5e881412987a1520ec7a": {"t": 1785764114.289419, "n": 11590, "h": "e007c5f9362877b0", "c": "import React, { useState, useMemo } from 'react';\nimport {\n Box,\n Typography,\n TextField,\n Button,\n Alert,\n InputAdornment,\n IconButton,\n CircularProgress,\n FormControl,\n Select,\n MenuItem,\n} from '@mui/material';\nimport type { SelectChangeEvent } from '@mui/material';\nimport { Eye, EyeOff, CheckCircle2, XCircle, RefreshCw, RotateCcw } from 'lucide-react';\nimport { useSelector, useDispatch } from 'react-redux';\nimport { SettingGroup, Row, YStack } from '../../../components/settings/SettingComponents';\nimport CustomSwitch from '../../../components/CustomSwitch';\nimport type { RootState, AppDispatch } from '../../../shared/store';\nimport {\n setVisionEnabled,\n setVisionModelSource,\n setVisionPresetModelRef,\n updateVisionCustomConfig,\n setVisionPrompt,\n setVisionFailureStrategy,\n setVisionTimeoutMs,\n DEFAULT_VISION_PROMPT,\n type VisionModelSource,\n type VisionFailureStrategy,\n} from '../../../shared/store/slices/visionRecognitionSlice';\nimport { testVisionModelConnection, type VisionTestResult } from '../../../shared/services/vision/VisionRecognitionService';\nimport ModelPickerRow from './ModelPickerRow';\nimport { useAllModels, type ModelWithProvider } from './useAllModels';\n\n/** API 厂商预设(点击快捷填充 Base URL) */\nconst PROVIDER_PRESETS: Array<{ name: string; baseUrl: string }> = [\n { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1' },\n { name: '小米 MiMo', baseUrl: 'https://api.xiaomimimo.com/v1' },\n { name: 'Kimi (Moonshot)', baseUrl: 'https://api.moonshot.cn/v1' },\n { name: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1' },\n { name: '硅基流动', baseUrl: 'https://api.siliconflow.cn/v1' },\n { name: '智谱 GLM', baseUrl: 'https://open.bigmodel.cn/api/paas/v4' },\n { name: '阿里云百炼', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' },\n];\n\nconst VisionRecognitionTab: React.FC = () => {\n const dispatch = useDispatch<AppDispatch>();\n\n const visionState = useSelector((state: RootState) => state.visionRecognition);\n const allModels = useAllModels();\n\n const [showApiKey, setShowApiKey] = useState(false);\n const [isTesting, setIsTesting] = useState(false);\n const [testResult, setTestResult] = useState<VisionTestResult | null>(null);\n\n const selectedPresetModel = useMemo(() => {\n const ref = visionState.presetModelRef;\n if (!ref) return null;\n return allModels.find((model) => model.providerId === ref.providerId && model.id === ref.modelId) || null;\n }, [allModels, visionState.presetModelRef]);\n\n const handleModelSourceChange = (event: SelectChangeEvent) => {\n dispatch(setVisionModelSource(event.target.value as VisionModelSource));\n setTestResult(null);\n };\n\n const handlePresetModelSelect = (model: ModelWithProvider) => {\n dispatch(\n setVisionPresetModelRef({\n providerId: model.providerId || (model as any).provider,\n modelId: model.id,\n })\n );\n setTestResult(null);\n };\n\n const handleProviderPreset = (event: SelectChangeEvent) => {\n const preset = PROVIDER_PRESETS.find((p) => p.name === event.target.value);\n if (preset) {\n dispatch(updateVisionCustomConfig({ baseUrl: preset.baseUrl }));\n setTestResult(null);\n }\n };\n\n const handleCustomChange = (field: 'modelName' | 'baseUrl' | 'apiKey') =>\n (e: React.ChangeEvent<HTMLInputElement>) => {\n dispatch(updateVisionCustomConfig({ [field]: e.target.value }));\n setTestResult(null);\n };\n\n const handleTestConnection = async () => {\n setIsTesting(true);\n setTestResult(null);\n try {\n const result = await testVisionModelConnection(visionState);\n setTestResult(result);\n } finally {\n setIsTesting(false);\n }\n };\n\n const handleTimeoutChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n const value = e.target.value;\n if (value === '') return;\n const seconds = parseInt(value, 10);\n if (!isNaN(seconds) && seconds >= 5 && seconds <= 600) {\n dispatch(setVisionTimeoutMs(seconds * 1000));\n }\n };\n\n const isCustom = visionState.modelSource === 'custom';\n const canTest = isCustom\n ? !!(visionState.custom.modelName && visionState.custom.baseUrl && visionState.custom.apiKey)\n : !!visionState.presetModelRef;\n\n return (\n <YStack sx={{ gap: 3 }}>\n\n {/* ==================== 总开关 ==================== */}\n <SettingGroup title=\"视觉识别\">\n <Row>\n <Box sx={{ flex: 1 }}>\n <Typography>启用视觉识别</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n 发送图片给不支持视觉的模型时,自动用视觉模型分析图片内容并提供给当前模型\n </Typography>\n </Box>\n <CustomSwitch\n checked={visionState.enabled}\n onChange={() => dispatch(setVisionEnabled(!visionState.enabled))}\n />\n </Row>\n </SettingGroup>\n\n {/* ==================== 模型来源 ==================== */}\n <SettingGroup title=\"视觉模型\">\n <Row>\n <Typography sx={{ flex: 1 }}>模型来源</Typography>\n <FormControl size=\"small\" sx={{ minWidth: 180 }}>\n <Select value={visionState.modelSource} onChange={handleModelSourceChange}>\n <MenuItem value=\"preset\">使用已配置的模型</MenuItem>\n <MenuItem value=\"custom\">自定义API配置</MenuItem>\n </Select>\n </FormControl>\n </Row>\n\n {!isCustom && (\n <ModelPickerRow\n label=\"视觉模型\"\n selectedModel={selectedPresetModel}\n availableModels={allModels}\n onSelect={handlePresetModelSelect}\n />\n )}\n\n {isCustom && (\n <>\n <Row>\n <Typography sx={{ minWidth: 80 }}>厂商预设</Typography>\n <FormControl size=\"small\" sx={{ flex: 1 }}>\n <Select value=\"\" onChange={handleProviderPreset} displayEmpty>\n <MenuItem value=\"\">\n <em>选择厂商快捷填充 Base URL</em>\n </MenuItem>\n {PROVIDER_PRESETS.map((preset) => (\n <MenuItem key={preset.name} value={preset.name}>\n {preset.name}\n </MenuItem>\n ))}\n </Select>\n </FormControl>\n </Row>\n\n <Row>\n <Typography sx={{ minWidth: 80 }}>模型名称</Typography>\n <TextField\n size=\"small\"\n placeholder=\"如 gpt-4o-mini、moonshot-v1-8k-vision-preview\"\n value={visionState.custom.modelName}\n onChange={handleCustomChange('modelName')}\n sx={{ flex: 1 }}\n />\n </Row>\n\n <Row>\n <Typography sx={{ minWidth: 80 }}>Base URL</Typography>\n <TextField\n size=\"small\"\n placeholder=\"https://api.openai.com/v1\"\n value={visionState.custom.baseUrl}\n onChange={handleCustomChange('baseUrl')}\n sx={{ flex: 1 }}\n />\n </Row>\n\n <Row>\n <Typography sx={{ minWidth: 80 }}>API 密钥</Typography>\n <TextField\n size=\"small\"\n type={showApiKey ? 'text' : 'password'}\n placeholder=\"sk-...\"\n value={visionState.custom.apiKey}\n onChange={handleCustomChange('apiKey')}\n sx={{ flex: 1 }}\n InputProps={{\n endAdornment: (\n <InputAdornment position=\"end\">\n <IconButton size=\"small\" onClick={() => setShowApiKey(!showApiKey)} edge=\"end\">\n {showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}\n </IconButton>\n </InputAdornment>\n ),\n }}\n />\n </Row>\n </>\n )}\n\n <Box sx={{ px: 2, pb: 2 }}>\n <Button\n fullWidth\n variant=\"outlined\"\n size=\"small\"\n onClick={handleTestConnection}\n disabled={isTesting || !canTest}\n startIcon={isTesting ? <CircularProgress size={16} /> : <RefreshCw size={16} />}\n sx={{ textTransform: 'none' }}\n >\n {isTesting ? '测试中...' : '测试 API 连接'}\n </Button>\n\n {testResult && (\n <Alert\n severity={testResult.success ? 'success' : 'error'}\n icon={testResult.success ? <CheckCircle2 size={18} /> : <XCircle size={18} />}\n sx={{ mt: 1.5, borderRadius: 2 }}\n >\n <Typography variant=\"body2\">{testResult.message}</Typography>\n {testResult.success && testResult.latencyMs !== undefined && (\n <Typography variant=\"caption\" color=\"text.secondary\">\n 响应时间: {testResult.latencyMs}ms\n </Typography>\n )}\n </Alert>\n )}\n </Box>\n </SettingGroup>\n\n {/* ==================== 高级设置 ==================== */}\n <SettingGroup title=\"高级设置\">\n <Row sx={{ alignItems: 'flex-start', flexDirection: 'column', gap: 1 }}>\n <Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>\n <Box sx={{ flex: 1 }}>\n <Typography>分析提示词</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n 指导视觉模型如何描述图片\n </Typography>\n </Box>\n <IconButton\n size=\"small\"\n onClick={() => dispatch(setVisionPrompt(DEFAULT_VISION_PROMPT))}\n title=\"恢复默认\"\n >\n <RotateCcw size={16} />\n </IconButton>\n </Box>\n <TextField\n fullWidth\n multiline\n minRows={3}\n maxRows={8}\n size=\"small\"\n value={visionState.prompt}\n onChange={(e) => dispatch(setVisionPrompt(e.target.value))}\n />\n </Row>\n\n <Row>\n <Box sx={{ flex: 1 }}>\n <Typography>分析失败时</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n 视觉模型分析失败后的处理方式\n </Typography>\n </Box>\n <FormControl size=\"small\" sx={{ minWidth: 160 }}>\n <Select\n value={visionState.onFailure}\n onChange={(e) => dispatch(setVisionFailureStrategy(e.target.value as VisionFailureStrategy))}\n >\n <MenuItem value=\"abort\">中止并报错</MenuItem>\n <MenuItem value=\"continueWithoutImage\">移除图片继续发送</MenuItem>\n </Select>\n </FormControl>\n </Row>\n\n <Row>\n <Typography sx={{ flex: 1 }}>分析超时(秒)</Typography>\n <TextField\n size=\"small\"\n value={Math.round(visionState.timeoutMs / 1000)}\n onChange={handleTimeoutChange}\n inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }}\n sx={{ width: 100 }}\n />\n </Row>\n </SettingGroup>\n\n {/* ==================== 配置说明 ==================== */}\n <Box sx={{ px: 1.5 }}>\n <YStack sx={{ gap: 0.75 }}>\n {[\n { label: '使用已配置的模型', desc: '从现有供应商中选择一个支持视觉的模型,复用其密钥与地址' },\n { label: '自定义API配置', desc: '独立填写模型名、Base URL 与密钥(OpenAI 兼容协议),与主API互不影响' },\n { label: '工作方式', desc: '仅当当前对话模型不支持图片时触发;分析结果只注入本次请求,聊天记录仍保留原图' },\n ].map((item) => (\n <Typography key={item.label} variant=\"caption\" color=\"text.secondary\">\n <strong>{item.label}</strong> — {item.desc}\n </Typography>\n ))}\n </YStack>\n </Box>\n\n </YStack>\n );\n};\n\nexport default VisionRecognitionTab;\n"}}}} |