feat: add VS Code ChatGPT plugin config sync functionality
This commit is contained in:
@@ -9,6 +9,7 @@ use crate::codex_config;
|
|||||||
use crate::config::{get_claude_settings_path, ConfigStatus};
|
use crate::config::{get_claude_settings_path, ConfigStatus};
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
use crate::vscode_config;
|
||||||
|
|
||||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
|
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
|
||||||
match app_type {
|
match app_type {
|
||||||
@@ -41,6 +42,44 @@ fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_base_url_from_toml(cfg_text: &str) -> Result<Option<String>, String> {
|
||||||
|
if cfg_text.trim().is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: toml::Value =
|
||||||
|
toml::from_str(cfg_text).map_err(|e| format!("解析 config.toml 失败: {}", e))?;
|
||||||
|
|
||||||
|
fn walk(value: &toml::Value) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
toml::Value::Table(table) => {
|
||||||
|
if let Some(toml::Value::String(v)) = table.get("base_url") {
|
||||||
|
if !v.trim().is_empty() {
|
||||||
|
return Some(v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for item in table.values() {
|
||||||
|
if let Some(found) = walk(item) {
|
||||||
|
return Some(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
toml::Value::Array(arr) => {
|
||||||
|
for item in arr {
|
||||||
|
if let Some(found) = walk(item) {
|
||||||
|
return Some(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(walk(&value))
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取所有供应商
|
/// 获取所有供应商
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_providers(
|
pub async fn get_providers(
|
||||||
@@ -360,6 +399,26 @@ pub async fn switch_provider(
|
|||||||
.get("config")
|
.get("config")
|
||||||
.and_then(|v| v.as_str());
|
.and_then(|v| v.as_str());
|
||||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||||
|
|
||||||
|
let is_official = provider
|
||||||
|
.category
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c == "official")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if is_official {
|
||||||
|
vscode_config::write_vscode_settings(None)?;
|
||||||
|
} else {
|
||||||
|
let cfg_text = cfg_text.unwrap_or_default();
|
||||||
|
match extract_base_url_from_toml(cfg_text)? {
|
||||||
|
Some(base_url) => {
|
||||||
|
vscode_config::write_vscode_settings(Some(&base_url))?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err("目标 Codex 配置缺少 base_url 字段".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AppType::Claude => {
|
AppType::Claude => {
|
||||||
use crate::config::{read_json_file, write_json_file};
|
use crate::config::{read_json_file, write_json_file};
|
||||||
@@ -569,6 +628,17 @@ pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, S
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 写入 VS Code 配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn write_vscode_settings_command(
|
||||||
|
base_url: Option<String>,
|
||||||
|
baseUrl: Option<String>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let payload = base_url.or(baseUrl);
|
||||||
|
vscode_config::write_vscode_settings(payload.as_deref())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取应用配置文件路径
|
/// 获取应用配置文件路径
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_app_config_path() -> Result<String, String> {
|
pub async fn get_app_config_path() -> Result<String, String> {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod config;
|
|||||||
mod migration;
|
mod migration;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod store;
|
mod store;
|
||||||
|
mod vscode_config;
|
||||||
|
|
||||||
use store::AppState;
|
use store::AppState;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -352,6 +353,7 @@ pub fn run() {
|
|||||||
commands::get_claude_code_config_path,
|
commands::get_claude_code_config_path,
|
||||||
commands::open_config_folder,
|
commands::open_config_folder,
|
||||||
commands::open_external,
|
commands::open_external,
|
||||||
|
commands::write_vscode_settings_command,
|
||||||
commands::get_app_config_path,
|
commands::get_app_config_path,
|
||||||
commands::open_app_config_folder,
|
commands::open_app_config_folder,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
|
|||||||
115
src-tauri/src/vscode_config.rs
Normal file
115
src-tauri/src/vscode_config.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::config::write_json_file;
|
||||||
|
|
||||||
|
/// VS Code 默认用户配置子目录
|
||||||
|
const MAC_CODE_USER_DIR: &str = "Library/Application Support/Code/User";
|
||||||
|
|
||||||
|
/// 解析 VS Code 用户 settings.json 路径
|
||||||
|
pub fn get_vscode_settings_path() -> PathBuf {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
return dirs::home_dir()
|
||||||
|
.expect("无法获取用户主目录")
|
||||||
|
.join(MAC_CODE_USER_DIR)
|
||||||
|
.join("settings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
return dirs::home_dir()
|
||||||
|
.expect("无法获取用户主目录")
|
||||||
|
.join(".config/Code/User")
|
||||||
|
.join("settings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
if let Some(data_dir) = dirs::data_dir() {
|
||||||
|
return data_dir.join("Code/User").join("settings.json");
|
||||||
|
}
|
||||||
|
return dirs::home_dir()
|
||||||
|
.expect("无法获取用户主目录")
|
||||||
|
.join("AppData/Roaming")
|
||||||
|
.join("Code/User")
|
||||||
|
.join("settings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||||
|
{
|
||||||
|
dirs::home_dir()
|
||||||
|
.expect("无法获取用户主目录")
|
||||||
|
.join(".config/Code/User")
|
||||||
|
.join("settings.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_settings(path: &Path) -> Result<Map<String, Value>, String> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Map::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content =
|
||||||
|
std::fs::read_to_string(path).map_err(|e| format!("读取 VS Code 设置失败: {}", e))?;
|
||||||
|
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return Ok(Map::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
match serde_json::from_str::<Value>(&content) {
|
||||||
|
Ok(Value::Object(obj)) => Ok(obj),
|
||||||
|
Ok(_) => Err("VS Code settings.json 必须为 JSON 对象".to_string()),
|
||||||
|
Err(err) => Err(format!("解析 VS Code settings.json 失败: {}", err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_settings(path: &Path, map: Map<String, Value>) -> Result<(), String> {
|
||||||
|
let value = Value::Object(map);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| format!("创建 VS Code 配置目录失败: {}", e))?;
|
||||||
|
}
|
||||||
|
write_json_file(path, &value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入或移除 chatgpt 相关 VS Code 配置
|
||||||
|
///
|
||||||
|
/// - `base_url` 为 Some 时更新/覆盖 `"chatgpt.apiBase"` 与 `"chatgpt.config"`
|
||||||
|
/// - `base_url` 为 None 时删除上述字段
|
||||||
|
pub fn write_vscode_settings(base_url: Option<&str>) -> Result<(), String> {
|
||||||
|
let path = get_vscode_settings_path();
|
||||||
|
let mut map = load_settings(&path)?;
|
||||||
|
|
||||||
|
match base_url {
|
||||||
|
Some(url) => {
|
||||||
|
if url.trim().is_empty() {
|
||||||
|
return Err("base_url 不能为空".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
map.insert(
|
||||||
|
"chatgpt.apiBase".to_string(),
|
||||||
|
Value::String(url.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let entry = map
|
||||||
|
.entry("chatgpt.config".to_string())
|
||||||
|
.or_insert_with(|| Value::Object(Map::new()));
|
||||||
|
|
||||||
|
let obj = match entry {
|
||||||
|
Value::Object(o) => o,
|
||||||
|
_ => return Err("VS Code settings 中 chatgpt.config 必须是 JSON 对象".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
obj.insert(
|
||||||
|
"preferred_auth_method".to_string(),
|
||||||
|
Value::String("apikey".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
map.remove("chatgpt.apiBase");
|
||||||
|
map.remove("chatgpt.config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
persist_settings(&path, map)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { X, Save } from "lucide-react";
|
import { X, Save } from "lucide-react";
|
||||||
|
import { extractBaseUrlFromToml } from "../../utils/providerConfigUtils";
|
||||||
|
|
||||||
interface CodexConfigEditorProps {
|
interface CodexConfigEditorProps {
|
||||||
authValue: string;
|
authValue: string;
|
||||||
@@ -29,6 +30,9 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
authError,
|
authError,
|
||||||
}) => {
|
}) => {
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||||
|
const [isWritingVscode, setIsWritingVscode] = useState(false);
|
||||||
|
const [vscodeError, setVscodeError] = useState("");
|
||||||
|
const [vscodeSuccess, setVscodeSuccess] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||||
@@ -36,6 +40,14 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
}
|
}
|
||||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vscodeSuccess) return;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setVscodeSuccess("");
|
||||||
|
}, 3000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [vscodeSuccess]);
|
||||||
|
|
||||||
// 支持按下 ESC 关闭弹窗
|
// 支持按下 ESC 关闭弹窗
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCommonConfigModalOpen) return;
|
if (!isCommonConfigModalOpen) return;
|
||||||
@@ -66,6 +78,42 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
onCommonConfigSnippetChange(value);
|
onCommonConfigSnippetChange(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleWriteVscodeConfig = async () => {
|
||||||
|
setVscodeError("");
|
||||||
|
setVscodeSuccess("");
|
||||||
|
|
||||||
|
if (typeof window === "undefined" || !window.api?.writeVscodeSettings) {
|
||||||
|
setVscodeError("当前环境暂不支持写入 VS Code 配置");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = configValue.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setVscodeError("请先填写 config.toml,再写入 VS Code 配置");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = extractBaseUrlFromToml(trimmed);
|
||||||
|
if (!baseUrl) {
|
||||||
|
setVscodeError("未在 config.toml 中找到 base_url 字段");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsWritingVscode(true);
|
||||||
|
try {
|
||||||
|
const success = await window.api.writeVscodeSettings(baseUrl);
|
||||||
|
if (success) {
|
||||||
|
setVscodeSuccess("已写入 VS Code 配置");
|
||||||
|
} else {
|
||||||
|
setVscodeError("写入 VS Code 配置失败,请稍后重试");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setVscodeError(`写入 VS Code 配置失败: ${String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setIsWritingVscode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -124,7 +172,15 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
写入通用配置
|
写入通用配置
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleWriteVscodeConfig}
|
||||||
|
disabled={isWritingVscode}
|
||||||
|
className="text-xs text-blue-500 dark:text-blue-400 hover:underline disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isWritingVscode ? "写入中..." : "写入 VS Code 配置"}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsCommonConfigModalOpen(true)}
|
onClick={() => setIsCommonConfigModalOpen(true)}
|
||||||
@@ -138,6 +194,16 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
{commonConfigError}
|
{commonConfigError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{vscodeError && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||||
|
{vscodeError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{vscodeSuccess && !vscodeError && (
|
||||||
|
<p className="text-xs text-emerald-600 dark:text-emerald-400 text-right">
|
||||||
|
{vscodeSuccess}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
id="codexConfig"
|
id="codexConfig"
|
||||||
value={configValue}
|
value={configValue}
|
||||||
|
|||||||
@@ -159,6 +159,16 @@ export const tauriAPI = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 写入 VS Code 配置
|
||||||
|
writeVscodeSettings: async (baseUrl?: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
return await invoke("write_vscode_settings_command", { baseUrl });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("写入 VS Code 配置失败:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 打开外部链接
|
// 打开外部链接
|
||||||
openExternal: async (url: string): Promise<void> => {
|
openExternal: async (url: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -288,3 +288,10 @@ export const hasTomlCommonConfigSnippet = (
|
|||||||
|
|
||||||
return existingSnippet === snippetString.trim();
|
return existingSnippet === snippetString.trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 从 Codex TOML 配置中提取 base_url
|
||||||
|
export const extractBaseUrlFromToml = (tomlString: string): string => {
|
||||||
|
if (!tomlString) return "";
|
||||||
|
const match = tomlString.match(/base_url\s*=\s*"([^"]+)"/);
|
||||||
|
return match?.[1] ?? "";
|
||||||
|
};
|
||||||
|
|||||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -30,6 +30,7 @@ declare global {
|
|||||||
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
|
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
|
||||||
selectConfigFile: () => Promise<string | null>;
|
selectConfigFile: () => Promise<string | null>;
|
||||||
openConfigFolder: (app?: AppType) => Promise<void>;
|
openConfigFolder: (app?: AppType) => Promise<void>;
|
||||||
|
writeVscodeSettings: (baseUrl?: string) => Promise<boolean>;
|
||||||
openExternal: (url: string) => Promise<void>;
|
openExternal: (url: string) => Promise<void>;
|
||||||
updateTrayMenu: () => Promise<boolean>;
|
updateTrayMenu: () => Promise<boolean>;
|
||||||
onProviderSwitched: (
|
onProviderSwitched: (
|
||||||
|
|||||||
Reference in New Issue
Block a user