refactor(settings): remove provider preset selector from external AI card
This commit is contained in:
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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 兼容协议",
|
||||
|
||||
Reference in new issue
Block a user