mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 10:25:05 +08:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db4a1ea907 | |||
| 58b7dc32b3 | |||
| 65ccabd5ad | |||
| a3a81806f8 | |||
| 5104045ffb | |||
| 7898096de3 | |||
| cc235c6c63 | |||
| 1b20b7ff88 | |||
| f8820aa22c | |||
| 3afec8a10f | |||
| 5ebc879f09 | |||
| 6b4ba64bbd | |||
| 5763b9094b | |||
| ce92f37ef0 | |||
| 2e676e5f53 | |||
| c3f29a62d1 | |||
| 165af5eec4 | |||
| 8b9c09d994 | |||
| a8d391bd74 | |||
| 7d9b20721e | |||
| d11df17b5d | |||
| 4c88174cb0 | |||
| 5a72888852 | |||
| 54f1bfb5d0 | |||
| 4efab15174 | |||
| 357d32c17e | |||
| 7532308519 | |||
| 4440a14082 | |||
| d1bb4480db | |||
| d04c279890 | |||
| 3125b5419c | |||
| 0fa6b33b5e | |||
| 1b71dc721c | |||
| 8e219b5eb1 | |||
| 51476953ae | |||
| 0f4ce74916 | |||
| 9514d08ef6 | |||
| adaef3522d | |||
| 4c8334c6fd | |||
| 6caf843843 | |||
| 6c38a8fd24 | |||
| 977813f725 | |||
| 11f1ef33e4 | |||
| 20f62bf4f8 | |||
| 508aa6070c |
@@ -61,8 +61,13 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://crazyrouter.com/register?ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
|
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
|
||||||
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, GPT, and more. All through a single OpenAI-compatible endpoint with zero code changes. Features include auto-failover, smart routing, unlimited concurrency, and global low-latency access. <a href="https://crazyrouter.com/register?ref=cc-switch">Register here</a> to get started.</td>
|
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">this link</a> to get <strong>$2 free credit</strong> instantly, plus enter promo code `CCSWITCH` on your first top-up for an extra <strong>30% bonus credit</strong>! </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||||
|
<td>Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, <strong>offering high cost-effective official Claude service at just ¥0.5/$ equivalent</strong>, supporting monthly and pay-as-you-go billing plans with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via <a href="https://www.sssaicode.com/register?ref=DCP0SM">this link</a> to enjoy $10 extra credit on every top-up!</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+7
-2
@@ -61,8 +61,13 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://crazyrouter.com/register?ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
|
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
|
||||||
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI、GPT など 300 以上のモデルにアクセス可能。OpenAI 互換エンドポイントでコード変更不要。自動フェイルオーバー、スマートルーティング、無制限同時接続、グローバル低遅延アクセスに対応。<a href="https://crazyrouter.com/register?ref=cc-switch">こちらから登録</a>してすぐにご利用いただけます。</td>
|
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">こちらのリンク</a>から登録すると <strong>$2 の無料クレジット</strong> を即時進呈。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます!</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||||
|
<td>SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。<strong>高コストパフォーマンスの公式 Claude サービスを 0.5¥/$ 換算で提供</strong>、月額制・Paygo など多様な課金方式に対応し、当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href="https://www.sssaicode.com/register?ref=DCP0SM">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+7
-2
@@ -62,8 +62,13 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://crazyrouter.com/register?ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
|
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
|
||||||
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI、GPT 等。通过单一 OpenAI 兼容端点实现零代码改动接入。支持自动故障转移、智能路由、无限并发和全球低延迟访问。<a href="https://crazyrouter.com/register?ref=cc-switch">点击这里注册</a>即可开始使用!</td>
|
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">此链接</a>注册即可获得 <strong>$2 免费额度</strong>,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 <strong>30% 奖励额度</strong>!</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
|
||||||
|
<td>感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,<strong>提供高性价比折合0.5¥/$的官方Claude服务</strong>,支持包月、Paygo多种计费方式、支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用<a href="https://www.sssaicode.com/register?ref=DCP0SM">此链接</a>注册每次充值均可享受10$的额外奖励!</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 447 KiB |
@@ -57,7 +57,7 @@ url = "2.5"
|
|||||||
auto-launch = "0.5"
|
auto-launch = "0.5"
|
||||||
once_cell = "1.21.3"
|
once_cell = "1.21.3"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
rusqlite = { version = "0.31", features = ["bundled", "backup", "hooks"] }
|
||||||
indexmap = { version = "2", features = ["serde"] }
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
rust_decimal = "1.33"
|
rust_decimal = "1.33"
|
||||||
uuid = { version = "1.11", features = ["v4"] }
|
uuid = { version = "1.11", features = ["v4"] }
|
||||||
|
|||||||
+16
-60
@@ -129,6 +129,20 @@ impl SkillApps {
|
|||||||
apps.set_enabled_for(app, true);
|
apps.set_enabled_for(app, true);
|
||||||
apps
|
apps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从来源标签列表构建启用状态
|
||||||
|
///
|
||||||
|
/// 标签与 AppType::as_str() 一致时启用对应应用,
|
||||||
|
/// 其他标签(如 "agents", "cc-switch")忽略。
|
||||||
|
pub fn from_labels(labels: &[String]) -> Self {
|
||||||
|
let mut apps = Self::default();
|
||||||
|
for label in labels {
|
||||||
|
if let Ok(app) = label.parse::<AppType>() {
|
||||||
|
apps.set_enabled_for(&app, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apps
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 已安装的 Skill(v3.10.0+ 统一结构)
|
/// 已安装的 Skill(v3.10.0+ 统一结构)
|
||||||
@@ -175,6 +189,8 @@ pub struct UnmanagedSkill {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
/// 在哪些应用目录中发现(如 ["claude", "codex"])
|
/// 在哪些应用目录中发现(如 ["claude", "codex"])
|
||||||
pub found_in: Vec<String>,
|
pub found_in: Vec<String>,
|
||||||
|
/// 发现路径(首个匹配的完整路径)
|
||||||
|
pub path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MCP 服务器定义(v3.7.0 统一结构)
|
/// MCP 服务器定义(v3.7.0 统一结构)
|
||||||
@@ -336,49 +352,6 @@ impl FromStr for AppType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通用配置片段(按应用分治)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct CommonConfigSnippets {
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub claude: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub codex: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub gemini: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub opencode: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub openclaw: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommonConfigSnippets {
|
|
||||||
/// 获取指定应用的通用配置片段
|
|
||||||
pub fn get(&self, app: &AppType) -> Option<&String> {
|
|
||||||
match app {
|
|
||||||
AppType::Claude => self.claude.as_ref(),
|
|
||||||
AppType::Codex => self.codex.as_ref(),
|
|
||||||
AppType::Gemini => self.gemini.as_ref(),
|
|
||||||
AppType::OpenCode => self.opencode.as_ref(),
|
|
||||||
AppType::OpenClaw => self.openclaw.as_ref(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置指定应用的通用配置片段
|
|
||||||
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
|
|
||||||
match app {
|
|
||||||
AppType::Claude => self.claude = snippet,
|
|
||||||
AppType::Codex => self.codex = snippet,
|
|
||||||
AppType::Gemini => self.gemini = snippet,
|
|
||||||
AppType::OpenCode => self.opencode = snippet,
|
|
||||||
AppType::OpenClaw => self.openclaw = snippet,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 多应用配置结构(向后兼容)
|
/// 多应用配置结构(向后兼容)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MultiAppConfig {
|
pub struct MultiAppConfig {
|
||||||
@@ -396,12 +369,6 @@ pub struct MultiAppConfig {
|
|||||||
/// Claude Skills 配置
|
/// Claude Skills 配置
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub skills: SkillStore,
|
pub skills: SkillStore,
|
||||||
/// 通用配置片段(按应用分治)
|
|
||||||
#[serde(default)]
|
|
||||||
pub common_config_snippets: CommonConfigSnippets,
|
|
||||||
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub claude_common_config_snippet: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_version() -> u32 {
|
fn default_version() -> u32 {
|
||||||
@@ -423,8 +390,6 @@ impl Default for MultiAppConfig {
|
|||||||
mcp: McpRoot::default(),
|
mcp: McpRoot::default(),
|
||||||
prompts: PromptRoot::default(),
|
prompts: PromptRoot::default(),
|
||||||
skills: SkillStore::default(),
|
skills: SkillStore::default(),
|
||||||
common_config_snippets: CommonConfigSnippets::default(),
|
|
||||||
claude_common_config_snippet: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,15 +483,6 @@ impl MultiAppConfig {
|
|||||||
updated = true;
|
updated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
|
|
||||||
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
|
|
||||||
log::info!(
|
|
||||||
"迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
|
|
||||||
);
|
|
||||||
config.common_config_snippets.claude = Some(old_claude_snippet);
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if updated {
|
if updated {
|
||||||
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
||||||
config.save()?;
|
config.save()?;
|
||||||
|
|||||||
@@ -164,38 +164,6 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_claude_common_config_snippet(
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.get_config_snippet("claude")
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_claude_common_config_snippet(
|
|
||||||
snippet: String,
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if !snippet.trim().is_empty() {
|
|
||||||
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let value = if snippet.trim().is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(snippet)
|
|
||||||
};
|
|
||||||
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.set_config_snippet("claude", value)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_common_config_snippet(
|
pub async fn get_common_config_snippet(
|
||||||
app_type: String,
|
app_type: String,
|
||||||
@@ -215,7 +183,7 @@ pub async fn set_common_config_snippet(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if !snippet.trim().is_empty() {
|
if !snippet.trim().is_empty() {
|
||||||
match app_type.as_str() {
|
match app_type.as_str() {
|
||||||
"claude" | "gemini" | "omo" => {
|
"claude" | "gemini" | "omo" | "omo-slim" => {
|
||||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||||
.map_err(invalid_json_format_error)?;
|
.map_err(invalid_json_format_error)?;
|
||||||
}
|
}
|
||||||
@@ -238,35 +206,28 @@ pub async fn set_common_config_snippet(
|
|||||||
if app_type == "omo"
|
if app_type == "omo"
|
||||||
&& state
|
&& state
|
||||||
.db
|
.db
|
||||||
.get_current_omo_provider("opencode")
|
.get_current_omo_provider("opencode", "omo")
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
crate::services::OmoService::write_config_to_file(state.inner())
|
crate::services::OmoService::write_config_to_file(
|
||||||
.map_err(|e| e.to_string())?;
|
state.inner(),
|
||||||
|
&crate::services::omo::STANDARD,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
if app_type == "omo-slim"
|
||||||
|
&& state
|
||||||
|
.db
|
||||||
|
.get_current_omo_provider("opencode", "omo-slim")
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
crate::services::OmoService::write_config_to_file(
|
||||||
|
state.inner(),
|
||||||
|
&crate::services::omo::SLIM,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn extract_common_config_snippet(
|
|
||||||
appType: String,
|
|
||||||
settingsConfig: Option<String>,
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
|
|
||||||
let settings: serde_json::Value =
|
|
||||||
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
|
|
||||||
|
|
||||||
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
|
||||||
app,
|
|
||||||
&settings,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use tauri_plugin_dialog::DialogExt;
|
|||||||
use crate::commands::sync_support::{
|
use crate::commands::sync_support::{
|
||||||
post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning,
|
post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning,
|
||||||
};
|
};
|
||||||
|
use crate::database::backup::BackupEntry;
|
||||||
|
use crate::database::Database;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::provider::ProviderService;
|
use crate::services::provider::ProviderService;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
@@ -118,3 +120,33 @@ pub async fn open_zip_file_dialog<R: tauri::Runtime>(
|
|||||||
|
|
||||||
Ok(result.map(|p| p.to_string()))
|
Ok(result.map(|p| p.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Database backup management ─────────────────────────────
|
||||||
|
|
||||||
|
/// List all database backup files
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_db_backups() -> Result<Vec<BackupEntry>, String> {
|
||||||
|
Database::list_backups().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore database from a backup file
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restore_db_backup(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
filename: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let db = state.db.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Restore failed: {e}"))?
|
||||||
|
.map_err(|e: AppError| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename a database backup file
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn rename_db_backup(
|
||||||
|
#[allow(non_snake_case)] oldFilename: String,
|
||||||
|
#[allow(non_snake_case)] newName: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
Database::rename_backup(&oldFilename, &newName).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
+239
-42
@@ -5,6 +5,7 @@ use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
|||||||
use crate::services::ProviderService;
|
use crate::services::ProviderService;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
@@ -85,50 +86,122 @@ pub struct ToolVersion {
|
|||||||
version: Option<String>,
|
version: Option<String>,
|
||||||
latest_version: Option<String>, // 新增字段:最新版本
|
latest_version: Option<String>, // 新增字段:最新版本
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
/// 工具运行环境: "windows", "wsl", "macos", "linux", "unknown"
|
||||||
|
env_type: String,
|
||||||
|
/// 当 env_type 为 "wsl" 时,返回该工具绑定的 WSL distro(用于按 distro 探测 shells)
|
||||||
|
wsl_distro: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_TOOLS: [&str; 4] = ["claude", "codex", "gemini", "opencode"];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WslShellPreferenceInput {
|
||||||
|
#[serde(default)]
|
||||||
|
pub wsl_shell: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub wsl_shell_flag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep platform-specific env detection in one place to avoid repeating cfg blocks.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn tool_env_type_and_wsl_distro(tool: &str) -> (String, Option<String>) {
|
||||||
|
if let Some(distro) = wsl_distro_for_tool(tool) {
|
||||||
|
("wsl".to_string(), Some(distro))
|
||||||
|
} else {
|
||||||
|
("windows".to_string(), None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||||||
|
("macos".to_string(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||||||
|
("linux".to_string(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||||
|
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||||||
|
("unknown".to_string(), None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
pub async fn get_tool_versions(
|
||||||
let tools = vec!["claude", "codex", "gemini", "opencode"];
|
tools: Option<Vec<String>>,
|
||||||
|
wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,
|
||||||
|
) -> Result<Vec<ToolVersion>, String> {
|
||||||
|
let requested: Vec<&str> = if let Some(tools) = tools.as_ref() {
|
||||||
|
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
|
||||||
|
VALID_TOOLS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|t| set.contains(t))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
VALID_TOOLS.to_vec()
|
||||||
|
};
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for tool in requested {
|
||||||
|
let pref = wsl_shell_by_tool.as_ref().and_then(|m| m.get(tool));
|
||||||
|
let tool_wsl_shell = pref.and_then(|p| p.wsl_shell.as_deref());
|
||||||
|
let tool_wsl_shell_flag = pref.and_then(|p| p.wsl_shell_flag.as_deref());
|
||||||
|
|
||||||
|
results.push(get_single_tool_version_impl(tool, tool_wsl_shell, tool_wsl_shell_flag).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取单个工具的版本信息(内部实现)
|
||||||
|
async fn get_single_tool_version_impl(
|
||||||
|
tool: &str,
|
||||||
|
wsl_shell: Option<&str>,
|
||||||
|
wsl_shell_flag: Option<&str>,
|
||||||
|
) -> ToolVersion {
|
||||||
|
debug_assert!(
|
||||||
|
VALID_TOOLS.contains(&tool),
|
||||||
|
"unexpected tool name in get_single_tool_version_impl: {tool}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 判断该工具的运行环境 & WSL distro(如有)
|
||||||
|
let (env_type, wsl_distro) = tool_env_type_and_wsl_distro(tool);
|
||||||
|
|
||||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
for tool in tools {
|
// 1. 获取本地版本
|
||||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
let (local_version, local_error) = if let Some(distro) = wsl_distro.as_deref() {
|
||||||
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
|
try_get_version_wsl(tool, distro, wsl_shell, wsl_shell_flag)
|
||||||
try_get_version_wsl(tool, &distro)
|
} else {
|
||||||
|
let direct_result = try_get_version(tool);
|
||||||
|
if direct_result.0.is_some() {
|
||||||
|
direct_result
|
||||||
} else {
|
} else {
|
||||||
// 先尝试直接执行
|
scan_cli_version(tool)
|
||||||
let direct_result = try_get_version(tool);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if direct_result.0.is_some() {
|
// 2. 获取远程最新版本
|
||||||
direct_result
|
let latest_version = match tool {
|
||||||
} else {
|
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||||
// 扫描常见的 npm 全局安装路径
|
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||||
scan_cli_version(tool)
|
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||||
}
|
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
||||||
};
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
// 2. 获取远程最新版本
|
ToolVersion {
|
||||||
let latest_version = match tool {
|
name: tool.to_string(),
|
||||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
version: local_version,
|
||||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
latest_version,
|
||||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
error: local_error,
|
||||||
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
env_type,
|
||||||
_ => None,
|
wsl_distro,
|
||||||
};
|
|
||||||
|
|
||||||
results.push(ToolVersion {
|
|
||||||
name: tool.to_string(),
|
|
||||||
version: local_version,
|
|
||||||
latest_version,
|
|
||||||
error: local_error,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(results)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper function to fetch latest version from npm registry
|
/// Helper function to fetch latest version from npm registry
|
||||||
@@ -242,8 +315,38 @@ fn is_valid_wsl_distro_name(name: &str) -> bool {
|
|||||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate that the given shell name is one of the allowed shells.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
|
fn is_valid_shell(shell: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
shell.rsplit('/').next().unwrap_or(shell),
|
||||||
|
"sh" | "bash" | "zsh" | "fish" | "dash"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate that the given shell flag is one of the allowed flags.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn is_valid_shell_flag(flag: &str) -> bool {
|
||||||
|
matches!(flag, "-c" | "-lc" | "-lic")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the default invocation flag for the given shell.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn default_flag_for_shell(shell: &str) -> &'static str {
|
||||||
|
match shell.rsplit('/').next().unwrap_or(shell) {
|
||||||
|
"dash" | "sh" => "-c",
|
||||||
|
"fish" => "-lc",
|
||||||
|
_ => "-lic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn try_get_version_wsl(
|
||||||
|
tool: &str,
|
||||||
|
distro: &str,
|
||||||
|
force_shell: Option<&str>,
|
||||||
|
force_shell_flag: Option<&str>,
|
||||||
|
) -> (Option<String>, Option<String>) {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
// 防御性断言:tool 只能是预定义的值
|
// 防御性断言:tool 只能是预定义的值
|
||||||
@@ -257,15 +360,47 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
|
|||||||
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
|
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 构建 Shell 脚本检测逻辑
|
||||||
|
let (shell, flag, cmd) = if let Some(shell) = force_shell {
|
||||||
|
// Defensive validation: never allow an arbitrary executable name here.
|
||||||
|
if !is_valid_shell(shell) {
|
||||||
|
return (None, Some(format!("[WSL:{distro}] invalid shell: {shell}")));
|
||||||
|
}
|
||||||
|
let shell = shell.rsplit('/').next().unwrap_or(shell);
|
||||||
|
let flag = if let Some(flag) = force_shell_flag {
|
||||||
|
if !is_valid_shell_flag(flag) {
|
||||||
|
return (
|
||||||
|
None,
|
||||||
|
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
flag
|
||||||
|
} else {
|
||||||
|
default_flag_for_shell(shell)
|
||||||
|
};
|
||||||
|
|
||||||
|
(shell.to_string(), flag, format!("{tool} --version"))
|
||||||
|
} else {
|
||||||
|
let cmd = if let Some(flag) = force_shell_flag {
|
||||||
|
if !is_valid_shell_flag(flag) {
|
||||||
|
return (
|
||||||
|
None,
|
||||||
|
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
format!("\"${{SHELL:-sh}}\" {flag} '{tool} --version'")
|
||||||
|
} else {
|
||||||
|
// 兜底:自动尝试 -lic, -lc, -c
|
||||||
|
format!(
|
||||||
|
"\"${{SHELL:-sh}}\" -lic '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -lc '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -c '{tool} --version'"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
("sh".to_string(), "-c", cmd)
|
||||||
|
};
|
||||||
|
|
||||||
let output = Command::new("wsl.exe")
|
let output = Command::new("wsl.exe")
|
||||||
.args([
|
.args(["-d", distro, "--", &shell, flag, &cmd])
|
||||||
"-d",
|
|
||||||
distro,
|
|
||||||
"--",
|
|
||||||
"sh",
|
|
||||||
"-lc",
|
|
||||||
&format!("{tool} --version"),
|
|
||||||
])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
@@ -306,7 +441,12 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
|
|||||||
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
||||||
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
|
fn try_get_version_wsl(
|
||||||
|
_tool: &str,
|
||||||
|
_distro: &str,
|
||||||
|
_force_shell: Option<&str>,
|
||||||
|
_force_shell_flag: Option<&str>,
|
||||||
|
) -> (Option<String>, Option<String>) {
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
Some("WSL check not supported on this platform".to_string()),
|
Some("WSL check not supported on this platform".to_string()),
|
||||||
@@ -1061,6 +1201,63 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_version() {
|
||||||
|
assert_eq!(extract_version("claude 1.0.20"), "1.0.20");
|
||||||
|
assert_eq!(extract_version("v2.3.4-beta.1"), "2.3.4-beta.1");
|
||||||
|
assert_eq!(extract_version("no version here"), "no version here");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod wsl_helpers {
|
||||||
|
use super::super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_valid_shell() {
|
||||||
|
assert!(is_valid_shell("bash"));
|
||||||
|
assert!(is_valid_shell("zsh"));
|
||||||
|
assert!(is_valid_shell("sh"));
|
||||||
|
assert!(is_valid_shell("fish"));
|
||||||
|
assert!(is_valid_shell("dash"));
|
||||||
|
assert!(is_valid_shell("/usr/bin/bash"));
|
||||||
|
assert!(is_valid_shell("/bin/zsh"));
|
||||||
|
assert!(!is_valid_shell("powershell"));
|
||||||
|
assert!(!is_valid_shell("cmd"));
|
||||||
|
assert!(!is_valid_shell(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_valid_shell_flag() {
|
||||||
|
assert!(is_valid_shell_flag("-c"));
|
||||||
|
assert!(is_valid_shell_flag("-lc"));
|
||||||
|
assert!(is_valid_shell_flag("-lic"));
|
||||||
|
assert!(!is_valid_shell_flag("-x"));
|
||||||
|
assert!(!is_valid_shell_flag(""));
|
||||||
|
assert!(!is_valid_shell_flag("--login"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_flag_for_shell() {
|
||||||
|
assert_eq!(default_flag_for_shell("sh"), "-c");
|
||||||
|
assert_eq!(default_flag_for_shell("dash"), "-c");
|
||||||
|
assert_eq!(default_flag_for_shell("/bin/dash"), "-c");
|
||||||
|
assert_eq!(default_flag_for_shell("fish"), "-lc");
|
||||||
|
assert_eq!(default_flag_for_shell("bash"), "-lic");
|
||||||
|
assert_eq!(default_flag_for_shell("zsh"), "-lic");
|
||||||
|
assert_eq!(default_flag_for_shell("/usr/bin/zsh"), "-lic");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_valid_wsl_distro_name() {
|
||||||
|
assert!(is_valid_wsl_distro_name("Ubuntu"));
|
||||||
|
assert!(is_valid_wsl_distro_name("Ubuntu-22.04"));
|
||||||
|
assert!(is_valid_wsl_distro_name("my_distro"));
|
||||||
|
assert!(!is_valid_wsl_distro_name(""));
|
||||||
|
assert!(!is_valid_wsl_distro_name("distro with spaces"));
|
||||||
|
assert!(!is_valid_wsl_distro_name(&"a".repeat(65)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn opencode_extra_search_paths_includes_install_and_fallback_dirs() {
|
fn opencode_extra_search_paths_includes_install_and_fallback_dirs() {
|
||||||
let home = PathBuf::from("/home/tester");
|
let home = PathBuf::from("/home/tester");
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::services::omo::OmoLocalFileData;
|
use crate::services::omo::{OmoLocalFileData, SLIM, STANDARD};
|
||||||
use crate::services::OmoService;
|
use crate::services::OmoService;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn read_omo_local_file() -> Result<OmoLocalFileData, String> {
|
pub async fn read_omo_local_file() -> Result<OmoLocalFileData, String> {
|
||||||
OmoService::read_local_file().map_err(|e| e.to_string())
|
OmoService::read_local_file(&STANDARD).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result<String, String> {
|
pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result<String, String> {
|
||||||
let provider = state
|
let provider = state
|
||||||
.db
|
.db
|
||||||
.get_current_omo_provider("opencode")
|
.get_current_omo_provider("opencode", "omo")
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(provider.map(|p| p.id).unwrap_or_default())
|
Ok(provider.map(|p| p.id).unwrap_or_default())
|
||||||
}
|
}
|
||||||
@@ -28,11 +28,11 @@ pub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), Strin
|
|||||||
if p.category.as_deref() == Some("omo") {
|
if p.category.as_deref() == Some("omo") {
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.clear_omo_provider_current("opencode", id)
|
.clear_omo_provider_current("opencode", id, "omo")
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OmoService::delete_config_file().map_err(|e| e.to_string())?;
|
OmoService::delete_config_file(&STANDARD).map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,3 +48,52 @@ pub async fn get_omo_provider_count(state: State<'_, AppState>) -> Result<usize,
|
|||||||
.count();
|
.count();
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── OMO Slim commands ───────────────────────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_omo_slim_local_file() -> Result<OmoLocalFileData, String> {
|
||||||
|
OmoService::read_local_file(&SLIM).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_current_omo_slim_provider_id(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let provider = state
|
||||||
|
.db
|
||||||
|
.get_current_omo_provider("opencode", "omo-slim")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(provider.map(|p| p.id).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn disable_current_omo_slim(state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let providers = state
|
||||||
|
.db
|
||||||
|
.get_all_providers("opencode")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
for (id, p) in &providers {
|
||||||
|
if p.category.as_deref() == Some("omo-slim") {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.clear_omo_provider_current("opencode", id, "omo-slim")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OmoService::delete_config_file(&SLIM).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_omo_slim_provider_count(state: State<'_, AppState>) -> Result<usize, String> {
|
||||||
|
let providers = state
|
||||||
|
.db
|
||||||
|
.get_all_providers("opencode")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let count = providers
|
||||||
|
.values()
|
||||||
|
.filter(|p| p.category.as_deref() == Some("omo-slim"))
|
||||||
|
.count();
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ use tauri::State;
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService};
|
use crate::services::{
|
||||||
|
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
||||||
|
};
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
@@ -67,7 +69,11 @@ pub fn remove_provider_from_live_config(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
fn switch_provider_internal(
|
||||||
|
state: &AppState,
|
||||||
|
app_type: AppType,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<SwitchResult, AppError> {
|
||||||
ProviderService::switch(state, app_type, id)
|
ProviderService::switch(state, app_type, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +82,7 @@ pub fn switch_provider_test_hook(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
app_type: AppType,
|
app_type: AppType,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<SwitchResult, AppError> {
|
||||||
switch_provider_internal(state, app_type, id)
|
switch_provider_internal(state, app_type, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,15 +91,15 @@ pub fn switch_provider(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<bool, String> {
|
) -> Result<SwitchResult, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
switch_provider_internal(&state, app_type, &id)
|
switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string())
|
||||||
.map(|_| true)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||||
ProviderService::import_default_config(state, app_type)
|
let imported = ProviderService::import_default_config(state, app_type)?;
|
||||||
|
|
||||||
|
Ok(imported)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||||
@@ -161,6 +167,12 @@ pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, Str
|
|||||||
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn patch_claude_live_settings(patch: serde_json::Value) -> Result<bool, String> {
|
||||||
|
ProviderService::patch_claude_live(patch).map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn test_api_endpoints(
|
pub async fn test_api_endpoints(
|
||||||
urls: Vec<String>,
|
urls: Vec<String>,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::future::Future;
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::commands::sync_support::{
|
use crate::commands::sync_support::{
|
||||||
@@ -13,8 +11,9 @@ use crate::services::webdav_sync as webdav_sync_service;
|
|||||||
use crate::settings::{self, WebDavSyncSettings};
|
use crate::settings::{self, WebDavSyncSettings};
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
|
||||||
fn persist_sync_error(settings: &mut WebDavSyncSettings, error: &AppError) {
|
fn persist_sync_error(settings: &mut WebDavSyncSettings, error: &AppError, source: &str) {
|
||||||
settings.status.last_error = Some(error.to_string());
|
settings.status.last_error = Some(error.to_string());
|
||||||
|
settings.status.last_error_source = Some(source.to_string());
|
||||||
let _ = settings::update_webdav_sync_status(settings.status.clone());
|
let _ = settings::update_webdav_sync_status(settings.status.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,20 +56,16 @@ fn resolve_password_for_request(
|
|||||||
incoming
|
incoming
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn webdav_sync_mutex() -> &'static tokio::sync::Mutex<()> {
|
fn webdav_sync_mutex() -> &'static tokio::sync::Mutex<()> {
|
||||||
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
|
webdav_sync_service::sync_mutex()
|
||||||
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_with_webdav_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
|
async fn run_with_webdav_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
|
||||||
where
|
where
|
||||||
Fut: Future<Output = Result<T, AppError>>,
|
Fut: std::future::Future<Output = Result<T, AppError>>,
|
||||||
{
|
{
|
||||||
let result = {
|
webdav_sync_service::run_with_sync_lock(operation).await
|
||||||
let _guard = webdav_sync_mutex().lock().await;
|
|
||||||
operation.await
|
|
||||||
};
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_sync_result<T, F>(result: Result<T, AppError>, on_error: F) -> Result<T, String>
|
fn map_sync_result<T, F>(result: Result<T, AppError>, on_error: F) -> Result<T, String>
|
||||||
@@ -112,7 +107,9 @@ pub async fn webdav_sync_upload(state: State<'_, AppState>) -> Result<Value, Str
|
|||||||
let mut settings = require_enabled_webdav_settings()?;
|
let mut settings = require_enabled_webdav_settings()?;
|
||||||
|
|
||||||
let result = run_with_webdav_lock(webdav_sync_service::upload(&db, &mut settings)).await;
|
let result = run_with_webdav_lock(webdav_sync_service::upload(&db, &mut settings)).await;
|
||||||
map_sync_result(result, |error| persist_sync_error(&mut settings, error))
|
map_sync_result(result, |error| {
|
||||||
|
persist_sync_error(&mut settings, error, "manual")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -120,10 +117,11 @@ pub async fn webdav_sync_download(state: State<'_, AppState>) -> Result<Value, S
|
|||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
let db_for_sync = db.clone();
|
let db_for_sync = db.clone();
|
||||||
let mut settings = require_enabled_webdav_settings()?;
|
let mut settings = require_enabled_webdav_settings()?;
|
||||||
|
let _auto_sync_suppression = crate::services::webdav_auto_sync::AutoSyncSuppressionGuard::new();
|
||||||
|
|
||||||
let sync_result = run_with_webdav_lock(webdav_sync_service::download(&db, &mut settings)).await;
|
let sync_result = run_with_webdav_lock(webdav_sync_service::download(&db, &mut settings)).await;
|
||||||
let mut result = map_sync_result(sync_result, |error| {
|
let mut result = map_sync_result(sync_result, |error| {
|
||||||
persist_sync_error(&mut settings, error)
|
persist_sync_error(&mut settings, error, "manual")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Post-download sync is best-effort: snapshot restore has already succeeded.
|
// Post-download sync is best-effort: snapshot restore has already succeeded.
|
||||||
@@ -179,8 +177,8 @@ mod tests {
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::settings::{AppSettings, WebDavSyncSettings};
|
use crate::settings::{AppSettings, WebDavSyncSettings};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -287,6 +285,7 @@ mod tests {
|
|||||||
persist_sync_error(
|
persist_sync_error(
|
||||||
&mut current,
|
&mut current,
|
||||||
&crate::error::AppError::Config("boom".to_string()),
|
&crate::error::AppError::Config("boom".to_string()),
|
||||||
|
"manual",
|
||||||
);
|
);
|
||||||
|
|
||||||
let after = crate::settings::get_webdav_sync_settings().expect("read webdav settings");
|
let after = crate::settings::get_webdav_sync_settings().expect("read webdav settings");
|
||||||
@@ -304,6 +303,7 @@ mod tests {
|
|||||||
.contains("boom"),
|
.contains("boom"),
|
||||||
"status error should be updated"
|
"status error should be updated"
|
||||||
);
|
);
|
||||||
|
assert_eq!(after.status.last_error_source.as_deref(), Some("manual"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use crate::config::write_text_file;
|
use crate::config::write_text_file;
|
||||||
use crate::openclaw_config::get_openclaw_dir;
|
use crate::openclaw_config::get_openclaw_dir;
|
||||||
|
|
||||||
@@ -24,6 +27,146 @@ fn validate_filename(filename: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Daily memory files (memory/YYYY-MM-DD.md) ---
|
||||||
|
|
||||||
|
static DAILY_MEMORY_RE: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^\d{4}-\d{2}-\d{2}\.md$").unwrap());
|
||||||
|
|
||||||
|
fn validate_daily_memory_filename(filename: &str) -> Result<(), String> {
|
||||||
|
if !DAILY_MEMORY_RE.is_match(filename) {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid daily memory filename: {filename}. Expected: YYYY-MM-DD.md"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DailyMemoryFileInfo {
|
||||||
|
pub filename: String,
|
||||||
|
pub date: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub modified_at: u64,
|
||||||
|
pub preview: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Daily memory commands ---
|
||||||
|
|
||||||
|
/// List all daily memory files under `workspace/memory/`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_daily_memory_files() -> Result<Vec<DailyMemoryFileInfo>, String> {
|
||||||
|
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
|
||||||
|
|
||||||
|
if !memory_dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut files: Vec<DailyMemoryFileInfo> = Vec::new();
|
||||||
|
|
||||||
|
let entries = std::fs::read_dir(&memory_dir)
|
||||||
|
.map_err(|e| format!("Failed to read memory directory: {e}"))?;
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if !name.ends_with(".md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta = match entry.metadata() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !meta.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let date = name.trim_end_matches(".md").to_string();
|
||||||
|
|
||||||
|
let size_bytes = meta.len();
|
||||||
|
let modified_at = meta
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let preview = std::fs::read_to_string(entry.path())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.chars()
|
||||||
|
.take(200)
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
files.push(DailyMemoryFileInfo {
|
||||||
|
filename: name,
|
||||||
|
date,
|
||||||
|
size_bytes,
|
||||||
|
modified_at,
|
||||||
|
preview,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by filename descending (newest date first, YYYY-MM-DD.md)
|
||||||
|
files.sort_by(|a, b| b.filename.cmp(&a.filename));
|
||||||
|
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a daily memory file.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_daily_memory_file(filename: String) -> Result<Option<String>, String> {
|
||||||
|
validate_daily_memory_filename(&filename)?;
|
||||||
|
|
||||||
|
let path = get_openclaw_dir()
|
||||||
|
.join("workspace")
|
||||||
|
.join("memory")
|
||||||
|
.join(&filename);
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::read_to_string(&path)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| format!("Failed to read daily memory file {filename}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a daily memory file (atomic write).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn write_daily_memory_file(filename: String, content: String) -> Result<(), String> {
|
||||||
|
validate_daily_memory_filename(&filename)?;
|
||||||
|
|
||||||
|
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&memory_dir)
|
||||||
|
.map_err(|e| format!("Failed to create memory directory: {e}"))?;
|
||||||
|
|
||||||
|
let path = memory_dir.join(&filename);
|
||||||
|
|
||||||
|
write_text_file(&path, &content)
|
||||||
|
.map_err(|e| format!("Failed to write daily memory file {filename}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a daily memory file (idempotent).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_daily_memory_file(filename: String) -> Result<(), String> {
|
||||||
|
validate_daily_memory_filename(&filename)?;
|
||||||
|
|
||||||
|
let path = get_openclaw_dir()
|
||||||
|
.join("workspace")
|
||||||
|
.join("memory")
|
||||||
|
.join(&filename);
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
std::fs::remove_file(&path)
|
||||||
|
.map_err(|e| format!("Failed to delete daily memory file {filename}: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Workspace file commands ---
|
||||||
|
|
||||||
/// Read an OpenClaw workspace file content.
|
/// Read an OpenClaw workspace file content.
|
||||||
/// Returns None if the file does not exist.
|
/// Returns None if the file does not exist.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 提供 SQL 导出/导入和二进制快照备份功能。
|
//! 提供 SQL 导出/导入和二进制快照备份功能。
|
||||||
|
|
||||||
use super::{lock_conn, Database, DB_BACKUP_RETAIN};
|
use super::{lock_conn, Database};
|
||||||
use crate::config::get_app_config_dir;
|
use crate::config::get_app_config_dir;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -15,6 +15,15 @@ use tempfile::NamedTempFile;
|
|||||||
|
|
||||||
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||||||
|
|
||||||
|
/// A database backup entry for the UI
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BackupEntry {
|
||||||
|
pub filename: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub created_at: String, // ISO 8601
|
||||||
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
|
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
|
||||||
pub fn export_sql_string(&self) -> Result<String, AppError> {
|
pub fn export_sql_string(&self) -> Result<String, AppError> {
|
||||||
@@ -120,8 +129,47 @@ impl Database {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Periodic backup: create a new backup if the latest one is older than the configured interval
|
||||||
|
pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> {
|
||||||
|
let interval_hours = crate::settings::effective_backup_interval_hours();
|
||||||
|
if interval_hours == 0 {
|
||||||
|
return Ok(()); // Auto-backup disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup_dir = get_app_config_dir().join("backups");
|
||||||
|
if !backup_dir.exists() {
|
||||||
|
self.backup_database_file()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
|
||||||
|
entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||||
|
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
|
||||||
|
.max()
|
||||||
|
});
|
||||||
|
|
||||||
|
let interval_secs = u64::from(interval_hours) * 3600;
|
||||||
|
let needs_backup = match latest {
|
||||||
|
None => true,
|
||||||
|
Some(last_modified) => {
|
||||||
|
last_modified.elapsed().unwrap_or_default()
|
||||||
|
> std::time::Duration::from_secs(interval_secs)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if needs_backup {
|
||||||
|
log::info!(
|
||||||
|
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
|
||||||
|
);
|
||||||
|
self.backup_database_file()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
||||||
fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
|
pub(crate) fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
|
||||||
let db_path = get_app_config_dir().join("cc-switch.db");
|
let db_path = get_app_config_dir().join("cc-switch.db");
|
||||||
if !db_path.exists() {
|
if !db_path.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -161,6 +209,7 @@ impl Database {
|
|||||||
|
|
||||||
/// 清理旧的数据库备份,保留最新的 N 个
|
/// 清理旧的数据库备份,保留最新的 N 个
|
||||||
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
|
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
|
||||||
|
let retain = crate::settings::effective_backup_retain_count();
|
||||||
let entries = match fs::read_dir(dir) {
|
let entries = match fs::read_dir(dir) {
|
||||||
Ok(iter) => iter
|
Ok(iter) => iter
|
||||||
.filter_map(|entry| entry.ok())
|
.filter_map(|entry| entry.ok())
|
||||||
@@ -175,11 +224,11 @@ impl Database {
|
|||||||
Err(_) => return Ok(()),
|
Err(_) => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if entries.len() <= DB_BACKUP_RETAIN {
|
if entries.len() <= retain {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN);
|
let remove_count = entries.len().saturating_sub(retain);
|
||||||
let mut sorted = entries;
|
let mut sorted = entries;
|
||||||
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
|
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
|
||||||
|
|
||||||
@@ -333,4 +382,153 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all database backup files, sorted by creation time (newest first)
|
||||||
|
pub fn list_backups() -> Result<Vec<BackupEntry>, AppError> {
|
||||||
|
let backup_dir = get_app_config_dir().join("backups");
|
||||||
|
if !backup_dir.exists() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries: Vec<BackupEntry> = fs::read_dir(&backup_dir)
|
||||||
|
.map_err(|e| AppError::io(&backup_dir, e))?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||||
|
.filter_map(|e| {
|
||||||
|
let metadata = e.metadata().ok()?;
|
||||||
|
let filename = e.file_name().to_string_lossy().to_string();
|
||||||
|
let size_bytes = metadata.len();
|
||||||
|
let created_at = metadata
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.map(|t| {
|
||||||
|
let dt: chrono::DateTime<Utc> = t.into();
|
||||||
|
dt.to_rfc3339()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Some(BackupEntry {
|
||||||
|
filename,
|
||||||
|
size_bytes,
|
||||||
|
created_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by created_at descending (newest first)
|
||||||
|
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore database from a backup file. Returns the safety backup ID.
|
||||||
|
pub fn restore_from_backup(&self, filename: &str) -> Result<String, AppError> {
|
||||||
|
// Security: validate filename to prevent path traversal
|
||||||
|
if filename.contains("..")
|
||||||
|
|| filename.contains('/')
|
||||||
|
|| filename.contains('\\')
|
||||||
|
|| !filename.ends_with(".db")
|
||||||
|
{
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"Invalid backup filename".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup_dir = get_app_config_dir().join("backups");
|
||||||
|
let backup_path = backup_dir.join(filename);
|
||||||
|
|
||||||
|
if !backup_path.exists() {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Backup file not found: {filename}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Create safety backup of current database
|
||||||
|
let safety_backup = self.backup_database_file()?;
|
||||||
|
let safety_id = safety_backup
|
||||||
|
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Step 2: Open the backup file and restore it to the main database
|
||||||
|
let source_conn =
|
||||||
|
Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut main_conn = lock_conn!(self.conn);
|
||||||
|
let backup = Backup::new(&source_conn, &mut main_conn)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
backup
|
||||||
|
.step(-1)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Run schema migrations (backup may be from an older version)
|
||||||
|
self.create_tables()?;
|
||||||
|
self.apply_schema_migrations()?;
|
||||||
|
self.ensure_model_pricing_seeded()?;
|
||||||
|
|
||||||
|
log::info!("Database restored from backup: {filename}, safety backup: {safety_id}");
|
||||||
|
Ok(safety_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename a backup file. Returns the new filename.
|
||||||
|
pub fn rename_backup(old_filename: &str, new_name: &str) -> Result<String, AppError> {
|
||||||
|
// Validate old filename (path traversal + .db suffix)
|
||||||
|
if old_filename.contains("..")
|
||||||
|
|| old_filename.contains('/')
|
||||||
|
|| old_filename.contains('\\')
|
||||||
|
|| !old_filename.ends_with(".db")
|
||||||
|
{
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"Invalid backup filename".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean new name
|
||||||
|
let trimmed = new_name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"New name cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length limit (without .db suffix)
|
||||||
|
let name_part = trimmed.strip_suffix(".db").unwrap_or(trimmed);
|
||||||
|
if name_part.len() > 100 {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"Name too long (max 100 characters)".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent path traversal in new name
|
||||||
|
if name_part.contains("..")
|
||||||
|
|| name_part.contains('/')
|
||||||
|
|| name_part.contains('\\')
|
||||||
|
|| name_part.contains('\0')
|
||||||
|
{
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"Invalid characters in new name".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_filename = format!("{name_part}.db");
|
||||||
|
|
||||||
|
let backup_dir = get_app_config_dir().join("backups");
|
||||||
|
let old_path = backup_dir.join(old_filename);
|
||||||
|
let new_path = backup_dir.join(&new_filename);
|
||||||
|
|
||||||
|
if !old_path.exists() {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"Backup file not found: {old_filename}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_path.exists() {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"A backup named '{new_filename}' already exists"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(&old_path, &new_path).map_err(|e| AppError::io(&old_path, e))?;
|
||||||
|
log::info!("Renamed backup: {old_filename} -> {new_filename}");
|
||||||
|
Ok(new_filename)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,19 +55,23 @@ impl Default for OmoGlobalConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
pub fn get_omo_global_config(&self) -> Result<OmoGlobalConfig, AppError> {
|
pub fn get_omo_global_config(&self, key: &str) -> Result<OmoGlobalConfig, AppError> {
|
||||||
let json_str = self.get_setting("common_config_omo")?;
|
let json_str = self.get_setting(key)?;
|
||||||
match json_str {
|
match json_str {
|
||||||
Some(s) => serde_json::from_str::<OmoGlobalConfig>(&s)
|
Some(s) => serde_json::from_str::<OmoGlobalConfig>(&s)
|
||||||
.map_err(|e| AppError::Config(format!("Failed to parse common_config_omo: {e}"))),
|
.map_err(|e| AppError::Config(format!("Failed to parse {key}: {e}"))),
|
||||||
None => Ok(OmoGlobalConfig::default()),
|
None => Ok(OmoGlobalConfig::default()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_omo_global_config(&self, config: &OmoGlobalConfig) -> Result<(), AppError> {
|
pub fn save_omo_global_config(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
config: &OmoGlobalConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
let json_str = serde_json::to_string(config)
|
let json_str = serde_json::to_string(config)
|
||||||
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?;
|
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?;
|
||||||
self.set_setting("common_config_omo", &json_str)?;
|
self.set_setting(key, &json_str)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,25 +364,26 @@ impl Database {
|
|||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
|
category: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let mut conn = lock_conn!(self.conn);
|
let mut conn = lock_conn!(self.conn);
|
||||||
let tx = conn
|
let tx = conn
|
||||||
.transaction()
|
.transaction()
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = 'omo'",
|
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = ?2",
|
||||||
params![app_type],
|
params![app_type, category],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
let updated = tx
|
let updated = tx
|
||||||
.execute(
|
.execute(
|
||||||
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = ?3",
|
||||||
params![provider_id, app_type],
|
params![provider_id, app_type, category],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
if updated != 1 {
|
if updated != 1 {
|
||||||
return Err(AppError::Database(format!(
|
return Err(AppError::Database(format!(
|
||||||
"Failed to set OMO provider current: provider '{provider_id}' not found in app '{app_type}'"
|
"Failed to set {category} provider current: provider '{provider_id}' not found in app '{app_type}'"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -393,12 +394,13 @@ impl Database {
|
|||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
|
category: &str,
|
||||||
) -> Result<bool, AppError> {
|
) -> Result<bool, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
match conn.query_row(
|
match conn.query_row(
|
||||||
"SELECT is_current FROM providers
|
"SELECT is_current FROM providers
|
||||||
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
WHERE id = ?1 AND app_type = ?2 AND category = ?3",
|
||||||
params![provider_id, app_type],
|
params![provider_id, app_type, category],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
) {
|
) {
|
||||||
Ok(is_current) => Ok(is_current),
|
Ok(is_current) => Ok(is_current),
|
||||||
@@ -411,25 +413,30 @@ impl Database {
|
|||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
|
category: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE providers SET is_current = 0
|
"UPDATE providers SET is_current = 0
|
||||||
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
WHERE id = ?1 AND app_type = ?2 AND category = ?3",
|
||||||
params![provider_id, app_type],
|
params![provider_id, app_type, category],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_current_omo_provider(&self, app_type: &str) -> Result<Option<Provider>, AppError> {
|
pub fn get_current_omo_provider(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
category: &str,
|
||||||
|
) -> Result<Option<Provider>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let row_data: Result<OmoProviderRow, rusqlite::Error> = conn.query_row(
|
let row_data: Result<OmoProviderRow, rusqlite::Error> = conn.query_row(
|
||||||
"SELECT id, name, settings_config, category, created_at, sort_index, notes, meta
|
"SELECT id, name, settings_config, category, created_at, sort_index, notes, meta
|
||||||
FROM providers
|
FROM providers
|
||||||
WHERE app_type = ?1 AND category = 'omo' AND is_current = 1
|
WHERE app_type = ?1 AND category = ?2 AND is_current = 1
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
params![app_type],
|
params![app_type, category],
|
||||||
|row| {
|
|row| {
|
||||||
Ok((
|
Ok((
|
||||||
row.get(0)?,
|
row.get(0)?,
|
||||||
@@ -444,7 +451,7 @@ impl Database {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let (id, name, settings_config_str, category, created_at, sort_index, notes, meta_str) =
|
let (id, name, settings_config_str, _row_category, created_at, sort_index, notes, meta_str) =
|
||||||
match row_data {
|
match row_data {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||||
@@ -453,7 +460,7 @@ impl Database {
|
|||||||
|
|
||||||
let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| {
|
let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| {
|
||||||
AppError::Database(format!(
|
AppError::Database(format!(
|
||||||
"Failed to parse OMO provider settings_config (provider_id={id}): {e}"
|
"Failed to parse {category} provider settings_config (provider_id={id}): {e}"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() {
|
let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() {
|
||||||
@@ -461,7 +468,7 @@ impl Database {
|
|||||||
} else {
|
} else {
|
||||||
serde_json::from_str(&meta_str).map_err(|e| {
|
serde_json::from_str(&meta_str).map_err(|e| {
|
||||||
AppError::Database(format!(
|
AppError::Database(format!(
|
||||||
"Failed to parse OMO provider meta (provider_id={id}): {e}"
|
"Failed to parse {category} provider meta (provider_id={id}): {e}"
|
||||||
))
|
))
|
||||||
})?
|
})?
|
||||||
};
|
};
|
||||||
@@ -471,7 +478,7 @@ impl Database {
|
|||||||
name,
|
name,
|
||||||
settings_config,
|
settings_config,
|
||||||
website_url: None,
|
website_url: None,
|
||||||
category,
|
category: Some(category.to_string()),
|
||||||
created_at,
|
created_at,
|
||||||
sort_index,
|
sort_index,
|
||||||
notes,
|
notes,
|
||||||
|
|||||||
@@ -58,9 +58,6 @@ impl Database {
|
|||||||
// 4. 迁移 Skills
|
// 4. 迁移 Skills
|
||||||
Self::migrate_skills(tx, config)?;
|
Self::migrate_skills(tx, config)?;
|
||||||
|
|
||||||
// 5. 迁移 Common Config
|
|
||||||
Self::migrate_common_config(tx, config)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,34 +209,4 @@ impl Database {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 迁移通用配置片段
|
|
||||||
fn migrate_common_config(
|
|
||||||
tx: &rusqlite::Transaction<'_>,
|
|
||||||
config: &MultiAppConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
if let Some(snippet) = &config.common_config_snippets.claude {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
||||||
params!["common_config_claude", snippet],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
|
||||||
}
|
|
||||||
if let Some(snippet) = &config.common_config_snippets.codex {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
||||||
params!["common_config_codex", snippet],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
|
||||||
}
|
|
||||||
if let Some(snippet) = &config.common_config_snippets.gemini {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
||||||
params!["common_config_gemini", snippet],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
//! └── settings.rs
|
//! └── settings.rs
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
mod backup;
|
pub(crate) mod backup;
|
||||||
mod dao;
|
mod dao;
|
||||||
mod migration;
|
mod migration;
|
||||||
mod schema;
|
mod schema;
|
||||||
@@ -37,15 +37,12 @@ pub use dao::OmoGlobalConfig;
|
|||||||
|
|
||||||
use crate::config::get_app_config_dir;
|
use crate::config::get_app_config_dir;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use rusqlite::Connection;
|
use rusqlite::{hooks::Action, Connection};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
// DAO 方法通过 impl Database 提供,无需额外导出
|
// DAO 方法通过 impl Database 提供,无需额外导出
|
||||||
|
|
||||||
/// 数据库备份保留数量
|
|
||||||
const DB_BACKUP_RETAIN: usize = 10;
|
|
||||||
|
|
||||||
/// 当前 Schema 版本号
|
/// 当前 Schema 版本号
|
||||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||||
pub(crate) const SCHEMA_VERSION: i32 = 5;
|
pub(crate) const SCHEMA_VERSION: i32 = 5;
|
||||||
@@ -76,6 +73,17 @@ pub struct Database {
|
|||||||
pub(crate) conn: Mutex<Connection>,
|
pub(crate) conn: Mutex<Connection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn register_db_change_hook(conn: &Connection) {
|
||||||
|
conn.update_hook(Some(
|
||||||
|
|action: Action, _database: &str, table: &str, _row_id: i64| match action {
|
||||||
|
Action::SQLITE_INSERT | Action::SQLITE_UPDATE | Action::SQLITE_DELETE => {
|
||||||
|
crate::services::webdav_auto_sync::notify_db_changed(table);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
/// 初始化数据库连接并创建表
|
/// 初始化数据库连接并创建表
|
||||||
///
|
///
|
||||||
@@ -93,11 +101,28 @@ impl Database {
|
|||||||
// 启用外键约束
|
// 启用外键约束
|
||||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
register_db_change_hook(&conn);
|
||||||
|
|
||||||
let db = Self {
|
let db = Self {
|
||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
};
|
};
|
||||||
db.create_tables()?;
|
db.create_tables()?;
|
||||||
|
|
||||||
|
// Pre-migration backup: only when upgrading from an existing database
|
||||||
|
{
|
||||||
|
let conn = lock_conn!(db.conn);
|
||||||
|
let version = Self::get_user_version(&conn)?;
|
||||||
|
drop(conn);
|
||||||
|
if version > 0 && version < SCHEMA_VERSION {
|
||||||
|
log::info!(
|
||||||
|
"Creating pre-migration database backup (v{version} → v{SCHEMA_VERSION})"
|
||||||
|
);
|
||||||
|
if let Err(e) = db.backup_database_file() {
|
||||||
|
log::warn!("Pre-migration backup failed, continuing migration: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.apply_schema_migrations()?;
|
db.apply_schema_migrations()?;
|
||||||
db.ensure_model_pricing_seeded()?;
|
db.ensure_model_pricing_seeded()?;
|
||||||
|
|
||||||
@@ -111,6 +136,7 @@ impl Database {
|
|||||||
// 启用外键约束
|
// 启用外键约束
|
||||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
register_db_change_hook(&conn);
|
||||||
|
|
||||||
let db = Self {
|
let db = Self {
|
||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
|
|||||||
@@ -513,8 +513,6 @@ fn schema_dry_run_does_not_write_to_disk() {
|
|||||||
mcp: Default::default(),
|
mcp: Default::default(),
|
||||||
prompts: Default::default(),
|
prompts: Default::default(),
|
||||||
skills: Default::default(),
|
skills: Default::default(),
|
||||||
common_config_snippets: Default::default(),
|
|
||||||
claude_common_config_snippet: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dry-run should succeed without any file I/O errors
|
// Dry-run should succeed without any file I/O errors
|
||||||
@@ -563,8 +561,6 @@ fn dry_run_validates_schema_compatibility() {
|
|||||||
mcp: Default::default(),
|
mcp: Default::default(),
|
||||||
prompts: Default::default(),
|
prompts: Default::default(),
|
||||||
skills: Default::default(),
|
skills: Default::default(),
|
||||||
common_config_snippets: Default::default(),
|
|
||||||
claude_common_config_snippet: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dry-run should validate the full migration path
|
// Dry-run should validate the full migration path
|
||||||
|
|||||||
+68
-51
@@ -448,52 +448,7 @@ pub fn run() {
|
|||||||
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
|
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
|
// 2. OpenCode 供应商导入(累加式模式,需特殊处理)
|
||||||
for app in [
|
|
||||||
crate::app_config::AppType::Claude,
|
|
||||||
crate::app_config::AppType::Codex,
|
|
||||||
crate::app_config::AppType::Gemini,
|
|
||||||
] {
|
|
||||||
match crate::services::provider::ProviderService::import_default_config(
|
|
||||||
&app_state,
|
|
||||||
app.clone(),
|
|
||||||
) {
|
|
||||||
Ok(true) => {
|
|
||||||
log::info!("✓ Imported default provider for {}", app.as_str());
|
|
||||||
|
|
||||||
// 首次运行:自动提取通用配置片段(仅当通用配置为空时)
|
|
||||||
if app_state
|
|
||||||
.db
|
|
||||||
.get_config_snippet(app.as_str())
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
match crate::services::provider::ProviderService::extract_common_config_snippet(&app_state, app.clone()) {
|
|
||||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
|
||||||
if let Err(e) = app_state.db.set_config_snippet(app.as_str(), Some(snippet)) {
|
|
||||||
log::warn!("✗ Failed to save common config snippet for {}: {e}", app.as_str());
|
|
||||||
} else {
|
|
||||||
log::info!("✓ Extracted common config snippet for {}", app.as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(_) => log::debug!("○ No common config to extract for {}", app.as_str()),
|
|
||||||
Err(e) => log::debug!("○ Failed to extract common config for {}: {e}", app.as_str()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false) => {} // 已有供应商,静默跳过
|
|
||||||
Err(e) => {
|
|
||||||
log::debug!(
|
|
||||||
"○ No default provider to import for {}: {}",
|
|
||||||
app.as_str(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.1 OpenCode 供应商导入(累加式模式,需特殊处理)
|
|
||||||
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
|
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
|
||||||
// 需要遍历 provider 字段下的每个供应商并导入
|
// 需要遍历 provider 字段下的每个供应商并导入
|
||||||
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
|
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
|
||||||
@@ -512,7 +467,7 @@ pub fn run() {
|
|||||||
.map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo")))
|
.map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo")))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !has_omo {
|
if !has_omo {
|
||||||
match crate::services::OmoService::import_from_local(&app_state) {
|
match crate::services::OmoService::import_from_local(&app_state, &crate::services::omo::STANDARD) {
|
||||||
Ok(provider) => {
|
Ok(provider) => {
|
||||||
log::info!("✓ Imported OMO config from local as provider '{}'", provider.name);
|
log::info!("✓ Imported OMO config from local as provider '{}'", provider.name);
|
||||||
}
|
}
|
||||||
@@ -526,7 +481,36 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2.3 OpenClaw 供应商导入(累加式模式,需特殊处理)
|
// 2.3 OMO Slim config import (when no omo-slim provider in DB, import from local)
|
||||||
|
{
|
||||||
|
let has_omo_slim = app_state
|
||||||
|
.db
|
||||||
|
.get_all_providers("opencode")
|
||||||
|
.map(|providers| {
|
||||||
|
providers
|
||||||
|
.values()
|
||||||
|
.any(|p| p.category.as_deref() == Some("omo-slim"))
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !has_omo_slim {
|
||||||
|
match crate::services::OmoService::import_from_local(&app_state, &crate::services::omo::SLIM) {
|
||||||
|
Ok(provider) => {
|
||||||
|
log::info!(
|
||||||
|
"✓ Imported OMO Slim config from local as provider '{}'",
|
||||||
|
provider.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(AppError::OmoConfigNotFound) => {
|
||||||
|
log::debug!("○ No OMO Slim config to import");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("✗ Failed to import OMO Slim config from local: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.4 OpenClaw 供应商导入(累加式模式,需特殊处理)
|
||||||
// OpenClaw 与 OpenCode 类似:配置文件中可同时存在多个供应商
|
// OpenClaw 与 OpenCode 类似:配置文件中可同时存在多个供应商
|
||||||
// 需要遍历 models.providers 字段下的每个供应商并导入
|
// 需要遍历 models.providers 字段下的每个供应商并导入
|
||||||
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
|
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
|
||||||
@@ -702,6 +686,10 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _tray = tray_builder.build(app)?;
|
let _tray = tray_builder.build(app)?;
|
||||||
|
crate::services::webdav_auto_sync::start_worker(
|
||||||
|
app_state.db.clone(),
|
||||||
|
app.handle().clone(),
|
||||||
|
);
|
||||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||||
app.manage(app_state);
|
app.manage(app_state);
|
||||||
|
|
||||||
@@ -780,6 +768,25 @@ pub fn run() {
|
|||||||
|
|
||||||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||||
restore_proxy_state_on_startup(&state).await;
|
restore_proxy_state_on_startup(&state).await;
|
||||||
|
|
||||||
|
// Periodic backup check (on startup)
|
||||||
|
if let Err(e) = state.db.periodic_backup_if_needed() {
|
||||||
|
log::warn!("Periodic backup failed on startup: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic backup timer: check every hour while the app is running
|
||||||
|
let db_for_timer = state.db.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let mut interval =
|
||||||
|
tokio::time::interval(std::time::Duration::from_secs(3600));
|
||||||
|
interval.tick().await; // skip immediate first tick (already checked above)
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if let Err(e) = db_for_timer.periodic_backup_if_needed() {
|
||||||
|
log::warn!("Periodic backup timer failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
|
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
|
||||||
@@ -838,12 +845,10 @@ pub fn run() {
|
|||||||
commands::get_skills_migration_result,
|
commands::get_skills_migration_result,
|
||||||
commands::get_app_config_path,
|
commands::get_app_config_path,
|
||||||
commands::open_app_config_folder,
|
commands::open_app_config_folder,
|
||||||
commands::get_claude_common_config_snippet,
|
|
||||||
commands::set_claude_common_config_snippet,
|
|
||||||
commands::get_common_config_snippet,
|
commands::get_common_config_snippet,
|
||||||
commands::set_common_config_snippet,
|
commands::set_common_config_snippet,
|
||||||
commands::extract_common_config_snippet,
|
|
||||||
commands::read_live_provider_settings,
|
commands::read_live_provider_settings,
|
||||||
|
commands::patch_claude_live_settings,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
commands::save_settings,
|
commands::save_settings,
|
||||||
commands::get_rectifier_config,
|
commands::get_rectifier_config,
|
||||||
@@ -908,6 +913,9 @@ pub fn run() {
|
|||||||
commands::save_file_dialog,
|
commands::save_file_dialog,
|
||||||
commands::open_file_dialog,
|
commands::open_file_dialog,
|
||||||
commands::open_zip_file_dialog,
|
commands::open_zip_file_dialog,
|
||||||
|
commands::list_db_backups,
|
||||||
|
commands::restore_db_backup,
|
||||||
|
commands::rename_db_backup,
|
||||||
commands::sync_current_providers_live,
|
commands::sync_current_providers_live,
|
||||||
// Deep link import
|
// Deep link import
|
||||||
commands::parse_deeplink,
|
commands::parse_deeplink,
|
||||||
@@ -1031,9 +1039,18 @@ pub fn run() {
|
|||||||
commands::get_current_omo_provider_id,
|
commands::get_current_omo_provider_id,
|
||||||
commands::get_omo_provider_count,
|
commands::get_omo_provider_count,
|
||||||
commands::disable_current_omo,
|
commands::disable_current_omo,
|
||||||
|
commands::read_omo_slim_local_file,
|
||||||
|
commands::get_current_omo_slim_provider_id,
|
||||||
|
commands::get_omo_slim_provider_count,
|
||||||
|
commands::disable_current_omo_slim,
|
||||||
// Workspace files (OpenClaw)
|
// Workspace files (OpenClaw)
|
||||||
commands::read_workspace_file,
|
commands::read_workspace_file,
|
||||||
commands::write_workspace_file,
|
commands::write_workspace_file,
|
||||||
|
// Daily memory files (OpenClaw workspace)
|
||||||
|
commands::list_daily_memory_files,
|
||||||
|
commands::read_daily_memory_file,
|
||||||
|
commands::write_daily_memory_file,
|
||||||
|
commands::delete_daily_memory_file,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
|
|||||||
@@ -145,14 +145,25 @@ pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
|
|||||||
|
|
||||||
match plugins {
|
match plugins {
|
||||||
Some(arr) => {
|
Some(arr) => {
|
||||||
|
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
|
||||||
if plugin_name.starts_with("oh-my-opencode")
|
if plugin_name.starts_with("oh-my-opencode")
|
||||||
&& !plugin_name.starts_with("oh-my-opencode-slim")
|
&& !plugin_name.starts_with("oh-my-opencode-slim")
|
||||||
{
|
{
|
||||||
|
// Adding standard OMO -> remove all Slim variants
|
||||||
arr.retain(|v| {
|
arr.retain(|v| {
|
||||||
v.as_str()
|
v.as_str()
|
||||||
.map(|s| !s.starts_with("oh-my-opencode-slim"))
|
.map(|s| !s.starts_with("oh-my-opencode-slim"))
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
});
|
});
|
||||||
|
} else if plugin_name.starts_with("oh-my-opencode-slim") {
|
||||||
|
// Adding Slim -> remove all standard OMO variants (but keep slim)
|
||||||
|
arr.retain(|v| {
|
||||||
|
v.as_str()
|
||||||
|
.map(|s| {
|
||||||
|
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
|
||||||
|
})
|
||||||
|
.unwrap_or(true)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
|
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
|
||||||
|
|||||||
@@ -235,6 +235,11 @@ pub struct ProviderMeta {
|
|||||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||||
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||||
pub api_format: Option<String>,
|
pub api_format: Option<String>,
|
||||||
|
/// Claude 认证字段名(仅 Claude 供应商使用)
|
||||||
|
/// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
|
||||||
|
/// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
|
||||||
|
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api_key_field: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderManager {
|
impl ProviderManager {
|
||||||
|
|||||||
@@ -263,10 +263,13 @@ impl ProviderAdapter for ClaudeAdapter {
|
|||||||
base = base.replace("/v1/v1", "/v1");
|
base = base.replace("/v1/v1", "/v1");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为 Claude 相关端点添加 ?beta=true 参数
|
// 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数
|
||||||
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
||||||
// 注:openai_chat 模式下会转发到 /v1/chat/completions,此处也需要保持一致
|
// 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数
|
||||||
if (endpoint.contains("/v1/messages") || endpoint.contains("/v1/chat/completions"))
|
// 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions,
|
||||||
|
// 但该端点是 OpenAI 标准,不支持 ?beta=true 参数
|
||||||
|
if endpoint.contains("/v1/messages")
|
||||||
|
&& !endpoint.contains("/v1/chat/completions")
|
||||||
&& !endpoint.contains('?')
|
&& !endpoint.contains('?')
|
||||||
{
|
{
|
||||||
format!("{base}?beta=true")
|
format!("{base}?beta=true")
|
||||||
@@ -513,6 +516,15 @@ mod tests {
|
|||||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_url_no_beta_for_openai_chat_completions() {
|
||||||
|
let adapter = ClaudeAdapter::new();
|
||||||
|
// OpenAI Chat Completions 端点不添加 ?beta=true
|
||||||
|
// 这是 Nvidia 等 apiFormat="openai_chat" 供应商使用的端点
|
||||||
|
let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions");
|
||||||
|
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_needs_transform() {
|
fn test_needs_transform() {
|
||||||
let adapter = ClaudeAdapter::new();
|
let adapter = ClaudeAdapter::new();
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
use super::provider::{sanitize_claude_settings_for_live, ProviderService};
|
|
||||||
use crate::app_config::{AppType, MultiAppConfig};
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -82,150 +78,4 @@ impl ConfigService {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步当前供应商到对应的 live 配置。
|
|
||||||
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
|
|
||||||
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
|
|
||||||
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
|
|
||||||
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_current_provider_for_app(
|
|
||||||
config: &mut MultiAppConfig,
|
|
||||||
app_type: &AppType,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let (current_id, provider) = {
|
|
||||||
let manager = match config.get_manager(app_type) {
|
|
||||||
Some(manager) => manager,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
if manager.current.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_id = manager.current.clone();
|
|
||||||
let provider = match manager.providers.get(¤t_id) {
|
|
||||||
Some(provider) => provider.clone(),
|
|
||||||
None => {
|
|
||||||
log::warn!(
|
|
||||||
"当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步"
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
(current_id, provider)
|
|
||||||
};
|
|
||||||
|
|
||||||
match app_type {
|
|
||||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
|
||||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
|
||||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
|
||||||
AppType::OpenCode => {
|
|
||||||
// OpenCode uses additive mode, no live sync needed
|
|
||||||
// OpenCode providers are managed directly in the config file
|
|
||||||
}
|
|
||||||
AppType::OpenClaw => {
|
|
||||||
// OpenClaw uses additive mode, no live sync needed
|
|
||||||
// OpenClaw providers are managed directly in the config file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_codex_live(
|
|
||||||
config: &mut MultiAppConfig,
|
|
||||||
provider_id: &str,
|
|
||||||
provider: &Provider,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
|
||||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
|
|
||||||
})?;
|
|
||||||
let auth = settings.get("auth").ok_or_else(|| {
|
|
||||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
|
|
||||||
})?;
|
|
||||||
if !auth.is_object() {
|
|
||||||
return Err(AppError::Config(format!(
|
|
||||||
"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
|
||||||
|
|
||||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
|
||||||
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
|
||||||
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
|
||||||
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
|
|
||||||
|
|
||||||
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
|
||||||
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
|
|
||||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
|
||||||
if let Some(obj) = target.settings_config.as_object_mut() {
|
|
||||||
obj.insert(
|
|
||||||
"config".to_string(),
|
|
||||||
serde_json::Value::String(cfg_text_after),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_claude_live(
|
|
||||||
config: &mut MultiAppConfig,
|
|
||||||
provider_id: &str,
|
|
||||||
provider: &Provider,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
use crate::config::{read_json_file, write_json_file};
|
|
||||||
|
|
||||||
let settings_path = crate::config::get_claude_settings_path();
|
|
||||||
if let Some(parent) = settings_path.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
|
|
||||||
write_json_file(&settings_path, &settings)?;
|
|
||||||
|
|
||||||
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
|
|
||||||
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
|
||||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
|
||||||
target.settings_config = live_after;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_gemini_live(
|
|
||||||
config: &mut MultiAppConfig,
|
|
||||||
provider_id: &str,
|
|
||||||
provider: &Provider,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
use crate::gemini_config::{env_to_json, read_gemini_env};
|
|
||||||
|
|
||||||
ProviderService::write_gemini_live(provider)?;
|
|
||||||
|
|
||||||
// 读回实际写入的内容并更新到配置中(包含 settings.json)
|
|
||||||
let live_after_env = read_gemini_env()?;
|
|
||||||
let settings_path = crate::gemini_config::get_gemini_settings_path();
|
|
||||||
let live_after_config = if settings_path.exists() {
|
|
||||||
crate::config::read_json_file(&settings_path)?
|
|
||||||
} else {
|
|
||||||
serde_json::json!({})
|
|
||||||
};
|
|
||||||
let mut live_after = env_to_json(&live_after_env);
|
|
||||||
if let Some(obj) = live_after.as_object_mut() {
|
|
||||||
obj.insert("config".to_string(), live_after_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
|
|
||||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
|
||||||
target.settings_config = live_after;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ pub mod speedtest;
|
|||||||
pub mod stream_check;
|
pub mod stream_check;
|
||||||
pub mod usage_stats;
|
pub mod usage_stats;
|
||||||
pub mod webdav;
|
pub mod webdav;
|
||||||
|
pub mod webdav_auto_sync;
|
||||||
pub mod webdav_sync;
|
pub mod webdav_sync;
|
||||||
|
|
||||||
pub use config::ConfigService;
|
pub use config::ConfigService;
|
||||||
pub use mcp::McpService;
|
pub use mcp::McpService;
|
||||||
pub use omo::OmoService;
|
pub use omo::OmoService;
|
||||||
pub use prompt::PromptService;
|
pub use prompt::PromptService;
|
||||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
pub use provider::{ProviderService, ProviderSortUpdate, SwitchResult};
|
||||||
pub use proxy::ProxyService;
|
pub use proxy::ProxyService;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||||
|
|||||||
+190
-74
@@ -20,15 +20,83 @@ pub struct OmoLocalFileData {
|
|||||||
|
|
||||||
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>, bool);
|
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>, bool);
|
||||||
|
|
||||||
|
// ── Variant descriptor ─────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct OmoVariant {
|
||||||
|
pub filename: &'static str,
|
||||||
|
pub category: &'static str,
|
||||||
|
pub provider_prefix: &'static str,
|
||||||
|
pub plugin_name: &'static str,
|
||||||
|
pub plugin_prefix: &'static str,
|
||||||
|
pub known_keys: &'static [&'static str],
|
||||||
|
pub has_categories: bool,
|
||||||
|
pub config_key: &'static str,
|
||||||
|
pub label: &'static str,
|
||||||
|
pub import_label: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const STANDARD: OmoVariant = OmoVariant {
|
||||||
|
filename: "oh-my-opencode.jsonc",
|
||||||
|
category: "omo",
|
||||||
|
provider_prefix: "omo-",
|
||||||
|
plugin_name: "oh-my-opencode@latest",
|
||||||
|
plugin_prefix: "oh-my-opencode",
|
||||||
|
known_keys: &[
|
||||||
|
"$schema",
|
||||||
|
"agents",
|
||||||
|
"categories",
|
||||||
|
"sisyphus_agent",
|
||||||
|
"disabled_agents",
|
||||||
|
"disabled_mcps",
|
||||||
|
"disabled_hooks",
|
||||||
|
"disabled_skills",
|
||||||
|
"lsp",
|
||||||
|
"experimental",
|
||||||
|
"background_task",
|
||||||
|
"browser_automation_engine",
|
||||||
|
"claude_code",
|
||||||
|
],
|
||||||
|
has_categories: true,
|
||||||
|
config_key: "common_config_omo",
|
||||||
|
label: "OMO",
|
||||||
|
import_label: "Imported",
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const SLIM: OmoVariant = OmoVariant {
|
||||||
|
filename: "oh-my-opencode-slim.jsonc",
|
||||||
|
category: "omo-slim",
|
||||||
|
provider_prefix: "omo-slim-",
|
||||||
|
plugin_name: "oh-my-opencode-slim@latest",
|
||||||
|
plugin_prefix: "oh-my-opencode-slim",
|
||||||
|
known_keys: &[
|
||||||
|
"$schema",
|
||||||
|
"agents",
|
||||||
|
"sisyphus_agent",
|
||||||
|
"disabled_agents",
|
||||||
|
"disabled_mcps",
|
||||||
|
"disabled_hooks",
|
||||||
|
"lsp",
|
||||||
|
"experimental",
|
||||||
|
],
|
||||||
|
has_categories: false,
|
||||||
|
config_key: "common_config_omo_slim",
|
||||||
|
label: "OMO Slim",
|
||||||
|
import_label: "Imported Slim",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Service ────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub struct OmoService;
|
pub struct OmoService;
|
||||||
|
|
||||||
impl OmoService {
|
impl OmoService {
|
||||||
fn config_path() -> PathBuf {
|
// ── Path helpers ────────────────────────────────────────
|
||||||
get_opencode_dir().join("oh-my-opencode.jsonc")
|
|
||||||
|
fn config_path(v: &OmoVariant) -> PathBuf {
|
||||||
|
get_opencode_dir().join(v.filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_local_config_path() -> Result<PathBuf, AppError> {
|
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
|
||||||
let config_path = Self::config_path();
|
let config_path = Self::config_path(v);
|
||||||
if config_path.exists() {
|
if config_path.exists() {
|
||||||
return Ok(config_path);
|
return Ok(config_path);
|
||||||
}
|
}
|
||||||
@@ -52,26 +120,15 @@ impl OmoService {
|
|||||||
.ok_or_else(|| AppError::Config("Expected JSON object".to_string()))
|
.ok_or_else(|| AppError::Config("Expected JSON object".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_other_fields(obj: &Map<String, Value>) -> Map<String, Value> {
|
// ── Field extraction ───────────────────────────────────
|
||||||
const KNOWN_KEYS: [&str; 13] = [
|
|
||||||
"$schema",
|
|
||||||
"agents",
|
|
||||||
"categories",
|
|
||||||
"sisyphus_agent",
|
|
||||||
"disabled_agents",
|
|
||||||
"disabled_mcps",
|
|
||||||
"disabled_hooks",
|
|
||||||
"disabled_skills",
|
|
||||||
"lsp",
|
|
||||||
"experimental",
|
|
||||||
"background_task",
|
|
||||||
"browser_automation_engine",
|
|
||||||
"claude_code",
|
|
||||||
];
|
|
||||||
|
|
||||||
|
fn extract_other_fields_with_keys(
|
||||||
|
obj: &Map<String, Value>,
|
||||||
|
known: &[&str],
|
||||||
|
) -> Map<String, Value> {
|
||||||
let mut other = Map::new();
|
let mut other = Map::new();
|
||||||
for (k, v) in obj {
|
for (k, v) in obj {
|
||||||
if !KNOWN_KEYS.contains(&k.as_str()) {
|
if !known.contains(&k.as_str()) {
|
||||||
other.insert(k.clone(), v.clone());
|
other.insert(k.clone(), v.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +176,8 @@ impl OmoService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Merge helpers ──────────────────────────────────────
|
||||||
|
|
||||||
fn insert_opt_value(result: &mut Map<String, Value>, key: &str, value: &Option<Value>) {
|
fn insert_opt_value(result: &mut Map<String, Value>, key: &str, value: &Option<Value>) {
|
||||||
if let Some(v) = value {
|
if let Some(v) = value {
|
||||||
result.insert(key.to_string(), v.clone());
|
result.insert(key.to_string(), v.clone());
|
||||||
@@ -142,34 +201,40 @@ impl OmoService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_config_file() -> Result<(), AppError> {
|
// ── Public API (variant-parameterized) ─────────────────
|
||||||
let config_path = Self::config_path();
|
|
||||||
|
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
|
||||||
|
let config_path = Self::config_path(v);
|
||||||
if config_path.exists() {
|
if config_path.exists() {
|
||||||
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
log::info!("OMO config file deleted: {config_path:?}");
|
log::info!("{} config file deleted: {config_path:?}", v.label);
|
||||||
}
|
}
|
||||||
crate::opencode_config::remove_plugin_by_prefix("oh-my-opencode")?;
|
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_config_to_file(state: &AppState) -> Result<(), AppError> {
|
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
|
||||||
let global = state.db.get_omo_global_config()?;
|
let global = state.db.get_omo_global_config(v.config_key)?;
|
||||||
let current_omo = state.db.get_current_omo_provider("opencode")?;
|
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
|
||||||
|
|
||||||
let profile_data = current_omo.as_ref().map(|p| {
|
let profile_data = current_omo.as_ref().map(|p| {
|
||||||
let agents = p.settings_config.get("agents").cloned();
|
let agents = p.settings_config.get("agents").cloned();
|
||||||
let categories = p.settings_config.get("categories").cloned();
|
let categories = if v.has_categories {
|
||||||
|
p.settings_config.get("categories").cloned()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let other_fields = p.settings_config.get("otherFields").cloned();
|
let other_fields = p.settings_config.get("otherFields").cloned();
|
||||||
let use_common_config = p
|
let use_common_config = p
|
||||||
.settings_config
|
.settings_config
|
||||||
.get("useCommonConfig")
|
.get("useCommonConfig")
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|val| val.as_bool())
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
(agents, categories, other_fields, use_common_config)
|
(agents, categories, other_fields, use_common_config)
|
||||||
});
|
});
|
||||||
|
|
||||||
let merged = Self::merge_config(&global, profile_data.as_ref());
|
let merged = Self::merge_config(v, &global, profile_data.as_ref());
|
||||||
let config_path = Self::config_path();
|
let config_path = Self::config_path(v);
|
||||||
|
|
||||||
if let Some(parent) = config_path.parent() {
|
if let Some(parent) = config_path.parent() {
|
||||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
@@ -177,15 +242,19 @@ impl OmoService {
|
|||||||
|
|
||||||
write_json_file(&config_path, &merged)?;
|
write_json_file(&config_path, &merged)?;
|
||||||
|
|
||||||
crate::opencode_config::add_plugin("oh-my-opencode@latest")?;
|
crate::opencode_config::add_plugin(v.plugin_name)?;
|
||||||
|
|
||||||
log::info!("OMO config written to {config_path:?}");
|
log::info!("{} config written to {config_path:?}", v.label);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_config(global: &OmoGlobalConfig, profile_data: Option<&OmoProfileData>) -> Value {
|
fn merge_config(
|
||||||
|
v: &OmoVariant,
|
||||||
|
global: &OmoGlobalConfig,
|
||||||
|
profile_data: Option<&OmoProfileData>,
|
||||||
|
) -> Value {
|
||||||
let mut result = Map::new();
|
let mut result = Map::new();
|
||||||
let use_common_config = profile_data.map(|(_, _, _, v)| *v).unwrap_or(true);
|
let use_common_config = profile_data.map(|(_, _, _, uc)| *uc).unwrap_or(true);
|
||||||
|
|
||||||
if use_common_config {
|
if use_common_config {
|
||||||
if let Some(url) = &global.schema_url {
|
if let Some(url) = &global.schema_url {
|
||||||
@@ -196,61 +265,69 @@ impl OmoService {
|
|||||||
Self::insert_string_array(&mut result, "disabled_agents", &global.disabled_agents);
|
Self::insert_string_array(&mut result, "disabled_agents", &global.disabled_agents);
|
||||||
Self::insert_string_array(&mut result, "disabled_mcps", &global.disabled_mcps);
|
Self::insert_string_array(&mut result, "disabled_mcps", &global.disabled_mcps);
|
||||||
Self::insert_string_array(&mut result, "disabled_hooks", &global.disabled_hooks);
|
Self::insert_string_array(&mut result, "disabled_hooks", &global.disabled_hooks);
|
||||||
Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills);
|
|
||||||
|
if v.has_categories {
|
||||||
|
Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills);
|
||||||
|
Self::insert_opt_value(&mut result, "background_task", &global.background_task);
|
||||||
|
Self::insert_opt_value(
|
||||||
|
&mut result,
|
||||||
|
"browser_automation_engine",
|
||||||
|
&global.browser_automation_engine,
|
||||||
|
);
|
||||||
|
Self::insert_opt_value(&mut result, "claude_code", &global.claude_code);
|
||||||
|
}
|
||||||
|
|
||||||
Self::insert_opt_value(&mut result, "lsp", &global.lsp);
|
Self::insert_opt_value(&mut result, "lsp", &global.lsp);
|
||||||
Self::insert_opt_value(&mut result, "experimental", &global.experimental);
|
Self::insert_opt_value(&mut result, "experimental", &global.experimental);
|
||||||
Self::insert_opt_value(&mut result, "background_task", &global.background_task);
|
|
||||||
Self::insert_opt_value(
|
|
||||||
&mut result,
|
|
||||||
"browser_automation_engine",
|
|
||||||
&global.browser_automation_engine,
|
|
||||||
);
|
|
||||||
Self::insert_opt_value(&mut result, "claude_code", &global.claude_code);
|
|
||||||
|
|
||||||
Self::insert_object_entries(&mut result, global.other_fields.as_ref());
|
Self::insert_object_entries(&mut result, global.other_fields.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((agents, categories, other_fields, _)) = profile_data {
|
if let Some((agents, categories, other_fields, _)) = profile_data {
|
||||||
Self::insert_opt_value(&mut result, "agents", agents);
|
Self::insert_opt_value(&mut result, "agents", agents);
|
||||||
Self::insert_opt_value(&mut result, "categories", categories);
|
if v.has_categories {
|
||||||
|
Self::insert_opt_value(&mut result, "categories", categories);
|
||||||
|
}
|
||||||
Self::insert_object_entries(&mut result, other_fields.as_ref());
|
Self::insert_object_entries(&mut result, other_fields.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
Value::Object(result)
|
Value::Object(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn import_from_local(state: &AppState) -> Result<crate::provider::Provider, AppError> {
|
pub fn import_from_local(
|
||||||
let actual_path = Self::resolve_local_config_path()?;
|
|
||||||
Self::import_from_path(state, &actual_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn import_from_path(
|
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
path: &std::path::Path,
|
v: &OmoVariant,
|
||||||
) -> Result<crate::provider::Provider, AppError> {
|
) -> Result<crate::provider::Provider, AppError> {
|
||||||
let obj = Self::read_jsonc_object(path)?;
|
let actual_path = Self::resolve_local_config_path(v)?;
|
||||||
|
let obj = Self::read_jsonc_object(&actual_path)?;
|
||||||
|
|
||||||
let mut settings = Map::new();
|
let mut settings = Map::new();
|
||||||
if let Some(agents) = obj.get("agents") {
|
if let Some(agents) = obj.get("agents") {
|
||||||
settings.insert("agents".to_string(), agents.clone());
|
settings.insert("agents".to_string(), agents.clone());
|
||||||
}
|
}
|
||||||
if let Some(categories) = obj.get("categories") {
|
if v.has_categories {
|
||||||
settings.insert("categories".to_string(), categories.clone());
|
if let Some(categories) = obj.get("categories") {
|
||||||
|
settings.insert("categories".to_string(), categories.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
|
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
|
||||||
|
|
||||||
let other = Self::extract_other_fields(&obj);
|
let other = Self::extract_other_fields_with_keys(&obj, v.known_keys);
|
||||||
if !other.is_empty() {
|
if !other.is_empty() {
|
||||||
settings.insert("otherFields".to_string(), Value::Object(other));
|
settings.insert("otherFields".to_string(), Value::Object(other));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut global = state.db.get_omo_global_config()?;
|
let mut global = state.db.get_omo_global_config(v.config_key)?;
|
||||||
Self::merge_global_from_obj(&obj, &mut global);
|
Self::merge_global_from_obj(&obj, &mut global);
|
||||||
global.updated_at = chrono::Utc::now().to_rfc3339();
|
global.updated_at = chrono::Utc::now().to_rfc3339();
|
||||||
state.db.save_omo_global_config(&global)?;
|
state.db.save_omo_global_config(v.config_key, &global)?;
|
||||||
|
|
||||||
let provider_id = format!("omo-{}", uuid::Uuid::new_v4());
|
let provider_id = format!("{}{}", v.provider_prefix, uuid::Uuid::new_v4());
|
||||||
let name = format!("Imported {}", chrono::Local::now().format("%Y-%m-%d %H:%M"));
|
let name = format!(
|
||||||
|
"{} {}",
|
||||||
|
v.import_label,
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
||||||
|
);
|
||||||
let settings_config =
|
let settings_config =
|
||||||
serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));
|
serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));
|
||||||
|
|
||||||
@@ -259,7 +336,7 @@ impl OmoService {
|
|||||||
name,
|
name,
|
||||||
settings_config,
|
settings_config,
|
||||||
website_url: None,
|
website_url: None,
|
||||||
category: Some("omo".to_string()),
|
category: Some(v.category.to_string()),
|
||||||
created_at: Some(chrono::Utc::now().timestamp_millis()),
|
created_at: Some(chrono::Utc::now().timestamp_millis()),
|
||||||
sort_index: None,
|
sort_index: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
@@ -272,13 +349,13 @@ impl OmoService {
|
|||||||
state.db.save_provider("opencode", &provider)?;
|
state.db.save_provider("opencode", &provider)?;
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_omo_provider_current("opencode", &provider.id)?;
|
.set_omo_provider_current("opencode", &provider.id, v.category)?;
|
||||||
Self::write_config_to_file(state)?;
|
Self::write_config_to_file(state, v)?;
|
||||||
Ok(provider)
|
Ok(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_local_file() -> Result<OmoLocalFileData, AppError> {
|
pub fn read_local_file(v: &OmoVariant) -> Result<OmoLocalFileData, AppError> {
|
||||||
let actual_path = Self::resolve_local_config_path()?;
|
let actual_path = Self::resolve_local_config_path(v)?;
|
||||||
let metadata = std::fs::metadata(&actual_path).ok();
|
let metadata = std::fs::metadata(&actual_path).ok();
|
||||||
let last_modified = metadata
|
let last_modified = metadata
|
||||||
.and_then(|m| m.modified().ok())
|
.and_then(|m| m.modified().ok())
|
||||||
@@ -286,22 +363,28 @@ impl OmoService {
|
|||||||
|
|
||||||
let obj = Self::read_jsonc_object(&actual_path)?;
|
let obj = Self::read_jsonc_object(&actual_path)?;
|
||||||
|
|
||||||
Ok(Self::build_local_file_data_from_obj(
|
Ok(Self::build_local_file_data(
|
||||||
|
v,
|
||||||
&obj,
|
&obj,
|
||||||
actual_path.to_string_lossy().to_string(),
|
actual_path.to_string_lossy().to_string(),
|
||||||
last_modified,
|
last_modified,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_local_file_data_from_obj(
|
fn build_local_file_data(
|
||||||
|
v: &OmoVariant,
|
||||||
obj: &Map<String, Value>,
|
obj: &Map<String, Value>,
|
||||||
file_path: String,
|
file_path: String,
|
||||||
last_modified: Option<String>,
|
last_modified: Option<String>,
|
||||||
) -> OmoLocalFileData {
|
) -> OmoLocalFileData {
|
||||||
let agents = obj.get("agents").cloned();
|
let agents = obj.get("agents").cloned();
|
||||||
let categories = obj.get("categories").cloned();
|
let categories = if v.has_categories {
|
||||||
|
obj.get("categories").cloned()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let other = Self::extract_other_fields(obj);
|
let other = Self::extract_other_fields_with_keys(obj, v.known_keys);
|
||||||
let other_fields = if other.is_empty() {
|
let other_fields = if other.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -401,7 +484,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_merge_config_empty() {
|
fn test_merge_config_empty() {
|
||||||
let global = OmoGlobalConfig::default();
|
let global = OmoGlobalConfig::default();
|
||||||
let merged = OmoService::merge_config(&global, None);
|
let merged = OmoService::merge_config(&STANDARD, &global, None);
|
||||||
assert!(merged.is_object());
|
assert!(merged.is_object());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,7 +501,7 @@ mod tests {
|
|||||||
let categories = None;
|
let categories = None;
|
||||||
let other_fields = None;
|
let other_fields = None;
|
||||||
let profile_data = (agents, categories, other_fields, true);
|
let profile_data = (agents, categories, other_fields, true);
|
||||||
let merged = OmoService::merge_config(&global, Some(&profile_data));
|
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
|
||||||
let obj = merged.as_object().unwrap();
|
let obj = merged.as_object().unwrap();
|
||||||
|
|
||||||
assert_eq!(obj["$schema"], "https://example.com/schema.json");
|
assert_eq!(obj["$schema"], "https://example.com/schema.json");
|
||||||
@@ -440,7 +523,7 @@ mod tests {
|
|||||||
let categories = None;
|
let categories = None;
|
||||||
let other_fields = None;
|
let other_fields = None;
|
||||||
let profile_data = (agents, categories, other_fields, false);
|
let profile_data = (agents, categories, other_fields, false);
|
||||||
let merged = OmoService::merge_config(&global, Some(&profile_data));
|
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
|
||||||
let obj = merged.as_object().unwrap();
|
let obj = merged.as_object().unwrap();
|
||||||
|
|
||||||
assert!(!obj.contains_key("$schema"));
|
assert!(!obj.contains_key("$schema"));
|
||||||
@@ -465,7 +548,8 @@ mod tests {
|
|||||||
});
|
});
|
||||||
let obj_map = obj.as_object().unwrap().clone();
|
let obj_map = obj.as_object().unwrap().clone();
|
||||||
|
|
||||||
let data = OmoService::build_local_file_data_from_obj(
|
let data = OmoService::build_local_file_data(
|
||||||
|
&STANDARD,
|
||||||
&obj_map,
|
&obj_map,
|
||||||
"/tmp/oh-my-opencode.jsonc".to_string(),
|
"/tmp/oh-my-opencode.jsonc".to_string(),
|
||||||
None,
|
None,
|
||||||
@@ -497,11 +581,43 @@ mod tests {
|
|||||||
let other_fields = Some(serde_json::json!("profile_non_object"));
|
let other_fields = Some(serde_json::json!("profile_non_object"));
|
||||||
let profile_data = (agents, categories, other_fields, true);
|
let profile_data = (agents, categories, other_fields, true);
|
||||||
|
|
||||||
let merged = OmoService::merge_config(&global, Some(&profile_data));
|
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
|
||||||
let obj = merged.as_object().unwrap();
|
let obj = merged.as_object().unwrap();
|
||||||
|
|
||||||
assert!(!obj.contains_key("0"));
|
assert!(!obj.contains_key("0"));
|
||||||
assert!(!obj.contains_key("global_non_object"));
|
assert!(!obj.contains_key("global_non_object"));
|
||||||
assert!(!obj.contains_key("profile_non_object"));
|
assert!(!obj.contains_key("profile_non_object"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_config_slim_excludes_categories_and_extra_fields() {
|
||||||
|
let global = OmoGlobalConfig {
|
||||||
|
schema_url: Some("https://slim.schema".to_string()),
|
||||||
|
disabled_agents: vec!["oracle".to_string()],
|
||||||
|
disabled_skills: vec!["playwright".to_string()],
|
||||||
|
background_task: Some(serde_json::json!({"key": "val"})),
|
||||||
|
browser_automation_engine: Some(serde_json::json!({"provider": "pw"})),
|
||||||
|
claude_code: Some(serde_json::json!({"mcp": true})),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let agents = Some(serde_json::json!({"orchestrator": {"model": "k2"}}));
|
||||||
|
let categories = Some(serde_json::json!({"code": {"model": "gpt"}}));
|
||||||
|
let other_fields = None;
|
||||||
|
let profile_data = (agents, categories, other_fields, true);
|
||||||
|
|
||||||
|
let merged = OmoService::merge_config(&SLIM, &global, Some(&profile_data));
|
||||||
|
let obj = merged.as_object().unwrap();
|
||||||
|
|
||||||
|
// Slim should NOT include these
|
||||||
|
assert!(!obj.contains_key("disabled_skills"));
|
||||||
|
assert!(!obj.contains_key("background_task"));
|
||||||
|
assert!(!obj.contains_key("browser_automation_engine"));
|
||||||
|
assert!(!obj.contains_key("claude_code"));
|
||||||
|
assert!(!obj.contains_key("categories"));
|
||||||
|
|
||||||
|
// Slim SHOULD include these
|
||||||
|
assert_eq!(obj["$schema"], "https://slim.schema");
|
||||||
|
assert!(obj.contains_key("agents"));
|
||||||
|
assert!(obj.contains_key("disabled_agents"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,439 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Key fields definitions for partial merge
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Claude env-level key fields that belong to the provider.
|
||||||
|
/// When adding a new field here, also update backfill_claude_key_fields().
|
||||||
|
const CLAUDE_KEY_ENV_FIELDS: &[&str] = &[
|
||||||
|
// --- API auth & endpoint ---
|
||||||
|
"ANTHROPIC_BASE_URL",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"ANTHROPIC_API_KEY",
|
||||||
|
// --- Model selection ---
|
||||||
|
"ANTHROPIC_MODEL",
|
||||||
|
"ANTHROPIC_REASONING_MODEL",
|
||||||
|
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||||
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||||
|
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||||
|
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||||
|
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||||
|
// --- AWS Bedrock ---
|
||||||
|
"CLAUDE_CODE_USE_BEDROCK",
|
||||||
|
"AWS_ACCESS_KEY_ID",
|
||||||
|
"AWS_SECRET_ACCESS_KEY",
|
||||||
|
"AWS_SESSION_TOKEN",
|
||||||
|
"AWS_REGION",
|
||||||
|
"AWS_PROFILE",
|
||||||
|
"ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION",
|
||||||
|
// --- Google Vertex AI ---
|
||||||
|
"CLAUDE_CODE_USE_VERTEX",
|
||||||
|
"ANTHROPIC_VERTEX_PROJECT_ID",
|
||||||
|
"CLOUD_ML_REGION",
|
||||||
|
// --- Microsoft Foundry ---
|
||||||
|
"CLAUDE_CODE_USE_FOUNDRY",
|
||||||
|
// --- Provider behavior ---
|
||||||
|
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
|
||||||
|
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
|
||||||
|
"API_TIMEOUT_MS",
|
||||||
|
"DISABLE_PROMPT_CACHING",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Claude top-level key fields (legacy + modern format).
|
||||||
|
/// When adding a new field here, also update backfill_claude_key_fields().
|
||||||
|
const CLAUDE_KEY_TOP_LEVEL: &[&str] = &[
|
||||||
|
"apiBaseUrl", // legacy
|
||||||
|
"primaryModel", // legacy
|
||||||
|
"smallFastModel", // legacy
|
||||||
|
"model", // modern
|
||||||
|
"apiKey", // Bedrock API Key auth
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Codex TOML key fields.
|
||||||
|
/// When adding a new field here, also update backfill_codex_key_fields().
|
||||||
|
const CODEX_KEY_TOP_LEVEL: &[&str] = &[
|
||||||
|
"model_provider",
|
||||||
|
"model",
|
||||||
|
"model_reasoning_effort",
|
||||||
|
"review_model",
|
||||||
|
"plan_mode_reasoning_effort",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Gemini env-level key fields.
|
||||||
|
/// When adding a new field here, also update backfill_gemini_key_fields().
|
||||||
|
const GEMINI_KEY_ENV_FIELDS: &[&str] = &[
|
||||||
|
"GOOGLE_GEMINI_BASE_URL",
|
||||||
|
"GEMINI_API_KEY",
|
||||||
|
"GEMINI_MODEL",
|
||||||
|
"GOOGLE_API_KEY",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Partial merge: write only key fields to live config
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Write only provider-specific key fields to live configuration,
|
||||||
|
/// preserving all other user settings in the live file.
|
||||||
|
///
|
||||||
|
/// Used for switch-mode apps (Claude, Codex, Gemini) during:
|
||||||
|
/// - `switch_normal()` — switching providers
|
||||||
|
/// - `sync_current_to_live()` — startup sync
|
||||||
|
/// - `add()` / `update()` when the provider is current
|
||||||
|
pub(crate) fn write_live_partial(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => write_claude_live_partial(provider),
|
||||||
|
AppType::Codex => write_codex_live_partial(provider),
|
||||||
|
AppType::Gemini => write_gemini_live_partial(provider),
|
||||||
|
// Additive mode apps still use full snapshot
|
||||||
|
AppType::OpenCode | AppType::OpenClaw => write_live_snapshot(app_type, provider),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a JSON merge patch (RFC 7396) directly to Claude live settings.json.
|
||||||
|
/// Used for user-level preferences (attribution, thinking, etc.) that are
|
||||||
|
/// independent of the active provider.
|
||||||
|
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
|
||||||
|
let path = get_claude_settings_path();
|
||||||
|
let mut live = if path.exists() {
|
||||||
|
read_json_file(&path).unwrap_or_else(|_| json!({}))
|
||||||
|
} else {
|
||||||
|
json!({})
|
||||||
|
};
|
||||||
|
json_merge_patch(&mut live, &patch);
|
||||||
|
let settings = sanitize_claude_settings_for_live(&live);
|
||||||
|
write_json_file(&path, &settings)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC 7396 JSON Merge Patch: null deletes, objects merge recursively, rest overwrites.
|
||||||
|
fn json_merge_patch(target: &mut Value, patch: &Value) {
|
||||||
|
if let Some(patch_obj) = patch.as_object() {
|
||||||
|
if !target.is_object() {
|
||||||
|
*target = json!({});
|
||||||
|
}
|
||||||
|
let target_obj = target.as_object_mut().unwrap();
|
||||||
|
for (key, value) in patch_obj {
|
||||||
|
if value.is_null() {
|
||||||
|
target_obj.remove(key);
|
||||||
|
} else if value.is_object() {
|
||||||
|
let entry = target_obj.entry(key.clone()).or_insert(json!({}));
|
||||||
|
json_merge_patch(entry, value);
|
||||||
|
// Clean up empty container objects
|
||||||
|
if entry.as_object().map_or(false, |o| o.is_empty()) {
|
||||||
|
target_obj.remove(key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
target_obj.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude: merge only key env and top-level fields into live settings.json
|
||||||
|
fn write_claude_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
let path = get_claude_settings_path();
|
||||||
|
|
||||||
|
// 1. Read existing live config (start from empty if file doesn't exist)
|
||||||
|
let mut live = if path.exists() {
|
||||||
|
read_json_file(&path).unwrap_or_else(|_| json!({}))
|
||||||
|
} else {
|
||||||
|
json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Ensure live.env exists as an object
|
||||||
|
if !live.get("env").is_some_and(|v| v.is_object()) {
|
||||||
|
live.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert("env".into(), json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Clear key env fields from live, then write from provider
|
||||||
|
let live_env = live.get_mut("env").unwrap().as_object_mut().unwrap();
|
||||||
|
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||||
|
live_env.remove(*key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(provider_env) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
{
|
||||||
|
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||||
|
if let Some(value) = provider_env.get(*key) {
|
||||||
|
live_env.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Handle top-level legacy key fields
|
||||||
|
let live_obj = live.as_object_mut().unwrap();
|
||||||
|
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||||
|
live_obj.remove(*key);
|
||||||
|
}
|
||||||
|
if let Some(provider_obj) = provider.settings_config.as_object() {
|
||||||
|
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||||
|
if let Some(value) = provider_obj.get(*key) {
|
||||||
|
live_obj.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Sanitize and write
|
||||||
|
let settings = sanitize_claude_settings_for_live(&live);
|
||||||
|
write_json_file(&path, &settings)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Codex: replace auth.json entirely, partially merge config.toml key fields
|
||||||
|
fn write_codex_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
let obj = provider
|
||||||
|
.settings_config
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
|
||||||
|
|
||||||
|
// auth.json is entirely provider-specific, replace it wholesale
|
||||||
|
let auth = obj
|
||||||
|
.get("auth")
|
||||||
|
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
|
||||||
|
|
||||||
|
let provider_config_str = obj.get("config").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
// Read existing config.toml (or start from empty)
|
||||||
|
let config_path = get_codex_config_path();
|
||||||
|
let existing_toml = if config_path.exists() {
|
||||||
|
std::fs::read_to_string(&config_path).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse both existing and provider TOML
|
||||||
|
let mut live_doc = existing_toml
|
||||||
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.unwrap_or_else(|_| toml_edit::DocumentMut::new());
|
||||||
|
|
||||||
|
// Remove key fields from live doc
|
||||||
|
let live_root = live_doc.as_table_mut();
|
||||||
|
for key in CODEX_KEY_TOP_LEVEL {
|
||||||
|
live_root.remove(key);
|
||||||
|
}
|
||||||
|
live_root.remove("model_providers");
|
||||||
|
|
||||||
|
// Parse provider TOML and extract key fields
|
||||||
|
if !provider_config_str.is_empty() {
|
||||||
|
if let Ok(provider_doc) = provider_config_str.parse::<toml_edit::DocumentMut>() {
|
||||||
|
let provider_root = provider_doc.as_table();
|
||||||
|
|
||||||
|
// Copy key top-level fields from provider
|
||||||
|
for key in CODEX_KEY_TOP_LEVEL {
|
||||||
|
if let Some(item) = provider_root.get(key) {
|
||||||
|
live_root.insert(key, item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy model_providers table from provider
|
||||||
|
if let Some(mp) = provider_root.get("model_providers") {
|
||||||
|
live_root.insert("model_providers", mp.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write using atomic write
|
||||||
|
crate::codex_config::write_codex_live_atomic(auth, Some(&live_doc.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gemini: merge only key env fields, preserve settings.json (MCP etc.)
|
||||||
|
fn write_gemini_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, write_gemini_env_atomic};
|
||||||
|
|
||||||
|
let auth_type = detect_gemini_auth_type(provider);
|
||||||
|
|
||||||
|
// 1. Read existing env from live .env file
|
||||||
|
let mut env_map = if get_gemini_env_path().exists() {
|
||||||
|
read_gemini_env().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
HashMap::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Remove key fields from existing env
|
||||||
|
for key in GEMINI_KEY_ENV_FIELDS {
|
||||||
|
env_map.remove(*key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Extract key fields from provider and merge
|
||||||
|
if let Some(provider_env) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
{
|
||||||
|
for key in GEMINI_KEY_ENV_FIELDS {
|
||||||
|
if let Some(value) = provider_env.get(*key).and_then(|v| v.as_str()) {
|
||||||
|
if !value.is_empty() {
|
||||||
|
env_map.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Handle auth type specific behavior
|
||||||
|
match auth_type {
|
||||||
|
GeminiAuthType::GoogleOfficial => {
|
||||||
|
// Google official uses OAuth, clear all env
|
||||||
|
env_map.clear();
|
||||||
|
write_gemini_env_atomic(&env_map)?;
|
||||||
|
}
|
||||||
|
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||||
|
// Validate and write env
|
||||||
|
crate::gemini_config::validate_gemini_settings_strict(&provider.settings_config)?;
|
||||||
|
write_gemini_env_atomic(&env_map)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Handle settings.json (same as write_gemini_live — preserve existing MCP etc.)
|
||||||
|
use crate::gemini_config::get_gemini_settings_path;
|
||||||
|
let settings_path = get_gemini_settings_path();
|
||||||
|
|
||||||
|
if let Some(config_value) = provider.settings_config.get("config") {
|
||||||
|
if config_value.is_object() {
|
||||||
|
let mut merged = if settings_path.exists() {
|
||||||
|
read_json_file::<Value>(&settings_path).unwrap_or_else(|_| json!({}))
|
||||||
|
} else {
|
||||||
|
json!({})
|
||||||
|
};
|
||||||
|
if let (Some(merged_obj), Some(config_obj)) =
|
||||||
|
(merged.as_object_mut(), config_value.as_object())
|
||||||
|
{
|
||||||
|
for (k, v) in config_obj {
|
||||||
|
merged_obj.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_json_file(&settings_path, &merged)?;
|
||||||
|
} else if !config_value.is_null() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.validation.invalid_config",
|
||||||
|
"Gemini 配置格式错误: config 必须是对象或 null",
|
||||||
|
"Gemini config invalid: config must be an object or null",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Set security flag based on auth type
|
||||||
|
match auth_type {
|
||||||
|
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
|
||||||
|
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||||
|
crate::gemini_config::write_packycode_settings()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Backfill: extract only key fields from live config
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Extract only provider-specific key fields from a live config value.
|
||||||
|
///
|
||||||
|
/// Used during backfill to ensure the provider's `settings_config` converges
|
||||||
|
/// to containing only key fields over time.
|
||||||
|
pub(crate) fn backfill_key_fields(app_type: &AppType, live_config: &Value) -> Value {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => backfill_claude_key_fields(live_config),
|
||||||
|
AppType::Codex => backfill_codex_key_fields(live_config),
|
||||||
|
AppType::Gemini => backfill_gemini_key_fields(live_config),
|
||||||
|
// Additive mode: return full config (no backfill needed)
|
||||||
|
_ => live_config.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backfill_claude_key_fields(live: &Value) -> Value {
|
||||||
|
let mut result = json!({});
|
||||||
|
let result_obj = result.as_object_mut().unwrap();
|
||||||
|
|
||||||
|
// Extract key env fields
|
||||||
|
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
|
||||||
|
let mut env_obj = serde_json::Map::new();
|
||||||
|
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||||
|
if let Some(value) = live_env.get(*key) {
|
||||||
|
env_obj.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !env_obj.is_empty() {
|
||||||
|
result_obj.insert("env".to_string(), Value::Object(env_obj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key top-level fields
|
||||||
|
if let Some(live_obj) = live.as_object() {
|
||||||
|
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||||
|
if let Some(value) = live_obj.get(*key) {
|
||||||
|
result_obj.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backfill_codex_key_fields(live: &Value) -> Value {
|
||||||
|
let mut result = json!({});
|
||||||
|
let result_obj = result.as_object_mut().unwrap();
|
||||||
|
|
||||||
|
// auth is entirely provider-specific — keep it as-is
|
||||||
|
if let Some(auth) = live.get("auth") {
|
||||||
|
result_obj.insert("auth".to_string(), auth.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key TOML fields from config string
|
||||||
|
if let Some(config_str) = live.get("config").and_then(|v| v.as_str()) {
|
||||||
|
if let Ok(doc) = config_str.parse::<toml_edit::DocumentMut>() {
|
||||||
|
let mut new_doc = toml_edit::DocumentMut::new();
|
||||||
|
let new_root = new_doc.as_table_mut();
|
||||||
|
|
||||||
|
// Copy key top-level fields
|
||||||
|
for key in CODEX_KEY_TOP_LEVEL {
|
||||||
|
if let Some(item) = doc.as_table().get(key) {
|
||||||
|
new_root.insert(key, item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy model_providers table
|
||||||
|
if let Some(mp) = doc.as_table().get("model_providers") {
|
||||||
|
new_root.insert("model_providers", mp.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let toml_str = new_doc.to_string();
|
||||||
|
if !toml_str.trim().is_empty() {
|
||||||
|
result_obj.insert("config".to_string(), Value::String(toml_str));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backfill_gemini_key_fields(live: &Value) -> Value {
|
||||||
|
let mut result = json!({});
|
||||||
|
let result_obj = result.as_object_mut().unwrap();
|
||||||
|
|
||||||
|
// Extract key env fields
|
||||||
|
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
|
||||||
|
let mut env_obj = serde_json::Map::new();
|
||||||
|
for key in GEMINI_KEY_ENV_FIELDS {
|
||||||
|
if let Some(value) = live_env.get(*key) {
|
||||||
|
env_obj.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !env_obj.is_empty() {
|
||||||
|
result_obj.insert("env".to_string(), Value::Object(env_obj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Sync all providers to live configuration (for additive mode apps)
|
/// Sync all providers to live configuration (for additive mode apps)
|
||||||
///
|
///
|
||||||
/// Writes all providers from the database to the live configuration file.
|
/// Writes all providers from the database to the live configuration file.
|
||||||
@@ -286,7 +719,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
|||||||
|
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
if let Some(provider) = providers.get(¤t_id) {
|
if let Some(provider) = providers.get(¤t_id) {
|
||||||
write_live_snapshot(&app_type, provider)?;
|
write_live_partial(&app_type, provider)?;
|
||||||
}
|
}
|
||||||
// Note: get_effective_current_provider already validates existence,
|
// Note: get_effective_current_provider already validates existence,
|
||||||
// so providers.get() should always succeed here
|
// so providers.get() should always succeed here
|
||||||
|
|||||||
@@ -27,17 +27,25 @@ pub use live::{
|
|||||||
|
|
||||||
// Internal re-exports (pub(crate))
|
// Internal re-exports (pub(crate))
|
||||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||||
pub(crate) use live::write_live_snapshot;
|
pub(crate) use live::write_live_partial;
|
||||||
|
|
||||||
// Internal re-exports
|
// Internal re-exports
|
||||||
use live::{
|
use live::{
|
||||||
remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
|
backfill_key_fields, remove_openclaw_provider_from_live, remove_opencode_provider_from_live,
|
||||||
|
write_live_snapshot,
|
||||||
};
|
};
|
||||||
use usage::validate_usage_script;
|
use usage::validate_usage_script;
|
||||||
|
|
||||||
/// Provider business logic service
|
/// Provider business logic service
|
||||||
pub struct ProviderService;
|
pub struct ProviderService;
|
||||||
|
|
||||||
|
/// Result of a provider switch operation, including any non-fatal warnings
|
||||||
|
#[derive(Debug, serde::Serialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SwitchResult {
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -77,47 +85,6 @@ mod tests {
|
|||||||
assert_eq!(api_key, "token");
|
assert_eq!(api_key, "token");
|
||||||
assert_eq!(base_url, "https://claude.example");
|
assert_eq!(base_url, "https://claude.example");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_codex_common_config_preserves_mcp_servers_base_url() {
|
|
||||||
let config_toml = r#"model_provider = "azure"
|
|
||||||
model = "gpt-4"
|
|
||||||
disable_response_storage = true
|
|
||||||
|
|
||||||
[model_providers.azure]
|
|
||||||
name = "Azure OpenAI"
|
|
||||||
base_url = "https://azure.example/v1"
|
|
||||||
wire_api = "responses"
|
|
||||||
|
|
||||||
[mcp_servers.my_server]
|
|
||||||
base_url = "http://localhost:8080"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let settings = json!({ "config": config_toml });
|
|
||||||
let extracted = ProviderService::extract_codex_common_config(&settings)
|
|
||||||
.expect("extract_codex_common_config should succeed");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
!extracted
|
|
||||||
.lines()
|
|
||||||
.any(|line| line.trim_start().starts_with("model_provider")),
|
|
||||||
"should remove top-level model_provider"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!extracted
|
|
||||||
.lines()
|
|
||||||
.any(|line| line.trim_start().starts_with("model =")),
|
|
||||||
"should remove top-level model"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!extracted.contains("[model_providers"),
|
|
||||||
"should remove entire model_providers table"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
extracted.contains("http://localhost:8080"),
|
|
||||||
"should keep mcp_servers.* base_url"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderService {
|
impl ProviderService {
|
||||||
@@ -167,8 +134,7 @@ impl ProviderService {
|
|||||||
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
|
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
|
||||||
if app_type.is_additive_mode() {
|
if app_type.is_additive_mode() {
|
||||||
// OMO providers use exclusive mode and write to dedicated config file.
|
// OMO providers use exclusive mode and write to dedicated config file.
|
||||||
if matches!(app_type, AppType::OpenCode)
|
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo")
|
||||||
&& provider.category.as_deref() == Some("omo")
|
|
||||||
{
|
{
|
||||||
// Do not auto-enable newly added OMO providers.
|
// Do not auto-enable newly added OMO providers.
|
||||||
// Users must explicitly switch/apply an OMO provider to activate it.
|
// Users must explicitly switch/apply an OMO provider to activate it.
|
||||||
@@ -185,7 +151,7 @@ impl ProviderService {
|
|||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||||
write_live_snapshot(&app_type, &provider)?;
|
write_live_partial(&app_type, &provider)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -207,14 +173,33 @@ impl ProviderService {
|
|||||||
|
|
||||||
// Additive mode apps (OpenCode, OpenClaw) - always update in live config
|
// Additive mode apps (OpenCode, OpenClaw) - always update in live config
|
||||||
if app_type.is_additive_mode() {
|
if app_type.is_additive_mode() {
|
||||||
if matches!(app_type, AppType::OpenCode)
|
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo")
|
||||||
&& provider.category.as_deref() == Some("omo")
|
|
||||||
{
|
{
|
||||||
let is_omo_current = state
|
let is_omo_current =
|
||||||
.db
|
state
|
||||||
.is_omo_provider_current(app_type.as_str(), &provider.id)?;
|
.db
|
||||||
|
.is_omo_provider_current(app_type.as_str(), &provider.id, "omo")?;
|
||||||
if is_omo_current {
|
if is_omo_current {
|
||||||
crate::services::OmoService::write_config_to_file(state)?;
|
crate::services::OmoService::write_config_to_file(
|
||||||
|
state,
|
||||||
|
&crate::services::omo::STANDARD,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
if matches!(app_type, AppType::OpenCode)
|
||||||
|
&& provider.category.as_deref() == Some("omo-slim")
|
||||||
|
{
|
||||||
|
let is_current = state.db.is_omo_provider_current(
|
||||||
|
app_type.as_str(),
|
||||||
|
&provider.id,
|
||||||
|
"omo-slim",
|
||||||
|
)?;
|
||||||
|
if is_current {
|
||||||
|
crate::services::OmoService::write_config_to_file(
|
||||||
|
state,
|
||||||
|
&crate::services::omo::SLIM,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
@@ -247,7 +232,7 @@ impl ProviderService {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||||
} else {
|
} else {
|
||||||
write_live_snapshot(&app_type, &provider)?;
|
write_live_partial(&app_type, &provider)?;
|
||||||
// Sync MCP
|
// Sync MCP
|
||||||
McpService::sync_all_enabled(state)?;
|
McpService::sync_all_enabled(state)?;
|
||||||
}
|
}
|
||||||
@@ -264,15 +249,16 @@ impl ProviderService {
|
|||||||
// Additive mode apps - no current provider concept
|
// Additive mode apps - no current provider concept
|
||||||
if app_type.is_additive_mode() {
|
if app_type.is_additive_mode() {
|
||||||
if matches!(app_type, AppType::OpenCode) {
|
if matches!(app_type, AppType::OpenCode) {
|
||||||
let is_omo = state
|
let provider_category = state
|
||||||
.db
|
.db
|
||||||
.get_provider_by_id(id, app_type.as_str())?
|
.get_provider_by_id(id, app_type.as_str())?
|
||||||
.and_then(|p| p.category)
|
.and_then(|p| p.category);
|
||||||
.as_deref()
|
|
||||||
== Some("omo");
|
|
||||||
|
|
||||||
if is_omo {
|
if provider_category.as_deref() == Some("omo") {
|
||||||
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
|
let was_current =
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.is_omo_provider_current(app_type.as_str(), id, "omo")?;
|
||||||
let omo_count = state
|
let omo_count = state
|
||||||
.db
|
.db
|
||||||
.get_all_providers(app_type.as_str())?
|
.get_all_providers(app_type.as_str())?
|
||||||
@@ -288,7 +274,36 @@ impl ProviderService {
|
|||||||
|
|
||||||
state.db.delete_provider(app_type.as_str(), id)?;
|
state.db.delete_provider(app_type.as_str(), id)?;
|
||||||
if was_current {
|
if was_current {
|
||||||
crate::services::OmoService::delete_config_file()?;
|
crate::services::OmoService::delete_config_file(
|
||||||
|
&crate::services::omo::STANDARD,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider_category.as_deref() == Some("omo-slim") {
|
||||||
|
let was_current =
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.is_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
|
||||||
|
let slim_count = state
|
||||||
|
.db
|
||||||
|
.get_all_providers(app_type.as_str())?
|
||||||
|
.values()
|
||||||
|
.filter(|p| p.category.as_deref() == Some("omo-slim"))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if slim_count <= 1 && was_current {
|
||||||
|
return Err(AppError::Message(
|
||||||
|
"无法删除当前启用的最后一个 OMO Slim 配置,请先停用".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.db.delete_provider(app_type.as_str(), id)?;
|
||||||
|
if was_current {
|
||||||
|
crate::services::OmoService::delete_config_file(
|
||||||
|
&crate::services::omo::SLIM,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -329,21 +344,46 @@ impl ProviderService {
|
|||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
match app_type {
|
match app_type {
|
||||||
AppType::OpenCode => {
|
AppType::OpenCode => {
|
||||||
let is_omo = state
|
let provider_category = state
|
||||||
.db
|
.db
|
||||||
.get_provider_by_id(id, app_type.as_str())?
|
.get_provider_by_id(id, app_type.as_str())?
|
||||||
.and_then(|p| p.category)
|
.and_then(|p| p.category);
|
||||||
.as_deref()
|
|
||||||
== Some("omo");
|
|
||||||
|
|
||||||
if is_omo {
|
if provider_category.as_deref() == Some("omo") {
|
||||||
state.db.clear_omo_provider_current(app_type.as_str(), id)?;
|
state
|
||||||
let still_has_current =
|
.db
|
||||||
state.db.get_current_omo_provider("opencode")?.is_some();
|
.clear_omo_provider_current(app_type.as_str(), id, "omo")?;
|
||||||
|
let still_has_current = state
|
||||||
|
.db
|
||||||
|
.get_current_omo_provider("opencode", "omo")?
|
||||||
|
.is_some();
|
||||||
if still_has_current {
|
if still_has_current {
|
||||||
crate::services::OmoService::write_config_to_file(state)?;
|
crate::services::OmoService::write_config_to_file(
|
||||||
|
state,
|
||||||
|
&crate::services::omo::STANDARD,
|
||||||
|
)?;
|
||||||
} else {
|
} else {
|
||||||
crate::services::OmoService::delete_config_file()?;
|
crate::services::OmoService::delete_config_file(
|
||||||
|
&crate::services::omo::STANDARD,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
} else if provider_category.as_deref() == Some("omo-slim") {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.clear_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
|
||||||
|
let still_has_current = state
|
||||||
|
.db
|
||||||
|
.get_current_omo_provider("opencode", "omo-slim")?
|
||||||
|
.is_some();
|
||||||
|
if still_has_current {
|
||||||
|
crate::services::OmoService::write_config_to_file(
|
||||||
|
state,
|
||||||
|
&crate::services::omo::SLIM,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
crate::services::OmoService::delete_config_file(
|
||||||
|
&crate::services::omo::SLIM,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
remove_opencode_provider_from_live(id)?;
|
remove_opencode_provider_from_live(id)?;
|
||||||
@@ -374,7 +414,7 @@ impl ProviderService {
|
|||||||
/// c. Update database is_current (as default for new devices)
|
/// c. Update database is_current (as default for new devices)
|
||||||
/// d. Write target provider config to live files
|
/// d. Write target provider config to live files
|
||||||
/// e. Sync MCP configuration
|
/// e. Sync MCP configuration
|
||||||
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {
|
||||||
// Check if provider exists
|
// Check if provider exists
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
let _provider = providers
|
let _provider = providers
|
||||||
@@ -386,6 +426,13 @@ impl ProviderService {
|
|||||||
return Self::switch_normal(state, app_type, id, &providers);
|
return Self::switch_normal(state, app_type, id, &providers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OMO Slim providers are switched through their own exclusive path.
|
||||||
|
if matches!(app_type, AppType::OpenCode)
|
||||||
|
&& _provider.category.as_deref() == Some("omo-slim")
|
||||||
|
{
|
||||||
|
return Self::switch_normal(state, app_type, id, &providers);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if proxy takeover mode is active AND proxy server is actually running
|
// Check if proxy takeover mode is active AND proxy server is actually running
|
||||||
// Both conditions must be true to use hot-switch mode
|
// Both conditions must be true to use hot-switch mode
|
||||||
// Use blocking wait since this is a sync function
|
// Use blocking wait since this is a sync function
|
||||||
@@ -439,7 +486,7 @@ impl ProviderService {
|
|||||||
|
|
||||||
// Note: No Live config write, no MCP sync
|
// Note: No Live config write, no MCP sync
|
||||||
// The proxy server will route requests to the new provider via is_current
|
// The proxy server will route requests to the new provider via is_current
|
||||||
return Ok(());
|
return Ok(SwitchResult::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal mode: full switch with Live config write
|
// Normal mode: full switch with Live config write
|
||||||
@@ -452,17 +499,33 @@ impl ProviderService {
|
|||||||
app_type: AppType,
|
app_type: AppType,
|
||||||
id: &str,
|
id: &str,
|
||||||
providers: &indexmap::IndexMap<String, Provider>,
|
providers: &indexmap::IndexMap<String, Provider>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<SwitchResult, AppError> {
|
||||||
let provider = providers
|
let provider = providers
|
||||||
.get(id)
|
.get(id)
|
||||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||||
|
|
||||||
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") {
|
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") {
|
||||||
state.db.set_omo_provider_current(app_type.as_str(), id)?;
|
state
|
||||||
crate::services::OmoService::write_config_to_file(state)?;
|
.db
|
||||||
return Ok(());
|
.set_omo_provider_current(app_type.as_str(), id, "omo")?;
|
||||||
|
crate::services::OmoService::write_config_to_file(
|
||||||
|
state,
|
||||||
|
&crate::services::omo::STANDARD,
|
||||||
|
)?;
|
||||||
|
return Ok(SwitchResult::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo-slim")
|
||||||
|
{
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
|
||||||
|
crate::services::OmoService::write_config_to_file(state, &crate::services::omo::SLIM)?;
|
||||||
|
return Ok(SwitchResult::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = SwitchResult::default();
|
||||||
|
|
||||||
// Backfill: Backfill current live config to current provider
|
// Backfill: Backfill current live config to current provider
|
||||||
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
||||||
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||||
@@ -475,9 +538,17 @@ impl ProviderService {
|
|||||||
// Only backfill when switching to a different provider
|
// Only backfill when switching to a different provider
|
||||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||||
current_provider.settings_config = live_config;
|
// Only extract key fields from live config for backfill
|
||||||
// Ignore backfill failure, don't affect switch flow
|
current_provider.settings_config =
|
||||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
backfill_key_fields(&app_type, &live_config);
|
||||||
|
if let Err(e) =
|
||||||
|
state.db.save_provider(app_type.as_str(), ¤t_provider)
|
||||||
|
{
|
||||||
|
log::warn!("Backfill failed: {e}");
|
||||||
|
result
|
||||||
|
.warnings
|
||||||
|
.push(format!("backfill_failed:{current_id}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,13 +564,10 @@ impl ProviderService {
|
|||||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
// Sync to live (partial merge: only key fields, preserving user settings)
|
||||||
write_live_snapshot(&app_type, provider)?;
|
write_live_partial(&app_type, provider)?;
|
||||||
|
|
||||||
// Sync MCP
|
Ok(result)
|
||||||
McpService::sync_all_enabled(state)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync current provider to live configuration (re-export)
|
/// Sync current provider to live configuration (re-export)
|
||||||
@@ -507,222 +575,6 @@ impl ProviderService {
|
|||||||
sync_current_to_live(state)
|
sync_current_to_live(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract common config snippet from current provider
|
|
||||||
///
|
|
||||||
/// Extracts the current provider's configuration and removes provider-specific fields
|
|
||||||
/// (API keys, model settings, endpoints) to create a reusable common config snippet.
|
|
||||||
pub fn extract_common_config_snippet(
|
|
||||||
state: &AppState,
|
|
||||||
app_type: AppType,
|
|
||||||
) -> Result<String, AppError> {
|
|
||||||
// Get current provider
|
|
||||||
let current_id = Self::current(state, app_type.clone())?;
|
|
||||||
if current_id.is_empty() {
|
|
||||||
return Err(AppError::Message("No current provider".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
|
||||||
let provider = providers
|
|
||||||
.get(¤t_id)
|
|
||||||
.ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
|
|
||||||
|
|
||||||
match app_type {
|
|
||||||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
|
||||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
|
||||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
|
||||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
|
||||||
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config snippet from a config value (e.g. editor content).
|
|
||||||
pub fn extract_common_config_snippet_from_settings(
|
|
||||||
app_type: AppType,
|
|
||||||
settings_config: &Value,
|
|
||||||
) -> Result<String, AppError> {
|
|
||||||
match app_type {
|
|
||||||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
|
||||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
|
||||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
|
||||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
|
||||||
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config for Claude (JSON format)
|
|
||||||
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
|
|
||||||
let mut config = settings.clone();
|
|
||||||
|
|
||||||
// Fields to exclude from common config
|
|
||||||
const ENV_EXCLUDES: &[&str] = &[
|
|
||||||
// Auth
|
|
||||||
"ANTHROPIC_API_KEY",
|
|
||||||
"ANTHROPIC_AUTH_TOKEN",
|
|
||||||
// Models (5 fields)
|
|
||||||
"ANTHROPIC_MODEL",
|
|
||||||
"ANTHROPIC_REASONING_MODEL",
|
|
||||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
||||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
||||||
// Endpoint
|
|
||||||
"ANTHROPIC_BASE_URL",
|
|
||||||
];
|
|
||||||
|
|
||||||
const TOP_LEVEL_EXCLUDES: &[&str] = &[
|
|
||||||
"apiBaseUrl",
|
|
||||||
// Legacy model fields
|
|
||||||
"primaryModel",
|
|
||||||
"smallFastModel",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Remove env fields
|
|
||||||
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
|
||||||
for key in ENV_EXCLUDES {
|
|
||||||
env.remove(*key);
|
|
||||||
}
|
|
||||||
// If env is empty after removal, remove the env object itself
|
|
||||||
if env.is_empty() {
|
|
||||||
config.as_object_mut().map(|obj| obj.remove("env"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove top-level fields
|
|
||||||
if let Some(obj) = config.as_object_mut() {
|
|
||||||
for key in TOP_LEVEL_EXCLUDES {
|
|
||||||
obj.remove(*key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if result is empty
|
|
||||||
if config.as_object().is_none_or(|obj| obj.is_empty()) {
|
|
||||||
return Ok("{}".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::to_string_pretty(&config)
|
|
||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config for Codex (TOML format)
|
|
||||||
fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {
|
|
||||||
// Codex config is stored as { "auth": {...}, "config": "toml string" }
|
|
||||||
let config_toml = settings
|
|
||||||
.get("config")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if config_toml.is_empty() {
|
|
||||||
return Ok(String::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut doc = config_toml
|
|
||||||
.parse::<toml_edit::DocumentMut>()
|
|
||||||
.map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
|
|
||||||
|
|
||||||
// Remove provider-specific fields.
|
|
||||||
let root = doc.as_table_mut();
|
|
||||||
root.remove("model");
|
|
||||||
root.remove("model_provider");
|
|
||||||
// Legacy/alt formats might use a top-level base_url.
|
|
||||||
root.remove("base_url");
|
|
||||||
|
|
||||||
// Remove entire model_providers table (provider-specific configuration)
|
|
||||||
root.remove("model_providers");
|
|
||||||
|
|
||||||
// Clean up multiple empty lines (keep at most one blank line).
|
|
||||||
let mut cleaned = String::new();
|
|
||||||
let mut blank_run = 0usize;
|
|
||||||
for line in doc.to_string().lines() {
|
|
||||||
if line.trim().is_empty() {
|
|
||||||
blank_run += 1;
|
|
||||||
if blank_run <= 1 {
|
|
||||||
cleaned.push('\n');
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
blank_run = 0;
|
|
||||||
cleaned.push_str(line);
|
|
||||||
cleaned.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(cleaned.trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config for Gemini (JSON format)
|
|
||||||
///
|
|
||||||
/// Extracts `.env` values while excluding provider-specific credentials:
|
|
||||||
/// - GOOGLE_GEMINI_BASE_URL
|
|
||||||
/// - GEMINI_API_KEY
|
|
||||||
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
|
|
||||||
let env = settings.get("env").and_then(|v| v.as_object());
|
|
||||||
|
|
||||||
let mut snippet = serde_json::Map::new();
|
|
||||||
if let Some(env) = env {
|
|
||||||
for (key, value) in env {
|
|
||||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Value::String(v) = value else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let trimmed = v.trim();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if snippet.is_empty() {
|
|
||||||
return Ok("{}".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::to_string_pretty(&Value::Object(snippet))
|
|
||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config for OpenCode (JSON format)
|
|
||||||
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
|
||||||
// OpenCode uses a different config structure with npm, options, models
|
|
||||||
// For common config, we exclude provider-specific fields like apiKey
|
|
||||||
let mut config = settings.clone();
|
|
||||||
|
|
||||||
// Remove provider-specific fields
|
|
||||||
if let Some(obj) = config.as_object_mut() {
|
|
||||||
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
|
|
||||||
options.remove("apiKey");
|
|
||||||
options.remove("baseURL");
|
|
||||||
}
|
|
||||||
// Keep npm and models as they might be common
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
|
||||||
return Ok("{}".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::to_string_pretty(&config)
|
|
||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config for OpenClaw (JSON format)
|
|
||||||
fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {
|
|
||||||
// OpenClaw uses a different config structure with baseUrl, apiKey, api, models
|
|
||||||
// For common config, we exclude provider-specific fields like apiKey
|
|
||||||
let mut config = settings.clone();
|
|
||||||
|
|
||||||
// Remove provider-specific fields
|
|
||||||
if let Some(obj) = config.as_object_mut() {
|
|
||||||
obj.remove("apiKey");
|
|
||||||
obj.remove("baseUrl");
|
|
||||||
// Keep api and models as they might be common
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
|
||||||
return Ok("{}".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::to_string_pretty(&config)
|
|
||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Import default configuration from live files (re-export)
|
/// Import default configuration from live files (re-export)
|
||||||
///
|
///
|
||||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||||
@@ -735,6 +587,11 @@ impl ProviderService {
|
|||||||
read_live_settings(app_type)
|
read_live_settings(app_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Patch Claude live settings directly (user-level preferences)
|
||||||
|
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
|
||||||
|
live::patch_claude_live(patch)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get custom endpoints list (re-export)
|
/// Get custom endpoints list (re-export)
|
||||||
pub fn get_custom_endpoints(
|
pub fn get_custom_endpoints(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -830,10 +687,6 @@ impl ProviderService {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
|
||||||
write_gemini_live(provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||||
match app_type {
|
match app_type {
|
||||||
AppType::Claude => {
|
AppType::Claude => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::database::Database;
|
|||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::server::ProxyServer;
|
use crate::proxy::server::ProxyServer;
|
||||||
use crate::proxy::types::*;
|
use crate::proxy::types::*;
|
||||||
use crate::services::provider::write_live_snapshot;
|
use crate::services::provider::write_live_partial;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -1266,7 +1266,7 @@ impl ProxyService {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
write_live_snapshot(app_type, provider)
|
write_live_partial(app_type, provider)
|
||||||
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
|||||||
+543
-159
@@ -10,7 +10,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
@@ -159,6 +159,154 @@ pub struct SkillMetadata {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== ~/.agents/ lock 文件解析 ==========
|
||||||
|
|
||||||
|
/// `~/.agents/.skill-lock.json` 文件结构
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AgentsLockFile {
|
||||||
|
skills: HashMap<String, AgentsLockSkill>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// lock 文件中单个 skill 的信息
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AgentsLockSkill {
|
||||||
|
source: Option<String>,
|
||||||
|
source_type: Option<String>,
|
||||||
|
source_url: Option<String>,
|
||||||
|
skill_path: Option<String>,
|
||||||
|
branch: Option<String>,
|
||||||
|
source_branch: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct LockRepoInfo {
|
||||||
|
owner: String,
|
||||||
|
repo: String,
|
||||||
|
skill_path: Option<String>,
|
||||||
|
branch: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_branch(branch: Option<String>) -> Option<String> {
|
||||||
|
branch.and_then(|b| {
|
||||||
|
let trimmed = b.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_branch_from_source_url(source_url: Option<&str>) -> Option<String> {
|
||||||
|
let source_url = source_url?;
|
||||||
|
let source_url = source_url.trim();
|
||||||
|
if source_url.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持 https://github.com/owner/repo/tree/<branch>/...
|
||||||
|
if let Some((_, after_tree)) = source_url.split_once("/tree/") {
|
||||||
|
let branch = after_tree
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())?;
|
||||||
|
return Some(branch.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持 URL fragment: ...git#branch
|
||||||
|
if let Some((_, fragment)) = source_url.split_once('#') {
|
||||||
|
let branch = fragment
|
||||||
|
.split('&')
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())?;
|
||||||
|
return Some(branch.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持 query: ...?branch=xxx / ?ref=xxx
|
||||||
|
if let Some((_, query)) = source_url.split_once('?') {
|
||||||
|
for pair in query.split('&') {
|
||||||
|
let Some((key, value)) = pair.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if matches!(key, "branch" | "ref") {
|
||||||
|
let branch = value.trim();
|
||||||
|
if !branch.is_empty() {
|
||||||
|
return Some(branch.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 `~/.agents/skills/` 目录(存在时返回)
|
||||||
|
fn get_agents_skills_dir() -> Option<PathBuf> {
|
||||||
|
dirs::home_dir()
|
||||||
|
.map(|h| h.join(".agents").join("skills"))
|
||||||
|
.filter(|p| p.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息
|
||||||
|
fn parse_agents_lock() -> HashMap<String, LockRepoInfo> {
|
||||||
|
let path = match dirs::home_dir() {
|
||||||
|
Some(h) => h.join(".agents").join(".skill-lock.json"),
|
||||||
|
None => {
|
||||||
|
log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件");
|
||||||
|
return HashMap::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let content = match fs::read_to_string(&path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
log::debug!("未找到 agents lock 文件: {}", path.display());
|
||||||
|
} else {
|
||||||
|
log::warn!("读取 agents lock 文件失败 ({}): {}", path.display(), e);
|
||||||
|
}
|
||||||
|
return HashMap::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let lock: AgentsLockFile = match serde_json::from_str(&content) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("解析 agents lock 文件失败 ({}): {}", path.display(), e);
|
||||||
|
return HashMap::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let parsed: HashMap<String, LockRepoInfo> = lock
|
||||||
|
.skills
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(name, skill)| {
|
||||||
|
let source = skill.source?;
|
||||||
|
if skill.source_type.as_deref() != Some("github") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (owner, repo) = source.split_once('/')?;
|
||||||
|
let branch = normalize_optional_branch(skill.branch)
|
||||||
|
.or_else(|| normalize_optional_branch(skill.source_branch))
|
||||||
|
.or_else(|| parse_branch_from_source_url(skill.source_url.as_deref()));
|
||||||
|
Some((
|
||||||
|
name,
|
||||||
|
LockRepoInfo {
|
||||||
|
owner: owner.to_string(),
|
||||||
|
repo: repo.to_string(),
|
||||||
|
skill_path: skill.skill_path,
|
||||||
|
branch,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
log::info!(
|
||||||
|
"agents lock 文件解析完成,共识别 {} 个 github skill",
|
||||||
|
parsed.len()
|
||||||
|
);
|
||||||
|
parsed
|
||||||
|
}
|
||||||
|
|
||||||
// ========== SkillService ==========
|
// ========== SkillService ==========
|
||||||
|
|
||||||
pub struct SkillService;
|
pub struct SkillService;
|
||||||
@@ -275,11 +423,25 @@ impl SkillService {
|
|||||||
) -> Result<InstalledSkill> {
|
) -> Result<InstalledSkill> {
|
||||||
let ssot_dir = Self::get_ssot_dir()?;
|
let ssot_dir = Self::get_ssot_dir()?;
|
||||||
|
|
||||||
// 使用目录最后一段作为安装名
|
// 允许多级目录(如 a/b/c),但必须是安全的相对路径。
|
||||||
let install_name = Path::new(&skill.directory)
|
let source_rel = Self::sanitize_skill_source_path(&skill.directory).ok_or_else(|| {
|
||||||
|
anyhow!(format_skill_error(
|
||||||
|
"INVALID_SKILL_DIRECTORY",
|
||||||
|
&[("directory", &skill.directory)],
|
||||||
|
Some("checkZipContent"),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
// 安装目录名始终使用最后一段,避免在 SSOT 中创建多级目录。
|
||||||
|
let install_name = source_rel
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
.and_then(|name| Self::sanitize_install_name(&name.to_string_lossy()))
|
||||||
.unwrap_or_else(|| skill.directory.clone());
|
.ok_or_else(|| {
|
||||||
|
anyhow!(format_skill_error(
|
||||||
|
"INVALID_SKILL_DIRECTORY",
|
||||||
|
&[("directory", &skill.directory)],
|
||||||
|
Some("checkZipContent"),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
// 检查数据库中是否已有同名 directory 的 skill(来自其他仓库)
|
// 检查数据库中是否已有同名 directory 的 skill(来自其他仓库)
|
||||||
let existing_skills = db.get_all_installed_skills()?;
|
let existing_skills = db.get_all_installed_skills()?;
|
||||||
@@ -358,7 +520,7 @@ impl SkillService {
|
|||||||
repo_branch = used_branch;
|
repo_branch = used_branch;
|
||||||
|
|
||||||
// 复制到 SSOT
|
// 复制到 SSOT
|
||||||
let source = temp_dir.join(&skill.directory);
|
let source = temp_dir.join(&source_rel);
|
||||||
if !source.exists() {
|
if !source.exists() {
|
||||||
let _ = fs::remove_dir_all(&temp_dir);
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
return Err(anyhow!(format_skill_error(
|
return Err(anyhow!(format_skill_error(
|
||||||
@@ -368,7 +530,24 @@ impl SkillService {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Self::copy_dir_recursive(&source, &dest)?;
|
let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone());
|
||||||
|
let canonical_source = source.canonicalize().map_err(|_| {
|
||||||
|
anyhow!(format_skill_error(
|
||||||
|
"SKILL_DIR_NOT_FOUND",
|
||||||
|
&[("path", &source.display().to_string())],
|
||||||
|
Some("checkRepoUrl"),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !canonical_source.starts_with(&canonical_temp) || !canonical_source.is_dir() {
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(anyhow!(format_skill_error(
|
||||||
|
"INVALID_SKILL_DIRECTORY",
|
||||||
|
&[("directory", &skill.directory)],
|
||||||
|
Some("checkZipContent"),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::copy_dir_recursive(&canonical_source, &dest)?;
|
||||||
let _ = fs::remove_dir_all(&temp_dir);
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
|
||||||
// 使用实际下载成功的分支,避免 readme_url / repo_branch 与真实分支不一致。
|
// 使用实际下载成功的分支,避免 readme_url / repo_branch 与真实分支不一致。
|
||||||
@@ -449,12 +628,7 @@ impl SkillService {
|
|||||||
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
|
||||||
|
|
||||||
// 从所有应用目录删除
|
// 从所有应用目录删除
|
||||||
for app in [
|
for app in AppType::all() {
|
||||||
AppType::Claude,
|
|
||||||
AppType::Codex,
|
|
||||||
AppType::Gemini,
|
|
||||||
AppType::OpenCode,
|
|
||||||
] {
|
|
||||||
let _ = Self::remove_from_app(&skill.directory, &app);
|
let _ = Self::remove_from_app(&skill.directory, &app);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,74 +685,49 @@ impl SkillService {
|
|||||||
.map(|s| s.directory.clone())
|
.map(|s| s.directory.clone())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// 收集所有待扫描的目录及其来源标签
|
||||||
|
let mut scan_sources: Vec<(PathBuf, String)> = Vec::new();
|
||||||
|
for app in AppType::all() {
|
||||||
|
if let Ok(d) = Self::get_app_skills_dir(&app) {
|
||||||
|
scan_sources.push((d, app.as_str().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(agents_dir) = get_agents_skills_dir() {
|
||||||
|
scan_sources.push((agents_dir, "agents".to_string()));
|
||||||
|
}
|
||||||
|
if let Ok(ssot_dir) = Self::get_ssot_dir() {
|
||||||
|
scan_sources.push((ssot_dir, "cc-switch".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
|
let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
|
||||||
|
|
||||||
for app in [
|
for (scan_dir, label) in &scan_sources {
|
||||||
AppType::Claude,
|
let entries = match fs::read_dir(scan_dir) {
|
||||||
AppType::Codex,
|
Ok(e) => e,
|
||||||
AppType::Gemini,
|
|
||||||
AppType::OpenCode,
|
|
||||||
] {
|
|
||||||
let app_dir = match Self::get_app_skills_dir(&app) {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
if !app_dir.exists() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for entry in fs::read_dir(&app_dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
if !path.is_dir() {
|
if !path.is_dir() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if dir_name.starts_with('.') || managed_dirs.contains(&dir_name) {
|
||||||
// 跳过隐藏目录(以 . 开头,如 .system)
|
|
||||||
if dir_name.starts_with('.') {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳过已管理的
|
|
||||||
if managed_dirs.contains(&dir_name) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有 SKILL.md
|
|
||||||
let skill_md = path.join("SKILL.md");
|
let skill_md = path.join("SKILL.md");
|
||||||
let (name, description) = if skill_md.exists() {
|
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
|
||||||
match Self::parse_skill_metadata_static(&skill_md) {
|
|
||||||
Ok(meta) => (
|
|
||||||
meta.name.unwrap_or_else(|| dir_name.clone()),
|
|
||||||
meta.description,
|
|
||||||
),
|
|
||||||
Err(_) => (dir_name.clone(), None),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(dir_name.clone(), None)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加或更新
|
|
||||||
let app_str = match app {
|
|
||||||
AppType::Claude => "claude",
|
|
||||||
AppType::Codex => "codex",
|
|
||||||
AppType::Gemini => "gemini",
|
|
||||||
AppType::OpenCode => "opencode",
|
|
||||||
AppType::OpenClaw => "openclaw",
|
|
||||||
};
|
|
||||||
|
|
||||||
unmanaged
|
unmanaged
|
||||||
.entry(dir_name.clone())
|
.entry(dir_name.clone())
|
||||||
.and_modify(|s| s.found_in.push(app_str.to_string()))
|
.and_modify(|s| s.found_in.push(label.clone()))
|
||||||
.or_insert(UnmanagedSkill {
|
.or_insert(UnmanagedSkill {
|
||||||
directory: dir_name,
|
directory: dir_name,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
found_in: vec![app_str.to_string()],
|
found_in: vec![label.clone()],
|
||||||
|
path: path.display().to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -594,34 +743,36 @@ impl SkillService {
|
|||||||
directories: Vec<String>,
|
directories: Vec<String>,
|
||||||
) -> Result<Vec<InstalledSkill>> {
|
) -> Result<Vec<InstalledSkill>> {
|
||||||
let ssot_dir = Self::get_ssot_dir()?;
|
let ssot_dir = Self::get_ssot_dir()?;
|
||||||
|
let agents_lock = parse_agents_lock();
|
||||||
let mut imported = Vec::new();
|
let mut imported = Vec::new();
|
||||||
|
|
||||||
|
// 将 lock 文件中发现的仓库保存到 skill_repos
|
||||||
|
save_repos_from_lock(db, &agents_lock, directories.iter().map(|s| s.as_str()));
|
||||||
|
|
||||||
|
// 收集所有候选搜索目录
|
||||||
|
let mut search_sources: Vec<(PathBuf, String)> = Vec::new();
|
||||||
|
for app in AppType::all() {
|
||||||
|
if let Ok(d) = Self::get_app_skills_dir(&app) {
|
||||||
|
search_sources.push((d, app.as_str().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(agents_dir) = get_agents_skills_dir() {
|
||||||
|
search_sources.push((agents_dir, "agents".to_string()));
|
||||||
|
}
|
||||||
|
search_sources.push((ssot_dir.clone(), "cc-switch".to_string()));
|
||||||
|
|
||||||
for dir_name in directories {
|
for dir_name in directories {
|
||||||
// 找到源目录(从任一应用目录复制)
|
// 在所有候选目录中查找
|
||||||
let mut source_path: Option<PathBuf> = None;
|
let mut source_path: Option<PathBuf> = None;
|
||||||
let mut found_in: Vec<String> = Vec::new();
|
let mut found_in: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for app in [
|
for (base, label) in &search_sources {
|
||||||
AppType::Claude,
|
let skill_path = base.join(&dir_name);
|
||||||
AppType::Codex,
|
if skill_path.exists() {
|
||||||
AppType::Gemini,
|
if source_path.is_none() {
|
||||||
AppType::OpenCode,
|
source_path = Some(skill_path);
|
||||||
] {
|
|
||||||
if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
|
|
||||||
let skill_path = app_dir.join(&dir_name);
|
|
||||||
if skill_path.exists() {
|
|
||||||
if source_path.is_none() {
|
|
||||||
source_path = Some(skill_path);
|
|
||||||
}
|
|
||||||
let app_str = match app {
|
|
||||||
AppType::Claude => "claude",
|
|
||||||
AppType::Codex => "codex",
|
|
||||||
AppType::Gemini => "gemini",
|
|
||||||
AppType::OpenCode => "opencode",
|
|
||||||
AppType::OpenClaw => "openclaw",
|
|
||||||
};
|
|
||||||
found_in.push(app_str.to_string());
|
|
||||||
}
|
}
|
||||||
|
found_in.push(label.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,40 +789,25 @@ impl SkillService {
|
|||||||
|
|
||||||
// 解析元数据
|
// 解析元数据
|
||||||
let skill_md = dest.join("SKILL.md");
|
let skill_md = dest.join("SKILL.md");
|
||||||
let (name, description) = if skill_md.exists() {
|
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
|
||||||
match Self::parse_skill_metadata_static(&skill_md) {
|
|
||||||
Ok(meta) => (
|
|
||||||
meta.name.unwrap_or_else(|| dir_name.clone()),
|
|
||||||
meta.description,
|
|
||||||
),
|
|
||||||
Err(_) => (dir_name.clone(), None),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(dir_name.clone(), None)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 构建启用状态
|
// 构建启用状态
|
||||||
let mut apps = SkillApps::default();
|
let apps = SkillApps::from_labels(&found_in);
|
||||||
for app_str in &found_in {
|
|
||||||
match app_str.as_str() {
|
// 从 lock 文件提取仓库信息
|
||||||
"claude" => apps.claude = true,
|
let (id, repo_owner, repo_name, repo_branch, readme_url) =
|
||||||
"codex" => apps.codex = true,
|
build_repo_info_from_lock(&agents_lock, &dir_name);
|
||||||
"gemini" => apps.gemini = true,
|
|
||||||
"opencode" => apps.opencode = true,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建记录
|
// 创建记录
|
||||||
let skill = InstalledSkill {
|
let skill = InstalledSkill {
|
||||||
id: format!("local:{dir_name}"),
|
id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
directory: dir_name,
|
directory: dir_name,
|
||||||
repo_owner: None,
|
repo_owner,
|
||||||
repo_name: None,
|
repo_name,
|
||||||
repo_branch: None,
|
repo_branch,
|
||||||
readme_url: None,
|
readme_url,
|
||||||
apps,
|
apps,
|
||||||
installed_at: chrono::Utc::now().timestamp(),
|
installed_at: chrono::Utc::now().timestamp(),
|
||||||
};
|
};
|
||||||
@@ -1055,6 +1191,79 @@ impl SkillService {
|
|||||||
Ok(meta)
|
Ok(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从 SKILL.md 读取名称和描述,不存在则用目录名兜底
|
||||||
|
fn read_skill_name_desc(skill_md: &Path, fallback_name: &str) -> (String, Option<String>) {
|
||||||
|
if skill_md.exists() {
|
||||||
|
match Self::parse_skill_metadata_static(skill_md) {
|
||||||
|
Ok(meta) => (
|
||||||
|
meta.name.unwrap_or_else(|| fallback_name.to_string()),
|
||||||
|
meta.description,
|
||||||
|
),
|
||||||
|
Err(_) => (fallback_name.to_string(), None),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(fallback_name.to_string(), None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验并规范化技能源路径(允许多级目录),拒绝路径穿越和绝对路径
|
||||||
|
fn sanitize_skill_source_path(raw: &str) -> Option<PathBuf> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut normalized = PathBuf::new();
|
||||||
|
let mut has_component = false;
|
||||||
|
|
||||||
|
for component in Path::new(trimmed).components() {
|
||||||
|
match component {
|
||||||
|
Component::Normal(name) => {
|
||||||
|
let segment = name.to_string_lossy().trim().to_string();
|
||||||
|
if segment.is_empty() || segment == "." || segment == ".." {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
normalized.push(segment);
|
||||||
|
has_component = true;
|
||||||
|
}
|
||||||
|
Component::CurDir
|
||||||
|
| Component::ParentDir
|
||||||
|
| Component::RootDir
|
||||||
|
| Component::Prefix(_) => {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
has_component.then_some(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验并规范化安装目录名(最终落盘目录名,仅单段)
|
||||||
|
fn sanitize_install_name(raw: &str) -> Option<String> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = Path::new(trimmed);
|
||||||
|
let mut components = path.components();
|
||||||
|
match (components.next(), components.next()) {
|
||||||
|
(Some(Component::Normal(name)), None) => {
|
||||||
|
let normalized = name.to_string_lossy().trim().to_string();
|
||||||
|
if normalized.is_empty()
|
||||||
|
|| normalized == "."
|
||||||
|
|| normalized == ".."
|
||||||
|
|| normalized.starts_with('.')
|
||||||
|
{
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
|
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
|
||||||
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
|
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
|
||||||
let mut seen = HashMap::new();
|
let mut seen = HashMap::new();
|
||||||
@@ -1078,7 +1287,7 @@ impl SkillService {
|
|||||||
let _ = temp_dir.keep();
|
let _ = temp_dir.keep();
|
||||||
|
|
||||||
let mut branches = Vec::new();
|
let mut branches = Vec::new();
|
||||||
if !repo.branch.is_empty() {
|
if !repo.branch.is_empty() && !repo.branch.eq_ignore_ascii_case("HEAD") {
|
||||||
branches.push(repo.branch.as_str());
|
branches.push(repo.branch.as_str());
|
||||||
}
|
}
|
||||||
if !branches.contains(&"main") {
|
if !branches.contains(&"main") {
|
||||||
@@ -1143,9 +1352,12 @@ impl SkillService {
|
|||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 第一遍:解压普通文件和目录,收集 symlink 条目
|
||||||
|
let mut symlinks: Vec<(PathBuf, String)> = Vec::new();
|
||||||
|
|
||||||
for i in 0..archive.len() {
|
for i in 0..archive.len() {
|
||||||
let mut file = archive.by_index(i)?;
|
let mut file = archive.by_index(i)?;
|
||||||
let file_path = file.name();
|
let file_path = file.name().to_string();
|
||||||
|
|
||||||
let relative_path =
|
let relative_path =
|
||||||
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
|
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
|
||||||
@@ -1160,7 +1372,12 @@ impl SkillService {
|
|||||||
|
|
||||||
let outpath = dest.join(relative_path);
|
let outpath = dest.join(relative_path);
|
||||||
|
|
||||||
if file.is_dir() {
|
if file.is_symlink() {
|
||||||
|
// 读取 symlink 目标路径
|
||||||
|
let mut target = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut file, &mut target)?;
|
||||||
|
symlinks.push((outpath, target.trim().to_string()));
|
||||||
|
} else if file.is_dir() {
|
||||||
fs::create_dir_all(&outpath)?;
|
fs::create_dir_all(&outpath)?;
|
||||||
} else {
|
} else {
|
||||||
if let Some(parent) = outpath.parent() {
|
if let Some(parent) = outpath.parent() {
|
||||||
@@ -1171,6 +1388,9 @@ impl SkillService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 第二遍:解析 symlink,将目标内容复制到 symlink 位置
|
||||||
|
Self::resolve_symlinks_in_dir(dest, &symlinks)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1193,6 +1413,58 @@ impl SkillService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析 ZIP 中的符号链接:将目标内容复制到 symlink 位置
|
||||||
|
///
|
||||||
|
/// GitHub ZIP 归档保留了 symlink 元数据,解压时可通过 `is_symlink()` 检测。
|
||||||
|
/// 此方法将 symlink 解析为实际文件/目录内容(而非创建真实 symlink),
|
||||||
|
/// 以确保跨平台兼容且 skill 内容自包含。
|
||||||
|
fn resolve_symlinks_in_dir(base_dir: &Path, symlinks: &[(PathBuf, String)]) -> Result<()> {
|
||||||
|
// 规范化 base_dir(macOS 上 /tmp → /private/tmp,需保持一致)
|
||||||
|
let canonical_base = base_dir
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| base_dir.to_path_buf());
|
||||||
|
|
||||||
|
for (link_path, target) in symlinks {
|
||||||
|
// 计算 symlink 的父目录,然后拼接目标的相对路径
|
||||||
|
let parent = link_path.parent().unwrap_or(base_dir);
|
||||||
|
let resolved = parent.join(target);
|
||||||
|
|
||||||
|
// 规范化路径(解析 .. 等)
|
||||||
|
let resolved = match resolved.canonicalize() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => {
|
||||||
|
log::warn!(
|
||||||
|
"Symlink 目标不存在,跳过: {} -> {}",
|
||||||
|
link_path.display(),
|
||||||
|
target
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 安全检查:确保目标在 base_dir 内(防止路径穿越)
|
||||||
|
if !resolved.starts_with(&canonical_base) {
|
||||||
|
log::warn!(
|
||||||
|
"Symlink 目标超出仓库范围,跳过: {} -> {}",
|
||||||
|
link_path.display(),
|
||||||
|
resolved.display()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制目标内容到 symlink 位置
|
||||||
|
if resolved.is_dir() {
|
||||||
|
Self::copy_dir_recursive(&resolved, link_path)?;
|
||||||
|
} else if resolved.is_file() {
|
||||||
|
if let Some(parent) = link_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::copy(&resolved, link_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 从 ZIP 文件安装 ==========
|
// ========== 从 ZIP 文件安装 ==========
|
||||||
|
|
||||||
/// 从本地 ZIP 文件安装 Skills
|
/// 从本地 ZIP 文件安装 Skills
|
||||||
@@ -1225,13 +1497,56 @@ impl SkillService {
|
|||||||
let ssot_dir = Self::get_ssot_dir()?;
|
let ssot_dir = Self::get_ssot_dir()?;
|
||||||
let mut installed = Vec::new();
|
let mut installed = Vec::new();
|
||||||
let existing_skills = db.get_all_installed_skills()?;
|
let existing_skills = db.get_all_installed_skills()?;
|
||||||
|
let zip_stem = zip_path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
for skill_dir in skill_dirs {
|
for skill_dir in skill_dirs {
|
||||||
|
// 解析元数据(提前解析,用于确定安装名)
|
||||||
|
let skill_md = skill_dir.join("SKILL.md");
|
||||||
|
let meta = if skill_md.exists() {
|
||||||
|
Self::parse_skill_metadata_static(&skill_md).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// 获取目录名称作为安装名
|
// 获取目录名称作为安装名
|
||||||
let install_name = skill_dir
|
// 当 SKILL.md 在 ZIP 根目录时,skill_dir == temp_dir,
|
||||||
.file_name()
|
// file_name() 会返回临时目录名(如 .tmpDZKGpF),需要回退到其他来源
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
let install_name = {
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
let dir_name = skill_dir
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if skill_dir == temp_dir || dir_name.is_empty() || dir_name.starts_with('.') {
|
||||||
|
// SKILL.md 在根目录:优先用元数据 name,否则用 ZIP 文件名
|
||||||
|
meta.as_ref()
|
||||||
|
.and_then(|m| m.name.as_deref())
|
||||||
|
.and_then(Self::sanitize_install_name)
|
||||||
|
.or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name))
|
||||||
|
} else {
|
||||||
|
Self::sanitize_install_name(&dir_name)
|
||||||
|
.or_else(|| {
|
||||||
|
meta.as_ref()
|
||||||
|
.and_then(|m| m.name.as_deref())
|
||||||
|
.and_then(Self::sanitize_install_name)
|
||||||
|
})
|
||||||
|
.or_else(|| zip_stem.as_deref().and_then(Self::sanitize_install_name))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let install_name = match install_name {
|
||||||
|
Some(name) => name,
|
||||||
|
None => {
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
return Err(anyhow!(format_skill_error(
|
||||||
|
"INVALID_SKILL_DIRECTORY",
|
||||||
|
&[("zip", &zip_path.display().to_string())],
|
||||||
|
Some("checkZipContent"),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 检查是否已有同名 directory 的 skill
|
// 检查是否已有同名 directory 的 skill
|
||||||
let conflict = existing_skills
|
let conflict = existing_skills
|
||||||
@@ -1247,18 +1562,12 @@ impl SkillService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析元数据
|
let (name, description) = match meta {
|
||||||
let skill_md = skill_dir.join("SKILL.md");
|
Some(m) => (
|
||||||
let (name, description) = if skill_md.exists() {
|
m.name.unwrap_or_else(|| install_name.clone()),
|
||||||
match Self::parse_skill_metadata_static(&skill_md) {
|
m.description,
|
||||||
Ok(meta) => (
|
),
|
||||||
meta.name.unwrap_or_else(|| install_name.clone()),
|
None => (install_name.clone(), None),
|
||||||
meta.description,
|
|
||||||
),
|
|
||||||
Err(_) => (install_name.clone(), None),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(install_name.clone(), None)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 复制到 SSOT
|
// 复制到 SSOT
|
||||||
@@ -1322,6 +1631,8 @@ impl SkillService {
|
|||||||
let temp_path = temp_dir.path().to_path_buf();
|
let temp_path = temp_dir.path().to_path_buf();
|
||||||
let _ = temp_dir.keep(); // Keep the directory, we'll clean up later
|
let _ = temp_dir.keep(); // Keep the directory, we'll clean up later
|
||||||
|
|
||||||
|
let mut symlinks: Vec<(PathBuf, String)> = Vec::new();
|
||||||
|
|
||||||
for i in 0..archive.len() {
|
for i in 0..archive.len() {
|
||||||
let mut file = archive.by_index(i)?;
|
let mut file = archive.by_index(i)?;
|
||||||
let file_path = match file.enclosed_name() {
|
let file_path = match file.enclosed_name() {
|
||||||
@@ -1331,7 +1642,11 @@ impl SkillService {
|
|||||||
|
|
||||||
let outpath = temp_path.join(&file_path);
|
let outpath = temp_path.join(&file_path);
|
||||||
|
|
||||||
if file.is_dir() {
|
if file.is_symlink() {
|
||||||
|
let mut target = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut file, &mut target)?;
|
||||||
|
symlinks.push((outpath, target.trim().to_string()));
|
||||||
|
} else if file.is_dir() {
|
||||||
fs::create_dir_all(&outpath)?;
|
fs::create_dir_all(&outpath)?;
|
||||||
} else {
|
} else {
|
||||||
if let Some(parent) = outpath.parent() {
|
if let Some(parent) = outpath.parent() {
|
||||||
@@ -1342,6 +1657,9 @@ impl SkillService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析 symlink
|
||||||
|
Self::resolve_symlinks_in_dir(&temp_path, &symlinks)?;
|
||||||
|
|
||||||
Ok(temp_path)
|
Ok(temp_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1414,38 +1732,109 @@ impl SkillService {
|
|||||||
|
|
||||||
// ========== 迁移支持 ==========
|
// ========== 迁移支持 ==========
|
||||||
|
|
||||||
|
/// 从 lock 文件信息构建 skill 的 ID、仓库字段和 readme URL
|
||||||
|
///
|
||||||
|
/// 返回 (id, repo_owner, repo_name, repo_branch, readme_url)
|
||||||
|
fn build_repo_info_from_lock(
|
||||||
|
lock: &HashMap<String, LockRepoInfo>,
|
||||||
|
dir_name: &str,
|
||||||
|
) -> (
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
) {
|
||||||
|
match lock.get(dir_name) {
|
||||||
|
Some(info) => {
|
||||||
|
let branch = info.branch.clone();
|
||||||
|
let url_branch = branch.clone().unwrap_or_else(|| "HEAD".to_string());
|
||||||
|
// 优先使用 lock 文件中的 skillPath,否则回退到 dir_name/SKILL.md
|
||||||
|
let fallback = format!("{dir_name}/SKILL.md");
|
||||||
|
let doc_path = info.skill_path.as_deref().unwrap_or(&fallback);
|
||||||
|
let url = Some(SkillService::build_skill_doc_url(
|
||||||
|
&info.owner,
|
||||||
|
&info.repo,
|
||||||
|
&url_branch,
|
||||||
|
doc_path,
|
||||||
|
));
|
||||||
|
(
|
||||||
|
format!("{}/{}:{dir_name}", info.owner, info.repo),
|
||||||
|
Some(info.owner.clone()),
|
||||||
|
Some(info.repo.clone()),
|
||||||
|
branch,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => (format!("local:{dir_name}"), None, None, None, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 lock 文件中发现的仓库保存到 skill_repos(去重)
|
||||||
|
fn save_repos_from_lock(
|
||||||
|
db: &Arc<Database>,
|
||||||
|
lock: &HashMap<String, LockRepoInfo>,
|
||||||
|
directories: impl Iterator<Item = impl AsRef<str>>,
|
||||||
|
) {
|
||||||
|
let existing_repos: HashSet<(String, String)> = db
|
||||||
|
.get_skill_repos()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.owner, r.name))
|
||||||
|
.collect();
|
||||||
|
let mut added = HashSet::new();
|
||||||
|
|
||||||
|
for dir_name in directories {
|
||||||
|
if let Some(info) = lock.get(dir_name.as_ref()) {
|
||||||
|
let key = (info.owner.clone(), info.repo.clone());
|
||||||
|
if !existing_repos.contains(&key) && added.insert(key) {
|
||||||
|
let skill_repo = SkillRepo {
|
||||||
|
owner: info.owner.clone(),
|
||||||
|
name: info.repo.clone(),
|
||||||
|
// 未知分支时使用 HEAD 语义,后续下载会回退到 main/master。
|
||||||
|
branch: info.branch.clone().unwrap_or_else(|| "HEAD".to_string()),
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
if let Err(e) = db.save_skill_repo(&skill_repo) {
|
||||||
|
log::warn!("保存 skill 仓库 {}/{} 失败: {}", info.owner, info.repo, e);
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"从 agents lock 文件发现并添加仓库: {}/{} ({})",
|
||||||
|
info.owner,
|
||||||
|
info.repo,
|
||||||
|
skill_repo.branch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 首次启动迁移:扫描应用目录,重建数据库
|
/// 首次启动迁移:扫描应用目录,重建数据库
|
||||||
pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||||||
let ssot_dir = SkillService::get_ssot_dir()?;
|
let ssot_dir = SkillService::get_ssot_dir()?;
|
||||||
|
let agents_lock = parse_agents_lock();
|
||||||
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
|
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
|
||||||
|
|
||||||
// 扫描各应用目录
|
// 扫描各应用目录
|
||||||
for app in [
|
for app in AppType::all() {
|
||||||
AppType::Claude,
|
|
||||||
AppType::Codex,
|
|
||||||
AppType::Gemini,
|
|
||||||
AppType::OpenCode,
|
|
||||||
] {
|
|
||||||
let app_dir = match SkillService::get_app_skills_dir(&app) {
|
let app_dir = match SkillService::get_app_skills_dir(&app) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
if !app_dir.exists() {
|
let entries = match fs::read_dir(&app_dir) {
|
||||||
continue;
|
Ok(e) => e,
|
||||||
}
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
for entry in fs::read_dir(&app_dir)? {
|
for entry in entries.flatten() {
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
if !path.is_dir() {
|
if !path.is_dir() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dir_name = entry.file_name().to_string_lossy().to_string();
|
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
|
||||||
// 跳过隐藏目录(以 . 开头,如 .system)
|
|
||||||
if dir_name.starts_with('.') {
|
if dir_name.starts_with('.') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1456,7 +1845,6 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
|||||||
SkillService::copy_dir_recursive(&path, &ssot_path)?;
|
SkillService::copy_dir_recursive(&path, &ssot_path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录启用状态
|
|
||||||
discovered
|
discovered
|
||||||
.entry(dir_name)
|
.entry(dir_name)
|
||||||
.or_default()
|
.or_default()
|
||||||
@@ -1467,32 +1855,28 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
|||||||
// 重建数据库
|
// 重建数据库
|
||||||
db.clear_skills()?;
|
db.clear_skills()?;
|
||||||
|
|
||||||
|
// 将 lock 文件中发现的仓库保存到 skill_repos
|
||||||
|
save_repos_from_lock(db, &agents_lock, discovered.keys());
|
||||||
|
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for (directory, apps) in discovered {
|
for (directory, apps) in discovered {
|
||||||
let ssot_path = ssot_dir.join(&directory);
|
let ssot_path = ssot_dir.join(&directory);
|
||||||
let skill_md = ssot_path.join("SKILL.md");
|
let skill_md = ssot_path.join("SKILL.md");
|
||||||
|
|
||||||
let (name, description) = if skill_md.exists() {
|
let (name, description) = SkillService::read_skill_name_desc(&skill_md, &directory);
|
||||||
match SkillService::parse_skill_metadata_static(&skill_md) {
|
|
||||||
Ok(meta) => (
|
let (id, repo_owner, repo_name, repo_branch, readme_url) =
|
||||||
meta.name.unwrap_or_else(|| directory.clone()),
|
build_repo_info_from_lock(&agents_lock, &directory);
|
||||||
meta.description,
|
|
||||||
),
|
|
||||||
Err(_) => (directory.clone(), None),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(directory.clone(), None)
|
|
||||||
};
|
|
||||||
|
|
||||||
let skill = InstalledSkill {
|
let skill = InstalledSkill {
|
||||||
id: format!("local:{directory}"),
|
id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
directory,
|
directory,
|
||||||
repo_owner: None,
|
repo_owner,
|
||||||
repo_name: None,
|
repo_name,
|
||||||
repo_branch: None,
|
repo_branch,
|
||||||
readme_url: None,
|
readme_url,
|
||||||
apps,
|
apps,
|
||||||
installed_at: chrono::Utc::now().timestamp(),
|
installed_at: chrono::Utc::now().timestamp(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::proxy::http_client;
|
use crate::proxy::http_client;
|
||||||
|
use futures::StreamExt;
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||||
/// Timeout for large file transfers (PUT/GET of db.sql, skills.zip).
|
/// Timeout for large file transfers (PUT/GET of db.sql, skills.zip).
|
||||||
@@ -237,15 +238,7 @@ pub async fn put_bytes(
|
|||||||
)
|
)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| webdav_transport_error("webdav.put_failed", "PUT 请求", "PUT request", url, &e))?;
|
||||||
webdav_transport_error(
|
|
||||||
"webdav.put_failed",
|
|
||||||
"PUT 请求",
|
|
||||||
"PUT request",
|
|
||||||
url,
|
|
||||||
&e,
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if resp.status().is_success() {
|
if resp.status().is_success() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -259,6 +252,7 @@ pub async fn put_bytes(
|
|||||||
pub async fn get_bytes(
|
pub async fn get_bytes(
|
||||||
url: &str,
|
url: &str,
|
||||||
auth: &WebDavAuth,
|
auth: &WebDavAuth,
|
||||||
|
max_bytes: usize,
|
||||||
) -> Result<Option<(Vec<u8>, Option<String>)>, AppError> {
|
) -> Result<Option<(Vec<u8>, Option<String>)>, AppError> {
|
||||||
let client = http_client::get();
|
let client = http_client::get();
|
||||||
let resp = apply_auth(
|
let resp = apply_auth(
|
||||||
@@ -269,15 +263,7 @@ pub async fn get_bytes(
|
|||||||
)
|
)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| webdav_transport_error("webdav.get_failed", "GET 请求", "GET request", url, &e))?;
|
||||||
webdav_transport_error(
|
|
||||||
"webdav.get_failed",
|
|
||||||
"GET 请求",
|
|
||||||
"GET request",
|
|
||||||
url,
|
|
||||||
&e,
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if resp.status() == StatusCode::NOT_FOUND {
|
if resp.status() == StatusCode::NOT_FOUND {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -285,22 +271,29 @@ pub async fn get_bytes(
|
|||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(webdav_status_error("GET", resp.status(), url));
|
return Err(webdav_status_error("GET", resp.status(), url));
|
||||||
}
|
}
|
||||||
|
ensure_content_length_within_limit(resp.headers(), max_bytes, url)?;
|
||||||
|
|
||||||
let etag = resp
|
let etag = resp
|
||||||
.headers()
|
.headers()
|
||||||
.get("etag")
|
.get("etag")
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
let bytes = resp
|
let mut bytes = Vec::new();
|
||||||
.bytes()
|
let mut stream = resp.bytes_stream();
|
||||||
.await
|
while let Some(chunk) = stream.next().await {
|
||||||
.map_err(|e| {
|
let chunk = chunk.map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"webdav.response_read_failed",
|
"webdav.response_read_failed",
|
||||||
format!("读取 WebDAV 响应失败: {e}"),
|
format!("读取 WebDAV 响应失败: {e}"),
|
||||||
format!("Failed to read WebDAV response: {e}"),
|
format!("Failed to read WebDAV response: {e}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
Ok(Some((bytes.to_vec(), etag)))
|
if bytes.len().saturating_add(chunk.len()) > max_bytes {
|
||||||
|
return Err(response_too_large_error(url, max_bytes));
|
||||||
|
}
|
||||||
|
bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
Ok(Some((bytes, etag)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HEAD request to retrieve the ETag. Returns `None` on 404.
|
/// HEAD request to retrieve the ETag. Returns `None` on 404.
|
||||||
@@ -315,13 +308,7 @@ pub async fn head_etag(url: &str, auth: &WebDavAuth) -> Result<Option<String>, A
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
webdav_transport_error(
|
webdav_transport_error("webdav.head_failed", "HEAD 请求", "HEAD request", url, &e)
|
||||||
"webdav.head_failed",
|
|
||||||
"HEAD 请求",
|
|
||||||
"HEAD request",
|
|
||||||
url,
|
|
||||||
&e,
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if resp.status() == StatusCode::NOT_FOUND {
|
if resp.status() == StatusCode::NOT_FOUND {
|
||||||
@@ -386,9 +373,7 @@ pub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError
|
|||||||
|
|
||||||
if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
|
if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
|
||||||
if jgy {
|
if jgy {
|
||||||
zh.push_str(
|
zh.push_str("。坚果云请使用「第三方应用密码」,并确认地址指向 /dav/ 下的目录。");
|
||||||
"。坚果云请使用「第三方应用密码」,并确认地址指向 /dav/ 下的目录。",
|
|
||||||
);
|
|
||||||
en.push_str(
|
en.push_str(
|
||||||
". For Jianguoyun, use an app-specific password and ensure the URL points under /dav/.",
|
". For Jianguoyun, use an app-specific password and ensure the URL points under /dav/.",
|
||||||
);
|
);
|
||||||
@@ -401,9 +386,7 @@ pub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError
|
|||||||
en.push_str(". Common Jianguoyun cause: URL is outside a writable /dav/ directory.");
|
en.push_str(". Common Jianguoyun cause: URL is outside a writable /dav/ directory.");
|
||||||
} else if op == "MKCOL" && status == StatusCode::CONFLICT {
|
} else if op == "MKCOL" && status == StatusCode::CONFLICT {
|
||||||
if jgy {
|
if jgy {
|
||||||
zh.push_str(
|
zh.push_str("。坚果云不允许自动创建顶层文件夹,请先在网页端手动创建后重试。");
|
||||||
"。坚果云不允许自动创建顶层文件夹,请先在网页端手动创建后重试。",
|
|
||||||
);
|
|
||||||
en.push_str(
|
en.push_str(
|
||||||
". Jianguoyun does not allow creating top-level folders automatically; create it manually first.",
|
". Jianguoyun does not allow creating top-level folders automatically; create it manually first.",
|
||||||
);
|
);
|
||||||
@@ -446,9 +429,47 @@ fn redact_url(raw: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn response_too_large_error(url: &str, max_bytes: usize) -> AppError {
|
||||||
|
let max_mb = max_bytes / 1024 / 1024;
|
||||||
|
AppError::localized(
|
||||||
|
"webdav.response_too_large",
|
||||||
|
format!(
|
||||||
|
"WebDAV 响应体超过上限({} MB): {}",
|
||||||
|
max_mb,
|
||||||
|
redact_url(url)
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"WebDAV response body exceeds limit ({} MB): {}",
|
||||||
|
max_mb,
|
||||||
|
redact_url(url)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_content_length_within_limit(
|
||||||
|
headers: &reqwest::header::HeaderMap,
|
||||||
|
max_bytes: usize,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let Some(content_length) = headers.get(reqwest::header::CONTENT_LENGTH) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Ok(raw) = content_length.to_str() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Ok(value) = raw.parse::<u64>() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if value > max_bytes as u64 {
|
||||||
|
return Err(response_too_large_error(url, max_bytes));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_remote_url_encodes_path_segments() {
|
fn build_remote_url_encodes_path_segments() {
|
||||||
@@ -498,10 +519,34 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn redact_url_hides_credentials_and_query_values() {
|
fn redact_url_hides_credentials_and_query_values() {
|
||||||
let redacted = redact_url("https://alice:secret@example.com:8443/dav?token=abc&foo=1");
|
let redacted = redact_url("https://alice:secret@example.com:8443/dav?token=abc&foo=1");
|
||||||
assert_eq!(
|
assert_eq!(redacted, "https://example.com:8443/dav?[keys:foo,token]");
|
||||||
redacted,
|
|
||||||
"https://example.com:8443/dav?[keys:foo,token]"
|
|
||||||
);
|
|
||||||
assert!(!redacted.contains("secret"));
|
assert!(!redacted.contains("secret"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_content_length_within_limit_accepts_missing_or_small_values() {
|
||||||
|
let empty = HeaderMap::new();
|
||||||
|
assert!(
|
||||||
|
ensure_content_length_within_limit(&empty, 1024, "https://dav.example.com").is_ok()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut small = HeaderMap::new();
|
||||||
|
small.insert(CONTENT_LENGTH, HeaderValue::from_static("1024"));
|
||||||
|
assert!(
|
||||||
|
ensure_content_length_within_limit(&small, 1024, "https://dav.example.com").is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_content_length_within_limit_rejects_oversized_values() {
|
||||||
|
let mut large = HeaderMap::new();
|
||||||
|
large.insert(CONTENT_LENGTH, HeaderValue::from_static("2048"));
|
||||||
|
|
||||||
|
let err = ensure_content_length_within_limit(&large, 1024, "https://dav.example.com")
|
||||||
|
.expect_err("oversized response should be rejected");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("too large") || err.to_string().contains("超过"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::sync::mpsc::error::TrySendError;
|
||||||
|
use tokio::sync::mpsc::{channel, Receiver, Sender};
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::webdav_sync as webdav_sync_service;
|
||||||
|
use crate::settings::{self, WebDavSyncSettings};
|
||||||
|
|
||||||
|
const AUTO_SYNC_DEBOUNCE_MS: u64 = 1000;
|
||||||
|
pub(crate) const MAX_AUTO_SYNC_WAIT_MS: u64 = 10_000;
|
||||||
|
|
||||||
|
static DB_CHANGE_TX: OnceLock<Sender<String>> = OnceLock::new();
|
||||||
|
static AUTO_SYNC_SUPPRESS_DEPTH: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
pub(crate) struct AutoSyncSuppressionGuard;
|
||||||
|
|
||||||
|
impl AutoSyncSuppressionGuard {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
AUTO_SYNC_SUPPRESS_DEPTH.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AutoSyncSuppressionGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ =
|
||||||
|
AUTO_SYNC_SUPPRESS_DEPTH.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |value| {
|
||||||
|
Some(value.saturating_sub(1))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_auto_sync_suppressed() -> bool {
|
||||||
|
AUTO_SYNC_SUPPRESS_DEPTH.load(Ordering::SeqCst) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_trigger_for_table(table: &str) -> bool {
|
||||||
|
let normalized = table.trim().to_ascii_lowercase();
|
||||||
|
matches!(
|
||||||
|
normalized.as_str(),
|
||||||
|
"providers"
|
||||||
|
| "provider_endpoints"
|
||||||
|
| "mcp_servers"
|
||||||
|
| "prompts"
|
||||||
|
| "skills"
|
||||||
|
| "skill_repos"
|
||||||
|
| "settings"
|
||||||
|
| "proxy_config"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn enqueue_change_signal(tx: &Sender<String>, table: &str) -> bool {
|
||||||
|
match tx.try_send(table.to_string()) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(TrySendError::Full(_)) | Err(TrySendError::Closed(_)) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn auto_sync_wait_duration(started_at: Instant, now: Instant) -> Option<Duration> {
|
||||||
|
let max_wait = Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS);
|
||||||
|
let debounce = Duration::from_millis(AUTO_SYNC_DEBOUNCE_MS);
|
||||||
|
let elapsed = now.saturating_duration_since(started_at);
|
||||||
|
if elapsed >= max_wait {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(debounce.min(max_wait - elapsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_run_auto_sync(settings: Option<&WebDavSyncSettings>) -> bool {
|
||||||
|
let Some(sync) = settings else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
sync.enabled && sync.auto_sync
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_auto_sync_error(settings: &mut WebDavSyncSettings, error: &AppError) {
|
||||||
|
settings.status.last_error = Some(error.to_string());
|
||||||
|
settings.status.last_error_source = Some("auto".to_string());
|
||||||
|
let _ = settings::update_webdav_sync_status(settings.status.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_auto_sync_status_updated(app: &AppHandle, status: &str, error: Option<&str>) {
|
||||||
|
let payload = match error {
|
||||||
|
Some(message) => json!({
|
||||||
|
"source": "auto",
|
||||||
|
"status": status,
|
||||||
|
"error": message,
|
||||||
|
}),
|
||||||
|
None => json!({
|
||||||
|
"source": "auto",
|
||||||
|
"status": status,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = app.emit("webdav-sync-status-updated", payload) {
|
||||||
|
log::debug!("[WebDAV] failed to emit sync status update event: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_auto_sync_upload(
|
||||||
|
db: &crate::database::Database,
|
||||||
|
app: &AppHandle,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let mut settings = settings::get_webdav_sync_settings();
|
||||||
|
if !should_run_auto_sync(settings.as_ref()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sync_settings = match settings.take() {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = webdav_sync_service::run_with_sync_lock(webdav_sync_service::upload(
|
||||||
|
db,
|
||||||
|
&mut sync_settings,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
emit_auto_sync_status_updated(app, "success", None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
persist_auto_sync_error(&mut sync_settings, &err);
|
||||||
|
emit_auto_sync_status_updated(app, "error", Some(&err.to_string()));
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn notify_db_changed(table: &str) {
|
||||||
|
if is_auto_sync_suppressed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !should_trigger_for_table(table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(tx) = DB_CHANGE_TX.get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = enqueue_change_signal(tx, table);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_worker(db: Arc<crate::database::Database>, app: tauri::AppHandle) {
|
||||||
|
if DB_CHANGE_TX.get().is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer size 1 is enough: we only need "dirty" signals, not every event.
|
||||||
|
let (tx, rx) = channel::<String>(1);
|
||||||
|
if DB_CHANGE_TX.set(tx).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
run_worker_loop(db, rx, app).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_worker_loop(
|
||||||
|
db: Arc<crate::database::Database>,
|
||||||
|
mut rx: Receiver<String>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
) {
|
||||||
|
while let Some(first_table) = rx.recv().await {
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let mut merged_count = 1usize;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(wait_for) = auto_sync_wait_duration(started_at, Instant::now()) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let timeout = tokio::time::timeout(wait_for, rx.recv()).await;
|
||||||
|
|
||||||
|
match timeout {
|
||||||
|
Ok(Some(_)) => merged_count += 1,
|
||||||
|
Ok(None) => return,
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"[WebDAV][AutoSync] Triggered by table={first_table}, merged_changes={merged_count}"
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(err) = run_auto_sync_upload(&db, &app).await {
|
||||||
|
log::warn!("[WebDAV][AutoSync] Upload failed: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
auto_sync_wait_duration, enqueue_change_signal, is_auto_sync_suppressed,
|
||||||
|
should_run_auto_sync, should_trigger_for_table, AutoSyncSuppressionGuard,
|
||||||
|
MAX_AUTO_SYNC_WAIT_MS,
|
||||||
|
};
|
||||||
|
use crate::settings::WebDavSyncSettings;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::mpsc::channel;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_trigger_sync_for_config_tables_only() {
|
||||||
|
assert!(should_trigger_for_table("providers"));
|
||||||
|
assert!(should_trigger_for_table("settings"));
|
||||||
|
assert!(!should_trigger_for_table("proxy_request_logs"));
|
||||||
|
assert!(!should_trigger_for_table("provider_health"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suppression_guard_enables_and_restores_state() {
|
||||||
|
assert!(!is_auto_sync_suppressed());
|
||||||
|
{
|
||||||
|
let _guard = AutoSyncSuppressionGuard::new();
|
||||||
|
assert!(is_auto_sync_suppressed());
|
||||||
|
}
|
||||||
|
assert!(!is_auto_sync_suppressed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn max_wait_caps_flush_latency_for_continuous_events() {
|
||||||
|
let started = Instant::now();
|
||||||
|
let later = started + Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS + 1);
|
||||||
|
assert!(auto_sync_wait_duration(started, later).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn enqueue_change_signal_drops_when_channel_is_full() {
|
||||||
|
let (tx, _rx) = channel::<String>(1);
|
||||||
|
assert!(enqueue_change_signal(&tx, "providers"));
|
||||||
|
assert!(!enqueue_change_signal(&tx, "providers"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_run_auto_sync_requires_enabled_and_auto_sync_flag() {
|
||||||
|
assert!(!should_run_auto_sync(None));
|
||||||
|
|
||||||
|
let disabled = WebDavSyncSettings {
|
||||||
|
enabled: false,
|
||||||
|
auto_sync: true,
|
||||||
|
..WebDavSyncSettings::default()
|
||||||
|
};
|
||||||
|
assert!(!should_run_auto_sync(Some(&disabled)));
|
||||||
|
|
||||||
|
let auto_sync_off = WebDavSyncSettings {
|
||||||
|
enabled: true,
|
||||||
|
auto_sync: false,
|
||||||
|
..WebDavSyncSettings::default()
|
||||||
|
};
|
||||||
|
assert!(!should_run_auto_sync(Some(&auto_sync_off)));
|
||||||
|
|
||||||
|
let enabled = WebDavSyncSettings {
|
||||||
|
enabled: true,
|
||||||
|
auto_sync: true,
|
||||||
|
..WebDavSyncSettings::default()
|
||||||
|
};
|
||||||
|
assert!(should_run_auto_sync(Some(&enabled)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_layer_does_not_depend_on_commands_layer() {
|
||||||
|
let source = include_str!("webdav_auto_sync.rs");
|
||||||
|
let needle = ["crate", "commands", ""].join("::");
|
||||||
|
assert!(
|
||||||
|
!source.contains(&needle),
|
||||||
|
"services layer should not depend on commands layer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::future::Future;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -33,6 +35,21 @@ const REMOTE_DB_SQL: &str = "db.sql";
|
|||||||
const REMOTE_SKILLS_ZIP: &str = "skills.zip";
|
const REMOTE_SKILLS_ZIP: &str = "skills.zip";
|
||||||
const REMOTE_MANIFEST: &str = "manifest.json";
|
const REMOTE_MANIFEST: &str = "manifest.json";
|
||||||
const MAX_DEVICE_NAME_LEN: usize = 64;
|
const MAX_DEVICE_NAME_LEN: usize = 64;
|
||||||
|
const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
|
||||||
|
pub(super) const MAX_SYNC_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub fn sync_mutex() -> &'static tokio::sync::Mutex<()> {
|
||||||
|
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
|
||||||
|
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_with_sync_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
|
||||||
|
where
|
||||||
|
Fut: Future<Output = Result<T, AppError>>,
|
||||||
|
{
|
||||||
|
let _guard = sync_mutex().lock().await;
|
||||||
|
operation.await
|
||||||
|
}
|
||||||
|
|
||||||
fn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> AppError {
|
fn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> AppError {
|
||||||
AppError::localized(key, zh, en)
|
AppError::localized(key, zh, en)
|
||||||
@@ -145,13 +162,15 @@ pub async fn download(
|
|||||||
let auth = auth_for(settings);
|
let auth = auth_for(settings);
|
||||||
|
|
||||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||||
let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth).await?.ok_or_else(|| {
|
let (manifest_bytes, etag) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES)
|
||||||
localized(
|
.await?
|
||||||
"webdav.sync.remote_empty",
|
.ok_or_else(|| {
|
||||||
"远端没有可下载的同步数据",
|
localized(
|
||||||
"No downloadable sync data found on the remote.",
|
"webdav.sync.remote_empty",
|
||||||
)
|
"远端没有可下载的同步数据",
|
||||||
})?;
|
"No downloadable sync data found on the remote.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let manifest: SyncManifest =
|
let manifest: SyncManifest =
|
||||||
serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
|
serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
|
||||||
@@ -181,7 +200,7 @@ pub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result<Option<V
|
|||||||
let auth = auth_for(settings);
|
let auth = auth_for(settings);
|
||||||
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
let manifest_url = remote_file_url(settings, REMOTE_MANIFEST)?;
|
||||||
|
|
||||||
let Some((bytes, _)) = get_bytes(&manifest_url, &auth).await? else {
|
let Some((bytes, _)) = get_bytes(&manifest_url, &auth, MAX_MANIFEST_BYTES).await? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,6 +233,7 @@ fn persist_sync_success(
|
|||||||
let status = WebDavSyncStatus {
|
let status = WebDavSyncStatus {
|
||||||
last_sync_at: Some(Utc::now().timestamp()),
|
last_sync_at: Some(Utc::now().timestamp()),
|
||||||
last_error: None,
|
last_error: None,
|
||||||
|
last_error_source: None,
|
||||||
last_local_manifest_hash: Some(manifest_hash.clone()),
|
last_local_manifest_hash: Some(manifest_hash.clone()),
|
||||||
last_remote_manifest_hash: Some(manifest_hash),
|
last_remote_manifest_hash: Some(manifest_hash),
|
||||||
last_remote_etag: etag,
|
last_remote_etag: etag,
|
||||||
@@ -319,14 +339,10 @@ fn sha256_hex(bytes: &[u8]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn detect_system_device_name() -> Option<String> {
|
fn detect_system_device_name() -> Option<String> {
|
||||||
let env_name = [
|
let env_name = ["CC_SWITCH_DEVICE_NAME", "COMPUTERNAME", "HOSTNAME"]
|
||||||
"CC_SWITCH_DEVICE_NAME",
|
.iter()
|
||||||
"COMPUTERNAME",
|
.filter_map(|key| std::env::var(key).ok())
|
||||||
"HOSTNAME",
|
.find_map(|value| normalize_device_name(&value));
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.filter_map(|key| std::env::var(key).ok())
|
|
||||||
.find_map(|value| normalize_device_name(&value));
|
|
||||||
|
|
||||||
if env_name.is_some() {
|
if env_name.is_some() {
|
||||||
return env_name;
|
return env_name;
|
||||||
@@ -341,21 +357,26 @@ fn detect_system_device_name() -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_device_name(raw: &str) -> Option<String> {
|
fn normalize_device_name(raw: &str) -> Option<String> {
|
||||||
let compact = raw.chars().fold(String::with_capacity(raw.len()), |mut acc, ch| {
|
let compact = raw
|
||||||
if ch.is_whitespace() {
|
.chars()
|
||||||
acc.push(' ');
|
.fold(String::with_capacity(raw.len()), |mut acc, ch| {
|
||||||
} else if !ch.is_control() {
|
if ch.is_whitespace() {
|
||||||
acc.push(ch);
|
acc.push(' ');
|
||||||
}
|
} else if !ch.is_control() {
|
||||||
acc
|
acc.push(ch);
|
||||||
});
|
}
|
||||||
|
acc
|
||||||
|
});
|
||||||
let normalized = compact.split_whitespace().collect::<Vec<_>>().join(" ");
|
let normalized = compact.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
let trimmed = normalized.trim();
|
let trimmed = normalized.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let limited = trimmed.chars().take(MAX_DEVICE_NAME_LEN).collect::<String>();
|
let limited = trimmed
|
||||||
|
.chars()
|
||||||
|
.take(MAX_DEVICE_NAME_LEN)
|
||||||
|
.collect::<String>();
|
||||||
if limited.is_empty() {
|
if limited.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -405,14 +426,18 @@ async fn download_and_verify(
|
|||||||
format!("Manifest missing artifact: {artifact_name}"),
|
format!("Manifest missing artifact: {artifact_name}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
validate_artifact_size_limit(artifact_name, meta.size)?;
|
||||||
|
|
||||||
let url = remote_file_url(settings, artifact_name)?;
|
let url = remote_file_url(settings, artifact_name)?;
|
||||||
let (bytes, _) = get_bytes(&url, auth).await?.ok_or_else(|| {
|
let (bytes, _) = get_bytes(&url, auth, MAX_SYNC_ARTIFACT_BYTES as usize)
|
||||||
localized(
|
.await?
|
||||||
"webdav.sync.remote_missing_artifact",
|
.ok_or_else(|| {
|
||||||
format!("远端缺少 artifact 文件: {artifact_name}"),
|
localized(
|
||||||
format!("Remote artifact file missing: {artifact_name}"),
|
"webdav.sync.remote_missing_artifact",
|
||||||
)
|
format!("远端缺少 artifact 文件: {artifact_name}"),
|
||||||
})?;
|
format!("Remote artifact file missing: {artifact_name}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Quick size check before expensive hash
|
// Quick size check before expensive hash
|
||||||
if bytes.len() as u64 != meta.size {
|
if bytes.len() as u64 != meta.size {
|
||||||
@@ -503,6 +528,21 @@ fn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth {
|
|||||||
auth_from_credentials(&settings.username, &settings.password)
|
auth_from_credentials(&settings.username, &settings.password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_artifact_size_limit(artifact_name: &str, size: u64) -> Result<(), AppError> {
|
||||||
|
if size > MAX_SYNC_ARTIFACT_BYTES {
|
||||||
|
let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
|
||||||
|
return Err(localized(
|
||||||
|
"webdav.sync.artifact_too_large",
|
||||||
|
format!("artifact {artifact_name} 超过下载上限({} MB)", max_mb),
|
||||||
|
format!(
|
||||||
|
"Artifact {artifact_name} exceeds download limit ({} MB)",
|
||||||
|
max_mb
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tests ───────────────────────────────────────────────────
|
// ─── Tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -646,4 +686,19 @@ mod tests {
|
|||||||
"manifest should not contain deviceId"
|
"manifest should not contain deviceId"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_artifact_size_limit_rejects_oversized_artifacts() {
|
||||||
|
let err = validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES + 1)
|
||||||
|
.expect_err("artifact larger than limit should be rejected");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("too large") || err.to_string().contains("超过"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_artifact_size_limit_accepts_limit_boundary() {
|
||||||
|
assert!(validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES).is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ use zip::DateTime;
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::services::skill::SkillService;
|
use crate::services::skill::SkillService;
|
||||||
|
|
||||||
use super::{io_context_localized, localized, REMOTE_SKILLS_ZIP};
|
use super::{io_context_localized, localized, MAX_SYNC_ARTIFACT_BYTES, REMOTE_SKILLS_ZIP};
|
||||||
|
|
||||||
/// Maximum total bytes allowed during zip extraction (512 MB).
|
|
||||||
const MAX_EXTRACT_BYTES: u64 = 512 * 1024 * 1024;
|
|
||||||
/// Maximum number of entries allowed in a zip archive.
|
/// Maximum number of entries allowed in a zip archive.
|
||||||
const MAX_EXTRACT_ENTRIES: usize = 10_000;
|
const MAX_EXTRACT_ENTRIES: usize = 10_000;
|
||||||
|
|
||||||
@@ -92,8 +90,14 @@ pub(super) fn restore_skills_zip(raw: &[u8]) -> Result<(), AppError> {
|
|||||||
if archive.len() > MAX_EXTRACT_ENTRIES {
|
if archive.len() > MAX_EXTRACT_ENTRIES {
|
||||||
return Err(localized(
|
return Err(localized(
|
||||||
"webdav.sync.skills_zip_too_many_entries",
|
"webdav.sync.skills_zip_too_many_entries",
|
||||||
format!("skills.zip 条目数过多({}),上限 {MAX_EXTRACT_ENTRIES}", archive.len()),
|
format!(
|
||||||
format!("skills.zip has too many entries ({}), limit is {MAX_EXTRACT_ENTRIES}", archive.len()),
|
"skills.zip 条目数过多({}),上限 {MAX_EXTRACT_ENTRIES}",
|
||||||
|
archive.len()
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"skills.zip has too many entries ({}), limit is {MAX_EXTRACT_ENTRIES}",
|
||||||
|
archive.len()
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,15 +122,13 @@ pub(super) fn restore_skills_zip(raw: &[u8]) -> Result<(), AppError> {
|
|||||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
}
|
}
|
||||||
let mut out = fs::File::create(&out_path).map_err(|e| AppError::io(&out_path, e))?;
|
let mut out = fs::File::create(&out_path).map_err(|e| AppError::io(&out_path, e))?;
|
||||||
let written = std::io::copy(&mut entry, &mut out).map_err(|e| AppError::io(&out_path, e))?;
|
let _written = copy_entry_with_total_limit(
|
||||||
total_bytes += written;
|
&mut entry,
|
||||||
if total_bytes > MAX_EXTRACT_BYTES {
|
&mut out,
|
||||||
return Err(localized(
|
&mut total_bytes,
|
||||||
"webdav.sync.skills_zip_too_large",
|
MAX_SYNC_ARTIFACT_BYTES,
|
||||||
format!("skills.zip 解压后体积超过上限({} MB)", MAX_EXTRACT_BYTES / 1024 / 1024),
|
&out_path,
|
||||||
format!("skills.zip extracted size exceeds limit ({} MB)", MAX_EXTRACT_BYTES / 1024 / 1024),
|
)?;
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let ssot = SkillService::get_ssot_dir().map_err(|e| {
|
let ssot = SkillService::get_ssot_dir().map_err(|e| {
|
||||||
@@ -327,10 +329,47 @@ fn mark_visited_dir(path: &Path, visited: &mut HashSet<PathBuf>) -> Result<bool,
|
|||||||
Ok(visited.insert(canonical))
|
Ok(visited.insert(canonical))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn copy_entry_with_total_limit<R: Read, W: Write>(
|
||||||
|
reader: &mut R,
|
||||||
|
writer: &mut W,
|
||||||
|
total_bytes: &mut u64,
|
||||||
|
max_total_bytes: u64,
|
||||||
|
out_path: &Path,
|
||||||
|
) -> Result<u64, AppError> {
|
||||||
|
let mut buffer = [0u8; 16 * 1024];
|
||||||
|
let mut written = 0u64;
|
||||||
|
loop {
|
||||||
|
let n = reader
|
||||||
|
.read(&mut buffer)
|
||||||
|
.map_err(|e| AppError::io(out_path, e))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_bytes.saturating_add(n as u64) > max_total_bytes {
|
||||||
|
let max_mb = max_total_bytes / 1024 / 1024;
|
||||||
|
return Err(localized(
|
||||||
|
"webdav.sync.skills_zip_too_large",
|
||||||
|
format!("skills.zip 解压后体积超过上限({} MB)", max_mb),
|
||||||
|
format!("skills.zip extracted size exceeds limit ({} MB)", max_mb),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
writer
|
||||||
|
.write_all(&buffer[..n])
|
||||||
|
.map_err(|e| AppError::io(out_path, e))?;
|
||||||
|
*total_bytes += n as u64;
|
||||||
|
written += n as u64;
|
||||||
|
}
|
||||||
|
Ok(written)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::mark_visited_dir;
|
use super::{copy_entry_with_total_limit, mark_visited_dir};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::path::Path;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -343,4 +382,29 @@ mod tests {
|
|||||||
assert!(mark_visited_dir(&dir, &mut visited).expect("first visit"));
|
assert!(mark_visited_dir(&dir, &mut visited).expect("first visit"));
|
||||||
assert!(!mark_visited_dir(&dir, &mut visited).expect("second visit"));
|
assert!(!mark_visited_dir(&dir, &mut visited).expect("second visit"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_entry_with_total_limit_rejects_oversized_stream_before_write() {
|
||||||
|
let mut reader = Cursor::new(vec![1u8; 16]);
|
||||||
|
let mut writer = Vec::new();
|
||||||
|
let mut total_bytes = 0u64;
|
||||||
|
|
||||||
|
let err = copy_entry_with_total_limit(
|
||||||
|
&mut reader,
|
||||||
|
&mut writer,
|
||||||
|
&mut total_bytes,
|
||||||
|
8,
|
||||||
|
Path::new("skills-extracted/file.bin"),
|
||||||
|
)
|
||||||
|
.expect_err("stream larger than limit should be rejected");
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("too large") || err.to_string().contains("超过"),
|
||||||
|
"unexpected error: {err}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
writer.len(),
|
||||||
|
0,
|
||||||
|
"should not write when the first chunk exceeds limit"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ pub mod terminal;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use providers::{claude, codex};
|
use providers::{claude, codex, gemini, openclaw, opencode};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -40,6 +40,9 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
|||||||
let mut sessions = Vec::new();
|
let mut sessions = Vec::new();
|
||||||
sessions.extend(codex::scan_sessions());
|
sessions.extend(codex::scan_sessions());
|
||||||
sessions.extend(claude::scan_sessions());
|
sessions.extend(claude::scan_sessions());
|
||||||
|
sessions.extend(opencode::scan_sessions());
|
||||||
|
sessions.extend(openclaw::scan_sessions());
|
||||||
|
sessions.extend(gemini::scan_sessions());
|
||||||
|
|
||||||
sessions.sort_by(|a, b| {
|
sessions.sort_by(|a, b| {
|
||||||
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
|
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
|
||||||
@@ -55,6 +58,9 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
|
|||||||
match provider_id {
|
match provider_id {
|
||||||
"codex" => codex::load_messages(path),
|
"codex" => codex::load_messages(path),
|
||||||
"claude" => claude::load_messages(path),
|
"claude" => claude::load_messages(path),
|
||||||
|
"opencode" => opencode::load_messages(path),
|
||||||
|
"openclaw" => openclaw::load_messages(path),
|
||||||
|
"gemini" => gemini::load_messages(path),
|
||||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||||
|
|
||||||
|
use super::utils::{parse_timestamp_to_ms, truncate_summary};
|
||||||
|
|
||||||
|
const PROVIDER_ID: &str = "gemini";
|
||||||
|
|
||||||
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||||
|
let gemini_dir = crate::gemini_config::get_gemini_dir();
|
||||||
|
let tmp_dir = gemini_dir.join("tmp");
|
||||||
|
if !tmp_dir.exists() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sessions = Vec::new();
|
||||||
|
|
||||||
|
// Iterate over project hash directories: tmp/<project_hash>/chats/session-*.json
|
||||||
|
let project_dirs = match std::fs::read_dir(&tmp_dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in project_dirs.flatten() {
|
||||||
|
let chats_dir = entry.path().join("chats");
|
||||||
|
if !chats_dir.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chat_files = match std::fs::read_dir(&chats_dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
for file_entry in chat_files.flatten() {
|
||||||
|
let path = file_entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(meta) = parse_session(&path) {
|
||||||
|
sessions.push(meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||||
|
let data = std::fs::read_to_string(path).map_err(|e| format!("Failed to read session: {e}"))?;
|
||||||
|
let value: Value =
|
||||||
|
serde_json::from_str(&data).map_err(|e| format!("Failed to parse session JSON: {e}"))?;
|
||||||
|
|
||||||
|
let messages = value
|
||||||
|
.get("messages")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or_else(|| "No messages array found".to_string())?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for msg in messages {
|
||||||
|
let content = match msg.get("content").and_then(Value::as_str) {
|
||||||
|
Some(c) if !c.trim().is_empty() => c.to_string(),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let role = match msg.get("type").and_then(Value::as_str) {
|
||||||
|
Some("gemini") => "assistant".to_string(),
|
||||||
|
Some("user") => "user".to_string(),
|
||||||
|
Some(other) => other.to_string(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ts = msg.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||||
|
|
||||||
|
result.push(SessionMessage { role, content, ts });
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||||
|
let data = std::fs::read_to_string(path).ok()?;
|
||||||
|
let value: Value = serde_json::from_str(&data).ok()?;
|
||||||
|
|
||||||
|
let session_id = value.get("sessionId").and_then(Value::as_str)?.to_string();
|
||||||
|
|
||||||
|
let created_at = value.get("startTime").and_then(parse_timestamp_to_ms);
|
||||||
|
let last_active_at = value.get("lastUpdated").and_then(parse_timestamp_to_ms);
|
||||||
|
|
||||||
|
// Derive title from first user message
|
||||||
|
let title = value
|
||||||
|
.get("messages")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|msgs| {
|
||||||
|
msgs.iter()
|
||||||
|
.find(|m| m.get("type").and_then(Value::as_str) == Some("user"))
|
||||||
|
.and_then(|m| m.get("content").and_then(Value::as_str))
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.map(|s| truncate_summary(s, 160))
|
||||||
|
});
|
||||||
|
|
||||||
|
let source_path = path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
Some(SessionMeta {
|
||||||
|
provider_id: PROVIDER_ID.to_string(),
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
title: title.clone(),
|
||||||
|
summary: title,
|
||||||
|
project_dir: None, // project hash is not reversible
|
||||||
|
created_at,
|
||||||
|
last_active_at: last_active_at.or(created_at),
|
||||||
|
source_path: Some(source_path),
|
||||||
|
resume_command: Some(format!("gemini --resume {session_id}")),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
pub mod claude;
|
pub mod claude;
|
||||||
pub mod codex;
|
pub mod codex;
|
||||||
|
pub mod gemini;
|
||||||
|
pub mod openclaw;
|
||||||
|
pub mod opencode;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::openclaw_config::get_openclaw_dir;
|
||||||
|
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||||
|
|
||||||
|
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||||
|
|
||||||
|
const PROVIDER_ID: &str = "openclaw";
|
||||||
|
|
||||||
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||||
|
let agents_dir = get_openclaw_dir().join("agents");
|
||||||
|
if !agents_dir.exists() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sessions = Vec::new();
|
||||||
|
|
||||||
|
// Traverse each agent directory
|
||||||
|
let agent_entries = match std::fs::read_dir(&agents_dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return sessions,
|
||||||
|
};
|
||||||
|
|
||||||
|
for agent_entry in agent_entries.flatten() {
|
||||||
|
let agent_path = agent_entry.path();
|
||||||
|
if !agent_path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessions_dir = agent_path.join("sessions");
|
||||||
|
if !sessions_dir.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_entries = match std::fs::read_dir(&sessions_dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in session_entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Skip sessions.json index file
|
||||||
|
if path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(|n| n == "sessions.json")
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(meta) = parse_session(&path) {
|
||||||
|
sessions.push(meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||||
|
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let value: Value = match serde_json::from_str(&line) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if value.get("type").and_then(Value::as_str) != Some("message") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = match value.get("message") {
|
||||||
|
Some(msg) => msg,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let raw_role = message
|
||||||
|
.get("role")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
|
||||||
|
// Map OpenClaw roles to our standard roles
|
||||||
|
let role = match raw_role {
|
||||||
|
"toolResult" => "tool".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = message.get("content").map(extract_text).unwrap_or_default();
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||||
|
|
||||||
|
messages.push(SessionMessage { role, content, ts });
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||||
|
let file = File::open(path).ok()?;
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
|
||||||
|
let mut session_id: Option<String> = None;
|
||||||
|
let mut cwd: Option<String> = None;
|
||||||
|
let mut created_at: Option<i64> = None;
|
||||||
|
let mut last_active_at: Option<i64> = None;
|
||||||
|
let mut summary: Option<String> = None;
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = match line {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let value: Value = match serde_json::from_str(&line) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||||
|
if created_at.is_none() {
|
||||||
|
created_at = Some(ts);
|
||||||
|
}
|
||||||
|
last_active_at = Some(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
let event_type = value.get("type").and_then(Value::as_str).unwrap_or("");
|
||||||
|
|
||||||
|
if event_type == "session" {
|
||||||
|
if session_id.is_none() {
|
||||||
|
session_id = value
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
if cwd.is_none() {
|
||||||
|
cwd = value
|
||||||
|
.get("cwd")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
}
|
||||||
|
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||||
|
created_at.get_or_insert(ts);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event_type != "message" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract first message content for summary
|
||||||
|
if summary.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = match value.get("message") {
|
||||||
|
Some(msg) => msg,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
summary = Some(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to filename as session ID
|
||||||
|
let session_id = session_id.or_else(|| {
|
||||||
|
path.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
});
|
||||||
|
let session_id = session_id?;
|
||||||
|
|
||||||
|
let title = cwd
|
||||||
|
.as_deref()
|
||||||
|
.and_then(path_basename)
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||||
|
|
||||||
|
Some(SessionMeta {
|
||||||
|
provider_id: PROVIDER_ID.to_string(),
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
project_dir: cwd,
|
||||||
|
created_at,
|
||||||
|
last_active_at,
|
||||||
|
source_path: Some(path.to_string_lossy().to_string()),
|
||||||
|
resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||||
|
|
||||||
|
use super::utils::{parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||||
|
|
||||||
|
const PROVIDER_ID: &str = "opencode";
|
||||||
|
|
||||||
|
/// Return the OpenCode data directory.
|
||||||
|
///
|
||||||
|
/// Respects `XDG_DATA_HOME` on all platforms; falls back to
|
||||||
|
/// `~/.local/share/opencode/storage/`.
|
||||||
|
fn get_opencode_data_dir() -> PathBuf {
|
||||||
|
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||||
|
if !xdg.is_empty() {
|
||||||
|
return PathBuf::from(xdg).join("opencode").join("storage");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirs::home_dir()
|
||||||
|
.map(|h| h.join(".local/share/opencode/storage"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from(".local/share/opencode/storage"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||||
|
let storage = get_opencode_data_dir();
|
||||||
|
let session_dir = storage.join("session");
|
||||||
|
if !session_dir.exists() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut json_files = Vec::new();
|
||||||
|
collect_json_files(&session_dir, &mut json_files);
|
||||||
|
|
||||||
|
let mut sessions = Vec::new();
|
||||||
|
for path in json_files {
|
||||||
|
if let Some(meta) = parse_session(&storage, &path) {
|
||||||
|
sessions.push(meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||||
|
// `path` is the message directory: storage/message/{sessionID}/
|
||||||
|
if !path.is_dir() {
|
||||||
|
return Err(format!("Message directory not found: {}", path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let storage = path
|
||||||
|
.parent()
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.ok_or_else(|| "Cannot determine storage root from message path".to_string())?;
|
||||||
|
|
||||||
|
let mut msg_files = Vec::new();
|
||||||
|
collect_json_files(path, &mut msg_files);
|
||||||
|
|
||||||
|
// Parse all messages and collect (created_ts, message_id, role, parts_text)
|
||||||
|
let mut entries: Vec<(i64, String, String, String)> = Vec::new();
|
||||||
|
|
||||||
|
for msg_path in &msg_files {
|
||||||
|
let data = match std::fs::read_to_string(msg_path) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let value: Value = match serde_json::from_str(&data) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg_id = match value.get("id").and_then(Value::as_str) {
|
||||||
|
Some(id) => id.to_string(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let role = value
|
||||||
|
.get("role")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let created_ts = value
|
||||||
|
.get("time")
|
||||||
|
.and_then(|t| t.get("created"))
|
||||||
|
.and_then(parse_timestamp_to_ms)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Collect text parts from storage/part/{messageID}/
|
||||||
|
let part_dir = storage.join("part").join(&msg_id);
|
||||||
|
let text = collect_parts_text(&part_dir);
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push((created_ts, msg_id, role, text));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by created timestamp
|
||||||
|
entries.sort_by_key(|(ts, _, _, _)| *ts);
|
||||||
|
|
||||||
|
let messages = entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(ts, _, role, content)| SessionMessage {
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
ts: if ts > 0 { Some(ts) } else { None },
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {
|
||||||
|
let data = std::fs::read_to_string(path).ok()?;
|
||||||
|
let value: Value = serde_json::from_str(&data).ok()?;
|
||||||
|
|
||||||
|
let session_id = value.get("id").and_then(Value::as_str)?.to_string();
|
||||||
|
let title = value
|
||||||
|
.get("title")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let directory = value
|
||||||
|
.get("directory")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let created_at = value
|
||||||
|
.get("time")
|
||||||
|
.and_then(|t| t.get("created"))
|
||||||
|
.and_then(parse_timestamp_to_ms);
|
||||||
|
let updated_at = value
|
||||||
|
.get("time")
|
||||||
|
.and_then(|t| t.get("updated"))
|
||||||
|
.and_then(parse_timestamp_to_ms);
|
||||||
|
|
||||||
|
// Derive title from directory basename if no explicit title
|
||||||
|
let display_title = title.or_else(|| {
|
||||||
|
directory
|
||||||
|
.as_deref()
|
||||||
|
.and_then(path_basename)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build source_path = message directory for this session
|
||||||
|
let msg_dir = storage.join("message").join(&session_id);
|
||||||
|
let source_path = msg_dir.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// Get summary from first user message
|
||||||
|
let summary = get_first_user_summary(storage, &session_id);
|
||||||
|
|
||||||
|
Some(SessionMeta {
|
||||||
|
provider_id: PROVIDER_ID.to_string(),
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
title: display_title,
|
||||||
|
summary,
|
||||||
|
project_dir: directory,
|
||||||
|
created_at,
|
||||||
|
last_active_at: updated_at.or(created_at),
|
||||||
|
source_path: Some(source_path),
|
||||||
|
resume_command: Some(format!("opencode session resume {session_id}")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the first user message's first text part to use as summary.
|
||||||
|
fn get_first_user_summary(storage: &Path, session_id: &str) -> Option<String> {
|
||||||
|
let msg_dir = storage.join("message").join(session_id);
|
||||||
|
if !msg_dir.is_dir() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut msg_files = Vec::new();
|
||||||
|
collect_json_files(&msg_dir, &mut msg_files);
|
||||||
|
|
||||||
|
// Collect user messages with timestamps for ordering
|
||||||
|
let mut user_msgs: Vec<(i64, String)> = Vec::new();
|
||||||
|
for msg_path in &msg_files {
|
||||||
|
let data = match std::fs::read_to_string(msg_path) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let value: Value = match serde_json::from_str(&data) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if value.get("role").and_then(Value::as_str) != Some("user") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg_id = match value.get("id").and_then(Value::as_str) {
|
||||||
|
Some(id) => id.to_string(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ts = value
|
||||||
|
.get("time")
|
||||||
|
.and_then(|t| t.get("created"))
|
||||||
|
.and_then(parse_timestamp_to_ms)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
user_msgs.push((ts, msg_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
user_msgs.sort_by_key(|(ts, _)| *ts);
|
||||||
|
|
||||||
|
// Take first user message and get its parts
|
||||||
|
let (_, first_id) = user_msgs.first()?;
|
||||||
|
let part_dir = storage.join("part").join(first_id);
|
||||||
|
let text = collect_parts_text(&part_dir);
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(truncate_summary(&text, 160))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect text content from all parts in a part directory.
|
||||||
|
fn collect_parts_text(part_dir: &Path) -> String {
|
||||||
|
if !part_dir.is_dir() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
collect_json_files(part_dir, &mut parts);
|
||||||
|
|
||||||
|
let mut texts = Vec::new();
|
||||||
|
for part_path in &parts {
|
||||||
|
let data = match std::fs::read_to_string(part_path) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let value: Value = match serde_json::from_str(&data) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include text-type parts
|
||||||
|
if value.get("type").and_then(Value::as_str) != Some("text") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(text) = value.get("text").and_then(Value::as_str) {
|
||||||
|
if !text.trim().is_empty() {
|
||||||
|
texts.push(text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
texts.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_json_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||||
|
if !root.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = match std::fs::read_dir(root) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_json_files(&path, files);
|
||||||
|
} else if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,8 @@ pub struct WebDavSyncStatus {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_error_source: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub last_remote_etag: Option<String>,
|
pub last_remote_etag: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub last_local_manifest_hash: Option<String>,
|
pub last_local_manifest_hash: Option<String>,
|
||||||
@@ -93,6 +95,8 @@ pub struct WebDavSyncSettings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub auto_sync: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@@ -110,6 +114,7 @@ impl Default for WebDavSyncSettings {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
auto_sync: false,
|
||||||
base_url: String::new(),
|
base_url: String::new(),
|
||||||
username: String::new(),
|
username: String::new(),
|
||||||
password: String::new(),
|
password: String::new(),
|
||||||
@@ -182,6 +187,15 @@ pub struct AppSettings {
|
|||||||
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
|
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub silent_startup: bool,
|
pub silent_startup: bool,
|
||||||
|
/// 是否在主页面启用本地代理功能(默认关闭)
|
||||||
|
#[serde(default)]
|
||||||
|
pub enable_local_proxy: bool,
|
||||||
|
/// User has confirmed the local proxy first-run notice
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub proxy_confirmed: Option<bool>,
|
||||||
|
/// User has confirmed the usage query first-run notice
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage_confirmed: Option<bool>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
|
||||||
@@ -231,6 +245,14 @@ pub struct AppSettings {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub webdav_backup: Option<serde_json::Value>,
|
pub webdav_backup: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
// ===== 备份策略设置 =====
|
||||||
|
/// Auto-backup interval in hours (default 24, 0 = disabled)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub backup_interval_hours: Option<u32>,
|
||||||
|
/// Maximum number of backup files to retain (default 10)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub backup_retain_count: Option<u32>,
|
||||||
|
|
||||||
// ===== 终端设置 =====
|
// ===== 终端设置 =====
|
||||||
/// 首选终端应用(可选,默认使用系统默认终端)
|
/// 首选终端应用(可选,默认使用系统默认终端)
|
||||||
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||||||
@@ -257,6 +279,9 @@ impl Default for AppSettings {
|
|||||||
skip_claude_onboarding: false,
|
skip_claude_onboarding: false,
|
||||||
launch_on_startup: false,
|
launch_on_startup: false,
|
||||||
silent_startup: false,
|
silent_startup: false,
|
||||||
|
enable_local_proxy: false,
|
||||||
|
proxy_confirmed: None,
|
||||||
|
usage_confirmed: None,
|
||||||
language: None,
|
language: None,
|
||||||
visible_apps: None,
|
visible_apps: None,
|
||||||
claude_config_dir: None,
|
claude_config_dir: None,
|
||||||
@@ -272,6 +297,8 @@ impl Default for AppSettings {
|
|||||||
skill_sync_method: SyncMethod::default(),
|
skill_sync_method: SyncMethod::default(),
|
||||||
webdav_sync: None,
|
webdav_sync: None,
|
||||||
webdav_backup: None,
|
webdav_backup: None,
|
||||||
|
backup_interval_hours: None,
|
||||||
|
backup_retain_count: None,
|
||||||
preferred_terminal: None,
|
preferred_terminal: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -606,6 +633,33 @@ pub fn get_skill_sync_method() -> SyncMethod {
|
|||||||
.skill_sync_method
|
.skill_sync_method
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 备份策略管理函数 =====
|
||||||
|
|
||||||
|
/// Get the effective auto-backup interval in hours (default 24)
|
||||||
|
pub fn effective_backup_interval_hours() -> u32 {
|
||||||
|
settings_store()
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||||
|
e.into_inner()
|
||||||
|
})
|
||||||
|
.backup_interval_hours
|
||||||
|
.unwrap_or(24)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the effective backup retain count (default 10, minimum 1)
|
||||||
|
pub fn effective_backup_retain_count() -> usize {
|
||||||
|
settings_store()
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||||
|
e.into_inner()
|
||||||
|
})
|
||||||
|
.backup_retain_count
|
||||||
|
.map(|n| (n as usize).max(1))
|
||||||
|
.unwrap_or(10)
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 终端设置管理函数 =====
|
// ===== 终端设置管理函数 =====
|
||||||
|
|
||||||
/// 获取首选终端应用
|
/// 获取首选终端应用
|
||||||
|
|||||||
+9
-23
@@ -15,7 +15,7 @@ pub struct TrayTexts {
|
|||||||
pub show_main: &'static str,
|
pub show_main: &'static str,
|
||||||
pub no_provider_hint: &'static str,
|
pub no_provider_hint: &'static str,
|
||||||
pub quit: &'static str,
|
pub quit: &'static str,
|
||||||
pub auto_label: &'static str,
|
pub _auto_label: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrayTexts {
|
impl TrayTexts {
|
||||||
@@ -25,20 +25,20 @@ impl TrayTexts {
|
|||||||
show_main: "Open main window",
|
show_main: "Open main window",
|
||||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||||
quit: "Quit",
|
quit: "Quit",
|
||||||
auto_label: "Auto (Failover)",
|
_auto_label: "Auto (Failover)",
|
||||||
},
|
},
|
||||||
"ja" => Self {
|
"ja" => Self {
|
||||||
show_main: "メインウィンドウを開く",
|
show_main: "メインウィンドウを開く",
|
||||||
no_provider_hint:
|
no_provider_hint:
|
||||||
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
||||||
quit: "終了",
|
quit: "終了",
|
||||||
auto_label: "自動 (フェイルオーバー)",
|
_auto_label: "自動 (フェイルオーバー)",
|
||||||
},
|
},
|
||||||
_ => Self {
|
_ => Self {
|
||||||
show_main: "打开主界面",
|
show_main: "打开主界面",
|
||||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||||
quit: "退出",
|
quit: "退出",
|
||||||
auto_label: "自动 (故障转移)",
|
_auto_label: "自动 (故障转移)",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ fn append_provider_section<'a>(
|
|||||||
manager: Option<&crate::provider::ProviderManager>,
|
manager: Option<&crate::provider::ProviderManager>,
|
||||||
section: &TrayAppSection,
|
section: &TrayAppSection,
|
||||||
tray_texts: &TrayTexts,
|
tray_texts: &TrayTexts,
|
||||||
app_state: &AppState,
|
_app_state: &AppState,
|
||||||
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||||
let Some(manager) = manager else {
|
let Some(manager) = manager else {
|
||||||
return Ok(menu_builder);
|
return Ok(menu_builder);
|
||||||
@@ -119,22 +119,9 @@ fn append_provider_section<'a>(
|
|||||||
return Ok(menu_builder.item(&empty_hint));
|
return Ok(menu_builder.item(&empty_hint));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 proxy 状态,决定 Auto 是否选中
|
// Auto (Failover) menu item is hidden from tray; the feature is still
|
||||||
let (proxy_enabled, auto_failover) =
|
// accessible from the Settings page. Keep the surrounding code intact so
|
||||||
app_state.db.get_proxy_flags_sync(section.app_type.as_str());
|
// it can be re-enabled easily in the future.
|
||||||
let auto_mode = proxy_enabled && auto_failover;
|
|
||||||
|
|
||||||
// 添加 Auto 菜单项(始终显示在供应商列表前)
|
|
||||||
let auto_item = CheckMenuItem::with_id(
|
|
||||||
app,
|
|
||||||
format!("{}{}", section.prefix, AUTO_SUFFIX),
|
|
||||||
tray_texts.auto_label,
|
|
||||||
true,
|
|
||||||
auto_mode,
|
|
||||||
None::<&str>,
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("创建{}Auto菜单项失败: {e}", section.log_name)))?;
|
|
||||||
menu_builder = menu_builder.item(&auto_item);
|
|
||||||
|
|
||||||
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||||
@@ -156,8 +143,7 @@ fn append_provider_section<'a>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (id, provider) in sorted_providers {
|
for (id, provider) in sorted_providers {
|
||||||
// Auto 模式下所有供应商都不选中
|
let is_current = manager.current == *id;
|
||||||
let is_current = !auto_mode && manager.current == *id;
|
|
||||||
let item = CheckMenuItem::with_id(
|
let item = CheckMenuItem::with_id(
|
||||||
app,
|
app,
|
||||||
format!("{}{}", section.prefix, id),
|
format!("{}{}", section.prefix, id),
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ use serde_json::json;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{AppError, AppType, ConfigService, MultiAppConfig, Provider};
|
||||||
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,
|
|
||||||
Provider, ProviderMeta,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[path = "support.rs"]
|
#[path = "support.rs"]
|
||||||
mod support;
|
mod support;
|
||||||
@@ -13,132 +10,6 @@ use support::{
|
|||||||
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
|
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_claude_provider_writes_live_settings() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
let home = ensure_test_home();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
let provider_config = json!({
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_AUTH_TOKEN": "test-key",
|
|
||||||
"ANTHROPIC_BASE_URL": "https://api.test"
|
|
||||||
},
|
|
||||||
"ui": {
|
|
||||||
"displayName": "Test Provider"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let provider = Provider::with_id(
|
|
||||||
"prov-1".to_string(),
|
|
||||||
"Test Claude".to_string(),
|
|
||||||
provider_config.clone(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Claude)
|
|
||||||
.expect("claude manager");
|
|
||||||
manager.providers.insert("prov-1".to_string(), provider);
|
|
||||||
manager.current = "prov-1".to_string();
|
|
||||||
|
|
||||||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync live settings");
|
|
||||||
|
|
||||||
let settings_path = get_claude_settings_path();
|
|
||||||
assert!(
|
|
||||||
settings_path.exists(),
|
|
||||||
"live settings should be written to {}",
|
|
||||||
settings_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let live_value: serde_json::Value = read_json_file(&settings_path).expect("read live file");
|
|
||||||
assert_eq!(live_value, provider_config);
|
|
||||||
|
|
||||||
// 确认 SSOT 中的供应商也同步了最新内容
|
|
||||||
let updated = config
|
|
||||||
.get_manager(&AppType::Claude)
|
|
||||||
.and_then(|m| m.providers.get("prov-1"))
|
|
||||||
.expect("provider in config");
|
|
||||||
assert_eq!(updated.settings_config, provider_config);
|
|
||||||
|
|
||||||
// 额外确认写入位置位于测试 HOME 下
|
|
||||||
assert!(
|
|
||||||
settings_path.starts_with(home),
|
|
||||||
"settings path {settings_path:?} should reside under test HOME {home:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_codex_provider_writes_auth_and_config() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
|
|
||||||
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
|
|
||||||
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
|
|
||||||
|
|
||||||
let provider_config = json!({
|
|
||||||
"auth": {
|
|
||||||
"OPENAI_API_KEY": "codex-key"
|
|
||||||
},
|
|
||||||
"config": r#"base_url = "https://codex.test""#
|
|
||||||
});
|
|
||||||
|
|
||||||
let provider = Provider::with_id(
|
|
||||||
"codex-1".to_string(),
|
|
||||||
"Codex Test".to_string(),
|
|
||||||
provider_config.clone(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Codex)
|
|
||||||
.expect("codex manager");
|
|
||||||
manager.providers.insert("codex-1".to_string(), provider);
|
|
||||||
manager.current = "codex-1".to_string();
|
|
||||||
|
|
||||||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live");
|
|
||||||
|
|
||||||
let auth_path = cc_switch_lib::get_codex_auth_path();
|
|
||||||
let config_path = cc_switch_lib::get_codex_config_path();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
auth_path.exists(),
|
|
||||||
"auth.json should exist at {}",
|
|
||||||
auth_path.display()
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
config_path.exists(),
|
|
||||||
"config.toml should exist at {}",
|
|
||||||
config_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth");
|
|
||||||
assert_eq!(
|
|
||||||
auth_value,
|
|
||||||
provider_config.get("auth").cloned().expect("auth object")
|
|
||||||
);
|
|
||||||
|
|
||||||
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
|
|
||||||
// 验证基础配置正确写入
|
|
||||||
assert!(
|
|
||||||
toml_text.contains("base_url"),
|
|
||||||
"config.toml should contain base_url from provider config"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当前供应商应同步最新 config 文本
|
|
||||||
let manager = config.get_manager(&AppType::Codex).expect("codex manager");
|
|
||||||
let synced = manager.providers.get("codex-1").expect("codex provider");
|
|
||||||
let synced_cfg = synced
|
|
||||||
.settings_config
|
|
||||||
.get("config")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.expect("config string");
|
|
||||||
assert_eq!(synced_cfg, toml_text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sync_enabled_to_codex_writes_enabled_servers() {
|
fn sync_enabled_to_codex_writes_enabled_servers() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
@@ -338,46 +209,6 @@ fn sync_enabled_to_codex_returns_error_on_invalid_toml() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_codex_provider_missing_auth_returns_error() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
let provider = Provider::with_id(
|
|
||||||
"codex-missing-auth".to_string(),
|
|
||||||
"No Auth".to_string(),
|
|
||||||
json!({
|
|
||||||
"config": "model = \"test\""
|
|
||||||
}),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Codex)
|
|
||||||
.expect("codex manager");
|
|
||||||
manager.providers.insert(provider.id.clone(), provider);
|
|
||||||
manager.current = "codex-missing-auth".to_string();
|
|
||||||
|
|
||||||
let err = ConfigService::sync_current_providers_to_live(&mut config)
|
|
||||||
.expect_err("sync should fail when auth missing");
|
|
||||||
match err {
|
|
||||||
cc_switch_lib::AppError::Config(msg) => {
|
|
||||||
assert!(msg.contains("auth"), "error message should mention auth");
|
|
||||||
}
|
|
||||||
other => panic!("unexpected error variant: {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确认未产生任何 live 配置文件
|
|
||||||
assert!(
|
|
||||||
!cc_switch_lib::get_codex_auth_path().exists(),
|
|
||||||
"auth.json should not be created on failure"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!cc_switch_lib::get_codex_config_path().exists(),
|
|
||||||
"config.toml should not be created on failure"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn write_codex_live_atomic_persists_auth_and_config() {
|
fn write_codex_live_atomic_persists_auth_and_config() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
@@ -816,107 +647,6 @@ fn create_backup_retains_only_latest_entries() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_gemini_packycode_sets_security_selected_type() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
let home = ensure_test_home();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
{
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Gemini)
|
|
||||||
.expect("gemini manager");
|
|
||||||
manager.current = "packy-1".to_string();
|
|
||||||
manager.providers.insert(
|
|
||||||
"packy-1".to_string(),
|
|
||||||
Provider::with_id(
|
|
||||||
"packy-1".to_string(),
|
|
||||||
"PackyCode".to_string(),
|
|
||||||
json!({
|
|
||||||
"env": {
|
|
||||||
"GEMINI_API_KEY": "pk-key",
|
|
||||||
"GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com"
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Some("https://www.packyapi.com".to_string()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfigService::sync_current_providers_to_live(&mut config)
|
|
||||||
.expect("syncing gemini live should succeed");
|
|
||||||
|
|
||||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
|
||||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
|
||||||
assert!(
|
|
||||||
gemini_settings.exists(),
|
|
||||||
"Gemini settings.json should exist at {}",
|
|
||||||
gemini_settings.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings.json");
|
|
||||||
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json");
|
|
||||||
assert_eq!(
|
|
||||||
value
|
|
||||||
.pointer("/security/auth/selectedType")
|
|
||||||
.and_then(|v| v.as_str()),
|
|
||||||
Some("gemini-api-key"),
|
|
||||||
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_gemini_google_official_sets_oauth_security() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
let home = ensure_test_home();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
{
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Gemini)
|
|
||||||
.expect("gemini manager");
|
|
||||||
manager.current = "google-official".to_string();
|
|
||||||
let mut provider = Provider::with_id(
|
|
||||||
"google-official".to_string(),
|
|
||||||
"Google".to_string(),
|
|
||||||
json!({
|
|
||||||
"env": {}
|
|
||||||
}),
|
|
||||||
Some("https://ai.google.dev".to_string()),
|
|
||||||
);
|
|
||||||
provider.meta = Some(ProviderMeta {
|
|
||||||
partner_promotion_key: Some("google-official".to_string()),
|
|
||||||
..ProviderMeta::default()
|
|
||||||
});
|
|
||||||
manager
|
|
||||||
.providers
|
|
||||||
.insert("google-official".to_string(), provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfigService::sync_current_providers_to_live(&mut config)
|
|
||||||
.expect("syncing google official gemini should succeed");
|
|
||||||
|
|
||||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
|
||||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
|
||||||
assert!(
|
|
||||||
gemini_settings.exists(),
|
|
||||||
"Gemini settings should exist at {}",
|
|
||||||
gemini_settings.display()
|
|
||||||
);
|
|
||||||
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
|
|
||||||
let gemini_value: serde_json::Value =
|
|
||||||
serde_json::from_str(&gemini_raw).expect("parse gemini settings json");
|
|
||||||
assert_eq!(
|
|
||||||
gemini_value
|
|
||||||
.pointer("/security/auth/selectedType")
|
|
||||||
.and_then(|v| v.as_str()),
|
|
||||||
Some("oauth-personal"),
|
|
||||||
"Gemini settings should record oauth-personal for Google Official"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn export_sql_writes_to_target_path() {
|
fn export_sql_writes_to_target_path() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
|||||||
@@ -100,9 +100,12 @@ command = "say"
|
|||||||
);
|
);
|
||||||
|
|
||||||
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
|
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
|
||||||
|
// With partial merge, only key fields (model, provider, model_providers) are
|
||||||
|
// merged into config.toml. The existing MCP section should be preserved.
|
||||||
|
// MCP sync from DB is handled separately (at startup or explicit sync).
|
||||||
assert!(
|
assert!(
|
||||||
config_text.contains("mcp_servers.echo-server"),
|
config_text.contains("mcp_servers.legacy"),
|
||||||
"config.toml should contain synced MCP servers"
|
"config.toml should preserve existing MCP servers after partial merge"
|
||||||
);
|
);
|
||||||
|
|
||||||
let current_id = app_state
|
let current_id = app_state
|
||||||
@@ -126,12 +129,9 @@ command = "say"
|
|||||||
.get("config")
|
.get("config")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
// 供应商配置应该包含在 live 文件中
|
// With partial merge, only key fields (model_provider, model, model_providers)
|
||||||
// 注意:live 文件还会包含 MCP 同步后的内容
|
// are written to the live file. MCP servers are synced separately.
|
||||||
assert!(
|
// The provider's stored config should still contain mcp_servers.latest.
|
||||||
config_text.contains("mcp_servers.latest"),
|
|
||||||
"live file should contain provider's original config"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
new_config_text.contains("mcp_servers.latest"),
|
new_config_text.contains("mcp_servers.latest"),
|
||||||
"provider snapshot should contain provider's original config"
|
"provider snapshot should contain provider's original config"
|
||||||
@@ -268,11 +268,22 @@ fn switch_provider_updates_claude_live_and_state() {
|
|||||||
let legacy_provider = providers
|
let legacy_provider = providers
|
||||||
.get("old-provider")
|
.get("old-provider")
|
||||||
.expect("legacy provider still exists");
|
.expect("legacy provider still exists");
|
||||||
// 回填机制:切换前会将 live 配置回填到当前供应商
|
// Backfill mechanism: before switching, the live config's key fields are
|
||||||
// 这保护了用户在 live 文件中的手动修改
|
// backfilled to the current provider. With partial merge, only key fields
|
||||||
|
// (auth, model, endpoint) are extracted — non-key fields like workspace
|
||||||
|
// are NOT included in the backfill.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
legacy_provider.settings_config, legacy_live,
|
legacy_provider
|
||||||
"previous provider should be backfilled with live config"
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||||
|
.and_then(|key| key.as_str()),
|
||||||
|
Some("legacy-key"),
|
||||||
|
"previous provider should be backfilled with live auth key"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
legacy_provider.settings_config.get("workspace").is_none(),
|
||||||
|
"backfill should NOT include non-key fields like workspace"
|
||||||
);
|
);
|
||||||
|
|
||||||
let new_provider = providers.get("new-provider").expect("new provider exists");
|
let new_provider = providers.get("new-provider").expect("new provider exists");
|
||||||
|
|||||||
@@ -112,9 +112,12 @@ command = "say"
|
|||||||
|
|
||||||
let config_text =
|
let config_text =
|
||||||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||||||
|
// With partial merge, only key fields (model, provider, model_providers) are
|
||||||
|
// merged into config.toml. The existing MCP section should be preserved.
|
||||||
|
// MCP sync from DB is handled separately (at startup or explicit sync).
|
||||||
assert!(
|
assert!(
|
||||||
config_text.contains("mcp_servers.echo-server"),
|
config_text.contains("mcp_servers.legacy"),
|
||||||
"config.toml should contain synced MCP servers"
|
"config.toml should preserve existing MCP servers after partial merge"
|
||||||
);
|
);
|
||||||
|
|
||||||
let current_id = state
|
let current_id = state
|
||||||
@@ -143,11 +146,6 @@ command = "say"
|
|||||||
new_config_text.contains("mcp_servers.latest"),
|
new_config_text.contains("mcp_servers.latest"),
|
||||||
"provider config should contain original MCP servers"
|
"provider config should contain original MCP servers"
|
||||||
);
|
);
|
||||||
// live 文件额外包含同步的 MCP 服务器
|
|
||||||
assert!(
|
|
||||||
config_text.contains("mcp_servers.echo-server"),
|
|
||||||
"live config should include synced MCP servers"
|
|
||||||
);
|
|
||||||
|
|
||||||
let legacy = providers
|
let legacy = providers
|
||||||
.get("old-provider")
|
.get("old-provider")
|
||||||
@@ -414,9 +412,19 @@ fn provider_service_switch_claude_updates_live_and_state() {
|
|||||||
let legacy_provider = providers
|
let legacy_provider = providers
|
||||||
.get("old-provider")
|
.get("old-provider")
|
||||||
.expect("legacy provider still exists");
|
.expect("legacy provider still exists");
|
||||||
|
// With partial merge backfill, only key fields are extracted from live config
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
legacy_provider.settings_config, legacy_live,
|
legacy_provider
|
||||||
"previous provider should receive backfilled live config"
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||||
|
.and_then(|key| key.as_str()),
|
||||||
|
Some("legacy-key"),
|
||||||
|
"previous provider should receive backfilled auth key"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
legacy_provider.settings_config.get("workspace").is_none(),
|
||||||
|
"backfill should NOT include non-key fields like workspace"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+325
-213
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -34,6 +35,7 @@ import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
|||||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||||
|
import { useAutoCompact } from "@/hooks/useAutoCompact";
|
||||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||||
@@ -60,7 +62,10 @@ import { UniversalProviderPanel } from "@/components/universal";
|
|||||||
import { McpIcon } from "@/components/BrandIcons";
|
import { McpIcon } from "@/components/BrandIcons";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||||
import { useDisableCurrentOmo } from "@/lib/query/omo";
|
import {
|
||||||
|
useDisableCurrentOmo,
|
||||||
|
useDisableCurrentOmoSlim,
|
||||||
|
} from "@/lib/query/omo";
|
||||||
import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
|
import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
|
||||||
import EnvPanel from "@/components/openclaw/EnvPanel";
|
import EnvPanel from "@/components/openclaw/EnvPanel";
|
||||||
import ToolsPanel from "@/components/openclaw/ToolsPanel";
|
import ToolsPanel from "@/components/openclaw/ToolsPanel";
|
||||||
@@ -81,6 +86,12 @@ type View =
|
|||||||
| "openclawTools"
|
| "openclawTools"
|
||||||
| "openclawAgents";
|
| "openclawAgents";
|
||||||
|
|
||||||
|
interface WebDavSyncStatusUpdatedPayload {
|
||||||
|
source?: string;
|
||||||
|
status?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
|
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
|
||||||
const HEADER_HEIGHT = 64; // px
|
const HEADER_HEIGHT = 64; // px
|
||||||
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
||||||
@@ -169,7 +180,10 @@ function App() {
|
|||||||
if (
|
if (
|
||||||
currentView === "sessions" &&
|
currentView === "sessions" &&
|
||||||
activeApp !== "claude" &&
|
activeApp !== "claude" &&
|
||||||
activeApp !== "codex"
|
activeApp !== "codex" &&
|
||||||
|
activeApp !== "opencode" &&
|
||||||
|
activeApp !== "openclaw" &&
|
||||||
|
activeApp !== "gemini"
|
||||||
) {
|
) {
|
||||||
setCurrentView("providers");
|
setCurrentView("providers");
|
||||||
}
|
}
|
||||||
@@ -187,6 +201,9 @@ function App() {
|
|||||||
const effectiveEditingProvider = useLastValidValue(editingProvider);
|
const effectiveEditingProvider = useLastValidValue(editingProvider);
|
||||||
const effectiveUsageProvider = useLastValidValue(usageProvider);
|
const effectiveUsageProvider = useLastValidValue(usageProvider);
|
||||||
|
|
||||||
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isToolbarCompact = useAutoCompact(toolbarRef);
|
||||||
|
|
||||||
const promptPanelRef = useRef<any>(null);
|
const promptPanelRef = useRef<any>(null);
|
||||||
const mcpPanelRef = useRef<any>(null);
|
const mcpPanelRef = useRef<any>(null);
|
||||||
const skillsPageRef = useRef<any>(null);
|
const skillsPageRef = useRef<any>(null);
|
||||||
@@ -213,7 +230,12 @@ function App() {
|
|||||||
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||||
const currentProviderId = data?.currentProviderId ?? "";
|
const currentProviderId = data?.currentProviderId ?? "";
|
||||||
const hasSkillsSupport = true;
|
const hasSkillsSupport = true;
|
||||||
const hasSessionSupport = activeApp === "claude" || activeApp === "codex";
|
const hasSessionSupport =
|
||||||
|
activeApp === "claude" ||
|
||||||
|
activeApp === "codex" ||
|
||||||
|
activeApp === "opencode" ||
|
||||||
|
activeApp === "openclaw" ||
|
||||||
|
activeApp === "gemini";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
addProvider,
|
addProvider,
|
||||||
@@ -241,6 +263,23 @@ function App() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const disableOmoSlimMutation = useDisableCurrentOmoSlim();
|
||||||
|
const handleDisableOmoSlim = () => {
|
||||||
|
disableOmoSlimMutation.mutate(undefined, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" }));
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(
|
||||||
|
t("omo.disableFailed", {
|
||||||
|
defaultValue: "停用 OMO 失败: {{error}}",
|
||||||
|
error: extractErrorMessage(error),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | undefined;
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
|
||||||
@@ -292,6 +331,50 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
const setupListener = async () => {
|
||||||
|
try {
|
||||||
|
const off = await listen(
|
||||||
|
"webdav-sync-status-updated",
|
||||||
|
async (event) => {
|
||||||
|
const payload = (event.payload ??
|
||||||
|
{}) as WebDavSyncStatusUpdatedPayload;
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||||
|
|
||||||
|
if (payload.source !== "auto" || payload.status !== "error") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
t("settings.webdavSync.autoSyncFailedToast", {
|
||||||
|
error: payload.error || t("common.unknown"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!active) {
|
||||||
|
off();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsubscribe = off;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[App] Failed to subscribe webdav-sync-status-updated event",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void setupListener();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
}, [queryClient, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkEnvOnStartup = async () => {
|
const checkEnvOnStartup = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -647,7 +730,7 @@ function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case "sessions":
|
case "sessions":
|
||||||
return <SessionManagerPage />;
|
return <SessionManagerPage key={activeApp} appId={activeApp} />;
|
||||||
case "workspace":
|
case "workspace":
|
||||||
return <WorkspaceFilesPanel />;
|
return <WorkspaceFilesPanel />;
|
||||||
case "openclawEnv":
|
case "openclawEnv":
|
||||||
@@ -695,6 +778,11 @@ function App() {
|
|||||||
onDisableOmo={
|
onDisableOmo={
|
||||||
activeApp === "opencode" ? handleDisableOmo : undefined
|
activeApp === "opencode" ? handleDisableOmo : undefined
|
||||||
}
|
}
|
||||||
|
onDisableOmoSlim={
|
||||||
|
activeApp === "opencode"
|
||||||
|
? handleDisableOmoSlim
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDuplicate={handleDuplicateProvider}
|
onDuplicate={handleDuplicateProvider}
|
||||||
onConfigureUsage={setUsageProvider}
|
onConfigureUsage={setUsageProvider}
|
||||||
onOpenWebsite={handleOpenWebsite}
|
onOpenWebsite={handleOpenWebsite}
|
||||||
@@ -877,228 +965,252 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 h-[32px]"
|
ref={toolbarRef}
|
||||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
className="flex flex-1 min-w-0 overflow-x-hidden justify-end items-center"
|
||||||
>
|
>
|
||||||
{currentView === "prompts" && (
|
<div
|
||||||
<Button
|
className="flex shrink-0 items-center gap-1.5"
|
||||||
variant="ghost"
|
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||||
size="sm"
|
>
|
||||||
onClick={() => promptPanelRef.current?.openAdd()}
|
{currentView === "prompts" && (
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
{t("prompts.add")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{currentView === "mcp" && (
|
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => mcpPanelRef.current?.openImport()}
|
onClick={() => promptPanelRef.current?.openAdd()}
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
{t("mcp.importExisting")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
{t("mcp.addMcp")}
|
{t("prompts.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
)}
|
||||||
)}
|
{currentView === "mcp" && (
|
||||||
{currentView === "skills" && (
|
<>
|
||||||
<>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => mcpPanelRef.current?.openImport()}
|
||||||
onClick={() =>
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
unifiedSkillsPanelRef.current?.openInstallFromZip()
|
>
|
||||||
}
|
<Download className="w-4 h-4 mr-2" />
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
{t("mcp.importExisting")}
|
||||||
>
|
</Button>
|
||||||
<FolderArchive className="w-4 h-4 mr-2" />
|
<Button
|
||||||
{t("skills.installFromZip.button")}
|
variant="ghost"
|
||||||
</Button>
|
size="sm"
|
||||||
<Button
|
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||||
variant="ghost"
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
size="sm"
|
>
|
||||||
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
{t("mcp.addMcp")}
|
||||||
>
|
</Button>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
</>
|
||||||
{t("skills.import")}
|
)}
|
||||||
</Button>
|
{currentView === "skills" && (
|
||||||
<Button
|
<>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => setCurrentView("skillsDiscovery")}
|
size="sm"
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
onClick={() =>
|
||||||
>
|
unifiedSkillsPanelRef.current?.openInstallFromZip()
|
||||||
<Search className="w-4 h-4 mr-2" />
|
}
|
||||||
{t("skills.discover")}
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
</Button>
|
>
|
||||||
</>
|
<FolderArchive className="w-4 h-4 mr-2" />
|
||||||
)}
|
{t("skills.installFromZip.button")}
|
||||||
{currentView === "skillsDiscovery" && (
|
</Button>
|
||||||
<>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
|
||||||
onClick={() => skillsPageRef.current?.refresh()}
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
>
|
||||||
>
|
<Download className="w-4 h-4 mr-2" />
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
{t("skills.import")}
|
||||||
{t("skills.refresh")}
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => setCurrentView("skillsDiscovery")}
|
||||||
onClick={() => skillsPageRef.current?.openRepoManager()}
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
>
|
||||||
>
|
<Search className="w-4 h-4 mr-2" />
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
{t("skills.discover")}
|
||||||
{t("skills.repoManager")}
|
</Button>
|
||||||
</Button>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
{currentView === "skillsDiscovery" && (
|
||||||
{currentView === "providers" && (
|
<>
|
||||||
<>
|
<Button
|
||||||
{activeApp !== "opencode" && activeApp !== "openclaw" && (
|
variant="ghost"
|
||||||
<>
|
size="sm"
|
||||||
<ProxyToggle activeApp={activeApp} />
|
onClick={() => skillsPageRef.current?.refresh()}
|
||||||
<div
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
className={cn(
|
>
|
||||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
isCurrentAppTakeoverActive
|
{t("skills.refresh")}
|
||||||
? "opacity-100 max-w-[100px] scale-100"
|
</Button>
|
||||||
: "opacity-0 max-w-0 scale-75 pointer-events-none",
|
<Button
|
||||||
)}
|
variant="ghost"
|
||||||
>
|
size="sm"
|
||||||
<FailoverToggle activeApp={activeApp} />
|
onClick={() => skillsPageRef.current?.openRepoManager()}
|
||||||
</div>
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
</>
|
>
|
||||||
)}
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
{t("skills.repoManager")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{currentView === "providers" && (
|
||||||
|
<>
|
||||||
|
{activeApp !== "opencode" &&
|
||||||
|
activeApp !== "openclaw" &&
|
||||||
|
settingsData?.enableLocalProxy && (
|
||||||
|
<>
|
||||||
|
<ProxyToggle activeApp={activeApp} />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||||
|
isCurrentAppTakeoverActive
|
||||||
|
? "opacity-100 max-w-[100px] scale-100"
|
||||||
|
: "opacity-0 max-w-0 scale-75 pointer-events-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FailoverToggle activeApp={activeApp} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<AppSwitcher
|
<AppSwitcher
|
||||||
activeApp={activeApp}
|
activeApp={activeApp}
|
||||||
onSwitch={setActiveApp}
|
onSwitch={setActiveApp}
|
||||||
visibleApps={visibleApps}
|
visibleApps={visibleApps}
|
||||||
compact={
|
compact={isToolbarCompact}
|
||||||
isCurrentAppTakeoverActive &&
|
/>
|
||||||
Object.values(visibleApps).filter(Boolean).length >= 4
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||||
{activeApp === "openclaw" ? (
|
<AnimatePresence mode="wait">
|
||||||
<>
|
<motion.div
|
||||||
<Button
|
key={activeApp === "openclaw" ? "openclaw" : "default"}
|
||||||
variant="ghost"
|
className="flex items-center gap-1"
|
||||||
size="sm"
|
initial={{ opacity: 0 }}
|
||||||
onClick={() => setCurrentView("workspace")}
|
animate={{ opacity: 1 }}
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
exit={{ opacity: 0 }}
|
||||||
title={t("workspace.manage")}
|
transition={{ duration: 0.15 }}
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-4 h-4" />
|
{activeApp === "openclaw" ? (
|
||||||
</Button>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentView("openclawEnv")}
|
onClick={() => setCurrentView("workspace")}
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
title={t("openclaw.env.title")}
|
title={t("workspace.manage")}
|
||||||
>
|
>
|
||||||
<KeyRound className="w-4 h-4" />
|
<FolderOpen className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentView("openclawTools")}
|
onClick={() => setCurrentView("openclawEnv")}
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
title={t("openclaw.tools.title")}
|
title={t("openclaw.env.title")}
|
||||||
>
|
>
|
||||||
<Shield className="w-4 h-4" />
|
<KeyRound className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentView("openclawAgents")}
|
onClick={() => setCurrentView("openclawTools")}
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
title={t("openclaw.agents.title")}
|
title={t("openclaw.tools.title")}
|
||||||
>
|
>
|
||||||
<Cpu className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
<Button
|
||||||
) : (
|
variant="ghost"
|
||||||
<>
|
size="sm"
|
||||||
<Button
|
onClick={() => setCurrentView("openclawAgents")}
|
||||||
variant="ghost"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
size="sm"
|
title={t("openclaw.agents.title")}
|
||||||
onClick={() => setCurrentView("skills")}
|
>
|
||||||
className={cn(
|
<Cpu className="w-4 h-4" />
|
||||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
</Button>
|
||||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
<Button
|
||||||
hasSkillsSupport
|
variant="ghost"
|
||||||
? "opacity-100 w-8 scale-100 px-2"
|
size="sm"
|
||||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
onClick={() => setCurrentView("sessions")}
|
||||||
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
title={t("sessionManager.title")}
|
||||||
|
>
|
||||||
|
<History className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentView("skills")}
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||||
|
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||||
|
hasSkillsSupport
|
||||||
|
? "opacity-100 w-8 scale-100 px-2"
|
||||||
|
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||||
|
)}
|
||||||
|
title={t("skills.manage")}
|
||||||
|
>
|
||||||
|
<Wrench className="flex-shrink-0 w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentView("prompts")}
|
||||||
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
title={t("prompts.manage")}
|
||||||
|
>
|
||||||
|
<Book className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentView("sessions")}
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||||
|
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||||
|
hasSessionSupport
|
||||||
|
? "opacity-100 w-8 scale-100 px-2"
|
||||||
|
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||||
|
)}
|
||||||
|
title={t("sessionManager.title")}
|
||||||
|
>
|
||||||
|
<History className="flex-shrink-0 w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentView("mcp")}
|
||||||
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
title={t("mcp.title")}
|
||||||
|
>
|
||||||
|
<McpIcon size={16} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
title={t("skills.manage")}
|
</motion.div>
|
||||||
>
|
</AnimatePresence>
|
||||||
<Wrench className="flex-shrink-0 w-4 h-4" />
|
</div>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentView("prompts")}
|
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
title={t("prompts.manage")}
|
|
||||||
>
|
|
||||||
<Book className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentView("sessions")}
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
|
||||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
|
||||||
hasSessionSupport
|
|
||||||
? "opacity-100 w-8 scale-100 px-2"
|
|
||||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
|
||||||
)}
|
|
||||||
title={t("sessionManager.title")}
|
|
||||||
>
|
|
||||||
<History className="flex-shrink-0 w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentView("mcp")}
|
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
title={t("mcp.title")}
|
|
||||||
>
|
|
||||||
<McpIcon size={16} />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsAddOpen(true)}
|
onClick={() => setIsAddOpen(true)}
|
||||||
size="icon"
|
size="icon"
|
||||||
className={`ml-2 ${addActionButtonClass}`}
|
className={`ml-2 ${addActionButtonClass}`}
|
||||||
>
|
>
|
||||||
<Plus className="w-5 h-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import type { VisibleApps } from "@/types";
|
import type { VisibleApps } from "@/types";
|
||||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface AppSwitcherProps {
|
interface AppSwitcherProps {
|
||||||
activeApp: AppId;
|
activeApp: AppId;
|
||||||
@@ -52,18 +53,28 @@ export function AppSwitcher({
|
|||||||
key={app}
|
key={app}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch(app)}
|
onClick={() => handleSwitch(app)}
|
||||||
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={cn(
|
||||||
|
"group inline-flex items-center px-3 h-8 rounded-md text-sm font-medium transition-all duration-200",
|
||||||
activeApp === app
|
activeApp === app
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "bg-background text-foreground shadow-sm"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
icon={appIconName[app]}
|
icon={appIconName[app]}
|
||||||
name={appDisplayName[app]}
|
name={appDisplayName[app]}
|
||||||
size={iconSize}
|
size={iconSize}
|
||||||
/>
|
/>
|
||||||
{!compact && <span>{appDisplayName[app]}</span>}
|
<span
|
||||||
|
className={cn(
|
||||||
|
"transition-all duration-200 whitespace-nowrap overflow-hidden",
|
||||||
|
compact
|
||||||
|
? "max-w-0 opacity-0 ml-0"
|
||||||
|
: "max-w-[80px] opacity-100 ml-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{appDisplayName[app]}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle, Info } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
@@ -16,6 +16,7 @@ interface ConfirmDialogProps {
|
|||||||
message: string;
|
message: string;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
|
variant?: "destructive" | "info";
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
@@ -26,11 +27,16 @@ export function ConfirmDialog({
|
|||||||
message,
|
message,
|
||||||
confirmText,
|
confirmText,
|
||||||
cancelText,
|
cancelText,
|
||||||
|
variant = "destructive",
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const IconComponent = variant === "info" ? Info : AlertTriangle;
|
||||||
|
const iconClass =
|
||||||
|
variant === "info" ? "h-5 w-5 text-blue-500" : "h-5 w-5 text-destructive";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
@@ -43,7 +49,7 @@ export function ConfirmDialog({
|
|||||||
<DialogContent className="max-w-sm" zIndex="alert">
|
<DialogContent className="max-w-sm" zIndex="alert">
|
||||||
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
||||||
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
<IconComponent className={iconClass} />
|
||||||
{title}
|
{title}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="whitespace-pre-line text-sm leading-relaxed">
|
<DialogDescription className="whitespace-pre-line text-sm leading-relaxed">
|
||||||
@@ -54,7 +60,10 @@ export function ConfirmDialog({
|
|||||||
<Button variant="outline" onClick={onCancel}>
|
<Button variant="outline" onClick={onCancel}>
|
||||||
{cancelText || t("common.cancel")}
|
{cancelText || t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={onConfirm}>
|
<Button
|
||||||
|
variant={variant === "info" ? "default" : "destructive"}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
{confirmText || t("common.confirm")}
|
{confirmText || t("common.confirm")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { toast } from "sonner";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Provider, UsageScript, UsageData } from "@/types";
|
import { Provider, UsageScript, UsageData } from "@/types";
|
||||||
import { usageApi, type AppId } from "@/lib/api";
|
import { usageApi, settingsApi, type AppId } from "@/lib/api";
|
||||||
|
import { useSettingsQuery } from "@/lib/query";
|
||||||
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||||
import JsonEditor from "./JsonEditor";
|
import JsonEditor from "./JsonEditor";
|
||||||
import * as prettier from "prettier/standalone";
|
import * as prettier from "prettier/standalone";
|
||||||
@@ -15,6 +16,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||||
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface UsageScriptModalProps {
|
interface UsageScriptModalProps {
|
||||||
@@ -112,6 +114,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { data: settingsData } = useSettingsQuery();
|
||||||
|
const [showUsageConfirm, setShowUsageConfirm] = useState(false);
|
||||||
|
|
||||||
// 生成带国际化的预设模板
|
// 生成带国际化的预设模板
|
||||||
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
||||||
@@ -247,6 +251,27 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [showAccessToken, setShowAccessToken] = useState(false);
|
const [showAccessToken, setShowAccessToken] = useState(false);
|
||||||
|
|
||||||
|
const handleEnableToggle = (checked: boolean) => {
|
||||||
|
if (checked && !settingsData?.usageConfirmed) {
|
||||||
|
setShowUsageConfirm(true);
|
||||||
|
} else {
|
||||||
|
setScript({ ...script, enabled: checked });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUsageConfirm = async () => {
|
||||||
|
setShowUsageConfirm(false);
|
||||||
|
try {
|
||||||
|
if (settingsData) {
|
||||||
|
await settingsApi.save({ ...settingsData, usageConfirmed: true });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save usage confirmed:", error);
|
||||||
|
}
|
||||||
|
setScript({ ...script, enabled: true });
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (script.enabled && !script.code.trim()) {
|
if (script.enabled && !script.code.trim()) {
|
||||||
toast.error(t("usageScript.scriptEmpty"));
|
toast.error(t("usageScript.scriptEmpty"));
|
||||||
@@ -436,9 +461,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
<Switch
|
<Switch
|
||||||
checked={script.enabled}
|
checked={script.enabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={handleEnableToggle}
|
||||||
setScript({ ...script, enabled: checked })
|
|
||||||
}
|
|
||||||
aria-label={t("usageScript.enableUsageQuery")}
|
aria-label={t("usageScript.enableUsageQuery")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -844,6 +867,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showUsageConfirm}
|
||||||
|
variant="info"
|
||||||
|
title={t("confirm.usage.title")}
|
||||||
|
message={t("confirm.usage.message")}
|
||||||
|
confirmText={t("confirm.usage.confirm")}
|
||||||
|
onConfirm={() => void handleUsageConfirm()}
|
||||||
|
onCancel={() => setShowUsageConfirm(false)}
|
||||||
|
/>
|
||||||
</FullScreenPanel>
|
</FullScreenPanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
|||||||
interface AppCountBarProps {
|
interface AppCountBarProps {
|
||||||
totalLabel: string;
|
totalLabel: string;
|
||||||
counts: Record<AppId, number>;
|
counts: Record<AppId, number>;
|
||||||
|
appIds?: AppId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppCountBar: React.FC<AppCountBarProps> = ({
|
export const AppCountBar: React.FC<AppCountBarProps> = ({
|
||||||
totalLabel,
|
totalLabel,
|
||||||
counts,
|
counts,
|
||||||
|
appIds = APP_IDS,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4">
|
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4">
|
||||||
@@ -18,7 +20,7 @@ export const AppCountBar: React.FC<AppCountBarProps> = ({
|
|||||||
{totalLabel}
|
{totalLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
|
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
|
||||||
{APP_IDS.map((app) => (
|
{appIds.map((app) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={app}
|
key={app}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
@@ -10,15 +10,17 @@ import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
|||||||
interface AppToggleGroupProps {
|
interface AppToggleGroupProps {
|
||||||
apps: Record<AppId, boolean>;
|
apps: Record<AppId, boolean>;
|
||||||
onToggle: (app: AppId, enabled: boolean) => void;
|
onToggle: (app: AppId, enabled: boolean) => void;
|
||||||
|
appIds?: AppId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
|
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
|
||||||
apps,
|
apps,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
appIds = APP_IDS,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
{APP_IDS.map((app) => {
|
{appIds.map((app) => {
|
||||||
const { label, icon, activeClass } = APP_ICON_MAP[app];
|
const { label, icon, activeClass } = APP_ICON_MAP[app];
|
||||||
const enabled = apps[app];
|
const enabled = apps[app];
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Edit3, Trash2, ExternalLink } from "lucide-react";
|
|||||||
import { settingsApi } from "@/lib/api";
|
import { settingsApi } from "@/lib/api";
|
||||||
import { mcpPresets } from "@/config/mcpPresets";
|
import { mcpPresets } from "@/config/mcpPresets";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { APP_IDS } from "@/config/appConfig";
|
import { MCP_SKILLS_APP_IDS } from "@/config/appConfig";
|
||||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||||
@@ -58,7 +58,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
const enabledCounts = useMemo(() => {
|
const enabledCounts = useMemo(() => {
|
||||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
||||||
serverEntries.forEach(([_, server]) => {
|
serverEntries.forEach(([_, server]) => {
|
||||||
for (const app of APP_IDS) {
|
for (const app of MCP_SKILLS_APP_IDS) {
|
||||||
if (server.apps[app]) counts[app]++;
|
if (server.apps[app]) counts[app]++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -136,6 +136,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
<AppCountBar
|
<AppCountBar
|
||||||
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
|
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
|
||||||
counts={enabledCounts}
|
counts={enabledCounts}
|
||||||
|
appIds={MCP_SKILLS_APP_IDS}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||||
@@ -277,6 +278,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
<AppToggleGroup
|
<AppToggleGroup
|
||||||
apps={server.apps}
|
apps={server.apps}
|
||||||
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
|
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
|
||||||
|
appIds={MCP_SKILLS_APP_IDS}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const AgentsDefaultsPanel: React.FC = () => {
|
|||||||
const { data: agentsData, isLoading } = useOpenClawAgentsDefaults();
|
const { data: agentsData, isLoading } = useOpenClawAgentsDefaults();
|
||||||
const saveAgentsMutation = useSaveOpenClawAgentsDefaults();
|
const saveAgentsMutation = useSaveOpenClawAgentsDefaults();
|
||||||
const [defaults, setDefaults] = useState<OpenClawAgentsDefaults | null>(null);
|
const [defaults, setDefaults] = useState<OpenClawAgentsDefaults | null>(null);
|
||||||
const [primaryModel, setPrimaryModel] = useState("");
|
|
||||||
const [fallbacks, setFallbacks] = useState("");
|
const [fallbacks, setFallbacks] = useState("");
|
||||||
|
|
||||||
// Extra known fields from agents.defaults
|
// Extra known fields from agents.defaults
|
||||||
@@ -26,13 +25,15 @@ const AgentsDefaultsPanel: React.FC = () => {
|
|||||||
const [contextTokens, setContextTokens] = useState("");
|
const [contextTokens, setContextTokens] = useState("");
|
||||||
const [maxConcurrent, setMaxConcurrent] = useState("");
|
const [maxConcurrent, setMaxConcurrent] = useState("");
|
||||||
|
|
||||||
|
// Primary model is read-only — set via the "Set as default model" button on provider cards
|
||||||
|
const primaryModel = agentsData?.model?.primary ?? "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// agentsData is undefined while loading, null when config section is absent
|
// agentsData is undefined while loading, null when config section is absent
|
||||||
if (agentsData === undefined) return;
|
if (agentsData === undefined) return;
|
||||||
setDefaults(agentsData);
|
setDefaults(agentsData);
|
||||||
|
|
||||||
if (agentsData) {
|
if (agentsData) {
|
||||||
setPrimaryModel(agentsData.model?.primary ?? "");
|
|
||||||
setFallbacks((agentsData.model?.fallbacks ?? []).join(", "));
|
setFallbacks((agentsData.model?.fallbacks ?? []).join(", "));
|
||||||
|
|
||||||
// Extract known extra fields
|
// Extract known extra fields
|
||||||
@@ -48,17 +49,21 @@ const AgentsDefaultsPanel: React.FC = () => {
|
|||||||
// Preserve all unknown fields from original data
|
// Preserve all unknown fields from original data
|
||||||
const updated: OpenClawAgentsDefaults = { ...defaults };
|
const updated: OpenClawAgentsDefaults = { ...defaults };
|
||||||
|
|
||||||
// Model configuration
|
// Model configuration — primary is read-only, preserve original value
|
||||||
const fallbackList = fallbacks
|
const fallbackList = fallbacks
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (primaryModel.trim()) {
|
const origPrimary = defaults?.model?.primary;
|
||||||
|
if (origPrimary) {
|
||||||
updated.model = {
|
updated.model = {
|
||||||
primary: primaryModel.trim(),
|
primary: origPrimary,
|
||||||
...(fallbackList.length > 0 ? { fallbacks: fallbackList } : {}),
|
...(fallbackList.length > 0 ? { fallbacks: fallbackList } : {}),
|
||||||
};
|
};
|
||||||
|
} else if (fallbackList.length > 0) {
|
||||||
|
// No primary set but user provided fallbacks — keep fallbacks only
|
||||||
|
updated.model = { primary: "", fallbacks: fallbackList };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional fields
|
// Optional fields
|
||||||
@@ -122,12 +127,9 @@ const AgentsDefaultsPanel: React.FC = () => {
|
|||||||
<Label className="mb-1.5 block">
|
<Label className="mb-1.5 block">
|
||||||
{t("openclaw.agents.primaryModel")}
|
{t("openclaw.agents.primaryModel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<div className="h-9 px-3 flex items-center rounded-md border border-input bg-muted/50 font-mono text-xs text-muted-foreground">
|
||||||
value={primaryModel}
|
{primaryModel || t("openclaw.agents.notSet")}
|
||||||
onChange={(e) => setPrimaryModel(e.target.value)}
|
</div>
|
||||||
placeholder="provider/model-id"
|
|
||||||
className="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{t("openclaw.agents.primaryModelHint")}
|
{t("openclaw.agents.primaryModelHint")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
Edit,
|
Edit,
|
||||||
Loader2,
|
// Loader2, // Hidden: stream check feature disabled
|
||||||
Minus,
|
Minus,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
Terminal,
|
Terminal,
|
||||||
TestTube2,
|
// TestTube2, // Hidden: stream check feature disabled
|
||||||
Trash2,
|
Trash2,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -46,14 +46,14 @@ export function ProviderActions({
|
|||||||
appId,
|
appId,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
isInConfig = false,
|
isInConfig = false,
|
||||||
isTesting,
|
isTesting: _isTesting, // Hidden: stream check feature disabled
|
||||||
isProxyTakeover = false,
|
isProxyTakeover = false,
|
||||||
isOmo = false,
|
isOmo = false,
|
||||||
isLastOmo = false,
|
isLastOmo = false,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onTest,
|
onTest: _onTest, // Hidden: stream check feature disabled
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRemoveFromConfig,
|
onRemoveFromConfig,
|
||||||
@@ -207,7 +207,7 @@ export function ProviderActions({
|
|||||||
onClick={isDefaultModel ? undefined : onSetAsDefault}
|
onClick={isDefaultModel ? undefined : onSetAsDefault}
|
||||||
disabled={isDefaultModel}
|
disabled={isDefaultModel}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[4.5rem] px-2.5",
|
"w-fit px-2.5",
|
||||||
isDefaultModel
|
isDefaultModel
|
||||||
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
|
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
|
||||||
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||||
@@ -215,8 +215,8 @@ export function ProviderActions({
|
|||||||
>
|
>
|
||||||
<Zap className="h-4 w-4" />
|
<Zap className="h-4 w-4" />
|
||||||
{isDefaultModel
|
{isDefaultModel
|
||||||
? t("provider.isDefault", { defaultValue: "默认" })
|
? t("provider.isDefault", { defaultValue: "当前默认" })
|
||||||
: t("provider.setAsDefault", { defaultValue: "启用" })}
|
: t("provider.setAsDefault", { defaultValue: "设为默认" })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -252,6 +252,7 @@ export function ProviderActions({
|
|||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Hidden: stream check feature disabled
|
||||||
{onTest && (
|
{onTest && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -268,6 +269,7 @@ export function ProviderActions({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
*/}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -29,11 +29,14 @@ interface ProviderCardProps {
|
|||||||
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
|
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
|
||||||
isOmo?: boolean;
|
isOmo?: boolean;
|
||||||
isLastOmo?: boolean;
|
isLastOmo?: boolean;
|
||||||
|
isOmoSlim?: boolean;
|
||||||
|
isLastOmoSlim?: boolean;
|
||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
onRemoveFromConfig?: (provider: Provider) => void;
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
onDisableOmo?: () => void;
|
onDisableOmo?: () => void;
|
||||||
|
onDisableOmoSlim?: () => void;
|
||||||
onConfigureUsage: (provider: Provider) => void;
|
onConfigureUsage: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
@@ -92,11 +95,14 @@ export function ProviderCard({
|
|||||||
isInConfig = true,
|
isInConfig = true,
|
||||||
isOmo = false,
|
isOmo = false,
|
||||||
isLastOmo = false,
|
isLastOmo = false,
|
||||||
|
isOmoSlim = false,
|
||||||
|
isLastOmoSlim = false,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRemoveFromConfig,
|
onRemoveFromConfig,
|
||||||
onDisableOmo,
|
onDisableOmo,
|
||||||
|
onDisableOmoSlim,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -117,6 +123,11 @@ export function ProviderCard({
|
|||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// OMO and OMO Slim share the same card behavior
|
||||||
|
const isAnyOmo = isOmo || isOmoSlim;
|
||||||
|
const isLastAnyOmo = isOmo ? isLastOmo : isLastOmoSlim;
|
||||||
|
const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;
|
||||||
|
|
||||||
const { data: health } = useProviderHealth(provider.id, appId);
|
const { data: health } = useProviderHealth(provider.id, appId);
|
||||||
|
|
||||||
const fallbackUrlText = t("provider.notConfigured", {
|
const fallbackUrlText = t("provider.notConfigured", {
|
||||||
@@ -186,11 +197,11 @@ export function ProviderCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 判断是否是"当前使用中"的供应商
|
// 判断是否是"当前使用中"的供应商
|
||||||
// - OMO 供应商:使用 isCurrent
|
// - OMO/OMO Slim 供应商:使用 isCurrent
|
||||||
// - 累加模式应用(OpenCode 非 OMO / OpenClaw):不存在"当前"概念,始终返回 false
|
// - 累加模式应用(OpenCode 非 OMO / OpenClaw):不存在"当前"概念,始终返回 false
|
||||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||||
// - 普通模式:isCurrent
|
// - 普通模式:isCurrent
|
||||||
const isActiveProvider = isOmo
|
const isActiveProvider = isAnyOmo
|
||||||
? isCurrent
|
? isCurrent
|
||||||
: appId === "opencode" || appId === "openclaw"
|
: appId === "opencode" || appId === "openclaw"
|
||||||
? false
|
? false
|
||||||
@@ -198,10 +209,10 @@ export function ProviderCard({
|
|||||||
? activeProviderId === provider.id
|
? activeProviderId === provider.id
|
||||||
: isCurrent;
|
: isCurrent;
|
||||||
|
|
||||||
const shouldUseGreen = !isOmo && isProxyTakeover && isActiveProvider;
|
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
|
||||||
const shouldUseBlue =
|
const shouldUseBlue =
|
||||||
(isOmo && isActiveProvider) ||
|
(isAnyOmo && isActiveProvider) ||
|
||||||
(!isOmo && !isProxyTakeover && isActiveProvider);
|
(!isAnyOmo && !isProxyTakeover && isActiveProvider);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -265,6 +276,12 @@ export function ProviderCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isOmoSlim && (
|
||||||
|
<span className="inline-flex items-center rounded-md bg-indigo-100 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
|
||||||
|
Slim
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{isProxyRunning && isInFailoverQueue && health && (
|
{isProxyRunning && isInFailoverQueue && health && (
|
||||||
<ProviderHealthBadge
|
<ProviderHealthBadge
|
||||||
consecutiveFailures={health.consecutive_failures}
|
consecutiveFailures={health.consecutive_failures}
|
||||||
@@ -372,8 +389,8 @@ export function ProviderCard({
|
|||||||
isInConfig={isInConfig}
|
isInConfig={isInConfig}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
isProxyTakeover={isProxyTakeover}
|
isProxyTakeover={isProxyTakeover}
|
||||||
isOmo={isOmo}
|
isOmo={isAnyOmo}
|
||||||
isLastOmo={isLastOmo}
|
isLastOmo={isLastAnyOmo}
|
||||||
onSwitch={() => onSwitch(provider)}
|
onSwitch={() => onSwitch(provider)}
|
||||||
onEdit={() => onEdit(provider)}
|
onEdit={() => onEdit(provider)}
|
||||||
onDuplicate={() => onDuplicate(provider)}
|
onDuplicate={() => onDuplicate(provider)}
|
||||||
@@ -385,7 +402,7 @@ export function ProviderCard({
|
|||||||
? () => onRemoveFromConfig(provider)
|
? () => onRemoveFromConfig(provider)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onDisableOmo={onDisableOmo}
|
onDisableOmo={handleDisableAnyOmo}
|
||||||
onOpenTerminal={
|
onOpenTerminal={
|
||||||
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Users } from "lucide-react";
|
import { Download, Users } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface ProviderEmptyStateProps {
|
interface ProviderEmptyStateProps {
|
||||||
onCreate?: () => void;
|
onCreate?: () => void;
|
||||||
|
onImport?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
|
export function ProviderEmptyState({
|
||||||
|
onCreate,
|
||||||
|
onImport,
|
||||||
|
}: ProviderEmptyStateProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -18,11 +22,19 @@ export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
|
|||||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||||
{t("provider.noProvidersDescription")}
|
{t("provider.noProvidersDescription")}
|
||||||
</p>
|
</p>
|
||||||
{onCreate && (
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
<Button className="mt-6" onClick={onCreate}>
|
{onImport && (
|
||||||
{t("provider.addProvider")}
|
<Button onClick={onImport}>
|
||||||
</Button>
|
<Download className="mr-2 h-4 w-4" />
|
||||||
)}
|
{t("provider.importCurrent")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onCreate && (
|
||||||
|
<Button variant={onImport ? "outline" : "default"} onClick={onCreate}>
|
||||||
|
{t("provider.addProvider")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
import type { Provider } from "@/types";
|
import type { Provider } from "@/types";
|
||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import { providersApi } from "@/lib/api/providers";
|
import { providersApi } from "@/lib/api/providers";
|
||||||
@@ -33,7 +34,12 @@ import {
|
|||||||
useAddToFailoverQueue,
|
useAddToFailoverQueue,
|
||||||
useRemoveFromFailoverQueue,
|
useRemoveFromFailoverQueue,
|
||||||
} from "@/lib/query/failover";
|
} from "@/lib/query/failover";
|
||||||
import { useCurrentOmoProviderId, useOmoProviderCount } from "@/lib/query/omo";
|
import {
|
||||||
|
useCurrentOmoProviderId,
|
||||||
|
useOmoProviderCount,
|
||||||
|
useCurrentOmoSlimProviderId,
|
||||||
|
useOmoSlimProviderCount,
|
||||||
|
} from "@/lib/query/omo";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -47,6 +53,7 @@ interface ProviderListProps {
|
|||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
onRemoveFromConfig?: (provider: Provider) => void;
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
onDisableOmo?: () => void;
|
onDisableOmo?: () => void;
|
||||||
|
onDisableOmoSlim?: () => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
@@ -68,6 +75,7 @@ export function ProviderList({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onRemoveFromConfig,
|
onRemoveFromConfig,
|
||||||
onDisableOmo,
|
onDisableOmo,
|
||||||
|
onDisableOmoSlim,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
@@ -135,6 +143,8 @@ export function ProviderList({
|
|||||||
const isOpenCode = appId === "opencode";
|
const isOpenCode = appId === "opencode";
|
||||||
const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);
|
const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);
|
||||||
const { data: omoProviderCount } = useOmoProviderCount(isOpenCode);
|
const { data: omoProviderCount } = useOmoProviderCount(isOpenCode);
|
||||||
|
const { data: currentOmoSlimId } = useCurrentOmoSlimProviderId(isOpenCode);
|
||||||
|
const { data: omoSlimProviderCount } = useOmoSlimProviderCount(isOpenCode);
|
||||||
|
|
||||||
const getFailoverPriority = useCallback(
|
const getFailoverPriority = useCallback(
|
||||||
(providerId: string): number | undefined => {
|
(providerId: string): number | undefined => {
|
||||||
@@ -170,6 +180,23 @@ export function ProviderList({
|
|||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Import current live config as default provider
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: () => providersApi.importDefault(appId),
|
||||||
|
onSuccess: (imported) => {
|
||||||
|
if (imported) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["providers", appId] });
|
||||||
|
toast.success(t("provider.importCurrentDescription"));
|
||||||
|
} else {
|
||||||
|
toast.info(t("provider.noProviders"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
@@ -222,8 +249,17 @@ export function ProviderList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only show import button for standard apps (not additive-mode apps like OpenCode/OpenClaw)
|
||||||
|
const showImportButton =
|
||||||
|
appId === "claude" || appId === "codex" || appId === "gemini";
|
||||||
|
|
||||||
if (sortedProviders.length === 0) {
|
if (sortedProviders.length === 0) {
|
||||||
return <ProviderEmptyState onCreate={onCreate} />;
|
return (
|
||||||
|
<ProviderEmptyState
|
||||||
|
onCreate={onCreate}
|
||||||
|
onImport={showImportButton ? () => importMutation.mutate() : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderProviderList = () => (
|
const renderProviderList = () => (
|
||||||
@@ -239,13 +275,20 @@ export function ProviderList({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filteredProviders.map((provider) => {
|
{filteredProviders.map((provider) => {
|
||||||
const isOmo = provider.category === "omo";
|
const isOmo = provider.category === "omo";
|
||||||
|
const isOmoSlim = provider.category === "omo-slim";
|
||||||
const isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
|
const isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
|
||||||
|
const isOmoSlimCurrent =
|
||||||
|
isOmoSlim && provider.id === (currentOmoSlimId || "");
|
||||||
return (
|
return (
|
||||||
<SortableProviderCard
|
<SortableProviderCard
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
isCurrent={
|
isCurrent={
|
||||||
isOmo ? isOmoCurrent : provider.id === currentProviderId
|
isOmo
|
||||||
|
? isOmoCurrent
|
||||||
|
: isOmoSlim
|
||||||
|
? isOmoSlimCurrent
|
||||||
|
: provider.id === currentProviderId
|
||||||
}
|
}
|
||||||
appId={appId}
|
appId={appId}
|
||||||
isInConfig={isProviderInConfig(provider.id)}
|
isInConfig={isProviderInConfig(provider.id)}
|
||||||
@@ -253,11 +296,18 @@ export function ProviderList({
|
|||||||
isLastOmo={
|
isLastOmo={
|
||||||
isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent
|
isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent
|
||||||
}
|
}
|
||||||
|
isOmoSlim={isOmoSlim}
|
||||||
|
isLastOmoSlim={
|
||||||
|
isOmoSlim &&
|
||||||
|
(omoSlimProviderCount ?? 0) <= 1 &&
|
||||||
|
isOmoSlimCurrent
|
||||||
|
}
|
||||||
onSwitch={onSwitch}
|
onSwitch={onSwitch}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onRemoveFromConfig={onRemoveFromConfig}
|
onRemoveFromConfig={onRemoveFromConfig}
|
||||||
onDisableOmo={onDisableOmo}
|
onDisableOmo={onDisableOmo}
|
||||||
|
onDisableOmoSlim={onDisableOmoSlim}
|
||||||
onDuplicate={onDuplicate}
|
onDuplicate={onDuplicate}
|
||||||
onConfigureUsage={onConfigureUsage}
|
onConfigureUsage={onConfigureUsage}
|
||||||
onOpenWebsite={onOpenWebsite}
|
onOpenWebsite={onOpenWebsite}
|
||||||
@@ -371,11 +421,14 @@ interface SortableProviderCardProps {
|
|||||||
isInConfig: boolean;
|
isInConfig: boolean;
|
||||||
isOmo: boolean;
|
isOmo: boolean;
|
||||||
isLastOmo: boolean;
|
isLastOmo: boolean;
|
||||||
|
isOmoSlim: boolean;
|
||||||
|
isLastOmoSlim: boolean;
|
||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
onRemoveFromConfig?: (provider: Provider) => void;
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
onDisableOmo?: () => void;
|
onDisableOmo?: () => void;
|
||||||
|
onDisableOmoSlim?: () => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
@@ -401,11 +454,14 @@ function SortableProviderCard({
|
|||||||
isInConfig,
|
isInConfig,
|
||||||
isOmo,
|
isOmo,
|
||||||
isLastOmo,
|
isLastOmo,
|
||||||
|
isOmoSlim,
|
||||||
|
isLastOmoSlim,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRemoveFromConfig,
|
onRemoveFromConfig,
|
||||||
onDisableOmo,
|
onDisableOmo,
|
||||||
|
onDisableOmoSlim,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
@@ -445,11 +501,14 @@ function SortableProviderCard({
|
|||||||
isInConfig={isInConfig}
|
isInConfig={isInConfig}
|
||||||
isOmo={isOmo}
|
isOmo={isOmo}
|
||||||
isLastOmo={isLastOmo}
|
isLastOmo={isLastOmo}
|
||||||
|
isOmoSlim={isOmoSlim}
|
||||||
|
isLastOmoSlim={isLastOmoSlim}
|
||||||
onSwitch={onSwitch}
|
onSwitch={onSwitch}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onRemoveFromConfig={onRemoveFromConfig}
|
onRemoveFromConfig={onRemoveFromConfig}
|
||||||
onDisableOmo={onDisableOmo}
|
onDisableOmo={onDisableOmo}
|
||||||
|
onDisableOmoSlim={onDisableOmoSlim}
|
||||||
onDuplicate={onDuplicate}
|
onDuplicate={onDuplicate}
|
||||||
onConfigureUsage={
|
onConfigureUsage={
|
||||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||||
import { ApiKeySection, EndpointField } from "./shared";
|
import { ApiKeySection, EndpointField } from "./shared";
|
||||||
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
|
import type {
|
||||||
|
ProviderCategory,
|
||||||
|
ClaudeApiFormat,
|
||||||
|
ClaudeApiKeyField,
|
||||||
|
} from "@/types";
|
||||||
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
||||||
|
|
||||||
interface EndpointCandidate {
|
interface EndpointCandidate {
|
||||||
@@ -68,6 +72,10 @@ interface ClaudeFormFieldsProps {
|
|||||||
// API Format (for third-party providers that use OpenAI Chat Completions format)
|
// API Format (for third-party providers that use OpenAI Chat Completions format)
|
||||||
apiFormat: ClaudeApiFormat;
|
apiFormat: ClaudeApiFormat;
|
||||||
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
||||||
|
|
||||||
|
// Auth Key Field (ANTHROPIC_AUTH_TOKEN vs ANTHROPIC_API_KEY)
|
||||||
|
apiKeyField: ClaudeApiKeyField;
|
||||||
|
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClaudeFormFields({
|
export function ClaudeFormFields({
|
||||||
@@ -102,6 +110,8 @@ export function ClaudeFormFields({
|
|||||||
speedTestEndpoints,
|
speedTestEndpoints,
|
||||||
apiFormat,
|
apiFormat,
|
||||||
onApiFormatChange,
|
onApiFormatChange,
|
||||||
|
apiKeyField,
|
||||||
|
onApiKeyFieldChange,
|
||||||
}: ClaudeFormFieldsProps) {
|
}: ClaudeFormFieldsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -219,6 +229,41 @@ export function ClaudeFormFields({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 认证字段选择(仅非官方供应商显示) */}
|
||||||
|
{shouldShowModelSelector && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel htmlFor="apiKeyField">
|
||||||
|
{t("providerForm.authField", { defaultValue: "认证字段" })}
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={apiKeyField}
|
||||||
|
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="apiKeyField" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
|
||||||
|
{t("providerForm.authFieldAuthToken", {
|
||||||
|
defaultValue: "Auth Token (默认)",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ANTHROPIC_API_KEY">
|
||||||
|
{t("providerForm.authFieldApiKey", {
|
||||||
|
defaultValue: "API Key",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("providerForm.authFieldHint", {
|
||||||
|
defaultValue:
|
||||||
|
"大多数第三方供应商使用 Auth Token;少数供应商需要 API Key",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 模型选择器 */}
|
{/* 模型选择器 */}
|
||||||
{shouldShowModelSelector && (
|
{shouldShowModelSelector && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
type ToggleKey = "hideAttribution" | "alwaysThinking" | "enableTeammates";
|
||||||
|
|
||||||
|
interface ClaudeQuickTogglesProps {
|
||||||
|
/** Called after a patch is applied to the live file, so the caller can mirror it in the JSON editor. */
|
||||||
|
onPatchApplied?: (patch: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultStates: Record<ToggleKey, boolean> = {
|
||||||
|
hideAttribution: false,
|
||||||
|
alwaysThinking: false,
|
||||||
|
enableTeammates: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function deriveStates(
|
||||||
|
cfg: Record<string, unknown>,
|
||||||
|
): Record<ToggleKey, boolean> {
|
||||||
|
const env = cfg?.env as Record<string, unknown> | undefined;
|
||||||
|
const attr = cfg?.attribution as Record<string, unknown> | undefined;
|
||||||
|
return {
|
||||||
|
hideAttribution: attr?.commit === "" && attr?.pr === "",
|
||||||
|
alwaysThinking: cfg?.alwaysThinkingEnabled === true,
|
||||||
|
enableTeammates: env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply RFC 7396 JSON Merge Patch in-place: null = delete, object = recurse, else overwrite. */
|
||||||
|
function jsonMergePatch(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
delete target[key];
|
||||||
|
} else if (typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
if (
|
||||||
|
typeof target[key] !== "object" ||
|
||||||
|
target[key] === null ||
|
||||||
|
Array.isArray(target[key])
|
||||||
|
) {
|
||||||
|
target[key] = {};
|
||||||
|
}
|
||||||
|
jsonMergePatch(
|
||||||
|
target[key] as Record<string, unknown>,
|
||||||
|
value as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
if (Object.keys(target[key] as Record<string, unknown>).length === 0) {
|
||||||
|
delete target[key];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
target[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { jsonMergePatch };
|
||||||
|
|
||||||
|
export function ClaudeQuickToggles({
|
||||||
|
onPatchApplied,
|
||||||
|
}: ClaudeQuickTogglesProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [states, setStates] = useState(defaultStates);
|
||||||
|
|
||||||
|
const readLive = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await invoke<Record<string, unknown>>(
|
||||||
|
"read_live_provider_settings",
|
||||||
|
{ app: "claude" },
|
||||||
|
);
|
||||||
|
setStates(deriveStates(cfg));
|
||||||
|
} catch {
|
||||||
|
// Live file missing or unreadable — show all unchecked
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
readLive();
|
||||||
|
}, [readLive]);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
async (key: ToggleKey) => {
|
||||||
|
let patch: Record<string, unknown>;
|
||||||
|
if (key === "hideAttribution") {
|
||||||
|
patch = states.hideAttribution
|
||||||
|
? { attribution: null }
|
||||||
|
: { attribution: { commit: "", pr: "" } };
|
||||||
|
} else if (key === "alwaysThinking") {
|
||||||
|
patch = states.alwaysThinking
|
||||||
|
? { alwaysThinkingEnabled: null }
|
||||||
|
: { alwaysThinkingEnabled: true };
|
||||||
|
} else {
|
||||||
|
patch = states.enableTeammates
|
||||||
|
? { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: null } }
|
||||||
|
: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
setStates((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("patch_claude_live_settings", { patch });
|
||||||
|
onPatchApplied?.(patch);
|
||||||
|
} catch {
|
||||||
|
// Revert on failure
|
||||||
|
readLive();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[states, readLive, onPatchApplied],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["hideAttribution", "claudeConfig.hideAttribution"],
|
||||||
|
["alwaysThinking", "claudeConfig.alwaysThinking"],
|
||||||
|
["enableTeammates", "claudeConfig.enableTeammates"],
|
||||||
|
] as const
|
||||||
|
).map(([key, i18nKey]) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-1.5 text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<Checkbox checked={states[key]} onCheckedChange={() => toggle(key)} />
|
||||||
|
{t(i18nKey)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Save, Download, Loader2 } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
|
||||||
|
|
||||||
interface CodexCommonConfigModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
error?: string;
|
|
||||||
onExtract?: () => void;
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CodexCommonConfigModal - Common Codex configuration editor modal
|
|
||||||
* Allows editing of common TOML configuration shared across providers
|
|
||||||
*/
|
|
||||||
export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
error,
|
|
||||||
onExtract,
|
|
||||||
isExtracting,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FullScreenPanel
|
|
||||||
isOpen={isOpen}
|
|
||||||
title={t("codexConfig.editCommonConfigTitle")}
|
|
||||||
onClose={onClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
{onExtract && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onExtract}
|
|
||||||
disabled={isExtracting}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isExtracting ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{t("codexConfig.extractFromCurrent", {
|
|
||||||
defaultValue: "从编辑内容提取",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={onClose} className="gap-2">
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("codexConfig.commonConfigHint")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<JsonEditor
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={`# Common Codex config
|
|
||||||
|
|
||||||
# Add your common TOML configuration here`}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
rows={16}
|
|
||||||
showValidation={false}
|
|
||||||
language="javascript"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FullScreenPanel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
|
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
|
||||||
import { CodexCommonConfigModal } from "./CodexCommonConfigModal";
|
|
||||||
|
|
||||||
interface CodexConfigEditorProps {
|
interface CodexConfigEditorProps {
|
||||||
authValue: string;
|
authValue: string;
|
||||||
@@ -13,23 +12,9 @@ interface CodexConfigEditorProps {
|
|||||||
|
|
||||||
onAuthBlur?: () => void;
|
onAuthBlur?: () => void;
|
||||||
|
|
||||||
useCommonConfig: boolean;
|
|
||||||
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
|
|
||||||
commonConfigSnippet: string;
|
|
||||||
|
|
||||||
onCommonConfigSnippetChange: (value: string) => void;
|
|
||||||
|
|
||||||
commonConfigError: string;
|
|
||||||
|
|
||||||
authError: string;
|
authError: string;
|
||||||
|
|
||||||
configError: string; // config.toml 错误提示
|
configError: string;
|
||||||
|
|
||||||
onExtract?: () => void;
|
|
||||||
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||||
@@ -38,25 +23,9 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
onAuthChange,
|
onAuthChange,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
onAuthBlur,
|
onAuthBlur,
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
commonConfigSnippet,
|
|
||||||
onCommonConfigSnippetChange,
|
|
||||||
commonConfigError,
|
|
||||||
authError,
|
authError,
|
||||||
configError,
|
configError,
|
||||||
onExtract,
|
|
||||||
isExtracting,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
|
||||||
|
|
||||||
// Auto-open common config modal if there's an error
|
|
||||||
useEffect(() => {
|
|
||||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
|
||||||
setIsCommonConfigModalOpen(true);
|
|
||||||
}
|
|
||||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Auth JSON Section */}
|
{/* Auth JSON Section */}
|
||||||
@@ -71,23 +40,8 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
<CodexConfigSection
|
<CodexConfigSection
|
||||||
value={configValue}
|
value={configValue}
|
||||||
onChange={onConfigChange}
|
onChange={onConfigChange}
|
||||||
useCommonConfig={useCommonConfig}
|
|
||||||
onCommonConfigToggle={onCommonConfigToggle}
|
|
||||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
|
||||||
commonConfigError={commonConfigError}
|
|
||||||
configError={configError}
|
configError={configError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Common Config Modal */}
|
|
||||||
<CodexCommonConfigModal
|
|
||||||
isOpen={isCommonConfigModalOpen}
|
|
||||||
onClose={() => setIsCommonConfigModalOpen(false)}
|
|
||||||
value={commonConfigSnippet}
|
|
||||||
onChange={onCommonConfigSnippetChange}
|
|
||||||
error={commonConfigError}
|
|
||||||
onExtract={onExtract}
|
|
||||||
isExtracting={isExtracting}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,10 +78,6 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
|||||||
interface CodexConfigSectionProps {
|
interface CodexConfigSectionProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
useCommonConfig: boolean;
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
onEditCommonConfig: () => void;
|
|
||||||
commonConfigError?: string;
|
|
||||||
configError?: string;
|
configError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +87,6 @@ interface CodexConfigSectionProps {
|
|||||||
export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
onEditCommonConfig,
|
|
||||||
commonConfigError,
|
|
||||||
configError,
|
configError,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -117,40 +109,12 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<label
|
||||||
<label
|
htmlFor="codexConfig"
|
||||||
htmlFor="codexConfig"
|
className="block text-sm font-medium text-foreground"
|
||||||
className="block text-sm font-medium text-foreground"
|
>
|
||||||
>
|
{t("codexConfig.configToml")}
|
||||||
{t("codexConfig.configToml")}
|
</label>
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={useCommonConfig}
|
|
||||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
{t("codexConfig.writeCommonConfig")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEditCommonConfig}
|
|
||||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
{t("codexConfig.editCommonConfig")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{commonConfigError && (
|
|
||||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Save, Download, Loader2 } from "lucide-react";
|
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
|
||||||
|
|
||||||
interface CommonConfigEditorProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
useCommonConfig: boolean;
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
commonConfigSnippet: string;
|
|
||||||
onCommonConfigSnippetChange: (value: string) => void;
|
|
||||||
commonConfigError: string;
|
|
||||||
onEditClick: () => void;
|
|
||||||
isModalOpen: boolean;
|
|
||||||
onModalClose: () => void;
|
|
||||||
onExtract?: () => void;
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommonConfigEditor({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
commonConfigSnippet,
|
|
||||||
onCommonConfigSnippetChange,
|
|
||||||
commonConfigError,
|
|
||||||
onEditClick,
|
|
||||||
isModalOpen,
|
|
||||||
onModalClose,
|
|
||||||
onExtract,
|
|
||||||
isExtracting,
|
|
||||||
}: CommonConfigEditorProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="settingsConfig">{t("provider.configJson")}</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="useCommonConfig"
|
|
||||||
checked={useCommonConfig}
|
|
||||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{t("claudeConfig.writeCommonConfig", {
|
|
||||||
defaultValue: "写入通用配置",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEditClick}
|
|
||||||
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
|
||||||
>
|
|
||||||
{t("claudeConfig.editCommonConfig", {
|
|
||||||
defaultValue: "编辑通用配置",
|
|
||||||
})}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{commonConfigError && !isModalOpen && (
|
|
||||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<JsonEditor
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={`{
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
|
||||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
rows={14}
|
|
||||||
showValidation={true}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FullScreenPanel
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
title={t("claudeConfig.editCommonConfigTitle", {
|
|
||||||
defaultValue: "编辑通用配置片段",
|
|
||||||
})}
|
|
||||||
onClose={onModalClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
{onExtract && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onExtract}
|
|
||||||
disabled={isExtracting}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isExtracting ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{t("claudeConfig.extractFromCurrent", {
|
|
||||||
defaultValue: "从编辑内容提取",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="outline" onClick={onModalClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={onModalClose} className="gap-2">
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("claudeConfig.commonConfigHint", {
|
|
||||||
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<JsonEditor
|
|
||||||
value={commonConfigSnippet}
|
|
||||||
onChange={onCommonConfigSnippetChange}
|
|
||||||
placeholder={`{
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
rows={16}
|
|
||||||
showValidation={true}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
{commonConfigError && (
|
|
||||||
<p className="text-sm text-red-500 dark:text-red-400">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FullScreenPanel>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Save, Download, Loader2 } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
|
||||||
|
|
||||||
interface GeminiCommonConfigModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
error?: string;
|
|
||||||
onExtract?: () => void;
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GeminiCommonConfigModal - Common Gemini configuration editor modal
|
|
||||||
* Allows editing of common env snippet shared across Gemini providers
|
|
||||||
*/
|
|
||||||
export const GeminiCommonConfigModal: React.FC<
|
|
||||||
GeminiCommonConfigModalProps
|
|
||||||
> = ({ isOpen, onClose, value, onChange, error, onExtract, isExtracting }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FullScreenPanel
|
|
||||||
isOpen={isOpen}
|
|
||||||
title={t("geminiConfig.editCommonConfigTitle", {
|
|
||||||
defaultValue: "编辑 Gemini 通用配置片段",
|
|
||||||
})}
|
|
||||||
onClose={onClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
{onExtract && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onExtract}
|
|
||||||
disabled={isExtracting}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isExtracting ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{t("geminiConfig.extractFromCurrent", {
|
|
||||||
defaultValue: "从编辑内容提取",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={onClose} className="gap-2">
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("geminiConfig.commonConfigHint", {
|
|
||||||
defaultValue:
|
|
||||||
"该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<JsonEditor
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={`{
|
|
||||||
"GEMINI_MODEL": "gemini-3-pro-preview"
|
|
||||||
}`}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
rows={16}
|
|
||||||
showValidation={true}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FullScreenPanel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections";
|
import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections";
|
||||||
import { GeminiCommonConfigModal } from "./GeminiCommonConfigModal";
|
|
||||||
|
|
||||||
interface GeminiConfigEditorProps {
|
interface GeminiConfigEditorProps {
|
||||||
envValue: string;
|
envValue: string;
|
||||||
@@ -8,15 +7,8 @@ interface GeminiConfigEditorProps {
|
|||||||
onEnvChange: (value: string) => void;
|
onEnvChange: (value: string) => void;
|
||||||
onConfigChange: (value: string) => void;
|
onConfigChange: (value: string) => void;
|
||||||
onEnvBlur?: () => void;
|
onEnvBlur?: () => void;
|
||||||
useCommonConfig: boolean;
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
commonConfigSnippet: string;
|
|
||||||
onCommonConfigSnippetChange: (value: string) => void;
|
|
||||||
commonConfigError: string;
|
|
||||||
envError: string;
|
envError: string;
|
||||||
configError: string;
|
configError: string;
|
||||||
onExtract?: () => void;
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||||
@@ -25,25 +17,9 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
|||||||
onEnvChange,
|
onEnvChange,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
onEnvBlur,
|
onEnvBlur,
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
commonConfigSnippet,
|
|
||||||
onCommonConfigSnippetChange,
|
|
||||||
commonConfigError,
|
|
||||||
envError,
|
envError,
|
||||||
configError,
|
configError,
|
||||||
onExtract,
|
|
||||||
isExtracting,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
|
||||||
|
|
||||||
// Auto-open common config modal if there's an error
|
|
||||||
useEffect(() => {
|
|
||||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
|
||||||
setIsCommonConfigModalOpen(true);
|
|
||||||
}
|
|
||||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Env Section */}
|
{/* Env Section */}
|
||||||
@@ -52,10 +28,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
|||||||
onChange={onEnvChange}
|
onChange={onEnvChange}
|
||||||
onBlur={onEnvBlur}
|
onBlur={onEnvBlur}
|
||||||
error={envError}
|
error={envError}
|
||||||
useCommonConfig={useCommonConfig}
|
|
||||||
onCommonConfigToggle={onCommonConfigToggle}
|
|
||||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
|
||||||
commonConfigError={commonConfigError}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Config JSON Section */}
|
{/* Config JSON Section */}
|
||||||
@@ -64,17 +36,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
|||||||
onChange={onConfigChange}
|
onChange={onConfigChange}
|
||||||
configError={configError}
|
configError={configError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Common Config Modal */}
|
|
||||||
<GeminiCommonConfigModal
|
|
||||||
isOpen={isCommonConfigModalOpen}
|
|
||||||
onClose={() => setIsCommonConfigModalOpen(false)}
|
|
||||||
value={commonConfigSnippet}
|
|
||||||
onChange={onCommonConfigSnippetChange}
|
|
||||||
error={commonConfigError}
|
|
||||||
onExtract={onExtract}
|
|
||||||
isExtracting={isExtracting}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ interface GeminiEnvSectionProps {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
useCommonConfig: boolean;
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
onEditCommonConfig: () => void;
|
|
||||||
commonConfigError?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,10 +17,6 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
error,
|
error,
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
onEditCommonConfig,
|
|
||||||
commonConfigError,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
@@ -53,44 +45,12 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<label
|
||||||
<label
|
htmlFor="geminiEnv"
|
||||||
htmlFor="geminiEnv"
|
className="block text-sm font-medium text-foreground"
|
||||||
className="block text-sm font-medium text-foreground"
|
>
|
||||||
>
|
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
||||||
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
</label>
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={useCommonConfig}
|
|
||||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
{t("geminiConfig.writeCommonConfig", {
|
|
||||||
defaultValue: "写入通用配置",
|
|
||||||
})}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEditCommonConfig}
|
|
||||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
{t("geminiConfig.editCommonConfig", {
|
|
||||||
defaultValue: "编辑通用配置",
|
|
||||||
})}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{commonConfigError && (
|
|
||||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface OmoCommonConfigEditorProps {
|
|||||||
onGlobalConfigStateChange: (config: OmoGlobalConfig) => void;
|
onGlobalConfigStateChange: (config: OmoGlobalConfig) => void;
|
||||||
globalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
|
globalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
|
||||||
fieldsKey: number;
|
fieldsKey: number;
|
||||||
|
isSlim?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OmoCommonConfigEditor({
|
export function OmoCommonConfigEditor({
|
||||||
@@ -37,6 +38,7 @@ export function OmoCommonConfigEditor({
|
|||||||
onGlobalConfigStateChange,
|
onGlobalConfigStateChange,
|
||||||
globalConfigRef,
|
globalConfigRef,
|
||||||
fieldsKey,
|
fieldsKey,
|
||||||
|
isSlim = false,
|
||||||
}: OmoCommonConfigEditorProps) {
|
}: OmoCommonConfigEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
@@ -153,6 +155,7 @@ export function OmoCommonConfigEditor({
|
|||||||
ref={globalConfigRef as React.Ref<OmoGlobalConfigFieldsRef>}
|
ref={globalConfigRef as React.Ref<OmoGlobalConfigFieldsRef>}
|
||||||
onStateChange={onGlobalConfigStateChange}
|
onStateChange={onGlobalConfigStateChange}
|
||||||
hideSaveButtons
|
hideSaveButtons
|
||||||
|
isSlim={isSlim}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FullScreenPanel>
|
</FullScreenPanel>
|
||||||
|
|||||||
@@ -41,10 +41,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useReadOmoLocalFile } from "@/lib/query/omo";
|
import { useReadOmoLocalFile, useReadOmoSlimLocalFile } from "@/lib/query/omo";
|
||||||
import {
|
import {
|
||||||
OMO_BUILTIN_AGENTS,
|
OMO_BUILTIN_AGENTS,
|
||||||
OMO_BUILTIN_CATEGORIES,
|
OMO_BUILTIN_CATEGORIES,
|
||||||
|
OMO_SLIM_BUILTIN_AGENTS,
|
||||||
type OmoAgentDef,
|
type OmoAgentDef,
|
||||||
type OmoCategoryDef,
|
type OmoCategoryDef,
|
||||||
} from "@/types/omo";
|
} from "@/types/omo";
|
||||||
@@ -69,12 +70,13 @@ interface OmoFormFieldsProps {
|
|||||||
>;
|
>;
|
||||||
agents: Record<string, Record<string, unknown>>;
|
agents: Record<string, Record<string, unknown>>;
|
||||||
onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;
|
onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;
|
||||||
categories: Record<string, Record<string, unknown>>;
|
categories?: Record<string, Record<string, unknown>>;
|
||||||
onCategoriesChange: (
|
onCategoriesChange?: (
|
||||||
categories: Record<string, Record<string, unknown>>,
|
categories: Record<string, Record<string, unknown>>,
|
||||||
) => void;
|
) => void;
|
||||||
otherFieldsStr: string;
|
otherFieldsStr: string;
|
||||||
onOtherFieldsStrChange: (value: string) => void;
|
onOtherFieldsStrChange: (value: string) => void;
|
||||||
|
isSlim?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CustomModelItem = {
|
export type CustomModelItem = {
|
||||||
@@ -121,6 +123,9 @@ function DeferredKeyInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key));
|
const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key));
|
||||||
|
const BUILTIN_AGENT_KEYS_SLIM = new Set(
|
||||||
|
OMO_SLIM_BUILTIN_AGENTS.map((a) => a.key),
|
||||||
|
);
|
||||||
const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key));
|
const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key));
|
||||||
const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__";
|
const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__";
|
||||||
|
|
||||||
@@ -303,13 +308,21 @@ export function OmoFormFields({
|
|||||||
presetMetaMap: _presetMetaMap = {},
|
presetMetaMap: _presetMetaMap = {},
|
||||||
agents,
|
agents,
|
||||||
onAgentsChange,
|
onAgentsChange,
|
||||||
categories,
|
categories = {},
|
||||||
onCategoriesChange,
|
onCategoriesChange,
|
||||||
otherFieldsStr,
|
otherFieldsStr,
|
||||||
onOtherFieldsStrChange,
|
onOtherFieldsStrChange,
|
||||||
|
isSlim = false,
|
||||||
}: OmoFormFieldsProps) {
|
}: OmoFormFieldsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const builtinAgentDefs = isSlim
|
||||||
|
? OMO_SLIM_BUILTIN_AGENTS
|
||||||
|
: OMO_BUILTIN_AGENTS;
|
||||||
|
const builtinAgentKeys = isSlim
|
||||||
|
? BUILTIN_AGENT_KEYS_SLIM
|
||||||
|
: BUILTIN_AGENT_KEYS;
|
||||||
|
|
||||||
const [mainAgentsOpen, setMainAgentsOpen] = useState(true);
|
const [mainAgentsOpen, setMainAgentsOpen] = useState(true);
|
||||||
const [subAgentsOpen, setSubAgentsOpen] = useState(true);
|
const [subAgentsOpen, setSubAgentsOpen] = useState(true);
|
||||||
const [categoriesOpen, setCategoriesOpen] = useState(true);
|
const [categoriesOpen, setCategoriesOpen] = useState(true);
|
||||||
@@ -329,7 +342,7 @@ export function OmoFormFields({
|
|||||||
>({});
|
>({});
|
||||||
|
|
||||||
const [customAgents, setCustomAgents] = useState<CustomModelItem[]>(() =>
|
const [customAgents, setCustomAgents] = useState<CustomModelItem[]>(() =>
|
||||||
collectCustomModels(agents, BUILTIN_AGENT_KEYS),
|
collectCustomModels(agents, builtinAgentKeys),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [customCategories, setCustomCategories] = useState<CustomModelItem[]>(
|
const [customCategories, setCustomCategories] = useState<CustomModelItem[]>(
|
||||||
@@ -337,7 +350,7 @@ export function OmoFormFields({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCustomAgents(collectCustomModels(agents, BUILTIN_AGENT_KEYS));
|
setCustomAgents(collectCustomModels(agents, builtinAgentKeys));
|
||||||
}, [agents]);
|
}, [agents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -349,17 +362,18 @@ export function OmoFormFields({
|
|||||||
onAgentsChange(
|
onAgentsChange(
|
||||||
mergeCustomModelsIntoStore(
|
mergeCustomModelsIntoStore(
|
||||||
agents,
|
agents,
|
||||||
BUILTIN_AGENT_KEYS,
|
builtinAgentKeys,
|
||||||
customs,
|
customs,
|
||||||
modelVariantsMap,
|
modelVariantsMap,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[agents, onAgentsChange, modelVariantsMap],
|
[agents, onAgentsChange, modelVariantsMap, builtinAgentKeys],
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncCustomCategories = useCallback(
|
const syncCustomCategories = useCallback(
|
||||||
(customs: CustomModelItem[]) => {
|
(customs: CustomModelItem[]) => {
|
||||||
|
if (!onCategoriesChange) return;
|
||||||
onCategoriesChange(
|
onCategoriesChange(
|
||||||
mergeCustomModelsIntoStore(
|
mergeCustomModelsIntoStore(
|
||||||
categories,
|
categories,
|
||||||
@@ -709,7 +723,7 @@ export function OmoFormFields({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedAgents = { ...agents };
|
const updatedAgents = { ...agents };
|
||||||
for (const agentDef of OMO_BUILTIN_AGENTS) {
|
for (const agentDef of builtinAgentDefs) {
|
||||||
const recommendedValue = resolveRecommendedModel(agentDef.recommended);
|
const recommendedValue = resolveRecommendedModel(agentDef.recommended);
|
||||||
if (recommendedValue && !updatedAgents[agentDef.key]?.model) {
|
if (recommendedValue && !updatedAgents[agentDef.key]?.model) {
|
||||||
updatedAgents[agentDef.key] = {
|
updatedAgents[agentDef.key] = {
|
||||||
@@ -720,30 +734,35 @@ export function OmoFormFields({
|
|||||||
}
|
}
|
||||||
onAgentsChange(updatedAgents);
|
onAgentsChange(updatedAgents);
|
||||||
|
|
||||||
const updatedCategories = { ...categories };
|
if (!isSlim && onCategoriesChange) {
|
||||||
for (const catDef of OMO_BUILTIN_CATEGORIES) {
|
const updatedCategories = { ...categories };
|
||||||
const recommendedValue = resolveRecommendedModel(catDef.recommended);
|
for (const catDef of OMO_BUILTIN_CATEGORIES) {
|
||||||
if (recommendedValue && !updatedCategories[catDef.key]?.model) {
|
const recommendedValue = resolveRecommendedModel(catDef.recommended);
|
||||||
updatedCategories[catDef.key] = {
|
if (recommendedValue && !updatedCategories[catDef.key]?.model) {
|
||||||
...updatedCategories[catDef.key],
|
updatedCategories[catDef.key] = {
|
||||||
model: recommendedValue,
|
...updatedCategories[catDef.key],
|
||||||
};
|
model: recommendedValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
onCategoriesChange(updatedCategories);
|
||||||
}
|
}
|
||||||
onCategoriesChange(updatedCategories);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const configuredAgentCount = Object.keys(agents).length;
|
const configuredAgentCount = Object.keys(agents).length;
|
||||||
const configuredCategoryCount = Object.keys(categories).length;
|
const configuredCategoryCount = isSlim ? 0 : Object.keys(categories).length;
|
||||||
const mainAgents = OMO_BUILTIN_AGENTS.filter((a) => a.group === "main");
|
const mainAgents = builtinAgentDefs.filter((a) => a.group === "main");
|
||||||
const subAgents = OMO_BUILTIN_AGENTS.filter((a) => a.group === "sub");
|
const subAgents = builtinAgentDefs.filter((a) => a.group === "sub");
|
||||||
|
|
||||||
const readLocalFile = useReadOmoLocalFile();
|
const readLocalFile = useReadOmoLocalFile();
|
||||||
|
const readSlimLocalFile = useReadOmoSlimLocalFile();
|
||||||
const [localFilePath, setLocalFilePath] = useState<string | null>(null);
|
const [localFilePath, setLocalFilePath] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleImportFromLocal = useCallback(async () => {
|
const handleImportFromLocal = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await readLocalFile.mutateAsync();
|
const data = isSlim
|
||||||
|
? await readSlimLocalFile.mutateAsync()
|
||||||
|
: await readLocalFile.mutateAsync();
|
||||||
const importedAgents =
|
const importedAgents =
|
||||||
(data.agents as Record<string, Record<string, unknown>> | undefined) ||
|
(data.agents as Record<string, Record<string, unknown>> | undefined) ||
|
||||||
{};
|
{};
|
||||||
@@ -753,16 +772,20 @@ export function OmoFormFields({
|
|||||||
| undefined) || {};
|
| undefined) || {};
|
||||||
|
|
||||||
onAgentsChange(importedAgents);
|
onAgentsChange(importedAgents);
|
||||||
onCategoriesChange(importedCategories);
|
if (!isSlim && onCategoriesChange) {
|
||||||
|
onCategoriesChange(importedCategories);
|
||||||
|
}
|
||||||
onOtherFieldsStrChange(
|
onOtherFieldsStrChange(
|
||||||
data.otherFields ? JSON.stringify(data.otherFields, null, 2) : "",
|
data.otherFields ? JSON.stringify(data.otherFields, null, 2) : "",
|
||||||
);
|
);
|
||||||
setAgentAdvancedDrafts({});
|
setAgentAdvancedDrafts({});
|
||||||
setCategoryAdvancedDrafts({});
|
setCategoryAdvancedDrafts({});
|
||||||
setCustomAgents(collectCustomModels(importedAgents, BUILTIN_AGENT_KEYS));
|
setCustomAgents(collectCustomModels(importedAgents, builtinAgentKeys));
|
||||||
setCustomCategories(
|
if (!isSlim) {
|
||||||
collectCustomModels(importedCategories, BUILTIN_CATEGORY_KEYS),
|
setCustomCategories(
|
||||||
);
|
collectCustomModels(importedCategories, BUILTIN_CATEGORY_KEYS),
|
||||||
|
);
|
||||||
|
}
|
||||||
setLocalFilePath(data.filePath);
|
setLocalFilePath(data.filePath);
|
||||||
toast.success(
|
toast.success(
|
||||||
t("omo.importLocalReplaceSuccess", {
|
t("omo.importLocalReplaceSuccess", {
|
||||||
@@ -792,7 +815,7 @@ export function OmoFormFields({
|
|||||||
) => {
|
) => {
|
||||||
const isAgent = scope === "agent";
|
const isAgent = scope === "agent";
|
||||||
const store = isAgent ? agents : categories;
|
const store = isAgent ? agents : categories;
|
||||||
const setter = isAgent ? onAgentsChange : onCategoriesChange;
|
const setter = isAgent ? onAgentsChange : onCategoriesChange!;
|
||||||
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
|
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
|
||||||
const expanded = isAgent ? expandedAgents : expandedCategories;
|
const expanded = isAgent ? expandedAgents : expandedCategories;
|
||||||
|
|
||||||
@@ -866,7 +889,7 @@ export function OmoFormFields({
|
|||||||
) => {
|
) => {
|
||||||
const isAgent = scope === "agent";
|
const isAgent = scope === "agent";
|
||||||
const store = isAgent ? agents : categories;
|
const store = isAgent ? agents : categories;
|
||||||
const setter = isAgent ? onAgentsChange : onCategoriesChange;
|
const setter = isAgent ? onAgentsChange : onCategoriesChange!;
|
||||||
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
|
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
|
||||||
const expanded = isAgent ? expandedAgents : expandedCategories;
|
const expanded = isAgent ? expandedAgents : expandedCategories;
|
||||||
const customs = isAgent ? customAgents : customCategories;
|
const customs = isAgent ? customAgents : customCategories;
|
||||||
@@ -1153,30 +1176,31 @@ export function OmoFormFields({
|
|||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{renderModelSection({
|
{!isSlim &&
|
||||||
title: t("omo.categories", { defaultValue: "Categories" }),
|
renderModelSection({
|
||||||
isOpen: categoriesOpen,
|
title: t("omo.categories", { defaultValue: "Categories" }),
|
||||||
onToggle: () => setCategoriesOpen(!categoriesOpen),
|
isOpen: categoriesOpen,
|
||||||
badge: `${OMO_BUILTIN_CATEGORIES.length + customCategories.length}`,
|
onToggle: () => setCategoriesOpen(!categoriesOpen),
|
||||||
action: renderCustomAddButton(() => addCustomModel("category")),
|
badge: `${OMO_BUILTIN_CATEGORIES.length + customCategories.length}`,
|
||||||
children: (
|
action: renderCustomAddButton(() => addCustomModel("category")),
|
||||||
<>
|
children: (
|
||||||
{OMO_BUILTIN_CATEGORIES.map(renderCategoryRow)}
|
<>
|
||||||
{customCategories.length > 0 && (
|
{OMO_BUILTIN_CATEGORIES.map(renderCategoryRow)}
|
||||||
<>
|
{customCategories.length > 0 && (
|
||||||
{renderCustomDivider(
|
<>
|
||||||
t("omo.customCategories", {
|
{renderCustomDivider(
|
||||||
defaultValue: "Custom Categories",
|
t("omo.customCategories", {
|
||||||
}),
|
defaultValue: "Custom Categories",
|
||||||
)}
|
}),
|
||||||
{customCategories.map((c, i) =>
|
)}
|
||||||
renderCustomModelRow("category", c, i),
|
{customCategories.map((c, i) =>
|
||||||
)}
|
renderCustomModelRow("category", c, i),
|
||||||
</>
|
)}
|
||||||
)}
|
</>
|
||||||
</>
|
)}
|
||||||
),
|
</>
|
||||||
})}
|
),
|
||||||
|
})}
|
||||||
|
|
||||||
{renderModelSection({
|
{renderModelSection({
|
||||||
title: t("omo.otherFieldsJson", {
|
title: t("omo.otherFieldsJson", {
|
||||||
|
|||||||
@@ -40,11 +40,18 @@ import {
|
|||||||
OMO_BACKGROUND_TASK_PLACEHOLDER,
|
OMO_BACKGROUND_TASK_PLACEHOLDER,
|
||||||
OMO_BROWSER_AUTOMATION_PLACEHOLDER,
|
OMO_BROWSER_AUTOMATION_PLACEHOLDER,
|
||||||
OMO_CLAUDE_CODE_PLACEHOLDER,
|
OMO_CLAUDE_CODE_PLACEHOLDER,
|
||||||
|
OMO_SLIM_DISABLEABLE_AGENTS,
|
||||||
|
OMO_SLIM_DISABLEABLE_MCPS,
|
||||||
|
OMO_SLIM_DISABLEABLE_HOOKS,
|
||||||
|
OMO_SLIM_DEFAULT_SCHEMA_URL,
|
||||||
} from "@/types/omo";
|
} from "@/types/omo";
|
||||||
import {
|
import {
|
||||||
useOmoGlobalConfig,
|
useOmoGlobalConfig,
|
||||||
useSaveOmoGlobalConfig,
|
useSaveOmoGlobalConfig,
|
||||||
useReadOmoLocalFile,
|
useReadOmoLocalFile,
|
||||||
|
useOmoSlimGlobalConfig,
|
||||||
|
useSaveOmoSlimGlobalConfig,
|
||||||
|
useReadOmoSlimLocalFile,
|
||||||
} from "@/lib/query/omo";
|
} from "@/lib/query/omo";
|
||||||
|
|
||||||
interface PresetOption {
|
interface PresetOption {
|
||||||
@@ -61,6 +68,7 @@ export interface OmoGlobalConfigFieldsRef {
|
|||||||
interface OmoGlobalConfigFieldsProps {
|
interface OmoGlobalConfigFieldsProps {
|
||||||
onStateChange?: (config: OmoGlobalConfig) => void;
|
onStateChange?: (config: OmoGlobalConfig) => void;
|
||||||
hideSaveButtons?: boolean;
|
hideSaveButtons?: boolean;
|
||||||
|
isSlim?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type OmoAdvancedFieldKey =
|
type OmoAdvancedFieldKey =
|
||||||
@@ -114,6 +122,11 @@ const OMO_ADVANCED_JSON_FIELDS: ReadonlyArray<{
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const OMO_SLIM_ADVANCED_KEYS: ReadonlySet<OmoAdvancedFieldKey> = new Set([
|
||||||
|
"lspStr",
|
||||||
|
"experimentalStr",
|
||||||
|
]);
|
||||||
|
|
||||||
function TagListEditor({
|
function TagListEditor({
|
||||||
label,
|
label,
|
||||||
values,
|
values,
|
||||||
@@ -310,12 +323,25 @@ function JsonTextareaField({
|
|||||||
export const OmoGlobalConfigFields = forwardRef<
|
export const OmoGlobalConfigFields = forwardRef<
|
||||||
OmoGlobalConfigFieldsRef,
|
OmoGlobalConfigFieldsRef,
|
||||||
OmoGlobalConfigFieldsProps
|
OmoGlobalConfigFieldsProps
|
||||||
>(function OmoGlobalConfigFields({ onStateChange, hideSaveButtons }, ref) {
|
>(function OmoGlobalConfigFields(
|
||||||
|
{ onStateChange, hideSaveButtons, isSlim = false },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: config } = useOmoGlobalConfig();
|
const { data: standardConfig } = useOmoGlobalConfig(!isSlim);
|
||||||
const saveMutation = useSaveOmoGlobalConfig();
|
const { data: slimConfig } = useOmoSlimGlobalConfig(isSlim);
|
||||||
|
const config = isSlim ? slimConfig : standardConfig;
|
||||||
|
const standardSaveMutation = useSaveOmoGlobalConfig();
|
||||||
|
const slimSaveMutation = useSaveOmoSlimGlobalConfig();
|
||||||
|
const saveMutation = isSlim ? slimSaveMutation : standardSaveMutation;
|
||||||
|
const standardReadLocal = useReadOmoLocalFile();
|
||||||
|
const slimReadLocal = useReadOmoSlimLocalFile();
|
||||||
|
|
||||||
const [schemaUrl, setSchemaUrl] = useState(OMO_DEFAULT_SCHEMA_URL);
|
const defaultSchemaUrl = isSlim
|
||||||
|
? OMO_SLIM_DEFAULT_SCHEMA_URL
|
||||||
|
: OMO_DEFAULT_SCHEMA_URL;
|
||||||
|
|
||||||
|
const [schemaUrl, setSchemaUrl] = useState(defaultSchemaUrl);
|
||||||
const [sisyphusAgentStr, setSisyphusAgentStr] = useState("");
|
const [sisyphusAgentStr, setSisyphusAgentStr] = useState("");
|
||||||
const [disabledAgents, setDisabledAgents] = useState<string[]>([]);
|
const [disabledAgents, setDisabledAgents] = useState<string[]>([]);
|
||||||
const [disabledMcps, setDisabledMcps] = useState<string[]>([]);
|
const [disabledMcps, setDisabledMcps] = useState<string[]>([]);
|
||||||
@@ -330,7 +356,7 @@ export const OmoGlobalConfigFields = forwardRef<
|
|||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
const applyGlobalState = useCallback((global: OmoGlobalConfig) => {
|
const applyGlobalState = useCallback((global: OmoGlobalConfig) => {
|
||||||
setSchemaUrl(global.schemaUrl || OMO_DEFAULT_SCHEMA_URL);
|
setSchemaUrl(global.schemaUrl || defaultSchemaUrl);
|
||||||
setSisyphusAgentStr(
|
setSisyphusAgentStr(
|
||||||
global.sisyphusAgent ? JSON.stringify(global.sisyphusAgent, null, 2) : "",
|
global.sisyphusAgent ? JSON.stringify(global.sisyphusAgent, null, 2) : "",
|
||||||
);
|
);
|
||||||
@@ -545,7 +571,7 @@ export const OmoGlobalConfigFields = forwardRef<
|
|||||||
placeholder: t("omo.disabledAgentsPlaceholder", {
|
placeholder: t("omo.disabledAgentsPlaceholder", {
|
||||||
defaultValue: "Disabled Agents",
|
defaultValue: "Disabled Agents",
|
||||||
}),
|
}),
|
||||||
presets: OMO_DISABLEABLE_AGENTS,
|
presets: isSlim ? OMO_SLIM_DISABLEABLE_AGENTS : OMO_DISABLEABLE_AGENTS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "mcps",
|
key: "mcps",
|
||||||
@@ -555,7 +581,7 @@ export const OmoGlobalConfigFields = forwardRef<
|
|||||||
placeholder: t("omo.disabledMcpsPlaceholder", {
|
placeholder: t("omo.disabledMcpsPlaceholder", {
|
||||||
defaultValue: "Disabled MCPs",
|
defaultValue: "Disabled MCPs",
|
||||||
}),
|
}),
|
||||||
presets: OMO_DISABLEABLE_MCPS,
|
presets: isSlim ? OMO_SLIM_DISABLEABLE_MCPS : OMO_DISABLEABLE_MCPS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "hooks",
|
key: "hooks",
|
||||||
@@ -565,21 +591,25 @@ export const OmoGlobalConfigFields = forwardRef<
|
|||||||
placeholder: t("omo.disabledHooksPlaceholder", {
|
placeholder: t("omo.disabledHooksPlaceholder", {
|
||||||
defaultValue: "Disabled Hooks",
|
defaultValue: "Disabled Hooks",
|
||||||
}),
|
}),
|
||||||
presets: OMO_DISABLEABLE_HOOKS,
|
presets: isSlim ? OMO_SLIM_DISABLEABLE_HOOKS : OMO_DISABLEABLE_HOOKS,
|
||||||
},
|
},
|
||||||
{
|
...(!isSlim
|
||||||
key: "skills",
|
? [
|
||||||
label: t("omo.disabledSkills", { defaultValue: "Skills" }),
|
{
|
||||||
values: disabledSkills,
|
key: "skills" as const,
|
||||||
onChange: setDisabledSkills,
|
label: t("omo.disabledSkills", { defaultValue: "Skills" }),
|
||||||
placeholder: t("omo.disabledSkillsPlaceholder", {
|
values: disabledSkills,
|
||||||
defaultValue: "Disabled Skills",
|
onChange: setDisabledSkills,
|
||||||
}),
|
placeholder: t("omo.disabledSkillsPlaceholder", {
|
||||||
presets: OMO_DISABLEABLE_SKILLS,
|
defaultValue: "Disabled Skills",
|
||||||
},
|
}),
|
||||||
] as const;
|
presets: OMO_DISABLEABLE_SKILLS,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
const readLocalFile = useReadOmoLocalFile();
|
const readLocalFile = isSlim ? slimReadLocal : standardReadLocal;
|
||||||
|
|
||||||
const handleImportGlobalFromLocal = useCallback(async () => {
|
const handleImportGlobalFromLocal = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -668,25 +698,27 @@ export const OmoGlobalConfigFields = forwardRef<
|
|||||||
<Input
|
<Input
|
||||||
value={schemaUrl}
|
value={schemaUrl}
|
||||||
onChange={(e) => setSchemaUrl(e.target.value)}
|
onChange={(e) => setSchemaUrl(e.target.value)}
|
||||||
placeholder={OMO_DEFAULT_SCHEMA_URL}
|
placeholder={defaultSchemaUrl}
|
||||||
className="text-sm h-8"
|
className="text-sm h-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
|
{!isSlim && (
|
||||||
<Label className="text-sm font-semibold">
|
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
|
||||||
{t("omo.sisyphusAgentConfig", {
|
<Label className="text-sm font-semibold">
|
||||||
defaultValue: "Sisyphus Agent",
|
{t("omo.sisyphusAgentConfig", {
|
||||||
})}
|
defaultValue: "Sisyphus Agent",
|
||||||
</Label>
|
})}
|
||||||
<Textarea
|
</Label>
|
||||||
value={sisyphusAgentStr}
|
<Textarea
|
||||||
onChange={(e) => setSisyphusAgentStr(e.target.value)}
|
value={sisyphusAgentStr}
|
||||||
placeholder={OMO_SISYPHUS_AGENT_PLACEHOLDER}
|
onChange={(e) => setSisyphusAgentStr(e.target.value)}
|
||||||
className="font-mono text-sm"
|
placeholder={OMO_SISYPHUS_AGENT_PLACEHOLDER}
|
||||||
style={{ minHeight: "140px" }}
|
className="font-mono text-sm"
|
||||||
/>
|
style={{ minHeight: "140px" }}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-3">
|
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -715,7 +747,9 @@ export const OmoGlobalConfigFields = forwardRef<
|
|||||||
<Label className="text-sm font-semibold">
|
<Label className="text-sm font-semibold">
|
||||||
{t("omo.advanced", { defaultValue: "Advanced Settings" })}
|
{t("omo.advanced", { defaultValue: "Advanced Settings" })}
|
||||||
</Label>
|
</Label>
|
||||||
{OMO_ADVANCED_JSON_FIELDS.map((field) => (
|
{OMO_ADVANCED_JSON_FIELDS.filter(
|
||||||
|
(field) => !isSlim || OMO_SLIM_ADVANCED_KEYS.has(field.key),
|
||||||
|
).map((field) => (
|
||||||
<JsonTextareaField
|
<JsonTextareaField
|
||||||
key={field.key}
|
key={field.key}
|
||||||
label={t(field.labelKey, { defaultValue: field.defaultLabel })}
|
label={t(field.labelKey, { defaultValue: field.defaultLabel })}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useState, useEffect } from "react";
|
|||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FlaskConical,
|
// FlaskConical, // Hidden: stream check feature disabled
|
||||||
Globe,
|
Globe,
|
||||||
Coins,
|
Coins,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -87,15 +87,16 @@ function parseProxyUrl(url: string): Partial<ProviderProxyConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderAdvancedConfig({
|
export function ProviderAdvancedConfig({
|
||||||
testConfig,
|
testConfig: _testConfig, // Hidden: stream check feature disabled
|
||||||
proxyConfig,
|
proxyConfig,
|
||||||
pricingConfig,
|
pricingConfig,
|
||||||
onTestConfigChange,
|
onTestConfigChange: _onTestConfigChange, // Hidden: stream check feature disabled
|
||||||
onProxyConfigChange,
|
onProxyConfigChange,
|
||||||
onPricingConfigChange,
|
onPricingConfigChange,
|
||||||
}: ProviderAdvancedConfigProps) {
|
}: ProviderAdvancedConfigProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
// Hidden: stream check feature disabled
|
||||||
|
// const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
||||||
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
|
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
|
||||||
proxyConfig.enabled,
|
proxyConfig.enabled,
|
||||||
);
|
);
|
||||||
@@ -110,10 +111,10 @@ export function ProviderAdvancedConfig({
|
|||||||
// 标记是否为用户主动输入(用于区分外部更新和用户输入)
|
// 标记是否为用户主动输入(用于区分外部更新和用户输入)
|
||||||
const [isUserTyping, setIsUserTyping] = useState(false);
|
const [isUserTyping, setIsUserTyping] = useState(false);
|
||||||
|
|
||||||
// 同步外部 testConfig.enabled 变化到展开状态
|
// Hidden: stream check feature disabled
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
setIsTestConfigOpen(testConfig.enabled);
|
// setIsTestConfigOpen(testConfig.enabled);
|
||||||
}, [testConfig.enabled]);
|
// }, [testConfig.enabled]);
|
||||||
|
|
||||||
// 同步外部 proxyConfig.enabled 变化到展开状态
|
// 同步外部 proxyConfig.enabled 变化到展开状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -167,7 +168,7 @@ export function ProviderAdvancedConfig({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 模型测试配置 */}
|
{/* Hidden: stream check feature disabled — model test config panel
|
||||||
<div className="rounded-lg border border-border/50 bg-muted/20">
|
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -343,6 +344,7 @@ export function ProviderAdvancedConfig({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
*/}
|
||||||
|
|
||||||
{/* 代理配置 */}
|
{/* 代理配置 */}
|
||||||
<div className="rounded-lg border border-border/50 bg-muted/20">
|
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
|||||||
|
import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types";
|
||||||
|
import type { OmoGlobalConfig } from "@/types/omo";
|
||||||
|
import type { PricingModelSourceOption } from "../ProviderAdvancedConfig";
|
||||||
|
|
||||||
|
// ── Default configs ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
||||||
|
export const CODEX_DEFAULT_CONFIG = JSON.stringify(
|
||||||
|
{ auth: {}, config: "" },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
export const GEMINI_DEFAULT_CONFIG = JSON.stringify(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
GOOGLE_GEMINI_BASE_URL: "",
|
||||||
|
GEMINI_API_KEY: "",
|
||||||
|
GEMINI_MODEL: "gemini-3-pro-preview",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const OPENCODE_DEFAULT_NPM = "@ai-sdk/openai-compatible";
|
||||||
|
export const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
|
||||||
|
{
|
||||||
|
npm: OPENCODE_DEFAULT_NPM,
|
||||||
|
options: {
|
||||||
|
baseURL: "",
|
||||||
|
apiKey: "",
|
||||||
|
},
|
||||||
|
models: {},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
export const OPENCODE_KNOWN_OPTION_KEYS = [
|
||||||
|
"baseURL",
|
||||||
|
"apiKey",
|
||||||
|
"headers",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const OPENCLAW_DEFAULT_CONFIG = JSON.stringify(
|
||||||
|
{
|
||||||
|
baseUrl: "",
|
||||||
|
apiKey: "",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EMPTY_OMO_GLOBAL_CONFIG: OmoGlobalConfig = {
|
||||||
|
id: "global",
|
||||||
|
disabledAgents: [],
|
||||||
|
disabledMcps: [],
|
||||||
|
disabledHooks: [],
|
||||||
|
disabledSkills: [],
|
||||||
|
updatedAt: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pure functions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function isKnownOpencodeOptionKey(key: string): boolean {
|
||||||
|
return OPENCODE_KNOWN_OPTION_KEYS.includes(
|
||||||
|
key as (typeof OPENCODE_KNOWN_OPTION_KEYS)[number],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOpencodeConfig(
|
||||||
|
settingsConfig?: Record<string, unknown>,
|
||||||
|
): OpenCodeProviderConfig {
|
||||||
|
const normalize = (
|
||||||
|
parsed: Partial<OpenCodeProviderConfig>,
|
||||||
|
): OpenCodeProviderConfig => ({
|
||||||
|
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
|
||||||
|
options:
|
||||||
|
parsed.options && typeof parsed.options === "object"
|
||||||
|
? (parsed.options as OpenCodeProviderConfig["options"])
|
||||||
|
: {},
|
||||||
|
models:
|
||||||
|
parsed.models && typeof parsed.models === "object"
|
||||||
|
? (parsed.models as Record<string, OpenCodeModel>)
|
||||||
|
: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(
|
||||||
|
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
|
||||||
|
) as Partial<OpenCodeProviderConfig>;
|
||||||
|
return normalize(parsed);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
npm: OPENCODE_DEFAULT_NPM,
|
||||||
|
options: {},
|
||||||
|
models: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOpencodeConfigStrict(
|
||||||
|
settingsConfig?: Record<string, unknown>,
|
||||||
|
): OpenCodeProviderConfig {
|
||||||
|
const parsed = JSON.parse(
|
||||||
|
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
|
||||||
|
) as Partial<OpenCodeProviderConfig>;
|
||||||
|
return {
|
||||||
|
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
|
||||||
|
options:
|
||||||
|
parsed.options && typeof parsed.options === "object"
|
||||||
|
? (parsed.options as OpenCodeProviderConfig["options"])
|
||||||
|
: {},
|
||||||
|
models:
|
||||||
|
parsed.models && typeof parsed.models === "object"
|
||||||
|
? (parsed.models as Record<string, OpenCodeModel>)
|
||||||
|
: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOpencodeExtraOptions(
|
||||||
|
options: OpenCodeProviderConfig["options"],
|
||||||
|
): Record<string, string> {
|
||||||
|
const extra: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(options || {})) {
|
||||||
|
if (!isKnownOpencodeOptionKey(k)) {
|
||||||
|
extra[k] = typeof v === "string" ? v : JSON.stringify(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extra;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { buildOmoProfilePreview } from "@/types/omo";
|
||||||
|
|
||||||
|
export const normalizePricingSource = (
|
||||||
|
value?: string,
|
||||||
|
): PricingModelSourceOption =>
|
||||||
|
value === "request" || value === "response" ? value : "inherit";
|
||||||
@@ -6,9 +6,10 @@ export { useCodexConfigState } from "./useCodexConfigState";
|
|||||||
export { useApiKeyLink } from "./useApiKeyLink";
|
export { useApiKeyLink } from "./useApiKeyLink";
|
||||||
export { useCustomEndpoints } from "./useCustomEndpoints";
|
export { useCustomEndpoints } from "./useCustomEndpoints";
|
||||||
export { useTemplateValues } from "./useTemplateValues";
|
export { useTemplateValues } from "./useTemplateValues";
|
||||||
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
|
|
||||||
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
|
||||||
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
||||||
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
||||||
export { useGeminiConfigState } from "./useGeminiConfigState";
|
export { useGeminiConfigState } from "./useGeminiConfigState";
|
||||||
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";
|
export { useOmoModelSource } from "./useOmoModelSource";
|
||||||
|
export { useOpencodeFormState } from "./useOpencodeFormState";
|
||||||
|
export { useOmoDraftState } from "./useOmoDraftState";
|
||||||
|
export { useOpenclawFormState } from "./useOpenclawFormState";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface UseApiKeyStateProps {
|
|||||||
selectedPresetId: string | null;
|
selectedPresetId: string | null;
|
||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
appType?: string;
|
appType?: string;
|
||||||
|
apiKeyField?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +25,7 @@ export function useApiKeyState({
|
|||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
category,
|
category,
|
||||||
appType,
|
appType,
|
||||||
|
apiKeyField,
|
||||||
}: UseApiKeyStateProps) {
|
}: UseApiKeyStateProps) {
|
||||||
const [apiKey, setApiKey] = useState(() => {
|
const [apiKey, setApiKey] = useState(() => {
|
||||||
if (initialConfig) {
|
if (initialConfig) {
|
||||||
@@ -58,7 +60,7 @@ export function useApiKeyState({
|
|||||||
initialConfig || "{}",
|
initialConfig || "{}",
|
||||||
key.trim(),
|
key.trim(),
|
||||||
{
|
{
|
||||||
// 最佳实践:仅在“新增模式”且“非官方类别”时补齐缺失字段
|
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
|
||||||
// - 新增模式:selectedPresetId !== null
|
// - 新增模式:selectedPresetId !== null
|
||||||
// - 非官方类别:category !== undefined && category !== "official"
|
// - 非官方类别:category !== undefined && category !== "official"
|
||||||
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
||||||
@@ -68,12 +70,20 @@ export function useApiKeyState({
|
|||||||
category !== undefined &&
|
category !== undefined &&
|
||||||
category !== "official",
|
category !== "official",
|
||||||
appType,
|
appType,
|
||||||
|
apiKeyField,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
onConfigChange(configString);
|
onConfigChange(configString);
|
||||||
},
|
},
|
||||||
[initialConfig, selectedPresetId, category, appType, onConfigChange],
|
[
|
||||||
|
initialConfig,
|
||||||
|
selectedPresetId,
|
||||||
|
category,
|
||||||
|
appType,
|
||||||
|
apiKeyField,
|
||||||
|
onConfigChange,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showApiKey = useCallback(
|
const showApiKey = useCallback(
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
updateTomlCommonConfigSnippet,
|
|
||||||
hasTomlCommonConfigSnippet,
|
|
||||||
} from "@/utils/providerConfigUtils";
|
|
||||||
import { configApi } from "@/lib/api";
|
|
||||||
|
|
||||||
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
|
|
||||||
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
|
|
||||||
# Add your common TOML configuration here`;
|
|
||||||
|
|
||||||
interface UseCodexCommonConfigProps {
|
|
||||||
codexConfig: string;
|
|
||||||
onConfigChange: (config: string) => void;
|
|
||||||
initialData?: {
|
|
||||||
settingsConfig?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
selectedPresetId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理 Codex 通用配置片段 (TOML 格式)
|
|
||||||
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
|
||||||
*/
|
|
||||||
export function useCodexCommonConfig({
|
|
||||||
codexConfig,
|
|
||||||
onConfigChange,
|
|
||||||
initialData,
|
|
||||||
selectedPresetId,
|
|
||||||
}: UseCodexCommonConfigProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
|
||||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
|
||||||
DEFAULT_CODEX_COMMON_CONFIG_SNIPPET,
|
|
||||||
);
|
|
||||||
const [commonConfigError, setCommonConfigError] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isExtracting, setIsExtracting] = useState(false);
|
|
||||||
|
|
||||||
// 用于跟踪是否正在通过通用配置更新
|
|
||||||
const isUpdatingFromCommonConfig = useRef(false);
|
|
||||||
// 用于跟踪新建模式是否已初始化默认勾选
|
|
||||||
const hasInitializedNewMode = useRef(false);
|
|
||||||
|
|
||||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
|
||||||
useEffect(() => {
|
|
||||||
hasInitializedNewMode.current = false;
|
|
||||||
}, [selectedPresetId]);
|
|
||||||
|
|
||||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const loadSnippet = async () => {
|
|
||||||
try {
|
|
||||||
// 使用统一 API 加载
|
|
||||||
const snippet = await configApi.getCommonConfigSnippet("codex");
|
|
||||||
|
|
||||||
if (snippet && snippet.trim()) {
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(snippet);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try {
|
|
||||||
const legacySnippet =
|
|
||||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
||||||
if (legacySnippet && legacySnippet.trim()) {
|
|
||||||
// 迁移到 config.json
|
|
||||||
await configApi.setCommonConfigSnippet("codex", legacySnippet);
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(legacySnippet);
|
|
||||||
}
|
|
||||||
// 清理 localStorage
|
|
||||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
|
||||||
console.log(
|
|
||||||
"[迁移] Codex 通用配置已从 localStorage 迁移到 config.json",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载 Codex 通用配置失败:", error);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSnippet();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData?.settingsConfig && !isLoading) {
|
|
||||||
const config =
|
|
||||||
typeof initialData.settingsConfig.config === "string"
|
|
||||||
? initialData.settingsConfig.config
|
|
||||||
: "";
|
|
||||||
const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}
|
|
||||||
}, [initialData, commonConfigSnippet, isLoading]);
|
|
||||||
|
|
||||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
|
||||||
useEffect(() => {
|
|
||||||
// 仅新建模式、加载完成、尚未初始化过
|
|
||||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
|
||||||
hasInitializedNewMode.current = true;
|
|
||||||
|
|
||||||
// 检查 TOML 片段是否有实质内容(不只是注释和空行)
|
|
||||||
const lines = commonConfigSnippet.split("\n");
|
|
||||||
const hasContent = lines.some((line) => {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
return trimmed && !trimmed.startsWith("#");
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasContent) {
|
|
||||||
setUseCommonConfig(true);
|
|
||||||
// 合并通用配置到当前配置
|
|
||||||
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!error) {
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
initialData,
|
|
||||||
commonConfigSnippet,
|
|
||||||
isLoading,
|
|
||||||
codexConfig,
|
|
||||||
onConfigChange,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 处理通用配置开关
|
|
||||||
const handleCommonConfigToggle = useCallback(
|
|
||||||
(checked: boolean) => {
|
|
||||||
const { updatedConfig, error: snippetError } =
|
|
||||||
updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
checked,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (snippetError) {
|
|
||||||
setCommonConfigError(snippetError);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCommonConfigError("");
|
|
||||||
setUseCommonConfig(checked);
|
|
||||||
// 标记正在通过通用配置更新
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
[codexConfig, commonConfigSnippet, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理通用配置片段变化
|
|
||||||
const handleCommonConfigSnippetChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const previousSnippet = commonConfigSnippet;
|
|
||||||
setCommonConfigSnippetState(value);
|
|
||||||
|
|
||||||
if (!value.trim()) {
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json(清空)
|
|
||||||
configApi.setCommonConfigSnippet("codex", "").catch((error) => {
|
|
||||||
console.error("保存 Codex 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("codexConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const { updatedConfig } = updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TOML 格式校验较为复杂,暂时不做校验,直接清空错误
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json
|
|
||||||
configApi.setCommonConfigSnippet("codex", value).catch((error) => {
|
|
||||||
console.error("保存 Codex 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("codexConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 若当前启用通用配置,需要替换为最新片段
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const removeResult = updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
if (removeResult.error) {
|
|
||||||
setCommonConfigError(removeResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const addResult = updateTomlCommonConfigSnippet(
|
|
||||||
removeResult.updatedConfig,
|
|
||||||
value,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (addResult.error) {
|
|
||||||
setCommonConfigError(addResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标记正在通过通用配置更新,避免触发状态检查
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(addResult.updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[commonConfigSnippet, codexConfig, useCommonConfig, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hasCommon = hasTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}, [codexConfig, commonConfigSnippet, isLoading]);
|
|
||||||
|
|
||||||
// 从编辑器当前内容提取通用配置片段
|
|
||||||
const handleExtract = useCallback(async () => {
|
|
||||||
setIsExtracting(true);
|
|
||||||
setCommonConfigError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const extracted = await configApi.extractCommonConfigSnippet("codex", {
|
|
||||||
settingsConfig: JSON.stringify({
|
|
||||||
config: codexConfig ?? "",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!extracted || !extracted.trim()) {
|
|
||||||
setCommonConfigError(t("codexConfig.extractNoCommonConfig"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新片段状态
|
|
||||||
setCommonConfigSnippetState(extracted);
|
|
||||||
|
|
||||||
// 保存到后端
|
|
||||||
await configApi.setCommonConfigSnippet("codex", extracted);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("提取 Codex 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("codexConfig.extractFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsExtracting(false);
|
|
||||||
}
|
|
||||||
}, [codexConfig, t]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
useCommonConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
commonConfigError,
|
|
||||||
isLoading,
|
|
||||||
isExtracting,
|
|
||||||
handleCommonConfigToggle,
|
|
||||||
handleCommonConfigSnippetChange,
|
|
||||||
handleExtract,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
updateCommonConfigSnippet,
|
|
||||||
hasCommonConfigSnippet,
|
|
||||||
validateJsonConfig,
|
|
||||||
} from "@/utils/providerConfigUtils";
|
|
||||||
import { configApi } from "@/lib/api";
|
|
||||||
|
|
||||||
const LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet";
|
|
||||||
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
|
|
||||||
"includeCoAuthoredBy": false
|
|
||||||
}`;
|
|
||||||
|
|
||||||
interface UseCommonConfigSnippetProps {
|
|
||||||
settingsConfig: string;
|
|
||||||
onConfigChange: (config: string) => void;
|
|
||||||
initialData?: {
|
|
||||||
settingsConfig?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
selectedPresetId?: string;
|
|
||||||
/** When false, the hook skips all logic and returns disabled state. Default: true */
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理 Claude 通用配置片段
|
|
||||||
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
|
||||||
*/
|
|
||||||
export function useCommonConfigSnippet({
|
|
||||||
settingsConfig,
|
|
||||||
onConfigChange,
|
|
||||||
initialData,
|
|
||||||
selectedPresetId,
|
|
||||||
enabled = true,
|
|
||||||
}: UseCommonConfigSnippetProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
|
||||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
|
||||||
DEFAULT_COMMON_CONFIG_SNIPPET,
|
|
||||||
);
|
|
||||||
const [commonConfigError, setCommonConfigError] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isExtracting, setIsExtracting] = useState(false);
|
|
||||||
|
|
||||||
// 用于跟踪是否正在通过通用配置更新
|
|
||||||
const isUpdatingFromCommonConfig = useRef(false);
|
|
||||||
// 用于跟踪新建模式是否已初始化默认勾选
|
|
||||||
const hasInitializedNewMode = useRef(false);
|
|
||||||
|
|
||||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
hasInitializedNewMode.current = false;
|
|
||||||
}, [selectedPresetId, enabled]);
|
|
||||||
|
|
||||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const loadSnippet = async () => {
|
|
||||||
try {
|
|
||||||
// 使用统一 API 加载
|
|
||||||
const snippet = await configApi.getCommonConfigSnippet("claude");
|
|
||||||
|
|
||||||
if (snippet && snippet.trim()) {
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(snippet);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try {
|
|
||||||
const legacySnippet =
|
|
||||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
||||||
if (legacySnippet && legacySnippet.trim()) {
|
|
||||||
// 迁移到 config.json
|
|
||||||
await configApi.setCommonConfigSnippet("claude", legacySnippet);
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(legacySnippet);
|
|
||||||
}
|
|
||||||
// 清理 localStorage
|
|
||||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
|
||||||
console.log(
|
|
||||||
"[迁移] Claude 通用配置已从 localStorage 迁移到 config.json",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载通用配置失败:", error);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSnippet();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [enabled]);
|
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
if (initialData && !isLoading) {
|
|
||||||
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
|
||||||
const hasCommon = hasCommonConfigSnippet(
|
|
||||||
configString,
|
|
||||||
commonConfigSnippet,
|
|
||||||
);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}
|
|
||||||
}, [enabled, initialData, commonConfigSnippet, isLoading]);
|
|
||||||
|
|
||||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
// 仅新建模式、加载完成、尚未初始化过
|
|
||||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
|
||||||
hasInitializedNewMode.current = true;
|
|
||||||
|
|
||||||
// 检查片段是否有实质内容
|
|
||||||
try {
|
|
||||||
const snippetObj = JSON.parse(commonConfigSnippet);
|
|
||||||
const hasContent = Object.keys(snippetObj).length > 0;
|
|
||||||
if (hasContent) {
|
|
||||||
setUseCommonConfig(true);
|
|
||||||
// 合并通用配置到当前配置
|
|
||||||
const { updatedConfig, error } = updateCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!error) {
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore parse error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
enabled,
|
|
||||||
initialData,
|
|
||||||
commonConfigSnippet,
|
|
||||||
isLoading,
|
|
||||||
settingsConfig,
|
|
||||||
onConfigChange,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 处理通用配置开关
|
|
||||||
const handleCommonConfigToggle = useCallback(
|
|
||||||
(checked: boolean) => {
|
|
||||||
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
checked,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (snippetError) {
|
|
||||||
setCommonConfigError(snippetError);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCommonConfigError("");
|
|
||||||
setUseCommonConfig(checked);
|
|
||||||
// 标记正在通过通用配置更新
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
[settingsConfig, commonConfigSnippet, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理通用配置片段变化
|
|
||||||
const handleCommonConfigSnippetChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const previousSnippet = commonConfigSnippet;
|
|
||||||
setCommonConfigSnippetState(value);
|
|
||||||
|
|
||||||
if (!value.trim()) {
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json(清空)
|
|
||||||
configApi.setCommonConfigSnippet("claude", "").catch((error) => {
|
|
||||||
console.error("保存通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const { updatedConfig } = updateCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证JSON格式
|
|
||||||
const validationError = validateJsonConfig(value, "通用配置片段");
|
|
||||||
if (validationError) {
|
|
||||||
setCommonConfigError(validationError);
|
|
||||||
} else {
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json
|
|
||||||
configApi.setCommonConfigSnippet("claude", value).catch((error) => {
|
|
||||||
console.error("保存通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 若当前启用通用配置且格式正确,需要替换为最新片段
|
|
||||||
if (useCommonConfig && !validationError) {
|
|
||||||
const removeResult = updateCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
if (removeResult.error) {
|
|
||||||
setCommonConfigError(removeResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const addResult = updateCommonConfigSnippet(
|
|
||||||
removeResult.updatedConfig,
|
|
||||||
value,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (addResult.error) {
|
|
||||||
setCommonConfigError(addResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标记正在通过通用配置更新,避免触发状态检查
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(addResult.updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[commonConfigSnippet, settingsConfig, useCommonConfig, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hasCommon = hasCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}, [enabled, settingsConfig, commonConfigSnippet, isLoading]);
|
|
||||||
|
|
||||||
// 从编辑器当前内容提取通用配置片段
|
|
||||||
const handleExtract = useCallback(async () => {
|
|
||||||
setIsExtracting(true);
|
|
||||||
setCommonConfigError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const extracted = await configApi.extractCommonConfigSnippet("claude", {
|
|
||||||
settingsConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!extracted || extracted === "{}") {
|
|
||||||
setCommonConfigError(t("claudeConfig.extractNoCommonConfig"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证 JSON 格式
|
|
||||||
const validationError = validateJsonConfig(extracted, "提取的配置");
|
|
||||||
if (validationError) {
|
|
||||||
setCommonConfigError(validationError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新片段状态
|
|
||||||
setCommonConfigSnippetState(extracted);
|
|
||||||
|
|
||||||
// 保存到后端
|
|
||||||
await configApi.setCommonConfigSnippet("claude", extracted);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("提取通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("claudeConfig.extractFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsExtracting(false);
|
|
||||||
}
|
|
||||||
}, [settingsConfig, t]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
useCommonConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
commonConfigError,
|
|
||||||
isLoading,
|
|
||||||
isExtracting,
|
|
||||||
handleCommonConfigToggle,
|
|
||||||
handleCommonConfigSnippetChange,
|
|
||||||
handleExtract,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { configApi } from "@/lib/api";
|
|
||||||
|
|
||||||
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
|
||||||
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
|
|
||||||
|
|
||||||
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
|
||||||
"GOOGLE_GEMINI_BASE_URL",
|
|
||||||
"GEMINI_API_KEY",
|
|
||||||
] as const;
|
|
||||||
type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
|
|
||||||
|
|
||||||
interface UseGeminiCommonConfigProps {
|
|
||||||
envValue: string;
|
|
||||||
onEnvChange: (env: string) => void;
|
|
||||||
envStringToObj: (envString: string) => Record<string, string>;
|
|
||||||
envObjToString: (envObj: Record<string, unknown>) => string;
|
|
||||||
initialData?: {
|
|
||||||
settingsConfig?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
selectedPresetId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return (
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
!Array.isArray(value) &&
|
|
||||||
Object.prototype.toString.call(value) === "[object Object]"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理 Gemini 通用配置片段 (JSON 格式)
|
|
||||||
* 写入 Gemini 的 .env,但会排除以下敏感字段:
|
|
||||||
* - GOOGLE_GEMINI_BASE_URL
|
|
||||||
* - GEMINI_API_KEY
|
|
||||||
*/
|
|
||||||
export function useGeminiCommonConfig({
|
|
||||||
envValue,
|
|
||||||
onEnvChange,
|
|
||||||
envStringToObj,
|
|
||||||
envObjToString,
|
|
||||||
initialData,
|
|
||||||
selectedPresetId,
|
|
||||||
}: UseGeminiCommonConfigProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
|
||||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
|
||||||
DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET,
|
|
||||||
);
|
|
||||||
const [commonConfigError, setCommonConfigError] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isExtracting, setIsExtracting] = useState(false);
|
|
||||||
|
|
||||||
// 用于跟踪是否正在通过通用配置更新
|
|
||||||
const isUpdatingFromCommonConfig = useRef(false);
|
|
||||||
// 用于跟踪新建模式是否已初始化默认勾选
|
|
||||||
const hasInitializedNewMode = useRef(false);
|
|
||||||
|
|
||||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
|
||||||
useEffect(() => {
|
|
||||||
hasInitializedNewMode.current = false;
|
|
||||||
}, [selectedPresetId]);
|
|
||||||
|
|
||||||
const parseSnippetEnv = useCallback(
|
|
||||||
(
|
|
||||||
snippetString: string,
|
|
||||||
): { env: Record<string, string>; error?: string } => {
|
|
||||||
const trimmed = snippetString.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return { env: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(trimmed);
|
|
||||||
} catch {
|
|
||||||
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPlainObject(parsed)) {
|
|
||||||
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = Object.keys(parsed);
|
|
||||||
const forbiddenKeys = keys.filter((key) =>
|
|
||||||
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
|
|
||||||
);
|
|
||||||
if (forbiddenKeys.length > 0) {
|
|
||||||
return {
|
|
||||||
env: {},
|
|
||||||
error: t("geminiConfig.commonConfigInvalidKeys", {
|
|
||||||
keys: forbiddenKeys.join(", "),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(parsed)) {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
return {
|
|
||||||
env: {},
|
|
||||||
error: t("geminiConfig.commonConfigInvalidValues"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const normalized = value.trim();
|
|
||||||
if (!normalized) continue;
|
|
||||||
env[key] = normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { env };
|
|
||||||
},
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasEnvCommonConfigSnippet = useCallback(
|
|
||||||
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
|
||||||
const entries = Object.entries(snippetEnv);
|
|
||||||
if (entries.length === 0) return false;
|
|
||||||
return entries.every(([key, value]) => envObj[key] === value);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const applySnippetToEnv = useCallback(
|
|
||||||
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
|
||||||
const updated = { ...envObj };
|
|
||||||
for (const [key, value] of Object.entries(snippetEnv)) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
updated[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeSnippetFromEnv = useCallback(
|
|
||||||
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
|
||||||
const updated = { ...envObj };
|
|
||||||
for (const [key, value] of Object.entries(snippetEnv)) {
|
|
||||||
if (typeof value === "string" && updated[key] === value) {
|
|
||||||
delete updated[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const loadSnippet = async () => {
|
|
||||||
try {
|
|
||||||
// 使用统一 API 加载
|
|
||||||
const snippet = await configApi.getCommonConfigSnippet("gemini");
|
|
||||||
|
|
||||||
if (snippet && snippet.trim()) {
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(snippet);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try {
|
|
||||||
const legacySnippet =
|
|
||||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
||||||
if (legacySnippet && legacySnippet.trim()) {
|
|
||||||
const parsed = parseSnippetEnv(legacySnippet);
|
|
||||||
if (parsed.error) {
|
|
||||||
console.warn(
|
|
||||||
"[迁移] legacy Gemini 通用配置片段格式不符合当前规则,跳过迁移",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 迁移到 config.json
|
|
||||||
await configApi.setCommonConfigSnippet("gemini", legacySnippet);
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(legacySnippet);
|
|
||||||
}
|
|
||||||
// 清理 localStorage
|
|
||||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
|
||||||
console.log(
|
|
||||||
"[迁移] Gemini 通用配置已从 localStorage 迁移到 config.json",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载 Gemini 通用配置失败:", error);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSnippet();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [parseSnippetEnv]);
|
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData?.settingsConfig && !isLoading) {
|
|
||||||
try {
|
|
||||||
const env =
|
|
||||||
isPlainObject(initialData.settingsConfig.env) &&
|
|
||||||
Object.keys(initialData.settingsConfig.env).length > 0
|
|
||||||
? (initialData.settingsConfig.env as Record<string, string>)
|
|
||||||
: {};
|
|
||||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
|
||||||
if (parsed.error) return;
|
|
||||||
const hasCommon = hasEnvCommonConfigSnippet(
|
|
||||||
env,
|
|
||||||
parsed.env as Record<string, string>,
|
|
||||||
);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
} catch {
|
|
||||||
// ignore parse error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
commonConfigSnippet,
|
|
||||||
hasEnvCommonConfigSnippet,
|
|
||||||
initialData,
|
|
||||||
isLoading,
|
|
||||||
parseSnippetEnv,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
|
||||||
useEffect(() => {
|
|
||||||
// 仅新建模式、加载完成、尚未初始化过
|
|
||||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
|
||||||
hasInitializedNewMode.current = true;
|
|
||||||
|
|
||||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
|
||||||
if (parsed.error) return;
|
|
||||||
const hasContent = Object.keys(parsed.env).length > 0;
|
|
||||||
if (!hasContent) return;
|
|
||||||
|
|
||||||
setUseCommonConfig(true);
|
|
||||||
const currentEnv = envStringToObj(envValue);
|
|
||||||
const merged = applySnippetToEnv(currentEnv, parsed.env);
|
|
||||||
const nextEnvString = envObjToString(merged);
|
|
||||||
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onEnvChange(nextEnvString);
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
initialData,
|
|
||||||
isLoading,
|
|
||||||
commonConfigSnippet,
|
|
||||||
envValue,
|
|
||||||
envStringToObj,
|
|
||||||
envObjToString,
|
|
||||||
applySnippetToEnv,
|
|
||||||
onEnvChange,
|
|
||||||
parseSnippetEnv,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 处理通用配置开关
|
|
||||||
const handleCommonConfigToggle = useCallback(
|
|
||||||
(checked: boolean) => {
|
|
||||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
|
||||||
if (parsed.error) {
|
|
||||||
setCommonConfigError(parsed.error);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Object.keys(parsed.env).length === 0) {
|
|
||||||
setCommonConfigError(t("geminiConfig.noCommonConfigToApply"));
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentEnv = envStringToObj(envValue);
|
|
||||||
const updatedEnvObj = checked
|
|
||||||
? applySnippetToEnv(currentEnv, parsed.env)
|
|
||||||
: removeSnippetFromEnv(currentEnv, parsed.env);
|
|
||||||
|
|
||||||
setCommonConfigError("");
|
|
||||||
setUseCommonConfig(checked);
|
|
||||||
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onEnvChange(envObjToString(updatedEnvObj));
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
applySnippetToEnv,
|
|
||||||
commonConfigSnippet,
|
|
||||||
envObjToString,
|
|
||||||
envStringToObj,
|
|
||||||
envValue,
|
|
||||||
onEnvChange,
|
|
||||||
parseSnippetEnv,
|
|
||||||
removeSnippetFromEnv,
|
|
||||||
t,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理通用配置片段变化
|
|
||||||
const handleCommonConfigSnippetChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const previousSnippet = commonConfigSnippet;
|
|
||||||
setCommonConfigSnippetState(value);
|
|
||||||
|
|
||||||
if (!value.trim()) {
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json(清空)
|
|
||||||
configApi.setCommonConfigSnippet("gemini", "").catch((error) => {
|
|
||||||
console.error("保存 Gemini 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("geminiConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const parsed = parseSnippetEnv(previousSnippet);
|
|
||||||
if (!parsed.error && Object.keys(parsed.env).length > 0) {
|
|
||||||
const currentEnv = envStringToObj(envValue);
|
|
||||||
const updatedEnv = removeSnippetFromEnv(currentEnv, parsed.env);
|
|
||||||
onEnvChange(envObjToString(updatedEnv));
|
|
||||||
}
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验 JSON 格式
|
|
||||||
const parsed = parseSnippetEnv(value);
|
|
||||||
if (parsed.error) {
|
|
||||||
setCommonConfigError(parsed.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCommonConfigError("");
|
|
||||||
configApi.setCommonConfigSnippet("gemini", value).catch((error) => {
|
|
||||||
console.error("保存 Gemini 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("geminiConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 若当前启用通用配置,需要替换为最新片段
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const prevParsed = parseSnippetEnv(previousSnippet);
|
|
||||||
const prevEnv = prevParsed.error ? {} : prevParsed.env;
|
|
||||||
const nextEnv = parsed.env;
|
|
||||||
const currentEnv = envStringToObj(envValue);
|
|
||||||
|
|
||||||
const withoutOld =
|
|
||||||
Object.keys(prevEnv).length > 0
|
|
||||||
? removeSnippetFromEnv(currentEnv, prevEnv)
|
|
||||||
: currentEnv;
|
|
||||||
const withNew =
|
|
||||||
Object.keys(nextEnv).length > 0
|
|
||||||
? applySnippetToEnv(withoutOld, nextEnv)
|
|
||||||
: withoutOld;
|
|
||||||
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onEnvChange(envObjToString(withNew));
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
applySnippetToEnv,
|
|
||||||
commonConfigSnippet,
|
|
||||||
envObjToString,
|
|
||||||
envStringToObj,
|
|
||||||
envValue,
|
|
||||||
onEnvChange,
|
|
||||||
parseSnippetEnv,
|
|
||||||
removeSnippetFromEnv,
|
|
||||||
t,
|
|
||||||
useCommonConfig,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当 env 变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
|
||||||
if (parsed.error) return;
|
|
||||||
const envObj = envStringToObj(envValue);
|
|
||||||
setUseCommonConfig(
|
|
||||||
hasEnvCommonConfigSnippet(envObj, parsed.env as Record<string, string>),
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
envValue,
|
|
||||||
commonConfigSnippet,
|
|
||||||
envStringToObj,
|
|
||||||
hasEnvCommonConfigSnippet,
|
|
||||||
isLoading,
|
|
||||||
parseSnippetEnv,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 从编辑器当前内容提取通用配置片段
|
|
||||||
const handleExtract = useCallback(async () => {
|
|
||||||
setIsExtracting(true);
|
|
||||||
setCommonConfigError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const extracted = await configApi.extractCommonConfigSnippet("gemini", {
|
|
||||||
settingsConfig: JSON.stringify({
|
|
||||||
env: envStringToObj(envValue),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!extracted || extracted === "{}") {
|
|
||||||
setCommonConfigError(t("geminiConfig.extractNoCommonConfig"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证 JSON 格式
|
|
||||||
const parsed = parseSnippetEnv(extracted);
|
|
||||||
if (parsed.error) {
|
|
||||||
setCommonConfigError(t("geminiConfig.extractedConfigInvalid"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新片段状态
|
|
||||||
setCommonConfigSnippetState(extracted);
|
|
||||||
|
|
||||||
// 保存到后端
|
|
||||||
await configApi.setCommonConfigSnippet("gemini", extracted);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("提取 Gemini 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("geminiConfig.extractFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsExtracting(false);
|
|
||||||
}
|
|
||||||
}, [envStringToObj, envValue, parseSnippetEnv, t]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
useCommonConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
commonConfigError,
|
|
||||||
isLoading,
|
|
||||||
isExtracting,
|
|
||||||
handleCommonConfigToggle,
|
|
||||||
handleCommonConfigSnippetChange,
|
|
||||||
handleExtract,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { OmoGlobalConfig } from "@/types/omo";
|
||||||
|
import {
|
||||||
|
mergeOmoConfigPreview,
|
||||||
|
mergeOmoSlimConfigPreview,
|
||||||
|
buildOmoSlimProfilePreview,
|
||||||
|
} from "@/types/omo";
|
||||||
|
import { type OmoGlobalConfigFieldsRef } from "../OmoGlobalConfigFields";
|
||||||
|
import * as configApi from "@/lib/api/config";
|
||||||
|
import {
|
||||||
|
EMPTY_OMO_GLOBAL_CONFIG,
|
||||||
|
buildOmoProfilePreview,
|
||||||
|
} from "../helpers/opencodeFormUtils";
|
||||||
|
|
||||||
|
interface UseOmoDraftStateParams {
|
||||||
|
initialOmoSettings: Record<string, unknown> | undefined;
|
||||||
|
queriedOmoGlobalConfig: OmoGlobalConfig | undefined;
|
||||||
|
isEditMode: boolean;
|
||||||
|
appId: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OmoDraftState {
|
||||||
|
omoAgents: Record<string, Record<string, unknown>>;
|
||||||
|
setOmoAgents: React.Dispatch<
|
||||||
|
React.SetStateAction<Record<string, Record<string, unknown>>>
|
||||||
|
>;
|
||||||
|
omoCategories: Record<string, Record<string, unknown>>;
|
||||||
|
setOmoCategories: React.Dispatch<
|
||||||
|
React.SetStateAction<Record<string, Record<string, unknown>>>
|
||||||
|
>;
|
||||||
|
omoOtherFieldsStr: string;
|
||||||
|
setOmoOtherFieldsStr: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
useOmoCommonConfig: boolean;
|
||||||
|
setUseOmoCommonConfig: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
isOmoConfigModalOpen: boolean;
|
||||||
|
setIsOmoConfigModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
isOmoSaving: boolean;
|
||||||
|
omoGlobalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
|
||||||
|
omoFieldsKey: number;
|
||||||
|
effectiveOmoGlobalConfig: OmoGlobalConfig;
|
||||||
|
mergedOmoJsonPreview: string;
|
||||||
|
handleOmoGlobalConfigSave: () => Promise<void>;
|
||||||
|
handleOmoEditClick: () => void;
|
||||||
|
resetOmoDraftState: (useCommonConfig?: boolean) => void;
|
||||||
|
setOmoGlobalState: React.Dispatch<
|
||||||
|
React.SetStateAction<OmoGlobalConfig | null>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOmoDraftState({
|
||||||
|
initialOmoSettings,
|
||||||
|
queriedOmoGlobalConfig,
|
||||||
|
isEditMode,
|
||||||
|
appId,
|
||||||
|
category,
|
||||||
|
}: UseOmoDraftStateParams): OmoDraftState {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isSlim = category === "omo-slim";
|
||||||
|
const commonConfigKey = isSlim ? "omo_slim" : "omo";
|
||||||
|
|
||||||
|
const [omoAgents, setOmoAgents] = useState<
|
||||||
|
Record<string, Record<string, unknown>>
|
||||||
|
>(
|
||||||
|
() =>
|
||||||
|
(initialOmoSettings?.agents as Record<string, Record<string, unknown>>) ||
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [omoCategories, setOmoCategories] = useState<
|
||||||
|
Record<string, Record<string, unknown>>
|
||||||
|
>(
|
||||||
|
() =>
|
||||||
|
(initialOmoSettings?.categories as Record<
|
||||||
|
string,
|
||||||
|
Record<string, unknown>
|
||||||
|
>) || {},
|
||||||
|
);
|
||||||
|
const [omoOtherFieldsStr, setOmoOtherFieldsStr] = useState(() => {
|
||||||
|
const otherFields = initialOmoSettings?.otherFields;
|
||||||
|
return otherFields ? JSON.stringify(otherFields, null, 2) : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [omoGlobalState, setOmoGlobalState] = useState<OmoGlobalConfig | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isOmoConfigModalOpen, setIsOmoConfigModalOpen] = useState(false);
|
||||||
|
const [useOmoCommonConfig, setUseOmoCommonConfig] = useState(() => {
|
||||||
|
const raw = initialOmoSettings?.useCommonConfig;
|
||||||
|
return typeof raw === "boolean" ? raw : true;
|
||||||
|
});
|
||||||
|
const [isOmoSaving, setIsOmoSaving] = useState(false);
|
||||||
|
const omoGlobalConfigRef = useRef<OmoGlobalConfigFieldsRef>(null);
|
||||||
|
const [omoFieldsKey, setOmoFieldsKey] = useState(0);
|
||||||
|
const effectiveOmoGlobalConfig =
|
||||||
|
omoGlobalState ?? queriedOmoGlobalConfig ?? EMPTY_OMO_GLOBAL_CONFIG;
|
||||||
|
|
||||||
|
const mergedOmoJsonPreview = useMemo(() => {
|
||||||
|
if (useOmoCommonConfig) {
|
||||||
|
if (isSlim) {
|
||||||
|
const merged = mergeOmoSlimConfigPreview(
|
||||||
|
effectiveOmoGlobalConfig,
|
||||||
|
omoAgents,
|
||||||
|
omoOtherFieldsStr,
|
||||||
|
);
|
||||||
|
return JSON.stringify(merged, null, 2);
|
||||||
|
}
|
||||||
|
const merged = mergeOmoConfigPreview(
|
||||||
|
effectiveOmoGlobalConfig,
|
||||||
|
omoAgents,
|
||||||
|
omoCategories,
|
||||||
|
omoOtherFieldsStr,
|
||||||
|
);
|
||||||
|
return JSON.stringify(merged, null, 2);
|
||||||
|
} else {
|
||||||
|
if (isSlim) {
|
||||||
|
return JSON.stringify(
|
||||||
|
buildOmoSlimProfilePreview(omoAgents, omoOtherFieldsStr),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return JSON.stringify(
|
||||||
|
buildOmoProfilePreview(omoAgents, omoCategories, omoOtherFieldsStr),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
useOmoCommonConfig,
|
||||||
|
effectiveOmoGlobalConfig,
|
||||||
|
omoAgents,
|
||||||
|
omoCategories,
|
||||||
|
omoOtherFieldsStr,
|
||||||
|
isSlim,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Auto-detect whether common config has content for new OMO/OMO Slim profiles
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
appId !== "opencode" ||
|
||||||
|
(category !== "omo" && category !== "omo-slim") ||
|
||||||
|
isEditMode
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
let active = true;
|
||||||
|
(async () => {
|
||||||
|
let next = false;
|
||||||
|
try {
|
||||||
|
const raw = await configApi.getCommonConfigSnippet(commonConfigKey);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
next = Object.keys(parsed).some(
|
||||||
|
(k) => k !== "id" && k !== "updatedAt",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (active) setUseOmoCommonConfig(next);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [appId, category, isEditMode, commonConfigKey]);
|
||||||
|
|
||||||
|
const handleOmoGlobalConfigSave = useCallback(async () => {
|
||||||
|
if (!omoGlobalConfigRef.current) return;
|
||||||
|
setIsOmoSaving(true);
|
||||||
|
try {
|
||||||
|
const config = omoGlobalConfigRef.current.buildCurrentConfigStrict();
|
||||||
|
await configApi.setCommonConfigSnippet(
|
||||||
|
commonConfigKey,
|
||||||
|
JSON.stringify(config),
|
||||||
|
);
|
||||||
|
setIsOmoConfigModalOpen(false);
|
||||||
|
toast.success(
|
||||||
|
t("omo.globalConfigSaved", { defaultValue: "Global config saved" }),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(String(err));
|
||||||
|
} finally {
|
||||||
|
setIsOmoSaving(false);
|
||||||
|
}
|
||||||
|
}, [t, commonConfigKey]);
|
||||||
|
|
||||||
|
const handleOmoEditClick = useCallback(() => {
|
||||||
|
setOmoFieldsKey((k) => k + 1);
|
||||||
|
setIsOmoConfigModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetOmoDraftState = useCallback((useCommonConfig = true) => {
|
||||||
|
setOmoAgents({});
|
||||||
|
setOmoCategories({});
|
||||||
|
setOmoOtherFieldsStr("");
|
||||||
|
setUseOmoCommonConfig(useCommonConfig);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
omoAgents,
|
||||||
|
setOmoAgents,
|
||||||
|
omoCategories,
|
||||||
|
setOmoCategories,
|
||||||
|
omoOtherFieldsStr,
|
||||||
|
setOmoOtherFieldsStr,
|
||||||
|
useOmoCommonConfig,
|
||||||
|
setUseOmoCommonConfig,
|
||||||
|
isOmoConfigModalOpen,
|
||||||
|
setIsOmoConfigModalOpen,
|
||||||
|
isOmoSaving,
|
||||||
|
omoGlobalConfigRef,
|
||||||
|
omoFieldsKey,
|
||||||
|
effectiveOmoGlobalConfig,
|
||||||
|
mergedOmoJsonPreview,
|
||||||
|
handleOmoGlobalConfigSave,
|
||||||
|
handleOmoEditClick,
|
||||||
|
resetOmoDraftState,
|
||||||
|
setOmoGlobalState,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { providersApi } from "@/lib/api";
|
||||||
|
import { useProvidersQuery } from "@/lib/query/queries";
|
||||||
|
import type { OpenCodeProviderConfig } from "@/types";
|
||||||
|
import { OPENCODE_PRESET_MODEL_VARIANTS } from "@/config/opencodeProviderPresets";
|
||||||
|
import { parseOpencodeConfigStrict } from "../helpers/opencodeFormUtils";
|
||||||
|
|
||||||
|
interface UseOmoModelSourceParams {
|
||||||
|
isOmoCategory: boolean;
|
||||||
|
providerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OmoModelBuild {
|
||||||
|
options: Array<{ value: string; label: string }>;
|
||||||
|
variantsMap: Record<string, string[]>;
|
||||||
|
presetMetaMap: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
limit?: { context?: number; output?: number };
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
parseFailedProviders: string[];
|
||||||
|
usedFallbackSource: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OmoModelSourceResult {
|
||||||
|
omoModelOptions: Array<{ value: string; label: string }>;
|
||||||
|
omoModelVariantsMap: Record<string, string[]>;
|
||||||
|
omoPresetMetaMap: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
limit?: { context?: number; output?: number };
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
existingOpencodeKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOmoModelSource({
|
||||||
|
isOmoCategory,
|
||||||
|
providerId,
|
||||||
|
}: UseOmoModelSourceParams): OmoModelSourceResult {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
|
||||||
|
const existingOpencodeKeys = useMemo(() => {
|
||||||
|
if (!opencodeProvidersData?.providers) return [];
|
||||||
|
return Object.keys(opencodeProvidersData.providers).filter(
|
||||||
|
(k) => k !== providerId,
|
||||||
|
);
|
||||||
|
}, [opencodeProvidersData?.providers, providerId]);
|
||||||
|
|
||||||
|
const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState<
|
||||||
|
string[] | null
|
||||||
|
>(null);
|
||||||
|
const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false);
|
||||||
|
const lastOmoModelSourceWarningRef = useRef<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
if (!isOmoCategory) {
|
||||||
|
setEnabledOpencodeProviderIds(null);
|
||||||
|
setOmoLiveIdsLoadFailed(false);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabledOpencodeProviderIds(null);
|
||||||
|
setOmoLiveIdsLoadFailed(false);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const ids = await providersApi.getOpenCodeLiveProviderIds();
|
||||||
|
if (active) {
|
||||||
|
setEnabledOpencodeProviderIds(ids);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
if (active) {
|
||||||
|
setOmoLiveIdsLoadFailed(true);
|
||||||
|
setEnabledOpencodeProviderIds(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [isOmoCategory]);
|
||||||
|
|
||||||
|
const omoModelBuild = useMemo<OmoModelBuild>(() => {
|
||||||
|
const empty: OmoModelBuild = {
|
||||||
|
options: [],
|
||||||
|
variantsMap: {},
|
||||||
|
presetMetaMap: {},
|
||||||
|
parseFailedProviders: [],
|
||||||
|
usedFallbackSource: false,
|
||||||
|
};
|
||||||
|
if (!isOmoCategory) {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allProviders = opencodeProvidersData?.providers;
|
||||||
|
if (!allProviders) {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldFilterByLive = !omoLiveIdsLoadFailed;
|
||||||
|
if (shouldFilterByLive && enabledOpencodeProviderIds === null) {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
const liveSet =
|
||||||
|
shouldFilterByLive && enabledOpencodeProviderIds
|
||||||
|
? new Set(enabledOpencodeProviderIds)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const dedupedOptions = new Map<string, string>();
|
||||||
|
const variantsMap: Record<string, string[]> = {};
|
||||||
|
const presetMetaMap: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
limit?: { context?: number; output?: number };
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
const parseFailedProviders: string[] = [];
|
||||||
|
|
||||||
|
for (const [providerKey, provider] of Object.entries(allProviders)) {
|
||||||
|
if (provider.category === "omo" || provider.category === "omo-slim") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (liveSet && !liveSet.has(providerKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedConfig: OpenCodeProviderConfig;
|
||||||
|
try {
|
||||||
|
parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig);
|
||||||
|
} catch (error) {
|
||||||
|
parseFailedProviders.push(providerKey);
|
||||||
|
console.warn(
|
||||||
|
"[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings",
|
||||||
|
{
|
||||||
|
providerKey,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const [modelId, model] of Object.entries(
|
||||||
|
parsedConfig.models || {},
|
||||||
|
)) {
|
||||||
|
const modelName =
|
||||||
|
typeof model.name === "string" && model.name.trim()
|
||||||
|
? model.name
|
||||||
|
: modelId;
|
||||||
|
const providerDisplayName =
|
||||||
|
typeof provider.name === "string" && provider.name.trim()
|
||||||
|
? provider.name
|
||||||
|
: providerKey;
|
||||||
|
const value = `${providerKey}/${modelId}`;
|
||||||
|
const label = `${providerDisplayName} / ${modelName} (${modelId})`;
|
||||||
|
if (!dedupedOptions.has(value)) {
|
||||||
|
dedupedOptions.set(value, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawVariants = model.variants;
|
||||||
|
if (
|
||||||
|
rawVariants &&
|
||||||
|
typeof rawVariants === "object" &&
|
||||||
|
!Array.isArray(rawVariants)
|
||||||
|
) {
|
||||||
|
const variantKeys = Object.keys(rawVariants).filter(Boolean);
|
||||||
|
if (variantKeys.length > 0) {
|
||||||
|
variantsMap[value] = variantKeys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preset fallback: for models without config-defined variants,
|
||||||
|
// check if the npm package has preset variant definitions.
|
||||||
|
// Also collect preset metadata (options, limit) for enrichment.
|
||||||
|
const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm];
|
||||||
|
if (presetModels) {
|
||||||
|
for (const modelId of Object.keys(parsedConfig.models || {})) {
|
||||||
|
const fullKey = `${providerKey}/${modelId}`;
|
||||||
|
const preset = presetModels.find((p) => p.id === modelId);
|
||||||
|
if (!preset) continue;
|
||||||
|
|
||||||
|
// Variant fallback
|
||||||
|
if (!variantsMap[fullKey] && preset.variants) {
|
||||||
|
const presetKeys = Object.keys(preset.variants).filter(Boolean);
|
||||||
|
if (presetKeys.length > 0) {
|
||||||
|
variantsMap[fullKey] = presetKeys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect preset metadata for model enrichment
|
||||||
|
const meta: (typeof presetMetaMap)[string] = {};
|
||||||
|
if (preset.options) meta.options = preset.options;
|
||||||
|
if (preset.contextLimit || preset.outputLimit) {
|
||||||
|
meta.limit = {};
|
||||||
|
if (preset.contextLimit) meta.limit.context = preset.contextLimit;
|
||||||
|
if (preset.outputLimit) meta.limit.output = preset.outputLimit;
|
||||||
|
}
|
||||||
|
if (Object.keys(meta).length > 0) {
|
||||||
|
presetMetaMap[fullKey] = meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
options: Array.from(dedupedOptions.entries())
|
||||||
|
.map(([value, label]) => ({ value, label }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN")),
|
||||||
|
variantsMap,
|
||||||
|
presetMetaMap,
|
||||||
|
parseFailedProviders,
|
||||||
|
usedFallbackSource: omoLiveIdsLoadFailed,
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
isOmoCategory,
|
||||||
|
opencodeProvidersData?.providers,
|
||||||
|
enabledOpencodeProviderIds,
|
||||||
|
omoLiveIdsLoadFailed,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Warning toast for parse failures / fallback
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOmoCategory) return;
|
||||||
|
const failed = omoModelBuild.parseFailedProviders;
|
||||||
|
const fallback = omoModelBuild.usedFallbackSource;
|
||||||
|
if (failed.length === 0 && !fallback) return;
|
||||||
|
|
||||||
|
const signature = `${fallback ? "fallback:" : ""}${failed
|
||||||
|
.slice()
|
||||||
|
.sort()
|
||||||
|
.join(",")}`;
|
||||||
|
if (lastOmoModelSourceWarningRef.current === signature) return;
|
||||||
|
lastOmoModelSourceWarningRef.current = signature;
|
||||||
|
|
||||||
|
if (failed.length > 0) {
|
||||||
|
toast.warning(
|
||||||
|
t("omo.modelSourcePartialWarning", {
|
||||||
|
count: failed.length,
|
||||||
|
defaultValue:
|
||||||
|
"Some provider model configs are invalid and were skipped.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fallback) {
|
||||||
|
toast.warning(
|
||||||
|
t("omo.modelSourceFallbackWarning", {
|
||||||
|
defaultValue:
|
||||||
|
"Failed to load live provider state. Falling back to configured providers.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isOmoCategory,
|
||||||
|
omoModelBuild.parseFailedProviders,
|
||||||
|
omoModelBuild.usedFallbackSource,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
omoModelOptions: omoModelBuild.options,
|
||||||
|
omoModelVariantsMap: omoModelBuild.variantsMap,
|
||||||
|
omoPresetMetaMap: omoModelBuild.presetMetaMap,
|
||||||
|
existingOpencodeKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import type { OpenClawModel } from "@/types";
|
||||||
|
import type { AppId } from "@/lib/api";
|
||||||
|
import { useProvidersQuery } from "@/lib/query/queries";
|
||||||
|
import { OPENCLAW_DEFAULT_CONFIG } from "../helpers/opencodeFormUtils";
|
||||||
|
|
||||||
|
interface UseOpenclawFormStateParams {
|
||||||
|
initialData?: {
|
||||||
|
settingsConfig?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
appId: AppId;
|
||||||
|
providerId?: string;
|
||||||
|
onSettingsConfigChange: (config: string) => void;
|
||||||
|
getSettingsConfig: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenclawFormState {
|
||||||
|
openclawProviderKey: string;
|
||||||
|
setOpenclawProviderKey: (key: string) => void;
|
||||||
|
openclawBaseUrl: string;
|
||||||
|
openclawApiKey: string;
|
||||||
|
openclawApi: string;
|
||||||
|
openclawModels: OpenClawModel[];
|
||||||
|
existingOpenclawKeys: string[];
|
||||||
|
handleOpenclawBaseUrlChange: (baseUrl: string) => void;
|
||||||
|
handleOpenclawApiKeyChange: (apiKey: string) => void;
|
||||||
|
handleOpenclawApiChange: (api: string) => void;
|
||||||
|
handleOpenclawModelsChange: (models: OpenClawModel[]) => void;
|
||||||
|
resetOpenclawState: (config?: {
|
||||||
|
baseUrl?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
api?: string;
|
||||||
|
models?: OpenClawModel[];
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOpenclawField<T>(
|
||||||
|
initialData: UseOpenclawFormStateParams["initialData"],
|
||||||
|
field: string,
|
||||||
|
fallback: T,
|
||||||
|
): T {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
initialData?.settingsConfig
|
||||||
|
? JSON.stringify(initialData.settingsConfig)
|
||||||
|
: OPENCLAW_DEFAULT_CONFIG,
|
||||||
|
);
|
||||||
|
return (config[field] as T) || fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpenclawFormState({
|
||||||
|
initialData,
|
||||||
|
appId,
|
||||||
|
providerId,
|
||||||
|
onSettingsConfigChange,
|
||||||
|
getSettingsConfig,
|
||||||
|
}: UseOpenclawFormStateParams): OpenclawFormState {
|
||||||
|
// Query existing providers for duplicate key checking
|
||||||
|
const { data: openclawProvidersData } = useProvidersQuery("openclaw");
|
||||||
|
const existingOpenclawKeys = useMemo(() => {
|
||||||
|
if (!openclawProvidersData?.providers) return [];
|
||||||
|
return Object.keys(openclawProvidersData.providers).filter(
|
||||||
|
(k) => k !== providerId,
|
||||||
|
);
|
||||||
|
}, [openclawProvidersData?.providers, providerId]);
|
||||||
|
|
||||||
|
const [openclawProviderKey, setOpenclawProviderKey] = useState<string>(() => {
|
||||||
|
if (appId !== "openclaw") return "";
|
||||||
|
return providerId || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [openclawBaseUrl, setOpenclawBaseUrl] = useState<string>(() => {
|
||||||
|
if (appId !== "openclaw") return "";
|
||||||
|
return parseOpenclawField(initialData, "baseUrl", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
const [openclawApiKey, setOpenclawApiKey] = useState<string>(() => {
|
||||||
|
if (appId !== "openclaw") return "";
|
||||||
|
return parseOpenclawField(initialData, "apiKey", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
const [openclawApi, setOpenclawApi] = useState<string>(() => {
|
||||||
|
if (appId !== "openclaw") return "openai-completions";
|
||||||
|
return parseOpenclawField(initialData, "api", "openai-completions");
|
||||||
|
});
|
||||||
|
|
||||||
|
const [openclawModels, setOpenclawModels] = useState<OpenClawModel[]>(() => {
|
||||||
|
if (appId !== "openclaw") return [];
|
||||||
|
return parseOpenclawField<OpenClawModel[]>(initialData, "models", []);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateOpenclawConfig = useCallback(
|
||||||
|
(updater: (config: Record<string, any>) => void) => {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
getSettingsConfig() || OPENCLAW_DEFAULT_CONFIG,
|
||||||
|
);
|
||||||
|
updater(config);
|
||||||
|
onSettingsConfigChange(JSON.stringify(config, null, 2));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getSettingsConfig, onSettingsConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenclawBaseUrlChange = useCallback(
|
||||||
|
(baseUrl: string) => {
|
||||||
|
setOpenclawBaseUrl(baseUrl);
|
||||||
|
updateOpenclawConfig((config) => {
|
||||||
|
config.baseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateOpenclawConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenclawApiKeyChange = useCallback(
|
||||||
|
(apiKey: string) => {
|
||||||
|
setOpenclawApiKey(apiKey);
|
||||||
|
updateOpenclawConfig((config) => {
|
||||||
|
config.apiKey = apiKey;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateOpenclawConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenclawApiChange = useCallback(
|
||||||
|
(api: string) => {
|
||||||
|
setOpenclawApi(api);
|
||||||
|
updateOpenclawConfig((config) => {
|
||||||
|
config.api = api;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateOpenclawConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenclawModelsChange = useCallback(
|
||||||
|
(models: OpenClawModel[]) => {
|
||||||
|
setOpenclawModels(models);
|
||||||
|
updateOpenclawConfig((config) => {
|
||||||
|
config.models = models;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateOpenclawConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetOpenclawState = useCallback(
|
||||||
|
(config?: {
|
||||||
|
baseUrl?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
api?: string;
|
||||||
|
models?: OpenClawModel[];
|
||||||
|
}) => {
|
||||||
|
setOpenclawProviderKey("");
|
||||||
|
setOpenclawBaseUrl(config?.baseUrl || "");
|
||||||
|
setOpenclawApiKey(config?.apiKey || "");
|
||||||
|
setOpenclawApi(config?.api || "openai-completions");
|
||||||
|
setOpenclawModels(config?.models || []);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
openclawProviderKey,
|
||||||
|
setOpenclawProviderKey,
|
||||||
|
openclawBaseUrl,
|
||||||
|
openclawApiKey,
|
||||||
|
openclawApi,
|
||||||
|
openclawModels,
|
||||||
|
existingOpenclawKeys,
|
||||||
|
handleOpenclawBaseUrlChange,
|
||||||
|
handleOpenclawApiKeyChange,
|
||||||
|
handleOpenclawApiChange,
|
||||||
|
handleOpenclawModelsChange,
|
||||||
|
resetOpenclawState,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types";
|
||||||
|
import {
|
||||||
|
OPENCODE_DEFAULT_NPM,
|
||||||
|
OPENCODE_DEFAULT_CONFIG,
|
||||||
|
isKnownOpencodeOptionKey,
|
||||||
|
parseOpencodeConfig,
|
||||||
|
toOpencodeExtraOptions,
|
||||||
|
} from "../helpers/opencodeFormUtils";
|
||||||
|
|
||||||
|
interface UseOpencodeFormStateParams {
|
||||||
|
initialData?: {
|
||||||
|
settingsConfig?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
appId: string;
|
||||||
|
providerId?: string;
|
||||||
|
onSettingsConfigChange: (config: string) => void;
|
||||||
|
getSettingsConfig: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpencodeFormState {
|
||||||
|
opencodeProviderKey: string;
|
||||||
|
setOpencodeProviderKey: (key: string) => void;
|
||||||
|
opencodeNpm: string;
|
||||||
|
opencodeApiKey: string;
|
||||||
|
opencodeBaseUrl: string;
|
||||||
|
opencodeModels: Record<string, OpenCodeModel>;
|
||||||
|
opencodeExtraOptions: Record<string, string>;
|
||||||
|
handleOpencodeNpmChange: (npm: string) => void;
|
||||||
|
handleOpencodeApiKeyChange: (apiKey: string) => void;
|
||||||
|
handleOpencodeBaseUrlChange: (baseUrl: string) => void;
|
||||||
|
handleOpencodeModelsChange: (models: Record<string, OpenCodeModel>) => void;
|
||||||
|
handleOpencodeExtraOptionsChange: (options: Record<string, string>) => void;
|
||||||
|
resetOpencodeState: (config?: OpenCodeProviderConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpencodeFormState({
|
||||||
|
initialData,
|
||||||
|
appId,
|
||||||
|
providerId,
|
||||||
|
onSettingsConfigChange,
|
||||||
|
getSettingsConfig,
|
||||||
|
}: UseOpencodeFormStateParams): OpencodeFormState {
|
||||||
|
const initialOpencodeConfig =
|
||||||
|
appId === "opencode"
|
||||||
|
? parseOpencodeConfig(initialData?.settingsConfig)
|
||||||
|
: null;
|
||||||
|
const initialOpencodeOptions = initialOpencodeConfig?.options || {};
|
||||||
|
|
||||||
|
const [opencodeProviderKey, setOpencodeProviderKey] = useState<string>(() => {
|
||||||
|
if (appId !== "opencode") return "";
|
||||||
|
return providerId || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [opencodeNpm, setOpencodeNpm] = useState<string>(() => {
|
||||||
|
if (appId !== "opencode") return OPENCODE_DEFAULT_NPM;
|
||||||
|
return initialOpencodeConfig?.npm || OPENCODE_DEFAULT_NPM;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [opencodeApiKey, setOpencodeApiKey] = useState<string>(() => {
|
||||||
|
if (appId !== "opencode") return "";
|
||||||
|
const value = initialOpencodeOptions.apiKey;
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [opencodeBaseUrl, setOpencodeBaseUrl] = useState<string>(() => {
|
||||||
|
if (appId !== "opencode") return "";
|
||||||
|
const value = initialOpencodeOptions.baseURL;
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [opencodeModels, setOpencodeModels] = useState<
|
||||||
|
Record<string, OpenCodeModel>
|
||||||
|
>(() => {
|
||||||
|
if (appId !== "opencode") return {};
|
||||||
|
return initialOpencodeConfig?.models || {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const [opencodeExtraOptions, setOpencodeExtraOptions] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>(() => {
|
||||||
|
if (appId !== "opencode") return {};
|
||||||
|
return toOpencodeExtraOptions(initialOpencodeOptions);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateOpencodeSettings = useCallback(
|
||||||
|
(updater: (config: Record<string, any>) => void) => {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(
|
||||||
|
getSettingsConfig() || OPENCODE_DEFAULT_CONFIG,
|
||||||
|
) as Record<string, any>;
|
||||||
|
updater(config);
|
||||||
|
onSettingsConfigChange(JSON.stringify(config, null, 2));
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
[getSettingsConfig, onSettingsConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpencodeNpmChange = useCallback(
|
||||||
|
(npm: string) => {
|
||||||
|
setOpencodeNpm(npm);
|
||||||
|
updateOpencodeSettings((config) => {
|
||||||
|
config.npm = npm;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateOpencodeSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpencodeApiKeyChange = useCallback(
|
||||||
|
(apiKey: string) => {
|
||||||
|
setOpencodeApiKey(apiKey);
|
||||||
|
updateOpencodeSettings((config) => {
|
||||||
|
if (!config.options) config.options = {};
|
||||||
|
config.options.apiKey = apiKey;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateOpencodeSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpencodeBaseUrlChange = useCallback(
|
||||||
|
(baseUrl: string) => {
|
||||||
|
setOpencodeBaseUrl(baseUrl);
|
||||||
|
updateOpencodeSettings((config) => {
|
||||||
|
if (!config.options) config.options = {};
|
||||||
|
config.options.baseURL = baseUrl.trim().replace(/\/+$/, "");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateOpencodeSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpencodeModelsChange = useCallback(
|
||||||
|
(models: Record<string, OpenCodeModel>) => {
|
||||||
|
setOpencodeModels(models);
|
||||||
|
updateOpencodeSettings((config) => {
|
||||||
|
config.models = models;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateOpencodeSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpencodeExtraOptionsChange = useCallback(
|
||||||
|
(options: Record<string, string>) => {
|
||||||
|
setOpencodeExtraOptions(options);
|
||||||
|
updateOpencodeSettings((config) => {
|
||||||
|
if (!config.options) config.options = {};
|
||||||
|
|
||||||
|
for (const k of Object.keys(config.options)) {
|
||||||
|
if (!isKnownOpencodeOptionKey(k)) {
|
||||||
|
delete config.options[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(options)) {
|
||||||
|
const trimmedKey = k.trim();
|
||||||
|
if (trimmedKey && !trimmedKey.startsWith("option-")) {
|
||||||
|
try {
|
||||||
|
config.options[trimmedKey] = JSON.parse(v);
|
||||||
|
} catch {
|
||||||
|
config.options[trimmedKey] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[updateOpencodeSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetOpencodeState = useCallback((config?: OpenCodeProviderConfig) => {
|
||||||
|
setOpencodeProviderKey("");
|
||||||
|
setOpencodeNpm(config?.npm || OPENCODE_DEFAULT_NPM);
|
||||||
|
setOpencodeBaseUrl(config?.options?.baseURL || "");
|
||||||
|
setOpencodeApiKey(config?.options?.apiKey || "");
|
||||||
|
setOpencodeModels(config?.models || {});
|
||||||
|
setOpencodeExtraOptions(toOpencodeExtraOptions(config?.options || {}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
opencodeProviderKey,
|
||||||
|
setOpencodeProviderKey,
|
||||||
|
opencodeNpm,
|
||||||
|
opencodeApiKey,
|
||||||
|
opencodeBaseUrl,
|
||||||
|
opencodeModels,
|
||||||
|
opencodeExtraOptions,
|
||||||
|
handleOpencodeNpmChange,
|
||||||
|
handleOpencodeApiKeyChange,
|
||||||
|
handleOpencodeBaseUrlChange,
|
||||||
|
handleOpencodeModelsChange,
|
||||||
|
handleOpencodeExtraOptionsChange,
|
||||||
|
resetOpencodeState,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -408,9 +408,6 @@ export function ProxyPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 空白区域避免冲突 */}
|
|
||||||
<div className="h-4"></div>
|
|
||||||
|
|
||||||
{/* 基础设置 - 监听地址/端口 */}
|
{/* 基础设置 - 监听地址/端口 */}
|
||||||
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
|
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -46,9 +46,15 @@ import {
|
|||||||
getSessionKey,
|
getSessionKey,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
type ProviderFilter = "all" | "codex" | "claude";
|
type ProviderFilter =
|
||||||
|
| "all"
|
||||||
|
| "codex"
|
||||||
|
| "claude"
|
||||||
|
| "opencode"
|
||||||
|
| "openclaw"
|
||||||
|
| "gemini";
|
||||||
|
|
||||||
export function SessionManagerPage() {
|
export function SessionManagerPage({ appId }: { appId: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, refetch } = useSessionsQuery();
|
const { data, isLoading, refetch } = useSessionsQuery();
|
||||||
const sessions = data ?? [];
|
const sessions = data ?? [];
|
||||||
@@ -63,7 +69,9 @@ export function SessionManagerPage() {
|
|||||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [providerFilter, setProviderFilter] = useState<ProviderFilter>("all");
|
const [providerFilter, setProviderFilter] = useState<ProviderFilter>(
|
||||||
|
appId as ProviderFilter,
|
||||||
|
);
|
||||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
|
|
||||||
// 使用 FlexSearch 全文搜索
|
// 使用 FlexSearch 全文搜索
|
||||||
@@ -265,9 +273,7 @@ export function SessionManagerPage() {
|
|||||||
icon={
|
icon={
|
||||||
providerFilter === "all"
|
providerFilter === "all"
|
||||||
? "apps"
|
? "apps"
|
||||||
: providerFilter === "codex"
|
: getProviderIconName(providerFilter)
|
||||||
? "openai"
|
|
||||||
: "claude"
|
|
||||||
}
|
}
|
||||||
name={providerFilter}
|
name={providerFilter}
|
||||||
size={14}
|
size={14}
|
||||||
@@ -309,6 +315,36 @@ export function SessionManagerPage() {
|
|||||||
<span>Claude Code</span>
|
<span>Claude Code</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="opencode">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderIcon
|
||||||
|
icon="opencode"
|
||||||
|
name="opencode"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<span>OpenCode</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="openclaw">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderIcon
|
||||||
|
icon="openclaw"
|
||||||
|
name="openclaw"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<span>OpenClaw</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="gemini">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderIcon
|
||||||
|
icon="gemini"
|
||||||
|
name="gemini"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<span>Gemini CLI</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export const getProviderLabel = (
|
|||||||
export const getProviderIconName = (providerId: string) => {
|
export const getProviderIconName = (providerId: string) => {
|
||||||
if (providerId === "codex") return "openai";
|
if (providerId === "codex") return "openai";
|
||||||
if (providerId === "claude") return "claude";
|
if (providerId === "claude") return "claude";
|
||||||
|
if (providerId === "opencode") return "opencode";
|
||||||
|
if (providerId === "openclaw") return "openclaw";
|
||||||
return providerId;
|
return providerId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
@@ -30,8 +37,48 @@ interface ToolVersion {
|
|||||||
version: string | null;
|
version: string | null;
|
||||||
latest_version: string | null;
|
latest_version: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
env_type: "windows" | "wsl" | "macos" | "linux" | "unknown";
|
||||||
|
wsl_distro: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOOL_NAMES = ["claude", "codex", "gemini", "opencode"] as const;
|
||||||
|
type ToolName = (typeof TOOL_NAMES)[number];
|
||||||
|
|
||||||
|
type WslShellPreference = {
|
||||||
|
wslShell?: string | null;
|
||||||
|
wslShellFlag?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WSL_SHELL_OPTIONS = ["sh", "bash", "zsh", "fish", "dash"] as const;
|
||||||
|
// UI-friendly order: login shell first.
|
||||||
|
const WSL_SHELL_FLAG_OPTIONS = ["-lic", "-lc", "-c"] as const;
|
||||||
|
|
||||||
|
const ENV_BADGE_CONFIG: Record<
|
||||||
|
string,
|
||||||
|
{ labelKey: string; className: string }
|
||||||
|
> = {
|
||||||
|
wsl: {
|
||||||
|
labelKey: "settings.envBadge.wsl",
|
||||||
|
className:
|
||||||
|
"bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
|
||||||
|
},
|
||||||
|
windows: {
|
||||||
|
labelKey: "settings.envBadge.windows",
|
||||||
|
className:
|
||||||
|
"bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
||||||
|
},
|
||||||
|
macos: {
|
||||||
|
labelKey: "settings.envBadge.macos",
|
||||||
|
className:
|
||||||
|
"bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20",
|
||||||
|
},
|
||||||
|
linux: {
|
||||||
|
labelKey: "settings.envBadge.linux",
|
||||||
|
className:
|
||||||
|
"bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const ONE_CLICK_INSTALL_COMMANDS = `# Claude Code (Native install - recommended)
|
const ONE_CLICK_INSTALL_COMMANDS = `# Claude Code (Native install - recommended)
|
||||||
curl -fsSL https://claude.ai/install.sh | bash
|
curl -fsSL https://claude.ai/install.sh | bash
|
||||||
# Codex
|
# Codex
|
||||||
@@ -59,30 +106,104 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
isChecking,
|
isChecking,
|
||||||
} = useUpdate();
|
} = useUpdate();
|
||||||
|
|
||||||
const loadToolVersions = useCallback(async () => {
|
const [wslShellByTool, setWslShellByTool] = useState<
|
||||||
|
Record<string, WslShellPreference>
|
||||||
|
>({});
|
||||||
|
const [loadingTools, setLoadingTools] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const refreshToolVersions = useCallback(
|
||||||
|
async (
|
||||||
|
toolNames: ToolName[],
|
||||||
|
wslOverrides?: Record<string, WslShellPreference>,
|
||||||
|
) => {
|
||||||
|
if (toolNames.length === 0) return;
|
||||||
|
|
||||||
|
// 单工具刷新使用统一后端入口(get_tool_versions)并带工具过滤。
|
||||||
|
setLoadingTools((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const name of toolNames) next[name] = true;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await settingsApi.getToolVersions(
|
||||||
|
toolNames,
|
||||||
|
wslOverrides,
|
||||||
|
);
|
||||||
|
|
||||||
|
setToolVersions((prev) => {
|
||||||
|
if (prev.length === 0) return updated;
|
||||||
|
const byName = new Map(updated.map((t) => [t.name, t]));
|
||||||
|
const merged = prev.map((t) => byName.get(t.name) ?? t);
|
||||||
|
const existing = new Set(prev.map((t) => t.name));
|
||||||
|
for (const u of updated) {
|
||||||
|
if (!existing.has(u.name)) merged.push(u);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AboutSection] Failed to refresh tools", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingTools((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const name of toolNames) next[name] = false;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadAllToolVersions = useCallback(async () => {
|
||||||
setIsLoadingTools(true);
|
setIsLoadingTools(true);
|
||||||
try {
|
try {
|
||||||
const tools = await settingsApi.getToolVersions();
|
// Respect current UI overrides (shell / flag) when doing a full refresh.
|
||||||
setToolVersions(tools);
|
const versions = await settingsApi.getToolVersions(
|
||||||
|
[...TOOL_NAMES],
|
||||||
|
wslShellByTool,
|
||||||
|
);
|
||||||
|
setToolVersions(versions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AboutSection] Failed to load tool versions", error);
|
console.error("[AboutSection] Failed to load tool versions", error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingTools(false);
|
setIsLoadingTools(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [wslShellByTool]);
|
||||||
|
|
||||||
|
const handleToolShellChange = async (toolName: ToolName, value: string) => {
|
||||||
|
const wslShell = value === "auto" ? null : value;
|
||||||
|
const nextPref: WslShellPreference = {
|
||||||
|
...(wslShellByTool[toolName] ?? {}),
|
||||||
|
wslShell,
|
||||||
|
};
|
||||||
|
setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref }));
|
||||||
|
await refreshToolVersions([toolName], { [toolName]: nextPref });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToolShellFlagChange = async (
|
||||||
|
toolName: ToolName,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const wslShellFlag = value === "auto" ? null : value;
|
||||||
|
const nextPref: WslShellPreference = {
|
||||||
|
...(wslShellByTool[toolName] ?? {}),
|
||||||
|
wslShellFlag,
|
||||||
|
};
|
||||||
|
setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref }));
|
||||||
|
await refreshToolVersions([toolName], { [toolName]: nextPref });
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const [appVersion, tools] = await Promise.all([
|
const [appVersion] = await Promise.all([
|
||||||
getVersion(),
|
getVersion(),
|
||||||
settingsApi.getToolVersions(),
|
loadAllToolVersions(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
setVersion(appVersion);
|
setVersion(appVersion);
|
||||||
setToolVersions(tools);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AboutSection] Failed to load info", error);
|
console.error("[AboutSection] Failed to load info", error);
|
||||||
@@ -92,7 +213,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
if (active) {
|
if (active) {
|
||||||
setIsLoadingVersion(false);
|
setIsLoadingVersion(false);
|
||||||
setIsLoadingTools(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -101,6 +221,10 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
|
// Mount-only: loadAllToolVersions is intentionally excluded to avoid
|
||||||
|
// re-fetching all tools whenever wslShellByTool changes. Single-tool
|
||||||
|
// refreshes are handled by refreshToolVersions in the shell/flag handlers.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ...
|
// ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ...
|
||||||
@@ -306,7 +430,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 gap-1.5 text-xs"
|
className="h-7 gap-1.5 text-xs"
|
||||||
onClick={loadToolVersions}
|
onClick={() => loadAllToolVersions()}
|
||||||
disabled={isLoadingTools}
|
disabled={isLoadingTools}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
@@ -317,8 +441,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
|
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 px-1">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 px-1">
|
||||||
{["claude", "codex", "gemini", "opencode"].map((toolName, index) => {
|
{TOOL_NAMES.map((toolName, index) => {
|
||||||
const tool = toolVersions.find((item) => item.name === toolName);
|
const tool = toolVersions.find((item) => item.name === toolName);
|
||||||
// Special case for OpenCode (capital C), others use capitalize
|
// Special case for OpenCode (capital C), others use capitalize
|
||||||
const displayName =
|
const displayName =
|
||||||
@@ -340,8 +465,64 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">{displayName}</span>
|
<span className="text-sm font-medium">{displayName}</span>
|
||||||
|
{/* Environment Badge */}
|
||||||
|
{tool?.env_type && ENV_BADGE_CONFIG[tool.env_type] && (
|
||||||
|
<span
|
||||||
|
className={`text-[9px] px-1.5 py-0.5 rounded-full border ${ENV_BADGE_CONFIG[tool.env_type].className}`}
|
||||||
|
>
|
||||||
|
{t(ENV_BADGE_CONFIG[tool.env_type].labelKey)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* WSL Shell Selector */}
|
||||||
|
{tool?.env_type === "wsl" && (
|
||||||
|
<Select
|
||||||
|
value={wslShellByTool[toolName]?.wslShell || "auto"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
handleToolShellChange(toolName, v)
|
||||||
|
}
|
||||||
|
disabled={isLoadingTools || loadingTools[toolName]}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-[70px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">
|
||||||
|
{t("common.auto")}
|
||||||
|
</SelectItem>
|
||||||
|
{WSL_SHELL_OPTIONS.map((shell) => (
|
||||||
|
<SelectItem key={shell} value={shell}>
|
||||||
|
{shell}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{/* WSL Shell Flag Selector */}
|
||||||
|
{tool?.env_type === "wsl" && (
|
||||||
|
<Select
|
||||||
|
value={wslShellByTool[toolName]?.wslShellFlag || "auto"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
handleToolShellFlagChange(toolName, v)
|
||||||
|
}
|
||||||
|
disabled={isLoadingTools || loadingTools[toolName]}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-[70px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">
|
||||||
|
{t("common.auto")}
|
||||||
|
</SelectItem>
|
||||||
|
{WSL_SHELL_FLAG_OPTIONS.map((flag) => (
|
||||||
|
<SelectItem key={flag} value={flag}>
|
||||||
|
{flag}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingTools ? (
|
{isLoadingTools || loadingTools[toolName] ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
) : tool?.version ? (
|
) : tool?.version ? (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Pencil, RotateCcw, Check, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useBackupManager } from "@/hooks/useBackupManager";
|
||||||
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
|
|
||||||
|
interface BackupListSectionProps {
|
||||||
|
backupIntervalHours?: number;
|
||||||
|
backupRetainCount?: number;
|
||||||
|
onSettingsChange: (updates: {
|
||||||
|
backupIntervalHours?: number;
|
||||||
|
backupRetainCount?: number;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBackupDate(isoString: string): string {
|
||||||
|
try {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse display name from backup filename */
|
||||||
|
function getDisplayName(filename: string): string {
|
||||||
|
// Try to parse db_backup_YYYYMMDD_HHMMSS format
|
||||||
|
const match = filename.match(
|
||||||
|
/^db_backup_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})(?:_\d+)?\.db$/,
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
const [, y, m, d, hh, mm, ss] = match;
|
||||||
|
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
// Otherwise show filename without .db suffix
|
||||||
|
return filename.replace(/\.db$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupListSection({
|
||||||
|
backupIntervalHours,
|
||||||
|
backupRetainCount,
|
||||||
|
onSettingsChange,
|
||||||
|
}: BackupListSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { backups, isLoading, restore, isRestoring, rename, isRenaming } =
|
||||||
|
useBackupManager();
|
||||||
|
const [confirmFilename, setConfirmFilename] = useState<string | null>(null);
|
||||||
|
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState("");
|
||||||
|
|
||||||
|
const handleRestore = async () => {
|
||||||
|
if (!confirmFilename) return;
|
||||||
|
try {
|
||||||
|
const safetyId = await restore(confirmFilename);
|
||||||
|
setConfirmFilename(null);
|
||||||
|
toast.success(
|
||||||
|
t("settings.backupManager.restoreSuccess", {
|
||||||
|
defaultValue: "Restore successful! Safety backup created",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
description: safetyId
|
||||||
|
? `${t("settings.backupManager.safetyBackupId", { defaultValue: "Safety Backup ID" })}: ${safetyId}`
|
||||||
|
: undefined,
|
||||||
|
duration: 6000,
|
||||||
|
closeButton: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const detail =
|
||||||
|
extractErrorMessage(error) ||
|
||||||
|
t("settings.backupManager.restoreFailed", {
|
||||||
|
defaultValue: "Restore failed",
|
||||||
|
});
|
||||||
|
toast.error(detail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartRename = (filename: string) => {
|
||||||
|
setEditingFilename(filename);
|
||||||
|
setEditValue(getDisplayName(filename));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelRename = () => {
|
||||||
|
setEditingFilename(null);
|
||||||
|
setEditValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmRename = async () => {
|
||||||
|
if (!editingFilename || !editValue.trim()) return;
|
||||||
|
try {
|
||||||
|
await rename({ oldFilename: editingFilename, newName: editValue.trim() });
|
||||||
|
setEditingFilename(null);
|
||||||
|
setEditValue("");
|
||||||
|
toast.success(
|
||||||
|
t("settings.backupManager.renameSuccess", {
|
||||||
|
defaultValue: "Backup renamed",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const detail =
|
||||||
|
extractErrorMessage(error) ||
|
||||||
|
t("settings.backupManager.renameFailed", {
|
||||||
|
defaultValue: "Rename failed",
|
||||||
|
});
|
||||||
|
toast.error(detail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalValue = String(backupIntervalHours ?? 24);
|
||||||
|
const retainValue = String(backupRetainCount ?? 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Backup policy settings */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">
|
||||||
|
{t("settings.backupManager.intervalLabel", {
|
||||||
|
defaultValue: "Auto-backup Interval",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={intervalValue}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onSettingsChange({ backupIntervalHours: Number(v) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">
|
||||||
|
{t("settings.backupManager.intervalDisabled", {
|
||||||
|
defaultValue: "Disabled",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="6">
|
||||||
|
{t("settings.backupManager.intervalHours", {
|
||||||
|
hours: 6,
|
||||||
|
defaultValue: "6 hours",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="12">
|
||||||
|
{t("settings.backupManager.intervalHours", {
|
||||||
|
hours: 12,
|
||||||
|
defaultValue: "12 hours",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="24">
|
||||||
|
{t("settings.backupManager.intervalHours", {
|
||||||
|
hours: 24,
|
||||||
|
defaultValue: "24 hours",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="48">
|
||||||
|
{t("settings.backupManager.intervalHours", {
|
||||||
|
hours: 48,
|
||||||
|
defaultValue: "48 hours",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="168">
|
||||||
|
{t("settings.backupManager.intervalDays", {
|
||||||
|
days: 7,
|
||||||
|
defaultValue: "7 days",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">
|
||||||
|
{t("settings.backupManager.retainLabel", {
|
||||||
|
defaultValue: "Backup Retention",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={retainValue}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onSettingsChange({ backupRetainCount: Number(v) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[3, 5, 10, 15, 20, 30, 50].map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>
|
||||||
|
{n}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backup list */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2">
|
||||||
|
{t("settings.backupManager.title", {
|
||||||
|
defaultValue: "Database Backups",
|
||||||
|
})}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-2">Loading...</div>
|
||||||
|
) : backups.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-2">
|
||||||
|
{t("settings.backupManager.empty", {
|
||||||
|
defaultValue: "No backups yet",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||||
|
{backups.map((backup) => (
|
||||||
|
<div
|
||||||
|
key={backup.filename}
|
||||||
|
className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{editingFilename === backup.filename ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleConfirmRename();
|
||||||
|
if (e.key === "Escape") handleCancelRename();
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
placeholder={t(
|
||||||
|
"settings.backupManager.namePlaceholder",
|
||||||
|
{ defaultValue: "Enter new name" },
|
||||||
|
)}
|
||||||
|
autoFocus
|
||||||
|
disabled={isRenaming}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0"
|
||||||
|
onClick={handleConfirmRename}
|
||||||
|
disabled={isRenaming || !editValue.trim()}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0"
|
||||||
|
onClick={handleCancelRename}
|
||||||
|
disabled={isRenaming}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="font-mono text-xs truncate">
|
||||||
|
{getDisplayName(backup.filename)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatBackupDate(backup.createdAt)} ·{" "}
|
||||||
|
{formatBytes(backup.sizeBytes)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingFilename !== backup.filename && (
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => handleStartRename(backup.filename)}
|
||||||
|
disabled={isRestoring || isRenaming}
|
||||||
|
title={t("settings.backupManager.rename", {
|
||||||
|
defaultValue: "Rename",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
disabled={isRestoring}
|
||||||
|
onClick={() => setConfirmFilename(backup.filename)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3 mr-1" />
|
||||||
|
{isRestoring
|
||||||
|
? t("settings.backupManager.restoring", {
|
||||||
|
defaultValue: "Restoring...",
|
||||||
|
})
|
||||||
|
: t("settings.backupManager.restore", {
|
||||||
|
defaultValue: "Restore",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!confirmFilename}
|
||||||
|
onOpenChange={(open) => !open && setConfirmFilename(null)}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-md" zIndex="alert">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("settings.backupManager.confirmTitle", {
|
||||||
|
defaultValue: "Confirm Restore",
|
||||||
|
})}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("settings.backupManager.confirmMessage", {
|
||||||
|
defaultValue:
|
||||||
|
"Restoring this backup will overwrite the current database. A safety backup will be created first.",
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setConfirmFilename(null)}
|
||||||
|
disabled={isRestoring}
|
||||||
|
>
|
||||||
|
{t("common.cancel", { defaultValue: "Cancel" })}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleRestore} disabled={isRestoring}>
|
||||||
|
{isRestoring
|
||||||
|
? t("settings.backupManager.restoring", {
|
||||||
|
defaultValue: "Restoring...",
|
||||||
|
})
|
||||||
|
: t("settings.backupManager.restore", {
|
||||||
|
defaultValue: "Restore",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ export function DirectorySettings({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
{/* CC Switch 配置目录 - 独立区块 */}
|
{/* CC Switch 配置目录 - 独立区块 */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<header className="space-y-1">
|
<header className="space-y-1">
|
||||||
@@ -131,7 +131,7 @@ export function DirectorySettings({
|
|||||||
onReset={() => onResetDirectory("opencode")}
|
onReset={() => onResetDirectory("opencode")}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function ImportExportSection({
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="space-y-4 rounded-xl glass-card p-6 border border-white/10">
|
<div className="space-y-4 rounded-lg border border-border bg-muted/40 p-6">
|
||||||
{/* Import and Export Buttons Side by Side */}
|
{/* Import and Export Buttons Side by Side */}
|
||||||
<div className="grid grid-cols-2 gap-4 items-stretch">
|
<div className="grid grid-cols-2 gap-4 items-stretch">
|
||||||
{/* Import Button */}
|
{/* Import Button */}
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
|
import { Server, Activity, ChevronDown, Zap, Globe } from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { ToggleRow } from "@/components/ui/toggle-row";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ProxyPanel } from "@/components/proxy";
|
||||||
|
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
||||||
|
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
||||||
|
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
|
||||||
|
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
|
||||||
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
|
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||||
|
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||||
|
|
||||||
|
interface ProxyTabContentProps {
|
||||||
|
settings: SettingsFormState;
|
||||||
|
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProxyTabContent({
|
||||||
|
settings,
|
||||||
|
onAutoSave,
|
||||||
|
}: ProxyTabContentProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [showProxyConfirm, setShowProxyConfirm] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isRunning,
|
||||||
|
startProxyServer,
|
||||||
|
stopWithRestore,
|
||||||
|
isPending: isProxyPending,
|
||||||
|
} = useProxyStatus();
|
||||||
|
|
||||||
|
const handleToggleProxy = async (checked: boolean) => {
|
||||||
|
try {
|
||||||
|
if (!checked) {
|
||||||
|
await stopWithRestore();
|
||||||
|
} else if (!settings?.proxyConfirmed) {
|
||||||
|
setShowProxyConfirm(true);
|
||||||
|
} else {
|
||||||
|
await startProxyServer();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Toggle proxy failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProxyConfirm = async () => {
|
||||||
|
setShowProxyConfirm(false);
|
||||||
|
try {
|
||||||
|
await onAutoSave({ proxyConfirmed: true });
|
||||||
|
await startProxyServer();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Proxy confirm failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<Accordion type="multiple" defaultValue={[]} className="w-full space-y-4">
|
||||||
|
{/* Local Proxy */}
|
||||||
|
<AccordionItem
|
||||||
|
value="proxy"
|
||||||
|
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
|
||||||
|
>
|
||||||
|
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
|
||||||
|
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Server className="h-5 w-5 text-green-500" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{t("settings.advanced.proxy.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
|
{t("settings.advanced.proxy.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 pl-4">
|
||||||
|
<Badge
|
||||||
|
variant={isRunning ? "default" : "secondary"}
|
||||||
|
className="gap-1.5 h-6"
|
||||||
|
>
|
||||||
|
<Activity
|
||||||
|
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
||||||
|
/>
|
||||||
|
{isRunning
|
||||||
|
? t("settings.advanced.proxy.running")
|
||||||
|
: t("settings.advanced.proxy.stopped")}
|
||||||
|
</Badge>
|
||||||
|
<Switch
|
||||||
|
checked={isRunning}
|
||||||
|
onCheckedChange={handleToggleProxy}
|
||||||
|
disabled={isProxyPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||||
|
<ToggleRow
|
||||||
|
icon={<Zap className="h-4 w-4 text-green-500" />}
|
||||||
|
title={t("settings.advanced.proxy.enableFeature")}
|
||||||
|
description={t(
|
||||||
|
"settings.advanced.proxy.enableFeatureDescription",
|
||||||
|
)}
|
||||||
|
checked={settings?.enableLocalProxy ?? false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onAutoSave({ enableLocalProxy: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="mt-4">
|
||||||
|
<ProxyPanel />
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Auto Failover */}
|
||||||
|
<AccordionItem
|
||||||
|
value="failover"
|
||||||
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Activity className="h-5 w-5 text-orange-500" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{t("settings.advanced.failover.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
|
{t("settings.advanced.failover.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{!isRunning && (
|
||||||
|
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||||
|
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||||
|
{t("proxy.failover.proxyRequired", {
|
||||||
|
defaultValue: "需要先启动代理服务才能配置故障转移",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs defaultValue="claude" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="claude">Claude</TabsTrigger>
|
||||||
|
<TabsTrigger value="codex">Codex</TabsTrigger>
|
||||||
|
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="claude" className="mt-4 space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
{t("proxy.failoverQueue.title")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.failoverQueue.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FailoverQueueManager
|
||||||
|
appType="claude"
|
||||||
|
disabled={!isRunning}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border/50 pt-6">
|
||||||
|
<AutoFailoverConfigPanel
|
||||||
|
appType="claude"
|
||||||
|
disabled={!isRunning}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="codex" className="mt-4 space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
{t("proxy.failoverQueue.title")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.failoverQueue.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FailoverQueueManager
|
||||||
|
appType="codex"
|
||||||
|
disabled={!isRunning}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border/50 pt-6">
|
||||||
|
<AutoFailoverConfigPanel
|
||||||
|
appType="codex"
|
||||||
|
disabled={!isRunning}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="gemini" className="mt-4 space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
{t("proxy.failoverQueue.title")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.failoverQueue.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FailoverQueueManager
|
||||||
|
appType="gemini"
|
||||||
|
disabled={!isRunning}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border/50 pt-6">
|
||||||
|
<AutoFailoverConfigPanel
|
||||||
|
appType="gemini"
|
||||||
|
disabled={!isRunning}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Rectifier */}
|
||||||
|
<AccordionItem
|
||||||
|
value="rectifier"
|
||||||
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Zap className="h-5 w-5 text-purple-500" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{t("settings.advanced.rectifier.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
|
{t("settings.advanced.rectifier.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||||
|
<RectifierConfigPanel />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Global Outbound Proxy */}
|
||||||
|
<AccordionItem
|
||||||
|
value="globalProxy"
|
||||||
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Globe className="h-5 w-5 text-cyan-500" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{t("settings.advanced.globalProxy.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
|
{t("settings.advanced.globalProxy.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||||
|
<GlobalProxySettings />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showProxyConfirm}
|
||||||
|
variant="info"
|
||||||
|
title={t("confirm.proxy.title")}
|
||||||
|
message={t("confirm.proxy.message")}
|
||||||
|
confirmText={t("confirm.proxy.confirm")}
|
||||||
|
onConfirm={() => void handleProxyConfirm()}
|
||||||
|
onCancel={() => setShowProxyConfirm(false)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,16 +4,11 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Save,
|
Save,
|
||||||
FolderSearch,
|
FolderSearch,
|
||||||
Activity,
|
|
||||||
Coins,
|
|
||||||
Database,
|
Database,
|
||||||
Server,
|
Cloud,
|
||||||
ChevronDown,
|
|
||||||
Zap,
|
|
||||||
Globe,
|
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
HardDriveDownload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -39,24 +34,18 @@ import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSe
|
|||||||
import { TerminalSettings } from "@/components/settings/TerminalSettings";
|
import { TerminalSettings } from "@/components/settings/TerminalSettings";
|
||||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||||
|
import { BackupListSection } from "@/components/settings/BackupListSection";
|
||||||
import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection";
|
import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection";
|
||||||
import { AboutSection } from "@/components/settings/AboutSection";
|
import { AboutSection } from "@/components/settings/AboutSection";
|
||||||
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
|
import { ProxyTabContent } from "@/components/settings/ProxyTabContent";
|
||||||
import { ProxyPanel } from "@/components/proxy";
|
// Hidden: stream check feature disabled
|
||||||
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
// import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
||||||
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
|
||||||
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
|
||||||
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
|
||||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||||
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
|
|
||||||
import { LogConfigPanel } from "@/components/settings/LogConfigPanel";
|
import { LogConfigPanel } from "@/components/settings/LogConfigPanel";
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
import { useSettings } from "@/hooks/useSettings";
|
||||||
import { useImportExport } from "@/hooks/useImportExport";
|
import { useImportExport } from "@/hooks/useImportExport";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -188,25 +177,6 @@ export function SettingsPage({
|
|||||||
|
|
||||||
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
|
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
|
||||||
|
|
||||||
const {
|
|
||||||
isRunning,
|
|
||||||
startProxyServer,
|
|
||||||
stopWithRestore,
|
|
||||||
isPending: isProxyPending,
|
|
||||||
} = useProxyStatus();
|
|
||||||
|
|
||||||
const handleToggleProxy = async (checked: boolean) => {
|
|
||||||
try {
|
|
||||||
if (!checked) {
|
|
||||||
await stopWithRestore();
|
|
||||||
} else {
|
|
||||||
await startProxyServer();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Toggle proxy failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden px-6">
|
<div className="flex flex-col h-full overflow-hidden px-6">
|
||||||
{isBusy ? (
|
{isBusy ? (
|
||||||
@@ -219,10 +189,11 @@ export function SettingsPage({
|
|||||||
onValueChange={setActiveTab}
|
onValueChange={setActiveTab}
|
||||||
className="flex flex-col h-full"
|
className="flex flex-col h-full"
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-4 mb-6 glass rounded-lg">
|
<TabsList className="grid w-full grid-cols-5 mb-6 glass rounded-lg">
|
||||||
<TabsTrigger value="general">
|
<TabsTrigger value="general">
|
||||||
{t("settings.tabGeneral")}
|
{t("settings.tabGeneral")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="proxy">{t("settings.tabProxy")}</TabsTrigger>
|
||||||
<TabsTrigger value="advanced">
|
<TabsTrigger value="advanced">
|
||||||
{t("settings.tabAdvanced")}
|
{t("settings.tabAdvanced")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -269,6 +240,15 @@ export function SettingsPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="proxy" className="space-y-6 mt-0 pb-4">
|
||||||
|
{settings ? (
|
||||||
|
<ProxyTabContent
|
||||||
|
settings={settings}
|
||||||
|
onAutoSave={handleAutoSave}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="advanced" className="space-y-6 mt-0 pb-4">
|
<TabsContent value="advanced" className="space-y-6 mt-0 pb-4">
|
||||||
{settings ? (
|
{settings ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -317,256 +297,6 @@ export function SettingsPage({
|
|||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem
|
|
||||||
value="proxy"
|
|
||||||
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
|
|
||||||
>
|
|
||||||
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
|
|
||||||
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Server className="h-5 w-5 text-green-500" />
|
|
||||||
<div className="text-left">
|
|
||||||
<h3 className="text-base font-semibold">
|
|
||||||
{t("settings.advanced.proxy.title")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
|
||||||
{t("settings.advanced.proxy.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
||||||
</AccordionPrimitive.Trigger>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 pl-4">
|
|
||||||
<Badge
|
|
||||||
variant={isRunning ? "default" : "secondary"}
|
|
||||||
className="gap-1.5 h-6"
|
|
||||||
>
|
|
||||||
<Activity
|
|
||||||
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
|
||||||
/>
|
|
||||||
{isRunning
|
|
||||||
? t("settings.advanced.proxy.running")
|
|
||||||
: t("settings.advanced.proxy.stopped")}
|
|
||||||
</Badge>
|
|
||||||
<Switch
|
|
||||||
checked={isRunning}
|
|
||||||
onCheckedChange={handleToggleProxy}
|
|
||||||
disabled={isProxyPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AccordionPrimitive.Header>
|
|
||||||
<AccordionContent className="px-6 pb-6 pt-0 border-t border-border/50">
|
|
||||||
<ProxyPanel />
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
|
||||||
value="failover"
|
|
||||||
className="rounded-xl glass-card overflow-hidden"
|
|
||||||
>
|
|
||||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Activity className="h-5 w-5 text-orange-500" />
|
|
||||||
<div className="text-left">
|
|
||||||
<h3 className="text-base font-semibold">
|
|
||||||
{t("settings.advanced.failover.title")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
|
||||||
{t("settings.advanced.failover.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 代理未运行时的提示 */}
|
|
||||||
{!isRunning && (
|
|
||||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
|
||||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
|
||||||
{t("proxy.failover.proxyRequired", {
|
|
||||||
defaultValue:
|
|
||||||
"需要先启动代理服务才能配置故障转移",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 故障转移设置 - 按应用分组 */}
|
|
||||||
<Tabs defaultValue="claude" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
|
||||||
<TabsTrigger value="claude">Claude</TabsTrigger>
|
|
||||||
<TabsTrigger value="codex">Codex</TabsTrigger>
|
|
||||||
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent
|
|
||||||
value="claude"
|
|
||||||
className="mt-4 space-y-6"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold">
|
|
||||||
{t("proxy.failoverQueue.title")}
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("proxy.failoverQueue.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<FailoverQueueManager
|
|
||||||
appType="claude"
|
|
||||||
disabled={!isRunning}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-border/50 pt-6">
|
|
||||||
<AutoFailoverConfigPanel
|
|
||||||
appType="claude"
|
|
||||||
disabled={!isRunning}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent
|
|
||||||
value="codex"
|
|
||||||
className="mt-4 space-y-6"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold">
|
|
||||||
{t("proxy.failoverQueue.title")}
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("proxy.failoverQueue.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<FailoverQueueManager
|
|
||||||
appType="codex"
|
|
||||||
disabled={!isRunning}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-border/50 pt-6">
|
|
||||||
<AutoFailoverConfigPanel
|
|
||||||
appType="codex"
|
|
||||||
disabled={!isRunning}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent
|
|
||||||
value="gemini"
|
|
||||||
className="mt-4 space-y-6"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold">
|
|
||||||
{t("proxy.failoverQueue.title")}
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("proxy.failoverQueue.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<FailoverQueueManager
|
|
||||||
appType="gemini"
|
|
||||||
disabled={!isRunning}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-border/50 pt-6">
|
|
||||||
<AutoFailoverConfigPanel
|
|
||||||
appType="gemini"
|
|
||||||
disabled={!isRunning}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
|
||||||
value="rectifier"
|
|
||||||
className="rounded-xl glass-card overflow-hidden"
|
|
||||||
>
|
|
||||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Zap className="h-5 w-5 text-purple-500" />
|
|
||||||
<div className="text-left">
|
|
||||||
<h3 className="text-base font-semibold">
|
|
||||||
{t("settings.advanced.rectifier.title")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
|
||||||
{t("settings.advanced.rectifier.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
|
||||||
<RectifierConfigPanel />
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
|
||||||
value="test"
|
|
||||||
className="rounded-xl glass-card overflow-hidden"
|
|
||||||
>
|
|
||||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Activity className="h-5 w-5 text-indigo-500" />
|
|
||||||
<div className="text-left">
|
|
||||||
<h3 className="text-base font-semibold">
|
|
||||||
{t("settings.advanced.modelTest.title")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
|
||||||
{t("settings.advanced.modelTest.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
|
||||||
<ModelTestConfigPanel />
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
|
||||||
value="pricing"
|
|
||||||
className="rounded-xl glass-card overflow-hidden"
|
|
||||||
>
|
|
||||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Coins className="h-5 w-5 text-yellow-500" />
|
|
||||||
<div className="text-left">
|
|
||||||
<h3 className="text-base font-semibold">
|
|
||||||
{t("settings.advanced.pricing.title")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
|
||||||
{t("settings.advanced.pricing.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
|
||||||
<PricingConfigPanel />
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
|
||||||
value="globalProxy"
|
|
||||||
className="rounded-xl glass-card overflow-hidden"
|
|
||||||
>
|
|
||||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Globe className="h-5 w-5 text-cyan-500" />
|
|
||||||
<div className="text-left">
|
|
||||||
<h3 className="text-base font-semibold">
|
|
||||||
{t("settings.advanced.globalProxy.title")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
|
||||||
{t("settings.advanced.globalProxy.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
|
||||||
<GlobalProxySettings />
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
<AccordionItem
|
||||||
value="data"
|
value="data"
|
||||||
className="rounded-xl glass-card overflow-hidden"
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
@@ -596,11 +326,61 @@ export function SettingsPage({
|
|||||||
onExport={exportConfig}
|
onExport={exportConfig}
|
||||||
onClear={clearSelection}
|
onClear={clearSelection}
|
||||||
/>
|
/>
|
||||||
<div className="pt-6">
|
</AccordionContent>
|
||||||
<WebdavSyncSection
|
</AccordionItem>
|
||||||
config={settings?.webdavSync}
|
|
||||||
/>
|
<AccordionItem
|
||||||
|
value="backup"
|
||||||
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<HardDriveDownload className="h-5 w-5 text-amber-500" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{t("settings.advanced.backup.title", {
|
||||||
|
defaultValue: "Backup & Restore",
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
|
{t("settings.advanced.backup.description", {
|
||||||
|
defaultValue:
|
||||||
|
"Manage automatic backups, view and restore database snapshots",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||||
|
<BackupListSection
|
||||||
|
backupIntervalHours={settings.backupIntervalHours}
|
||||||
|
backupRetainCount={settings.backupRetainCount}
|
||||||
|
onSettingsChange={(updates) =>
|
||||||
|
handleAutoSave(updates)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem
|
||||||
|
value="cloudSync"
|
||||||
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Cloud className="h-5 w-5 text-blue-500" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{t("settings.advanced.cloudSync.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
|
{t("settings.advanced.cloudSync.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||||
|
<WebdavSyncSection config={settings?.webdavSync} />
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -162,6 +163,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
|||||||
password: config?.password ?? "",
|
password: config?.password ?? "",
|
||||||
remoteRoot: config?.remoteRoot ?? "cc-switch-sync",
|
remoteRoot: config?.remoteRoot ?? "cc-switch-sync",
|
||||||
profile: config?.profile ?? "default",
|
profile: config?.profile ?? "default",
|
||||||
|
autoSync: config?.autoSync ?? false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Preset selector — derived from initial URL, updated on user selection
|
// Preset selector — derived from initial URL, updated on user selection
|
||||||
@@ -196,6 +198,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
|||||||
password: config.password ?? "",
|
password: config.password ?? "",
|
||||||
remoteRoot: config.remoteRoot ?? "cc-switch-sync",
|
remoteRoot: config.remoteRoot ?? "cc-switch-sync",
|
||||||
profile: config.profile ?? "default",
|
profile: config.profile ?? "default",
|
||||||
|
autoSync: config.autoSync ?? false,
|
||||||
});
|
});
|
||||||
setPasswordTouched(false);
|
setPasswordTouched(false);
|
||||||
setPresetId(detectPreset(config.baseUrl ?? ""));
|
setPresetId(detectPreset(config.baseUrl ?? ""));
|
||||||
@@ -237,6 +240,16 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
|||||||
}
|
}
|
||||||
}, [form.baseUrl, presetId]);
|
}, [form.baseUrl, presetId]);
|
||||||
|
|
||||||
|
const handleAutoSyncChange = useCallback((checked: boolean) => {
|
||||||
|
setForm((prev) => ({ ...prev, autoSync: checked }));
|
||||||
|
setDirty(true);
|
||||||
|
setJustSaved(false);
|
||||||
|
if (justSavedTimerRef.current) {
|
||||||
|
clearTimeout(justSavedTimerRef.current);
|
||||||
|
justSavedTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const buildSettings = useCallback((): WebDavSyncSettings | null => {
|
const buildSettings = useCallback((): WebDavSyncSettings | null => {
|
||||||
const baseUrl = form.baseUrl.trim();
|
const baseUrl = form.baseUrl.trim();
|
||||||
if (!baseUrl) return null;
|
if (!baseUrl) return null;
|
||||||
@@ -247,6 +260,7 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
|||||||
password: form.password,
|
password: form.password,
|
||||||
remoteRoot: form.remoteRoot.trim() || "cc-switch-sync",
|
remoteRoot: form.remoteRoot.trim() || "cc-switch-sync",
|
||||||
profile: form.profile.trim() || "default",
|
profile: form.profile.trim() || "default",
|
||||||
|
autoSync: form.autoSync,
|
||||||
};
|
};
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
@@ -433,6 +447,9 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
|||||||
const lastSyncDisplay = lastSyncAt
|
const lastSyncDisplay = lastSyncAt
|
||||||
? new Date(lastSyncAt * 1000).toLocaleString()
|
? new Date(lastSyncAt * 1000).toLocaleString()
|
||||||
: null;
|
: null;
|
||||||
|
const lastError = config?.status?.lastError?.trim();
|
||||||
|
const showAutoSyncError =
|
||||||
|
!!lastError && config?.status?.lastErrorSource === "auto";
|
||||||
|
|
||||||
// ─── Render ─────────────────────────────────────────────
|
// ─── Render ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -559,6 +576,23 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<label className="w-40 text-xs font-medium text-foreground shrink-0">
|
||||||
|
{t("settings.webdavSync.autoSync")}
|
||||||
|
<span className="block text-[10px] font-normal text-muted-foreground">
|
||||||
|
{t("settings.webdavSync.autoSyncHint")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="pt-1">
|
||||||
|
<Switch
|
||||||
|
checked={form.autoSync}
|
||||||
|
onCheckedChange={handleAutoSyncChange}
|
||||||
|
aria-label={t("settings.webdavSync.autoSync")}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Last sync time */}
|
{/* Last sync time */}
|
||||||
@@ -567,6 +601,17 @@ export function WebdavSyncSection({ config }: WebdavSyncSectionProps) {
|
|||||||
{t("settings.webdavSync.lastSync", { time: lastSyncDisplay })}
|
{t("settings.webdavSync.lastSync", { time: lastSyncDisplay })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{showAutoSyncError && (
|
||||||
|
<div className="rounded-lg border border-red-300/70 bg-red-50/80 px-3 py-2 text-xs text-red-900 dark:border-red-500/50 dark:bg-red-950/30 dark:text-red-200">
|
||||||
|
<p className="font-medium">
|
||||||
|
{t("settings.webdavSync.autoSyncLastErrorTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-all whitespace-pre-wrap">{lastError}</p>
|
||||||
|
<p className="mt-1 text-[11px] text-red-700/90 dark:text-red-300/80">
|
||||||
|
{t("settings.webdavSync.autoSyncLastErrorHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Config buttons + save status */}
|
{/* Config buttons + save status */}
|
||||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||||
|
|||||||
@@ -213,14 +213,12 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return byStatus.filter((skill) => {
|
return byStatus.filter((skill) => {
|
||||||
const name = skill.name?.toLowerCase() || "";
|
const name = skill.name?.toLowerCase() || "";
|
||||||
const description = skill.description?.toLowerCase() || "";
|
const repo =
|
||||||
const directory = skill.directory?.toLowerCase() || "";
|
skill.repoOwner && skill.repoName
|
||||||
|
? `${skill.repoOwner}/${skill.repoName}`.toLowerCase()
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return name.includes(query) || repo.includes(query);
|
||||||
name.includes(query) ||
|
|
||||||
description.includes(query) ||
|
|
||||||
directory.includes(query)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}, [skills, searchQuery, filterRepo, filterStatus]);
|
}, [skills, searchQuery, filterRepo, filterStatus]);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type { AppId } from "@/lib/api/types";
|
|||||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
import { settingsApi, skillsApi } from "@/lib/api";
|
import { settingsApi, skillsApi } from "@/lib/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { APP_IDS } from "@/config/appConfig";
|
import { MCP_SKILLS_APP_IDS } from "@/config/appConfig";
|
||||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||||
@@ -56,7 +56,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
|||||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
||||||
if (!skills) return counts;
|
if (!skills) return counts;
|
||||||
skills.forEach((skill) => {
|
skills.forEach((skill) => {
|
||||||
for (const app of APP_IDS) {
|
for (const app of MCP_SKILLS_APP_IDS) {
|
||||||
if (skill.apps[app]) counts[app]++;
|
if (skill.apps[app]) counts[app]++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -159,6 +159,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
|||||||
<AppCountBar
|
<AppCountBar
|
||||||
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
|
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
|
||||||
counts={enabledCounts}
|
counts={enabledCounts}
|
||||||
|
appIds={MCP_SKILLS_APP_IDS}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||||
@@ -282,6 +283,7 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
|||||||
<AppToggleGroup
|
<AppToggleGroup
|
||||||
apps={skill.apps}
|
apps={skill.apps}
|
||||||
onToggle={(app, enabled) => onToggleApp(skill.id, app, enabled)}
|
onToggle={(app, enabled) => onToggleApp(skill.id, app, enabled)}
|
||||||
|
appIds={MCP_SKILLS_APP_IDS}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
@@ -306,6 +308,7 @@ interface ImportSkillsDialogProps {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
foundIn: string[];
|
foundIn: string[];
|
||||||
|
path: string;
|
||||||
}>;
|
}>;
|
||||||
onImport: (directories: string[]) => void;
|
onImport: (directories: string[]) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -362,8 +365,11 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
|||||||
{skill.description}
|
{skill.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs text-muted-foreground/70 mt-1">
|
<div
|
||||||
{t("skills.foundIn")}: {skill.foundIn.join(", ")}
|
className="text-xs text-muted-foreground/50 mt-1 truncate"
|
||||||
|
title={skill.path}
|
||||||
|
>
|
||||||
|
{skill.path}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -8,10 +8,23 @@ import { ProviderStatsTable } from "./ProviderStatsTable";
|
|||||||
import { ModelStatsTable } from "./ModelStatsTable";
|
import { ModelStatsTable } from "./ModelStatsTable";
|
||||||
import type { TimeRange } from "@/types/usage";
|
import type { TimeRange } from "@/types/usage";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { BarChart3, ListFilter, Activity, RefreshCw } from "lucide-react";
|
import {
|
||||||
|
BarChart3,
|
||||||
|
ListFilter,
|
||||||
|
Activity,
|
||||||
|
RefreshCw,
|
||||||
|
Coins,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { usageKeys } from "@/lib/query/usage";
|
import { usageKeys } from "@/lib/query/usage";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
||||||
|
|
||||||
export function UsageDashboard() {
|
export function UsageDashboard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -129,6 +142,31 @@ export function UsageDashboard() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing Configuration */}
|
||||||
|
<Accordion type="multiple" defaultValue={[]} className="w-full space-y-4">
|
||||||
|
<AccordionItem
|
||||||
|
value="pricing"
|
||||||
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Coins className="h-5 w-5 text-yellow-500" />
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
{t("settings.advanced.pricing.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
|
{t("settings.advanced.pricing.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||||
|
<PricingConfigPanel />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Calendar, Trash2, Plus } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||||
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
|
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||||
|
import { workspaceApi, type DailyMemoryFileInfo } from "@/lib/api/workspace";
|
||||||
|
|
||||||
|
interface DailyMemoryPanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTodayFilename(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(now.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${d}.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// List state
|
||||||
|
const [files, setFiles] = useState<DailyMemoryFileInfo[]>([]);
|
||||||
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
const [editingFile, setEditingFile] = useState<string | null>(null);
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [loadingContent, setLoadingContent] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Delete state
|
||||||
|
const [deletingFile, setDeletingFile] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"],
|
||||||
|
});
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load file list
|
||||||
|
const loadFiles = useCallback(async () => {
|
||||||
|
setLoadingList(true);
|
||||||
|
try {
|
||||||
|
const list = await workspaceApi.listDailyMemoryFiles();
|
||||||
|
setFiles(list);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load daily memory files:", err);
|
||||||
|
toast.error(t("workspace.dailyMemory.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoadingList(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
void loadFiles();
|
||||||
|
}
|
||||||
|
}, [isOpen, loadFiles]);
|
||||||
|
|
||||||
|
// Open file for editing
|
||||||
|
const openFile = useCallback(
|
||||||
|
async (filename: string) => {
|
||||||
|
setLoadingContent(true);
|
||||||
|
setEditingFile(filename);
|
||||||
|
try {
|
||||||
|
const data = await workspaceApi.readDailyMemoryFile(filename);
|
||||||
|
setContent(data ?? "");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to read daily memory file:", err);
|
||||||
|
toast.error(t("workspace.dailyMemory.loadFailed"));
|
||||||
|
setEditingFile(null);
|
||||||
|
} finally {
|
||||||
|
setLoadingContent(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create today's note (deferred — file is only persisted on save)
|
||||||
|
const handleCreateToday = useCallback(async () => {
|
||||||
|
const filename = getTodayFilename();
|
||||||
|
// Check if already exists in the list
|
||||||
|
const existing = files.find((f) => f.filename === filename);
|
||||||
|
if (existing) {
|
||||||
|
// Just open it
|
||||||
|
await openFile(filename);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Open editor with empty content — no file created until user saves
|
||||||
|
setEditingFile(filename);
|
||||||
|
setContent("");
|
||||||
|
}, [files, openFile]);
|
||||||
|
|
||||||
|
// Save current file
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!editingFile) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await workspaceApi.writeDailyMemoryFile(editingFile, content);
|
||||||
|
toast.success(t("workspace.saveSuccess"));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save daily memory file:", err);
|
||||||
|
toast.error(t("workspace.saveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [editingFile, content, t]);
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!deletingFile) return;
|
||||||
|
try {
|
||||||
|
await workspaceApi.deleteDailyMemoryFile(deletingFile);
|
||||||
|
toast.success(t("workspace.dailyMemory.deleteSuccess"));
|
||||||
|
setDeletingFile(null);
|
||||||
|
// If we were editing this file, go back to list
|
||||||
|
if (editingFile === deletingFile) {
|
||||||
|
setEditingFile(null);
|
||||||
|
}
|
||||||
|
await loadFiles();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete daily memory file:", err);
|
||||||
|
toast.error(t("workspace.dailyMemory.deleteFailed"));
|
||||||
|
setDeletingFile(null);
|
||||||
|
}
|
||||||
|
}, [deletingFile, editingFile, loadFiles, t]);
|
||||||
|
|
||||||
|
// Back from edit mode to list mode
|
||||||
|
const handleBackToList = useCallback(() => {
|
||||||
|
setEditingFile(null);
|
||||||
|
setContent("");
|
||||||
|
void loadFiles();
|
||||||
|
}, [loadFiles]);
|
||||||
|
|
||||||
|
// Close panel entirely
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setEditingFile(null);
|
||||||
|
setContent("");
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// --- Edit mode ---
|
||||||
|
if (editingFile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FullScreenPanel
|
||||||
|
isOpen={isOpen}
|
||||||
|
title={t("workspace.editing", { filename: editingFile })}
|
||||||
|
onClose={handleBackToList}
|
||||||
|
footer={
|
||||||
|
<Button onClick={handleSave} disabled={saving || loadingContent}>
|
||||||
|
{saving ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loadingContent ? (
|
||||||
|
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||||
|
{t("prompts.loading")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MarkdownEditor
|
||||||
|
value={content}
|
||||||
|
onChange={setContent}
|
||||||
|
darkMode={isDarkMode}
|
||||||
|
placeholder={`# ${editingFile}\n\n...`}
|
||||||
|
minHeight="calc(100vh - 240px)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FullScreenPanel>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!deletingFile}
|
||||||
|
title={t("workspace.dailyMemory.confirmDeleteTitle")}
|
||||||
|
message={t("workspace.dailyMemory.confirmDeleteMessage", {
|
||||||
|
date: deletingFile?.replace(".md", "") ?? "",
|
||||||
|
})}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setDeletingFile(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- List mode ---
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FullScreenPanel
|
||||||
|
isOpen={isOpen}
|
||||||
|
title={t("workspace.dailyMemory.title")}
|
||||||
|
onClose={handleClose}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header with path and create button */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
~/.openclaw/workspace/memory/
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCreateToday}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
{t("workspace.dailyMemory.createToday")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
{loadingList ? (
|
||||||
|
<div className="flex items-center justify-center h-48 text-muted-foreground">
|
||||||
|
{t("prompts.loading")}
|
||||||
|
</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground gap-3">
|
||||||
|
<Calendar className="w-10 h-10 opacity-40" />
|
||||||
|
<p className="text-sm">{t("workspace.dailyMemory.empty")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{files.map((file) => (
|
||||||
|
<button
|
||||||
|
key={file.filename}
|
||||||
|
onClick={() => openFile(file.filename)}
|
||||||
|
className="w-full flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group"
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm text-foreground">
|
||||||
|
{file.date}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatFileSize(file.sizeBytes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{file.preview && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{file.preview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeletingFile(file.filename);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FullScreenPanel>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!deletingFile}
|
||||||
|
title={t("workspace.dailyMemory.confirmDeleteTitle")}
|
||||||
|
message={t("workspace.dailyMemory.confirmDeleteMessage", {
|
||||||
|
date: deletingFile?.replace(".md", "") ?? "",
|
||||||
|
})}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setDeletingFile(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DailyMemoryPanel;
|
||||||
@@ -12,10 +12,13 @@ import {
|
|||||||
Power,
|
Power,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Circle,
|
Circle,
|
||||||
|
Calendar,
|
||||||
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { workspaceApi } from "@/lib/api/workspace";
|
import { workspaceApi } from "@/lib/api/workspace";
|
||||||
import WorkspaceFileEditor from "./WorkspaceFileEditor";
|
import WorkspaceFileEditor from "./WorkspaceFileEditor";
|
||||||
|
import DailyMemoryPanel from "./DailyMemoryPanel";
|
||||||
|
|
||||||
interface WorkspaceFile {
|
interface WorkspaceFile {
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -51,6 +54,7 @@ const WorkspaceFilesPanel: React.FC = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [editingFile, setEditingFile] = useState<string | null>(null);
|
const [editingFile, setEditingFile] = useState<string | null>(null);
|
||||||
const [fileExists, setFileExists] = useState<Record<string, boolean>>({});
|
const [fileExists, setFileExists] = useState<Record<string, boolean>>({});
|
||||||
|
const [showDailyMemory, setShowDailyMemory] = useState(false);
|
||||||
|
|
||||||
const checkFileExistence = async () => {
|
const checkFileExistence = async () => {
|
||||||
const results: Record<string, boolean> = {};
|
const results: Record<string, boolean> = {};
|
||||||
@@ -115,6 +119,27 @@ const WorkspaceFilesPanel: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Daily Memory — inline with workspace files */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDailyMemory(true)}
|
||||||
|
className="flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group"
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
|
||||||
|
<Calendar className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="font-medium text-sm text-foreground">
|
||||||
|
{t("workspace.dailyMemory.cardTitle")}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t("workspace.dailyMemory.cardDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WorkspaceFileEditor
|
<WorkspaceFileEditor
|
||||||
@@ -122,6 +147,11 @@ const WorkspaceFilesPanel: React.FC = () => {
|
|||||||
isOpen={!!editingFile}
|
isOpen={!!editingFile}
|
||||||
onClose={handleEditorClose}
|
onClose={handleEditorClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DailyMemoryPanel
|
||||||
|
isOpen={showDailyMemory}
|
||||||
|
onClose={() => setShowDailyMemory(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ export const APP_IDS: AppId[] = [
|
|||||||
"openclaw",
|
"openclaw",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** App IDs shown in MCP & Skills panels (excludes OpenClaw) */
|
||||||
|
export const MCP_SKILLS_APP_IDS: AppId[] = [
|
||||||
|
"claude",
|
||||||
|
"codex",
|
||||||
|
"gemini",
|
||||||
|
"opencode",
|
||||||
|
];
|
||||||
|
|
||||||
export const APP_ICON_MAP: Record<AppId, AppConfig> = {
|
export const APP_ICON_MAP: Record<AppId, AppConfig> = {
|
||||||
claude: {
|
claude: {
|
||||||
label: "Claude",
|
label: "Claude",
|
||||||
|
|||||||
@@ -316,12 +316,10 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
name: "AiHubMix",
|
name: "AiHubMix",
|
||||||
websiteUrl: "https://aihubmix.com",
|
websiteUrl: "https://aihubmix.com",
|
||||||
apiKeyUrl: "https://aihubmix.com",
|
apiKeyUrl: "https://aihubmix.com",
|
||||||
// 说明:该供应商使用 ANTHROPIC_API_KEY(而非 ANTHROPIC_AUTH_TOKEN)
|
|
||||||
apiKeyField: "ANTHROPIC_API_KEY",
|
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
ANTHROPIC_BASE_URL: "https://aihubmix.com",
|
ANTHROPIC_BASE_URL: "https://aihubmix.com",
|
||||||
ANTHROPIC_API_KEY: "",
|
ANTHROPIC_AUTH_TOKEN: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
|
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
|
||||||
|
|||||||
@@ -1129,4 +1129,17 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
|||||||
iconColor: "#8B5CF6",
|
iconColor: "#8B5CF6",
|
||||||
isCustomTemplate: true,
|
isCustomTemplate: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Oh My OpenCode Slim",
|
||||||
|
websiteUrl: "https://github.com/alvinunreal/oh-my-opencode-slim",
|
||||||
|
settingsConfig: {
|
||||||
|
npm: "",
|
||||||
|
options: {},
|
||||||
|
models: {},
|
||||||
|
},
|
||||||
|
category: "omo-slim" as ProviderCategory,
|
||||||
|
icon: "opencode",
|
||||||
|
iconColor: "#6366F1",
|
||||||
|
isCustomTemplate: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects whether the container's children overflow the available width
|
||||||
|
* and returns a `compact` flag for the AppSwitcher.
|
||||||
|
*
|
||||||
|
* Uses ResizeObserver on a flex-constrained container. The container
|
||||||
|
* must have `flex-1 min-w-0 overflow-hidden` so its width is determined
|
||||||
|
* by the parent layout, not its own content — avoiding the oscillation
|
||||||
|
* problem when toggling compact mode.
|
||||||
|
*/
|
||||||
|
export function useAutoCompact(
|
||||||
|
containerRef: RefObject<HTMLDivElement | null>,
|
||||||
|
): boolean {
|
||||||
|
const [compact, setCompact] = useState(false);
|
||||||
|
const normalWidthRef = useRef(0);
|
||||||
|
const lockUntilRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
// During expand animation, ignore resize events to prevent flicker
|
||||||
|
if (Date.now() < lockUntilRef.current) return;
|
||||||
|
|
||||||
|
if (!compact) {
|
||||||
|
// Cache the total content width in normal mode
|
||||||
|
normalWidthRef.current = el.scrollWidth;
|
||||||
|
// Overflow detected → switch to compact
|
||||||
|
if (el.scrollWidth > el.clientWidth + 1) {
|
||||||
|
setCompact(true);
|
||||||
|
}
|
||||||
|
} else if (normalWidthRef.current > 0) {
|
||||||
|
// In compact mode: only recover to normal if
|
||||||
|
// available space >= what normal mode needed
|
||||||
|
if (el.clientWidth >= normalWidthRef.current) {
|
||||||
|
// Lock out resize events during the expand animation (200ms + 50ms margin)
|
||||||
|
lockUntilRef.current = Date.now() + 250;
|
||||||
|
setCompact(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [compact]);
|
||||||
|
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { backupsApi } from "@/lib/api";
|
||||||
|
|
||||||
|
export function useBackupManager() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: backups = [],
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["db-backups"],
|
||||||
|
queryFn: () => backupsApi.listDbBackups(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoreMutation = useMutation({
|
||||||
|
mutationFn: (filename: string) => backupsApi.restoreDbBackup(filename),
|
||||||
|
onSuccess: async () => {
|
||||||
|
// Invalidate all queries to refresh data from restored database
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
// Refetch backup list
|
||||||
|
await refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const renameMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
oldFilename,
|
||||||
|
newName,
|
||||||
|
}: {
|
||||||
|
oldFilename: string;
|
||||||
|
newName: string;
|
||||||
|
}) => backupsApi.renameDbBackup(oldFilename, newName),
|
||||||
|
onSuccess: () => refetch(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
backups,
|
||||||
|
isLoading,
|
||||||
|
restore: restoreMutation.mutateAsync,
|
||||||
|
isRestoring: restoreMutation.isPending,
|
||||||
|
rename: renameMutation.mutateAsync,
|
||||||
|
isRenaming: renameMutation.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user