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 兼容协议",