From 8cff6aafce35c218b98f2fae6cd0fdbdc013857e Mon Sep 17 00:00:00 2001
From: tianma-if
Date: Mon, 10 Aug 2026 14:05:33 +0800
Subject: [PATCH] refactor(settings): remove provider preset selector from
external AI card
---
.../EdgeEver/Editor/SystemImagePicker.swift | 102 ++++++++++++++++++
.../src/components/settings/AiModelCard.tsx | 92 ++++------------
packages/shared/src/i18n/en-US.ts | 9 --
packages/shared/src/i18n/zh-CN.ts | 9 --
4 files changed, 122 insertions(+), 90 deletions(-)
diff --git a/apps/ios/EdgeEver/Editor/SystemImagePicker.swift b/apps/ios/EdgeEver/Editor/SystemImagePicker.swift
index 658c3386..97a0b867 100644
--- a/apps/ios/EdgeEver/Editor/SystemImagePicker.swift
+++ b/apps/ios/EdgeEver/Editor/SystemImagePicker.swift
@@ -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
diff --git a/apps/web/src/components/settings/AiModelCard.tsx b/apps/web/src/components/settings/AiModelCard.tsx
index 131e256b..bf910b09 100644
--- a/apps/web/src/components/settings/AiModelCard.tsx
+++ b/apps/web/src/components/settings/AiModelCard.tsx
@@ -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, { 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 = {
+ "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("deepseek");
const [provider, setProvider] = useState("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 }) => {
) : null}
-
-
-
+
+ setDisplayName(event.target.value)} required maxLength={80} />
+
- {
- setBaseUrl(event.target.value);
- setPreset("custom");
- }}
- required
- inputMode="url"
- />
+ setBaseUrl(event.target.value)} required inputMode="url" />
- {
- setModelId(event.target.value);
- setPreset("custom");
- }}
- required
- />
+ setModelId(event.target.value)} required />
{hint} : null}
);
-
diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts
index e00541e6..c04cca15 100644
--- a/packages/shared/src/i18n/en-US.ts
+++ b/packages/shared/src/i18n/en-US.ts
@@ -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",
diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts
index af268c5e..d28c6de2 100644
--- a/packages/shared/src/i18n/zh-CN.ts
+++ b/packages/shared/src/i18n/zh-CN.ts
@@ -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 兼容协议",