refactor(settings): remove provider preset selector from external AI card

This commit is contained in:
tianma-if committed 2026-08-10 14:05:33 +08:00
1 parent 0e383881f3
commit 8cff6aafce
4 files changed
+122 -90

No files matched your search

@@ -1,3 +1,4 @@
import AVFoundation
import PhotosUI
import SwiftUI
import UIKit
@@ -11,6 +12,32 @@ enum ImagePickerResult {
case picked(Data, filename: String)
}
enum CameraCaptureNextStep: Equatable {
case openCamera
case requestPermission
case showSettings
case unavailable
}
enum CameraCaptureAccess {
static func nextStep(
isCameraAvailable: Bool,
authorizationStatus: AVAuthorizationStatus
) -> CameraCaptureNextStep {
guard isCameraAvailable else { return .unavailable }
switch authorizationStatus {
case .authorized:
return .openCamera
case .notDetermined:
return .requestPermission
case .denied, .restricted:
return .showSettings
@unknown default:
return .showSettings
}
}
}
/// UIKit PHPicker wrapper — loads UIImage/data via NSItemProvider (not Transferable).
struct SystemImagePicker: UIViewControllerRepresentable {
var onFinish: (ImagePickerResult) -> Void
@@ -120,7 +147,82 @@ struct SystemImagePicker: UIViewControllerRepresentable {
}
}
// MARK: - System camera
/// UIKit camera wrapper. Camera capture is intentionally separate from PHPicker:
/// PHPicker only reads the photo library and cannot launch the camera directly.
struct SystemCameraPicker: UIViewControllerRepresentable {
var onFinish: (ImagePickerResult) -> Void
static var isAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.allowsEditing = false
guard Self.isAvailable else {
DispatchQueue.main.async {
context.coordinator.finish(.failed("此设备没有可用相机。"))
}
return picker
}
picker.sourceType = .camera
picker.mediaTypes = [UTType.image.identifier]
picker.cameraCaptureMode = .photo
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onFinish: onFinish)
}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onFinish: (ImagePickerResult) -> Void
private var settled = false
init(onFinish: @escaping (ImagePickerResult) -> Void) {
self.onFinish = onFinish
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
finish(.cancelled)
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
guard let image = info[.originalImage] as? UIImage,
let data = image.jpegData(compressionQuality: 0.92),
!data.isEmpty
else {
finish(.failed("无法读取拍摄的照片,请重试。"))
return
}
finish(.picked(data, filename: ImagePickerData.cameraFilename()))
}
func finish(_ result: ImagePickerResult) {
guard !settled else { return }
settled = true
onFinish(result)
}
}
}
enum ImagePickerData {
static func cameraFilename(at date: Date = Date()) -> String {
let timestamp = ISO8601DateFormatter()
.string(from: date)
.replacingOccurrences(of: "-", with: "")
.replacingOccurrences(of: ":", with: "")
return "photo-\(timestamp).jpg"
}
/// HEIC / unknown → JPEG so upload mime is valid and ImageCompressor can decode.
static func normalize(_ data: Data) -> Data {
if data.starts(with: [0xFF, 0xD8, 0xFF]) { return data } // JPEG
@@ -12,34 +12,20 @@ import { Switch } from "@/components/ui/switch";
import { ApiRequestError, api } from "@/lib/api";
import { cn } from "@/lib/utils";
type PresetKey = "deepseek" | "openai" | "anthropic" | "google" | "ollama" | "custom";
const PRESET_CONFIGS: Record<Exclude<PresetKey, "custom">, { provider: AiProvider; displayName: string; baseUrl: string; modelId: string }> = {
deepseek: { provider: "openai-compatible", displayName: "DeepSeek", baseUrl: "https://api.deepseek.com", modelId: "deepseek-chat" },
openai: { provider: "openai-compatible", displayName: "OpenAI", baseUrl: "https://api.openai.com/v1", modelId: "gpt-4.1-mini" },
anthropic: { provider: "anthropic", displayName: "Anthropic Claude", baseUrl: "https://api.anthropic.com/v1", modelId: "claude-sonnet-4-5" },
google: { provider: "google", displayName: "Google Gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta", modelId: "gemini-2.5-flash" },
ollama: { provider: "openai-compatible", displayName: "Ollama", baseUrl: "http://localhost:11434/v1", modelId: "llama3.2" },
};
const inferPreset = (provider: AiProvider, baseUrl: string): PresetKey => {
if (baseUrl.includes("deepseek.com")) return "deepseek";
if (baseUrl.includes("openai.com")) return "openai";
if (provider === "anthropic" || baseUrl.includes("anthropic.com")) return "anthropic";
if (provider === "google" || baseUrl.includes("googleapis.com")) return "google";
if (baseUrl.includes("11434") || baseUrl.includes("ollama")) return "ollama";
return "custom";
const providerDefaults: Record<AiProvider, { displayName: string; baseUrl: string; modelId: string }> = {
"openai-compatible": { displayName: "OpenAI-compatible", baseUrl: "https://api.openai.com/v1", modelId: "gpt-4.1-mini" },
anthropic: { displayName: "Anthropic", baseUrl: "https://api.anthropic.com/v1", modelId: "claude-sonnet-4-5" },
google: { displayName: "Google Gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta", modelId: "gemini-2.5-flash" },
};
export const AiModelCard = ({ demoMode }: { demoMode: boolean }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const settingsQuery = useQuery({ queryKey: ["ai-settings"], queryFn: api.getAiSettings });
const [preset, setPreset] = useState<PresetKey>("deepseek");
const [provider, setProvider] = useState<AiProvider>("openai-compatible");
const [displayName, setDisplayName] = useState(PRESET_CONFIGS.deepseek.displayName);
const [baseUrl, setBaseUrl] = useState(PRESET_CONFIGS.deepseek.baseUrl);
const [modelId, setModelId] = useState(PRESET_CONFIGS.deepseek.modelId);
const [displayName, setDisplayName] = useState(providerDefaults["openai-compatible"].displayName);
const [baseUrl, setBaseUrl] = useState(providerDefaults["openai-compatible"].baseUrl);
const [modelId, setModelId] = useState(providerDefaults["openai-compatible"].modelId);
const [apiKey, setApiKey] = useState("");
const [isEnabled, setIsEnabled] = useState(true);
const [expanded, setExpanded] = useState(true);
@@ -52,14 +38,13 @@ export const AiModelCard = ({ demoMode }: { demoMode: boolean }) => {
setBaseUrl(settings.baseUrl);
setModelId(settings.modelId);
setIsEnabled(settings.isEnabled);
setPreset(inferPreset(settings.provider, settings.baseUrl));
}, [settingsQuery.data]);
const payload = () => ({
provider,
displayName: displayName.trim() || (preset !== "custom" ? PRESET_CONFIGS[preset].displayName : "External AI"),
baseUrl: baseUrl.trim(),
modelId: modelId.trim(),
displayName,
baseUrl,
modelId,
...(apiKey ? { apiKey } : {}),
});
@@ -72,22 +57,13 @@ export const AiModelCard = ({ demoMode }: { demoMode: boolean }) => {
},
});
const handlePresetChange = (nextPreset: PresetKey) => {
setPreset(nextPreset);
if (nextPreset !== "custom") {
const config = PRESET_CONFIGS[nextPreset];
setProvider(config.provider);
setDisplayName(config.displayName);
setBaseUrl(config.baseUrl);
setModelId(config.modelId);
}
testMutation.reset();
saveMutation.reset();
};
const handleProviderChange = (next: AiProvider) => {
const previousDefaults = providerDefaults[provider];
const nextDefaults = providerDefaults[next];
setProvider(next);
setPreset("custom");
if (!displayName || displayName === previousDefaults.displayName) setDisplayName(nextDefaults.displayName);
if (!baseUrl || baseUrl === previousDefaults.baseUrl) setBaseUrl(nextDefaults.baseUrl);
if (!modelId || modelId === previousDefaults.modelId) setModelId(nextDefaults.modelId);
testMutation.reset();
saveMutation.reset();
};
@@ -145,21 +121,6 @@ export const AiModelCard = ({ demoMode }: { demoMode: boolean }) => {
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<Field label={t("aiModel.preset")}>
<Select value={preset} onValueChange={(value) => handlePresetChange(value as PresetKey)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deepseek">{t("aiModel.presets.deepseek")}</SelectItem>
<SelectItem value="openai">{t("aiModel.presets.openai")}</SelectItem>
<SelectItem value="anthropic">{t("aiModel.presets.anthropic")}</SelectItem>
<SelectItem value="google">{t("aiModel.presets.google")}</SelectItem>
<SelectItem value="ollama">{t("aiModel.presets.ollama")}</SelectItem>
<SelectItem value="custom">{t("aiModel.presets.custom")}</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label={t("aiModel.provider")}>
<Select value={provider} onValueChange={(value) => handleProviderChange(value as AiProvider)}>
<SelectTrigger>
@@ -172,26 +133,14 @@ export const AiModelCard = ({ demoMode }: { demoMode: boolean }) => {
</SelectContent>
</Select>
</Field>
<Field label={t("aiModel.displayName")}>
<Input value={displayName} onChange={(event) => setDisplayName(event.target.value)} required maxLength={80} />
</Field>
<Field label={t("aiModel.baseUrl")}>
<Input
value={baseUrl}
onChange={(event) => {
setBaseUrl(event.target.value);
setPreset("custom");
}}
required
inputMode="url"
/>
<Input value={baseUrl} onChange={(event) => setBaseUrl(event.target.value)} required inputMode="url" />
</Field>
<Field label={t("aiModel.modelId")}>
<Input
value={modelId}
onChange={(event) => {
setModelId(event.target.value);
setPreset("custom");
}}
required
/>
<Input value={modelId} onChange={(event) => setModelId(event.target.value)} required />
</Field>
<Field label={t("aiModel.apiKey")} hint={hasSavedKey ? t("aiModel.apiKeySavedHint") : undefined}>
<Input
@@ -267,4 +216,3 @@ const Field = ({ label, hint, children }: { label: string; hint?: string; childr
{hint ? <span className="text-xs font-normal leading-4 text-slate-500">{hint}</span> : null}
</label>
);
-9
View File
@@ -272,15 +272,6 @@ export const enUS = {
aiModel: {
title: "External AI configuration",
description: "Connect your own cloud model endpoint. Credentials are encrypted on the EdgeEver server and isolated to your workspace.",
preset: "Provider preset",
presets: {
deepseek: "DeepSeek",
openai: "OpenAI",
anthropic: "Anthropic Claude",
google: "Google Gemini",
ollama: "Ollama (Local)",
custom: "Custom / Other compatible",
},
provider: "API protocol",
providers: {
"openai-compatible": "OpenAI-compatible",
-9
View File
@@ -272,15 +272,6 @@ export const zhCN = {
aiModel: {
title: "外部 AI 配置",
description: "连接你自己的云端模型服务。凭据由 EdgeEver 服务端加密保存,并按个人工作区隔离。",
preset: "服务商预设",
presets: {
deepseek: "DeepSeek",
openai: "OpenAI",
anthropic: "Anthropic Claude",
google: "Google Gemini",
ollama: "Ollama (本地运行)",
custom: "自定义 / 其他兼容协议",
},
provider: "API 协议",
providers: {
"openai-compatible": "OpenAI 兼容协议",