fix(desktop): smooth settings and config flows
This commit is contained in:
parent
0b3098ccf6
commit
bbe21e14df
1058
frontend/src/App.tsx
1058
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
AliasTargetInput,
|
AliasTargetInput,
|
||||||
AliasUpsertInput,
|
AliasUpsertInput,
|
||||||
|
ConfigExportView,
|
||||||
|
ConfigImportInput,
|
||||||
|
ConfigImportResult,
|
||||||
DesktopPrefsSaveResult,
|
DesktopPrefsSaveResult,
|
||||||
AliasView,
|
AliasView,
|
||||||
DesktopPrefsView,
|
DesktopPrefsView,
|
||||||
@ -60,6 +63,16 @@ export function getOverview(): Promise<Overview> {
|
|||||||
return isWails() ? bridge().Overview() : http<Overview>('/api/overview')
|
return isWails() ? bridge().Overview() : http<Overview>('/api/overview')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exportConfig(): Promise<ConfigExportView> {
|
||||||
|
return isWails() ? bridge().ExportConfig() : http<ConfigExportView>('/api/config/export')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importConfig(input: ConfigImportInput): Promise<ConfigImportResult> {
|
||||||
|
return isWails()
|
||||||
|
? bridge().ImportConfig(input)
|
||||||
|
: http<ConfigImportResult>('/api/config/import', { method: 'POST', body: JSON.stringify(input) })
|
||||||
|
}
|
||||||
|
|
||||||
export function listProviders(): Promise<ProviderView[]> {
|
export function listProviders(): Promise<ProviderView[]> {
|
||||||
return isWails() ? bridge().Providers() : http<ProviderView[]>('/api/providers')
|
return isWails() ? bridge().Providers() : http<ProviderView[]>('/api/providers')
|
||||||
}
|
}
|
||||||
|
|||||||
2
frontend/src/env.d.ts
vendored
2
frontend/src/env.d.ts
vendored
@ -7,6 +7,8 @@ declare global {
|
|||||||
App?: {
|
App?: {
|
||||||
Meta: () => Promise<Record<string, string>>
|
Meta: () => Promise<Record<string, string>>
|
||||||
Overview: () => Promise<import('./types').Overview>
|
Overview: () => Promise<import('./types').Overview>
|
||||||
|
ExportConfig: () => Promise<import('./types').ConfigExportView>
|
||||||
|
ImportConfig: (input: import('./types').ConfigImportInput) => Promise<import('./types').ConfigImportResult>
|
||||||
Providers: () => Promise<import('./types').ProviderView[]>
|
Providers: () => Promise<import('./types').ProviderView[]>
|
||||||
Aliases: () => Promise<import('./types').AliasView[]>
|
Aliases: () => Promise<import('./types').AliasView[]>
|
||||||
SaveProvider: (input: import('./types').ProviderUpsertInput) => Promise<import('./types').ProviderSaveResult>
|
SaveProvider: (input: import('./types').ProviderUpsertInput) => Promise<import('./types').ProviderSaveResult>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"eyebrow": "ocswitch desktop",
|
"eyebrow": "ocswitch desktop",
|
||||||
|
"brand": "ocswitch desktop",
|
||||||
"title": "Native control panel",
|
"title": "Native control panel",
|
||||||
"subtitle": "{{version}} · {{shell}} shell · {{path}}",
|
"subtitle": "{{version}} · {{shell}} shell · {{path}}",
|
||||||
"loadingConfig": "loading config",
|
"loadingConfig": "loading config",
|
||||||
@ -31,7 +32,17 @@
|
|||||||
"unbind": "Unbind",
|
"unbind": "Unbind",
|
||||||
"newProvider": "New provider",
|
"newProvider": "New provider",
|
||||||
"newAlias": "New alias",
|
"newAlias": "New alias",
|
||||||
"useInBindForm": "Use in bind form"
|
"useInBindForm": "Use in bind form",
|
||||||
|
"close": "Close",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"exportConfig": "Export config",
|
||||||
|
"importConfig": "Import config"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"deleteProviderTitle": "Delete provider",
|
||||||
|
"deleteAliasTitle": "Delete alias",
|
||||||
|
"unbindTargetTitle": "Unbind target"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"proxyRunning": "Proxy running",
|
"proxyRunning": "Proxy running",
|
||||||
@ -59,7 +70,10 @@
|
|||||||
"previewChanges": "Preview shows changes",
|
"previewChanges": "Preview shows changes",
|
||||||
"previewNoChanges": "Preview shows no changes",
|
"previewNoChanges": "Preview shows no changes",
|
||||||
"syncApplied": "Sync applied",
|
"syncApplied": "Sync applied",
|
||||||
"syncUpToDate": "Already up to date"
|
"syncUpToDate": "Already up to date",
|
||||||
|
"confirmDeleteProvider": "Delete provider {{id}}? This cannot be undone.",
|
||||||
|
"confirmDeleteAlias": "Delete alias {{alias}}? This cannot be undone.",
|
||||||
|
"confirmUnbindTarget": "Unbind {{provider}}/{{model}} from {{alias}}?"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"title": "Overview",
|
"title": "Overview",
|
||||||
@ -67,6 +81,7 @@
|
|||||||
"actionsTitle": "Actions",
|
"actionsTitle": "Actions",
|
||||||
"statsTitle": "System snapshot",
|
"statsTitle": "System snapshot",
|
||||||
"environmentTitle": "Environment",
|
"environmentTitle": "Environment",
|
||||||
|
"environmentSubtitle": "Only runtime and desktop app details stay here.",
|
||||||
"doctorTitle": "Doctor summary",
|
"doctorTitle": "Doctor summary",
|
||||||
"debugTitle": "Debug details",
|
"debugTitle": "Debug details",
|
||||||
"debugSummary": "Show raw overview JSON",
|
"debugSummary": "Show raw overview JSON",
|
||||||
@ -98,7 +113,9 @@
|
|||||||
"filterDisabled": "Disabled",
|
"filterDisabled": "Disabled",
|
||||||
"listCount": "{{shown}} shown / {{total}} total",
|
"listCount": "{{shown}} shown / {{total}} total",
|
||||||
"empty": "No providers configured yet.",
|
"empty": "No providers configured yet.",
|
||||||
|
"emptyHint": "Create one manually or import providers from your OpenCode config.",
|
||||||
"noMatches": "No providers match the current filters.",
|
"noMatches": "No providers match the current filters.",
|
||||||
|
"noMatchesHint": "Try a broader search term or switch the status filter.",
|
||||||
"id": "Provider id",
|
"id": "Provider id",
|
||||||
"name": "Display name",
|
"name": "Display name",
|
||||||
"baseUrl": "Base URL",
|
"baseUrl": "Base URL",
|
||||||
@ -114,6 +131,7 @@
|
|||||||
"apiKeyNotSet": "not set",
|
"apiKeyNotSet": "not set",
|
||||||
"headersNone": "none",
|
"headersNone": "none",
|
||||||
"modelsNone": "none",
|
"modelsNone": "none",
|
||||||
|
"modelsCount": "{{count}} models",
|
||||||
"sourceLabel": "Source",
|
"sourceLabel": "Source",
|
||||||
"placeholderId": "su8",
|
"placeholderId": "su8",
|
||||||
"placeholderName": "SU8",
|
"placeholderName": "SU8",
|
||||||
@ -142,7 +160,9 @@
|
|||||||
"searchPlaceholder": "Filter by alias, display name, provider, or model",
|
"searchPlaceholder": "Filter by alias, display name, provider, or model",
|
||||||
"listCount": "{{shown}} shown / {{total}} total",
|
"listCount": "{{shown}} shown / {{total}} total",
|
||||||
"empty": "No aliases configured yet.",
|
"empty": "No aliases configured yet.",
|
||||||
|
"emptyHint": "Create an alias first, then bind provider/model targets to it.",
|
||||||
"noMatches": "No aliases match the current filters.",
|
"noMatches": "No aliases match the current filters.",
|
||||||
|
"noMatchesHint": "Try a different search term or create a new alias route.",
|
||||||
"alias": "Alias",
|
"alias": "Alias",
|
||||||
"displayName": "Display name",
|
"displayName": "Display name",
|
||||||
"aliasForBinding": "Alias for binding",
|
"aliasForBinding": "Alias for binding",
|
||||||
@ -156,6 +176,7 @@
|
|||||||
"placeholderProviderId": "su8",
|
"placeholderProviderId": "su8",
|
||||||
"placeholderModel": "gpt-5.4",
|
"placeholderModel": "gpt-5.4",
|
||||||
"targets": "Targets",
|
"targets": "Targets",
|
||||||
|
"targetsCount": "{{count}} targets",
|
||||||
"routable": "{{available}}/{{total}} routable",
|
"routable": "{{available}}/{{total}} routable",
|
||||||
"statusSaved": "Saved alias {{alias}}",
|
"statusSaved": "Saved alias {{alias}}",
|
||||||
"statusEditing": "Editing alias {{alias}}",
|
"statusEditing": "Editing alias {{alias}}",
|
||||||
@ -194,9 +215,23 @@
|
|||||||
"appearanceTitle": "Appearance",
|
"appearanceTitle": "Appearance",
|
||||||
"languageTitle": "Language",
|
"languageTitle": "Language",
|
||||||
"behaviorTitle": "Desktop behavior",
|
"behaviorTitle": "Desktop behavior",
|
||||||
|
"configTitle": "Config file",
|
||||||
|
"importTitle": "Import mode",
|
||||||
|
"importModeText": "Import text",
|
||||||
|
"importModeFile": "Import file",
|
||||||
|
"importText": "Config text",
|
||||||
|
"importFile": "Config file",
|
||||||
|
"importPlaceholder": "Paste full ocswitch config JSON",
|
||||||
|
"fileEmpty": "No file selected yet.",
|
||||||
|
"exportDone": "Exported config {{path}}",
|
||||||
|
"importDone": "Imported config {{path}}",
|
||||||
|
"fileLoaded": "Loaded file {{name}}",
|
||||||
|
"importEmpty": "Provide config content first.",
|
||||||
"aboutTitle": "About",
|
"aboutTitle": "About",
|
||||||
|
"aboutSubtitle": "Read-only environment details for the current desktop shell.",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
|
"languageLabel": "Preferred language",
|
||||||
"themeSystem": "System",
|
"themeSystem": "System",
|
||||||
"themeLight": "Light",
|
"themeLight": "Light",
|
||||||
"themeDark": "Dark",
|
"themeDark": "Dark",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"eyebrow": "ocswitch desktop",
|
"eyebrow": "ocswitch desktop",
|
||||||
|
"brand": "ocswitch desktop",
|
||||||
"title": "桌面控制台",
|
"title": "桌面控制台",
|
||||||
"subtitle": "{{version}} · {{shell}} shell · {{path}}",
|
"subtitle": "{{version}} · {{shell}} shell · {{path}}",
|
||||||
"loadingConfig": "正在读取配置",
|
"loadingConfig": "正在读取配置",
|
||||||
@ -31,7 +32,17 @@
|
|||||||
"unbind": "解绑",
|
"unbind": "解绑",
|
||||||
"newProvider": "新建提供商",
|
"newProvider": "新建提供商",
|
||||||
"newAlias": "新建别名",
|
"newAlias": "新建别名",
|
||||||
"useInBindForm": "带入绑定表单"
|
"useInBindForm": "带入绑定表单",
|
||||||
|
"close": "关闭",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "确认",
|
||||||
|
"exportConfig": "导出配置",
|
||||||
|
"importConfig": "导入配置"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"deleteProviderTitle": "确认删除提供商",
|
||||||
|
"deleteAliasTitle": "确认删除别名",
|
||||||
|
"unbindTargetTitle": "确认解绑目标"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"proxyRunning": "代理运行中",
|
"proxyRunning": "代理运行中",
|
||||||
@ -59,7 +70,10 @@
|
|||||||
"previewChanges": "预览显示有变更",
|
"previewChanges": "预览显示有变更",
|
||||||
"previewNoChanges": "预览显示无变更",
|
"previewNoChanges": "预览显示无变更",
|
||||||
"syncApplied": "同步已应用",
|
"syncApplied": "同步已应用",
|
||||||
"syncUpToDate": "已经是最新状态"
|
"syncUpToDate": "已经是最新状态",
|
||||||
|
"confirmDeleteProvider": "确认删除提供商 {{id}}?此操作不可撤销。",
|
||||||
|
"confirmDeleteAlias": "确认删除别名 {{alias}}?此操作不可撤销。",
|
||||||
|
"confirmUnbindTarget": "确认从 {{alias}} 解绑 {{provider}}/{{model}}?"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"title": "概览",
|
"title": "概览",
|
||||||
@ -67,6 +81,7 @@
|
|||||||
"actionsTitle": "快捷操作",
|
"actionsTitle": "快捷操作",
|
||||||
"statsTitle": "系统快照",
|
"statsTitle": "系统快照",
|
||||||
"environmentTitle": "环境信息",
|
"environmentTitle": "环境信息",
|
||||||
|
"environmentSubtitle": "仅保留当前运行态和桌面应用相关信息。",
|
||||||
"doctorTitle": "Doctor 摘要",
|
"doctorTitle": "Doctor 摘要",
|
||||||
"debugTitle": "调试详情",
|
"debugTitle": "调试详情",
|
||||||
"debugSummary": "查看原始 overview JSON",
|
"debugSummary": "查看原始 overview JSON",
|
||||||
@ -98,7 +113,9 @@
|
|||||||
"filterDisabled": "已禁用",
|
"filterDisabled": "已禁用",
|
||||||
"listCount": "显示 {{shown}} / 共 {{total}}",
|
"listCount": "显示 {{shown}} / 共 {{total}}",
|
||||||
"empty": "还没有配置任何提供商。",
|
"empty": "还没有配置任何提供商。",
|
||||||
|
"emptyHint": "你可以手动新建提供商,或从 OpenCode 配置中批量导入。",
|
||||||
"noMatches": "当前筛选条件下没有匹配的提供商。",
|
"noMatches": "当前筛选条件下没有匹配的提供商。",
|
||||||
|
"noMatchesHint": "尝试放宽搜索词或切换状态筛选。",
|
||||||
"id": "提供商 id",
|
"id": "提供商 id",
|
||||||
"name": "显示名称",
|
"name": "显示名称",
|
||||||
"baseUrl": "Base URL",
|
"baseUrl": "Base URL",
|
||||||
@ -114,6 +131,7 @@
|
|||||||
"apiKeyNotSet": "未设置",
|
"apiKeyNotSet": "未设置",
|
||||||
"headersNone": "无",
|
"headersNone": "无",
|
||||||
"modelsNone": "无",
|
"modelsNone": "无",
|
||||||
|
"modelsCount": "{{count}} 个模型",
|
||||||
"sourceLabel": "来源",
|
"sourceLabel": "来源",
|
||||||
"placeholderId": "su8",
|
"placeholderId": "su8",
|
||||||
"placeholderName": "SU8",
|
"placeholderName": "SU8",
|
||||||
@ -142,7 +160,9 @@
|
|||||||
"searchPlaceholder": "按别名、显示名、provider 或 model 过滤",
|
"searchPlaceholder": "按别名、显示名、provider 或 model 过滤",
|
||||||
"listCount": "显示 {{shown}} / 共 {{total}}",
|
"listCount": "显示 {{shown}} / 共 {{total}}",
|
||||||
"empty": "还没有配置任何别名。",
|
"empty": "还没有配置任何别名。",
|
||||||
|
"emptyHint": "先新建别名,再把上游 provider/model 绑定到它。",
|
||||||
"noMatches": "当前筛选条件下没有匹配的别名。",
|
"noMatches": "当前筛选条件下没有匹配的别名。",
|
||||||
|
"noMatchesHint": "尝试修改搜索词,或直接新建一个新的别名路由。",
|
||||||
"alias": "别名",
|
"alias": "别名",
|
||||||
"displayName": "显示名称",
|
"displayName": "显示名称",
|
||||||
"aliasForBinding": "用于绑定的别名",
|
"aliasForBinding": "用于绑定的别名",
|
||||||
@ -156,6 +176,7 @@
|
|||||||
"placeholderProviderId": "su8",
|
"placeholderProviderId": "su8",
|
||||||
"placeholderModel": "gpt-5.4",
|
"placeholderModel": "gpt-5.4",
|
||||||
"targets": "目标",
|
"targets": "目标",
|
||||||
|
"targetsCount": "{{count}} 个目标",
|
||||||
"routable": "可路由 {{available}}/{{total}}",
|
"routable": "可路由 {{available}}/{{total}}",
|
||||||
"statusSaved": "已保存别名 {{alias}}",
|
"statusSaved": "已保存别名 {{alias}}",
|
||||||
"statusEditing": "正在编辑别名 {{alias}}",
|
"statusEditing": "正在编辑别名 {{alias}}",
|
||||||
@ -194,9 +215,23 @@
|
|||||||
"appearanceTitle": "外观",
|
"appearanceTitle": "外观",
|
||||||
"languageTitle": "语言",
|
"languageTitle": "语言",
|
||||||
"behaviorTitle": "桌面行为",
|
"behaviorTitle": "桌面行为",
|
||||||
|
"configTitle": "配置文件",
|
||||||
|
"importTitle": "导入方式",
|
||||||
|
"importModeText": "文本导入",
|
||||||
|
"importModeFile": "文件导入",
|
||||||
|
"importText": "配置文本",
|
||||||
|
"importFile": "配置文件",
|
||||||
|
"importPlaceholder": "粘贴完整 ocswitch 配置 JSON",
|
||||||
|
"fileEmpty": "尚未选择文件。",
|
||||||
|
"exportDone": "已导出配置 {{path}}",
|
||||||
|
"importDone": "已导入配置 {{path}}",
|
||||||
|
"fileLoaded": "已读取文件 {{name}}",
|
||||||
|
"importEmpty": "请先提供配置内容。",
|
||||||
"aboutTitle": "关于",
|
"aboutTitle": "关于",
|
||||||
|
"aboutSubtitle": "显示当前桌面壳环境信息,仅用于查看。",
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
|
"languageLabel": "首选语言",
|
||||||
"themeSystem": "跟随系统",
|
"themeSystem": "跟随系统",
|
||||||
"themeLight": "浅色",
|
"themeLight": "浅色",
|
||||||
"themeDark": "深色",
|
"themeDark": "深色",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,20 @@ export type DesktopPrefsSaveResult = {
|
|||||||
warnings?: string[]
|
warnings?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConfigExportView = {
|
||||||
|
configPath: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfigImportInput = {
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfigImportResult = {
|
||||||
|
configPath: string
|
||||||
|
warnings?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export type ProxyStatusView = {
|
export type ProxyStatusView = {
|
||||||
running: boolean
|
running: boolean
|
||||||
bindAddress: string
|
bindAddress: string
|
||||||
|
|||||||
4
frontend/wailsjs/go/desktop/App.d.ts
vendored
4
frontend/wailsjs/go/desktop/App.d.ts
vendored
@ -22,6 +22,10 @@ export function DesktopPrefs():Promise<app.DesktopPrefsView>;
|
|||||||
|
|
||||||
export function DoctorRun():Promise<app.DoctorRunResult>;
|
export function DoctorRun():Promise<app.DoctorRunResult>;
|
||||||
|
|
||||||
|
export function ExportConfig():Promise<app.ConfigExportView>;
|
||||||
|
|
||||||
|
export function ImportConfig(arg1:app.ConfigImportInput):Promise<app.ConfigImportResult>;
|
||||||
|
|
||||||
export function ImportProviders(arg1:app.ProviderImportInput):Promise<app.ProviderImportResult>;
|
export function ImportProviders(arg1:app.ProviderImportInput):Promise<app.ProviderImportResult>;
|
||||||
|
|
||||||
export function Meta():Promise<Record<string, string>>;
|
export function Meta():Promise<Record<string, string>>;
|
||||||
|
|||||||
@ -38,6 +38,14 @@ export function DoctorRun() {
|
|||||||
return window['go']['desktop']['App']['DoctorRun']();
|
return window['go']['desktop']['App']['DoctorRun']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExportConfig() {
|
||||||
|
return window['go']['desktop']['App']['ExportConfig']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportConfig(arg1) {
|
||||||
|
return window['go']['desktop']['App']['ImportConfig'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ImportProviders(arg1) {
|
export function ImportProviders(arg1) {
|
||||||
return window['go']['desktop']['App']['ImportProviders'](arg1);
|
return window['go']['desktop']['App']['ImportProviders'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,6 +90,46 @@ export namespace app {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ConfigExportView {
|
||||||
|
configPath: string;
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ConfigExportView(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.configPath = source["configPath"];
|
||||||
|
this.content = source["content"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ConfigImportInput {
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ConfigImportInput(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.content = source["content"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ConfigImportResult {
|
||||||
|
configPath: string;
|
||||||
|
warnings?: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ConfigImportResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.configPath = source["configPath"];
|
||||||
|
this.warnings = source["warnings"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class DesktopPrefsInput {
|
export class DesktopPrefsInput {
|
||||||
launchAtLogin: boolean;
|
launchAtLogin: boolean;
|
||||||
minimizeToTray: boolean;
|
minimizeToTray: boolean;
|
||||||
|
|||||||
90
internal/app/config_transfer.go
Normal file
90
internal/app/config_transfer.go
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Apale7/opencode-provider-switch/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) ExportConfig(ctx context.Context) (ConfigExportView, error) {
|
||||||
|
_ = ctx
|
||||||
|
cfg, err := s.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return ConfigExportView{}, err
|
||||||
|
}
|
||||||
|
content, err := marshalConfigContent(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return ConfigExportView{}, err
|
||||||
|
}
|
||||||
|
return ConfigExportView{ConfigPath: cfg.Path(), Content: content}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ImportConfig(ctx context.Context, in ConfigImportInput) (ConfigImportResult, error) {
|
||||||
|
_ = ctx
|
||||||
|
content := strings.TrimSpace(in.Content)
|
||||||
|
if content == "" {
|
||||||
|
return ConfigImportResult{}, fmt.Errorf("config content is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
imported := config.Default()
|
||||||
|
if err := json.Unmarshal([]byte(content), imported); err != nil {
|
||||||
|
return ConfigImportResult{}, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
if imported.Server.Host == "" {
|
||||||
|
imported.Server.Host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if imported.Server.Port == 0 {
|
||||||
|
imported.Server.Port = 9982
|
||||||
|
}
|
||||||
|
if imported.Server.APIKey == "" {
|
||||||
|
imported.Server.APIKey = config.DefaultLocalAPIKey
|
||||||
|
}
|
||||||
|
if errs := imported.Validate(); len(errs) > 0 {
|
||||||
|
return ConfigImportResult{}, errs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := s.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return ConfigImportResult{}, err
|
||||||
|
}
|
||||||
|
cfg.Server = imported.Server
|
||||||
|
cfg.Desktop = imported.Desktop
|
||||||
|
cfg.Providers = append([]config.Provider(nil), imported.Providers...)
|
||||||
|
cfg.Aliases = append([]config.Alias(nil), imported.Aliases...)
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
return ConfigImportResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ConfigImportResult{ConfigPath: cfg.Path()}
|
||||||
|
if s.currentProxyStatus(proxyBindAddress(cfg)).Running {
|
||||||
|
result.Warnings = append(result.Warnings, "proxy is still running with the previous in-memory config; restart it to apply imported settings")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalConfigContent(cfg *config.Config) (string, error) {
|
||||||
|
providers := append([]config.Provider(nil), cfg.Providers...)
|
||||||
|
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
|
||||||
|
aliases := append([]config.Alias(nil), cfg.Aliases...)
|
||||||
|
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
|
||||||
|
snapshot := struct {
|
||||||
|
Server config.Server `json:"server"`
|
||||||
|
Desktop config.Desktop `json:"desktop,omitempty"`
|
||||||
|
Providers []config.Provider `json:"providers"`
|
||||||
|
Aliases []config.Alias `json:"aliases"`
|
||||||
|
}{
|
||||||
|
Server: cfg.Server,
|
||||||
|
Desktop: cfg.Desktop,
|
||||||
|
Providers: providers,
|
||||||
|
Aliases: aliases,
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(snapshot, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal config: %w", err)
|
||||||
|
}
|
||||||
|
return string(append(data, '\n')), nil
|
||||||
|
}
|
||||||
@ -316,7 +316,7 @@ func (s *Service) currentProxyStatus(bindAddress string) ProxyStatusView {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
status := s.proxyStatus
|
status := s.proxyStatus
|
||||||
if status.BindAddress == "" {
|
if !status.Running || status.BindAddress == "" {
|
||||||
status.BindAddress = bindAddress
|
status.BindAddress = bindAddress
|
||||||
}
|
}
|
||||||
return status
|
return status
|
||||||
|
|||||||
@ -116,6 +116,52 @@ func TestStartStopProxyUpdatesStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetProxyStatusUsesCurrentConfigAddressWhenStopped(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "ocswitch.json")
|
||||||
|
firstPort := freePort(t)
|
||||||
|
secondPort := freePort(t)
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Server.Host = "127.0.0.1"
|
||||||
|
cfg.Server.Port = firstPort
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
t.Fatalf("cfg.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewService(path)
|
||||||
|
if err := svc.StartProxy(context.Background()); err != nil {
|
||||||
|
t.Fatalf("StartProxy() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.StopProxy(context.Background()); err != nil {
|
||||||
|
t.Fatalf("StopProxy() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err = config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() reload error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Server.Port = secondPort
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
t.Fatalf("cfg.Save() update error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := svc.GetProxyStatus(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProxyStatus() error = %v", err)
|
||||||
|
}
|
||||||
|
if status.Running {
|
||||||
|
t.Fatalf("status.Running = true, want false")
|
||||||
|
}
|
||||||
|
want := "127.0.0.1:" + itoa(secondPort)
|
||||||
|
if status.BindAddress != want {
|
||||||
|
t.Fatalf("status.BindAddress = %q, want %q", status.BindAddress, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpsertProviderReturnsWarningsAndKeepsCatalog(t *testing.T) {
|
func TestUpsertProviderReturnsWarningsAndKeepsCatalog(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@ -115,6 +115,20 @@ type DesktopPrefsInput struct {
|
|||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConfigExportView struct {
|
||||||
|
ConfigPath string `json:"configPath"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigImportInput struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigImportResult struct {
|
||||||
|
ConfigPath string `json:"configPath"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProviderUpsertInput struct {
|
type ProviderUpsertInput struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
|
|||||||
@ -86,12 +86,10 @@ func (a *App) SyncDesktopPreferences(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := a.auto.Sync(ctx, prefs); err != nil {
|
autoErr := a.auto.Sync(ctx, prefs)
|
||||||
return err
|
|
||||||
}
|
|
||||||
a.tray.Sync(ctx, prefs)
|
a.tray.Sync(ctx, prefs)
|
||||||
a.notify.Sync(ctx, prefs)
|
a.notify.Sync(ctx, prefs)
|
||||||
return nil
|
return autoErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsSaveResult, error) {
|
func (a *App) SaveDesktopPrefs(ctx context.Context, in app.DesktopPrefsInput) (app.DesktopPrefsSaveResult, error) {
|
||||||
@ -127,6 +125,22 @@ func (a *App) Overview() (app.Overview, error) {
|
|||||||
return a.bindings.GetOverview(a.callContext())
|
return a.bindings.GetOverview(a.callContext())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ExportConfig() (app.ConfigExportView, error) {
|
||||||
|
return a.bindings.ExportConfig(a.callContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ImportConfig(in app.ConfigImportInput) (app.ConfigImportResult, error) {
|
||||||
|
result, err := a.bindings.ImportConfig(a.callContext(), in)
|
||||||
|
if err != nil {
|
||||||
|
return app.ConfigImportResult{}, err
|
||||||
|
}
|
||||||
|
if syncErr := a.SyncDesktopPreferences(a.callContext()); syncErr != nil {
|
||||||
|
result.Warnings = append(result.Warnings, fmt.Sprintf("imported config but could not sync desktop integrations: %v", syncErr))
|
||||||
|
}
|
||||||
|
a.tray.RefreshProxyStatus(a.callContext())
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) Providers() ([]app.ProviderView, error) {
|
func (a *App) Providers() ([]app.ProviderView, error) {
|
||||||
return a.bindings.ListProviders(a.callContext())
|
return a.bindings.ListProviders(a.callContext())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,14 @@ func (b *Bindings) GetOverview(ctx context.Context) (app.Overview, error) {
|
|||||||
return b.service.GetOverview(ctx)
|
return b.service.GetOverview(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) ExportConfig(ctx context.Context) (app.ConfigExportView, error) {
|
||||||
|
return b.service.ExportConfig(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) ImportConfig(ctx context.Context, in app.ConfigImportInput) (app.ConfigImportResult, error) {
|
||||||
|
return b.service.ImportConfig(ctx, in)
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bindings) ListProviders(ctx context.Context) ([]app.ProviderView, error) {
|
func (b *Bindings) ListProviders(ctx context.Context) ([]app.ProviderView, error) {
|
||||||
return b.service.ListProviders(ctx)
|
return b.service.ListProviders(ctx)
|
||||||
}
|
}
|
||||||
@ -99,6 +107,14 @@ func (b *Bindings) Overview() (app.Overview, error) {
|
|||||||
return b.GetOverview(context.Background())
|
return b.GetOverview(context.Background())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) ExportConfigNow() (app.ConfigExportView, error) {
|
||||||
|
return b.ExportConfig(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bindings) ImportConfigNow(in app.ConfigImportInput) (app.ConfigImportResult, error) {
|
||||||
|
return b.ImportConfig(context.Background(), in)
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bindings) Providers() ([]app.ProviderView, error) {
|
func (b *Bindings) Providers() ([]app.ProviderView, error) {
|
||||||
return b.ListProviders(context.Background())
|
return b.ListProviders(context.Background())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,6 +123,28 @@ func newHandler(instance *App, version string, baseURL string) (http.Handler, er
|
|||||||
writeResult(w, data, err)
|
writeResult(w, data, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/config/export", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := b.ExportConfig(r.Context())
|
||||||
|
writeResult(w, data, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
api.HandleFunc("/api/config/import", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeMethodNotAllowed(w, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in appcore.ConfigImportInput
|
||||||
|
if !decodeJSONBody(w, r, &in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := instance.ImportConfig(in)
|
||||||
|
writeResult(w, data, err)
|
||||||
|
})
|
||||||
|
|
||||||
api.HandleFunc("/api/providers", func(w http.ResponseWriter, r *http.Request) {
|
api.HandleFunc("/api/providers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
|||||||
@ -136,6 +136,91 @@ func TestDesktopHTTPHandlerServesOverviewAndStaticApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("import config syncs desktop integrations and reports warning", func(t *testing.T) {
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.UpsertProvider(config.Provider{
|
||||||
|
ID: "demo",
|
||||||
|
Name: "Demo",
|
||||||
|
BaseURL: "https://example.com/v1",
|
||||||
|
APIKey: "sk-demo-12345678",
|
||||||
|
Models: []string{"gpt-4.1-mini"},
|
||||||
|
})
|
||||||
|
cfg.UpsertAlias(config.Alias{
|
||||||
|
Alias: "chat",
|
||||||
|
DisplayName: "Chat",
|
||||||
|
Enabled: true,
|
||||||
|
Targets: []config.Target{{
|
||||||
|
Provider: "demo",
|
||||||
|
Model: "gpt-4.1-mini",
|
||||||
|
Enabled: true,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
t.Fatalf("cfg.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalAuto := instance.auto
|
||||||
|
originalTray := instance.tray
|
||||||
|
originalNotify := instance.notify
|
||||||
|
auto := failingAutoStart{message: "startup folder unavailable"}
|
||||||
|
tray := &spyTray{}
|
||||||
|
notify := &spyNotifier{}
|
||||||
|
instance.auto = auto
|
||||||
|
instance.tray = tray
|
||||||
|
instance.notify = notify
|
||||||
|
defer func() {
|
||||||
|
instance.auto = originalAuto
|
||||||
|
instance.tray = originalTray
|
||||||
|
instance.notify = originalNotify
|
||||||
|
}()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/config/import", strings.NewReader(`{
|
||||||
|
"content":"{\"server\":{\"host\":\"127.0.0.1\",\"port\":9982,\"api_key\":\"ocswitch-local\"},\"desktop\":{\"launch_at_login\":true,\"minimize_to_tray\":true,\"notifications\":true,\"theme\":\"dark\",\"language\":\"zh-CN\"},\"providers\":[{\"id\":\"demo\",\"name\":\"Demo\",\"base_url\":\"https://example.com/v1\",\"api_key\":\"sk-demo-12345678\",\"models\":[\"gpt-4.1-mini\"]}],\"aliases\":[{\"alias\":\"chat\",\"display_name\":\"Chat\",\"enabled\":true,\"targets\":[{\"provider\":\"demo\",\"model\":\"gpt-4.1-mini\",\"enabled\":true}]}]}"
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Data struct {
|
||||||
|
ConfigPath string `json:"configPath"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if payload.Data.ConfigPath == "" {
|
||||||
|
t.Fatalf("configPath is empty: %#v", payload.Data)
|
||||||
|
}
|
||||||
|
if len(payload.Data.Warnings) != 1 || !strings.Contains(payload.Data.Warnings[0], "sync desktop integrations") {
|
||||||
|
t.Fatalf("warnings = %#v, want desktop sync warning", payload.Data.Warnings)
|
||||||
|
}
|
||||||
|
if tray.syncCalls != 1 {
|
||||||
|
t.Fatalf("tray syncCalls = %d, want 1", tray.syncCalls)
|
||||||
|
}
|
||||||
|
if tray.refreshCalls != 1 {
|
||||||
|
t.Fatalf("tray refreshCalls = %d, want 1", tray.refreshCalls)
|
||||||
|
}
|
||||||
|
if !notify.lastPrefs.Notifications {
|
||||||
|
t.Fatalf("notify prefs = %#v, want notifications enabled", notify.lastPrefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config.Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if !loaded.Desktop.LaunchAtLogin || !loaded.Desktop.MinimizeToTray || !loaded.Desktop.Notifications {
|
||||||
|
t.Fatalf("persisted desktop prefs = %#v", loaded.Desktop)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("provider save exposes warnings", func(t *testing.T) {
|
t.Run("provider save exposes warnings", func(t *testing.T) {
|
||||||
providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
@ -295,3 +380,44 @@ func (f failingAutoStart) Detach() {}
|
|||||||
func (f failingAutoStart) Sync(_ context.Context, _ app.DesktopPrefsView) error {
|
func (f failingAutoStart) Sync(_ context.Context, _ app.DesktopPrefsView) error {
|
||||||
return fmt.Errorf(f.message)
|
return fmt.Errorf(f.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type spyTray struct {
|
||||||
|
syncCalls int
|
||||||
|
refreshCalls int
|
||||||
|
lastPrefs app.DesktopPrefsView
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spyTray) Attach(_ context.Context) {}
|
||||||
|
|
||||||
|
func (s *spyTray) Detach() {}
|
||||||
|
|
||||||
|
func (s *spyTray) Sync(_ context.Context, prefs app.DesktopPrefsView) {
|
||||||
|
s.syncCalls++
|
||||||
|
s.lastPrefs = prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spyTray) RefreshProxyStatus(_ context.Context) {
|
||||||
|
s.refreshCalls++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spyTray) BeforeClose(_ context.Context) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type spyNotifier struct {
|
||||||
|
syncCalls int
|
||||||
|
lastPrefs app.DesktopPrefsView
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spyNotifier) Attach(_ context.Context) {}
|
||||||
|
|
||||||
|
func (s *spyNotifier) Detach() {}
|
||||||
|
|
||||||
|
func (s *spyNotifier) Sync(_ context.Context, prefs app.DesktopPrefsView) {
|
||||||
|
s.syncCalls++
|
||||||
|
s.lastPrefs = prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spyNotifier) Send(_ context.Context, _ string, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user