Compare commits

..

4 Commits

Author SHA1 Message Date
saladday 4b98ff9b51 refactor(webdav): remove services->commands auto-sync dependency 2026-02-15 16:55:29 +08:00
saladday 0b4912090a fix(webdav): only show auto-sync callout for auto-source errors 2026-02-15 01:01:02 +08:00
saladday 4be515e178 fix(webdav): enforce bounded download and extraction size
(cherry picked from commit 7777d6ec2b9bba07c8bbba9b04fe3ea6b15e0e79)
2026-02-15 00:27:08 +08:00
saladday ff29b939a8 feat(webdav): add robust auto sync with failure feedback
(cherry picked from commit bb6760124a62a964b36902c004e173534910728f)
2026-02-15 00:26:50 +08:00
40 changed files with 365 additions and 2015 deletions
+2 -7
View File
@@ -61,13 +61,8 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
</tr>
<tr>
<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, 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>
<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>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>
</tr>
</table>
+2 -7
View File
@@ -61,13 +61,8 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
</tr>
<tr>
<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 など 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>
<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>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>
</tr>
</table>
+2 -7
View File
@@ -62,13 +62,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
</tr>
<tr>
<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 等。全部模型低至官方定价的 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>
<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>感谢 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>
</tr>
</table>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

-16
View File
@@ -129,20 +129,6 @@ impl SkillApps {
apps.set_enabled_for(app, true);
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
}
}
/// 已安装的 Skillv3.10.0+ 统一结构)
@@ -189,8 +175,6 @@ pub struct UnmanagedSkill {
pub description: Option<String>,
/// 在哪些应用目录中发现(如 ["claude", "codex"]
pub found_in: Vec<String>,
/// 发现路径(首个匹配的完整路径)
pub path: String,
}
/// MCP 服务器定义(v3.7.0 统一结构)
-40
View File
@@ -407,43 +407,3 @@ pub async fn get_circuit_breaker_stats(
let _ = (state, provider_id, app_type);
Ok(None)
}
// ==================== URL 预览相关命令 ====================
use crate::app_config::AppType;
use crate::proxy::url_builder::{self, UrlPreview};
/// 构建 URL 预览
///
/// 根据 app_type、base_url 和 api_format 计算直连和代理模式的请求地址。
/// 这个命令与后端代理的 URL 构建逻辑保持一致。
#[tauri::command]
pub fn build_url_preview(
app_type: String,
base_url: String,
api_format: Option<String>,
) -> Result<UrlPreview, String> {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
Ok(url_builder::build_url_preview(
&app,
&base_url,
api_format.as_deref(),
))
}
/// 检查是否需要代理
///
/// 返回需要代理的原因(openai_chat_format, full_url, url_mismatch),
/// 或 null 表示不需要代理。
#[tauri::command]
pub fn check_proxy_requirement(
app_type: String,
base_url: String,
api_format: Option<String>,
) -> Result<Option<String>, String> {
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
Ok(
url_builder::check_proxy_requirement(&app, &base_url, api_format.as_deref())
.map(|s| s.to_string()),
)
}
-3
View File
@@ -971,9 +971,6 @@ pub fn run() {
commands::get_circuit_breaker_config,
commands::update_circuit_breaker_config,
commands::get_circuit_breaker_stats,
// URL preview (for endpoint field)
commands::build_url_preview,
commands::check_proxy_requirement,
// Failover queue management
commands::get_failover_queue,
commands::get_available_providers_for_failover,
+7 -63
View File
@@ -749,17 +749,15 @@ impl RequestForwarder {
// 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider);
let effective_endpoint = if needs_transform
&& adapter.name() == "Claude"
&& endpoint.starts_with("/v1/messages")
{
transform_claude_messages_endpoint(endpoint)
} else {
endpoint.to_string()
};
let effective_endpoint =
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
"/v1/chat/completions"
} else {
endpoint
};
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, &effective_endpoint);
let url = adapter.build_url(&base_url, effective_endpoint);
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, _mapped_model) =
@@ -929,57 +927,3 @@ fn extract_error_message(error: &ProxyError) -> Option<String> {
_ => Some(error.to_string()),
}
}
fn transform_claude_messages_endpoint(endpoint: &str) -> String {
let transformed = endpoint.replacen("/v1/messages", "/v1/chat/completions", 1);
let Some((path, query)) = transformed.split_once('?') else {
return transformed;
};
// 转换到 chat/completions 时,显式移除 beta=true,避免把旧接口参数带到新接口。
let filtered_params: Vec<&str> = query
.split('&')
.filter(|segment| !segment.is_empty())
.filter(|segment| !is_beta_true_query_pair(segment))
.collect();
if filtered_params.is_empty() {
path.to_string()
} else {
format!("{path}?{}", filtered_params.join("&"))
}
}
fn is_beta_true_query_pair(segment: &str) -> bool {
let Some((key, value)) = segment.split_once('=') else {
return false;
};
key.eq_ignore_ascii_case("beta") && value.eq_ignore_ascii_case("true")
}
#[cfg(test)]
mod tests {
use super::transform_claude_messages_endpoint;
#[test]
fn transform_claude_messages_endpoint_strips_beta_true_only() {
let endpoint = "/v1/messages?beta=true&foo=1";
let transformed = transform_claude_messages_endpoint(endpoint);
assert_eq!(transformed, "/v1/chat/completions?foo=1");
}
#[test]
fn transform_claude_messages_endpoint_drops_empty_query_after_strip() {
let endpoint = "/v1/messages?beta=true";
let transformed = transform_claude_messages_endpoint(endpoint);
assert_eq!(transformed, "/v1/chat/completions");
}
#[test]
fn transform_claude_messages_endpoint_keeps_other_query_values() {
let endpoint = "/v1/messages?beta=false&foo=1";
let transformed = transform_claude_messages_endpoint(endpoint);
assert_eq!(transformed, "/v1/chat/completions?beta=false&foo=1");
}
}
+33
View File
@@ -42,6 +42,22 @@ fn claude_model_extractor(events: &[Value], request_model: &str) -> String {
request_model.to_string()
}
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
if let Some(model) = usage.model {
return model;
}
}
// 回退:从事件中直接提取
events
.iter()
.find_map(|e| e.get("model")?.as_str())
.unwrap_or(request_model)
.to_string()
}
/// Codex 智能流式响应模型提取(自动检测格式)
fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
@@ -91,6 +107,14 @@ pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
app_type_str: "claude",
};
/// OpenAI Chat Completions API 解析配置(用于 Codex /v1/chat/completions
pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_openai_stream_events,
response_parser: TokenUsage::from_openai_response,
model_extractor: openai_model_extractor,
app_type_str: "codex",
};
/// Codex 智能解析配置(自动检测 OpenAI 或 Codex 格式)
pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_codex_stream_events_auto,
@@ -136,6 +160,15 @@ pub const CLAUDE_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
parser_config: &CLAUDE_PARSER_CONFIG,
};
/// Codex Chat Completions Handler 配置
#[allow(dead_code)]
pub const CODEX_CHAT_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
app_type: AppType::Codex,
tag: "Codex",
app_type_str: "codex",
parser_config: &OPENAI_PARSER_CONFIG,
};
/// Codex Responses Handler 配置
#[allow(dead_code)]
pub const CODEX_RESPONSES_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
+45 -9
View File
@@ -9,7 +9,9 @@
use super::{
error_mapper::{get_error_message, map_proxy_error_to_status},
handler_config::{CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG},
handler_config::{
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
},
handler_context::RequestContext,
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
@@ -54,7 +56,6 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
pub async fn handle_messages(
State(state): State<ProxyState>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
@@ -66,18 +67,12 @@ pub async fn handle_messages(
.and_then(|s| s.as_bool())
.unwrap_or(false);
// 透传客户端 query 参数(例如 ?beta=true),路径统一为 /v1/messages
let endpoint = uri
.query()
.map(|q| format!("/v1/messages?{q}"))
.unwrap_or_else(|| "/v1/messages".to_string());
// 转发请求
let forwarder = ctx.create_forwarder(&state);
let result = match forwarder
.forward_with_retry(
&AppType::Claude,
&endpoint,
"/v1/messages",
body.clone(),
headers,
ctx.get_providers(),
@@ -271,6 +266,47 @@ async fn handle_claude_transform(
// Codex API 处理器
// ============================================================================
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI
pub async fn handle_chat_completions(
State(state): State<ProxyState>,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let is_stream = body
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let forwarder = ctx.create_forwarder(&state);
let result = match forwarder
.forward_with_retry(
&AppType::Codex,
"/chat/completions",
body,
headers,
ctx.get_providers(),
)
.await
{
Ok(result) => result,
Err(mut err) => {
if let Some(provider) = err.provider.take() {
ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
}
};
ctx.provider = result.provider;
let response = result.response;
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
}
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
pub async fn handle_responses(
State(state): State<ProxyState>,
-2
View File
@@ -24,8 +24,6 @@ pub mod session;
pub mod thinking_budget_rectifier;
pub mod thinking_rectifier;
pub(crate) mod types;
pub mod url_builder;
pub(crate) mod url_utils;
pub mod usage;
// 公开导出给外部使用(commands, services等模块需要)
+16 -96
View File
@@ -14,7 +14,6 @@
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use crate::proxy::url_utils::{dedup_v1_v1_boundary_safe, split_url_suffix};
use reqwest::RequestBuilder;
/// Claude 适配器
@@ -253,48 +252,26 @@ impl ProviderAdapter for ClaudeAdapter {
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
let (base, suffix) = split_url_suffix(base_url);
let base_trimmed = base.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
// 检测 base_url 是否已经以 API 路径结尾(用户填写了完整路径)
// 支持的 API 路径模式:/v1/messages, /messages, /v1/chat/completions, /chat/completions
let api_path_patterns = [
"/v1/messages",
"/messages",
"/v1/chat/completions",
"/chat/completions",
];
let base_ends_with_api_path = api_path_patterns
.iter()
.any(|pattern| base_trimmed.to_lowercase().ends_with(pattern));
// 如果 base_url 已经以 API 路径结尾,直接使用 base_url,不再追加 endpoint
let base = if base_ends_with_api_path {
base_trimmed.to_string()
} else {
format!("{base_trimmed}/{endpoint_trimmed}")
};
let mut base = format!(
"{}/{}",
base_url.trim_end_matches('/'),
endpoint.trim_start_matches('/')
);
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
let base = dedup_v1_v1_boundary_safe(base);
while base.contains("/v1/v1") {
base = base.replace("/v1/v1", "/v1");
}
let url = format!("{base}{suffix}");
// 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数
// 为 Claude 相关端点添加 ?beta=true 参数
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
// 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数
// 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions
// 但该端点是 OpenAI 标准,不支持 ?beta=true 参数
if endpoint.contains("/v1/messages")
&& !endpoint.contains("/v1/chat/completions")
// 注openai_chat 模式下会转发到 /v1/chat/completions,此处也需要保持一致
if (endpoint.contains("/v1/messages") || endpoint.contains("/v1/chat/completions"))
&& !endpoint.contains('?')
&& !url.contains('?')
{
format!("{url}?beta=true")
format!("{base}?beta=true")
} else {
url
base
}
}
@@ -507,6 +484,7 @@ mod tests {
#[test]
fn test_build_url_anthropic() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
}
@@ -514,6 +492,7 @@ mod tests {
#[test]
fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
}
@@ -521,7 +500,7 @@ mod tests {
#[test]
fn test_build_url_no_beta_for_other_endpoints() {
let adapter = ClaudeAdapter::new();
// 非 /v1/messages 端点保持原样
// 非 /v1/messages 端点不添加 ?beta=true
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
assert_eq!(url, "https://api.anthropic.com/v1/complete");
}
@@ -534,65 +513,6 @@ mod tests {
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
}
#[test]
fn test_build_url_full_path_messages() {
let adapter = ClaudeAdapter::new();
// base_url 已包含完整路径 /v1/messages,不再追加
let url = adapter.build_url("https://example.com/api/v1/messages", "/v1/messages");
assert_eq!(url, "https://example.com/api/v1/messages?beta=true");
}
#[test]
fn test_build_url_full_path_chat_completions() {
let adapter = ClaudeAdapter::new();
// base_url 已包含完整路径 /v1/chat/completions,不再追加
let url = adapter.build_url(
"https://opencode.ai/zen/v1/chat/completions",
"/v1/chat/completions",
);
assert_eq!(url, "https://opencode.ai/zen/v1/chat/completions");
}
#[test]
fn test_build_url_full_path_short_suffix() {
let adapter = ClaudeAdapter::new();
// base_url 以 /messages 结尾(无 /v1 前缀)
let url = adapter.build_url("https://example.com/api/messages", "/v1/messages");
assert_eq!(url, "https://example.com/api/messages?beta=true");
// base_url 以 /chat/completions 结尾(无 /v1 前缀)
let url2 = adapter.build_url(
"https://example.com/api/chat/completions",
"/v1/chat/completions",
);
assert_eq!(url2, "https://example.com/api/chat/completions");
}
#[test]
fn test_build_url_v1_base_dedup() {
let adapter = ClaudeAdapter::new();
// base_url 以 /v1 结尾,endpoint 也以 /v1 开头,应该去重
// 场景:https://integrate.api.nvidia.com/v1 + /v1/chat/completions
let url = adapter.build_url(
"https://integrate.api.nvidia.com/v1",
"/v1/chat/completions",
);
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
// 另一个场景:/v1 + /v1/messages
let url2 = adapter.build_url("https://api.example.com/v1", "/v1/messages");
assert_eq!(url2, "https://api.example.com/v1/messages?beta=true");
}
#[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]
fn test_needs_transform() {
let adapter = ClaudeAdapter::new();
+6 -43
View File
@@ -8,7 +8,6 @@
use super::{AuthInfo, AuthStrategy, ProviderAdapter};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use crate::proxy::url_utils::{dedup_v1_v1_boundary_safe, split_url_suffix};
use regex::Regex;
use reqwest::RequestBuilder;
use std::sync::LazyLock;
@@ -139,23 +138,9 @@ impl ProviderAdapter for CodexAdapter {
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
let (base, suffix) = split_url_suffix(base_url);
let base_trimmed = base.trim_end_matches('/');
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
// 检测 base_url 是否已经以 API 路径结尾(用户填写了完整路径)
// 仅支持 Responses API 路径模式:/v1/responses, /responses
let api_path_patterns = ["/v1/responses", "/responses"];
let base_ends_with_api_path = api_path_patterns
.iter()
.any(|pattern| base_trimmed.to_lowercase().ends_with(pattern));
// 如果 base_url 已经以 API 路径结尾,直接使用 base_url,不再追加 endpoint
if base_ends_with_api_path {
return format!("{base_trimmed}{suffix}");
}
// OpenAI/Codex 的 base_url 可能是:
// - 纯 origin: https://api.openai.com (需要自动补 /v1)
// - 已含 /v1: https://api.openai.com/v1 (直接拼接)
@@ -182,8 +167,11 @@ impl ProviderAdapter for CodexAdapter {
};
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
url = dedup_v1_v1_boundary_safe(url);
format!("{url}{suffix}")
while url.contains("/v1/v1") {
url = url.replace("/v1/v1", "/v1");
}
url
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
@@ -280,31 +268,6 @@ mod tests {
assert_eq!(url, "https://www.packyapi.com/v1/responses");
}
#[test]
fn test_build_url_full_path_responses() {
let adapter = CodexAdapter::new();
// base_url 已包含完整路径 /v1/responses,不再追加
let url = adapter.build_url("https://example.com/v1/responses", "/responses");
assert_eq!(url, "https://example.com/v1/responses");
}
#[test]
fn test_build_url_full_path_short_suffix() {
let adapter = CodexAdapter::new();
// base_url 以 /responses 结尾(无 /v1 前缀)
let url = adapter.build_url("https://example.com/api/responses", "/responses");
assert_eq!(url, "https://example.com/api/responses");
}
#[test]
fn test_build_url_v1_base_dedup() {
let adapter = CodexAdapter::new();
// base_url 以 /v1 结尾,endpoint 也以 /v1 开头,应该去重
// 场景:https://integrate.api.nvidia.com/v1 + /v1/responses
let url = adapter.build_url("https://integrate.api.nvidia.com/v1", "/v1/responses");
assert_eq!(url, "https://integrate.api.nvidia.com/v1/responses");
}
// 官方客户端检测测试
#[test]
fn test_is_official_client_vscode() {
+2 -4
View File
@@ -9,7 +9,6 @@
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use crate::proxy::url_utils::split_url_suffix;
use reqwest::RequestBuilder;
/// Gemini 适配器
@@ -201,8 +200,7 @@ impl ProviderAdapter for GeminiAdapter {
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
let (base, suffix) = split_url_suffix(base_url);
let base_trimmed = base.trim_end_matches('/');
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
@@ -216,7 +214,7 @@ impl ProviderAdapter for GeminiAdapter {
}
}
format!("{url}{suffix}")
url
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
+14
View File
@@ -218,6 +218,20 @@ impl ProxyServer {
// Claude API (支持带前缀和不带前缀两种格式)
.route("/v1/messages", post(handlers::handle_messages))
.route("/claude/v1/messages", post(handlers::handle_messages))
// OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
.route("/chat/completions", post(handlers::handle_chat_completions))
.route(
"/v1/chat/completions",
post(handlers::handle_chat_completions),
)
.route(
"/v1/v1/chat/completions",
post(handlers::handle_chat_completions),
)
.route(
"/codex/v1/chat/completions",
post(handlers::handle_chat_completions),
)
// OpenAI Responses API (Codex CLI,支持带前缀和不带前缀)
.route("/responses", post(handlers::handle_responses))
.route("/v1/responses", post(handlers::handle_responses))
-437
View File
@@ -1,437 +0,0 @@
//! URL 构建工具模块
//!
//! 提供统一的 URL 构建逻辑,供前端预览和后端代理使用。
use crate::app_config::AppType;
use crate::proxy::providers::{ClaudeAdapter, CodexAdapter, ProviderAdapter};
use crate::proxy::url_utils::{dedup_v1_v1_boundary_safe, split_url_suffix};
use serde::{Deserialize, Serialize};
/// URL 预览结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlPreview {
/// 直连模式请求地址
pub direct_url: String,
/// 代理模式请求地址
pub proxy_url: String,
/// 是否为全链接(base_url 已包含 API 路径)
pub is_full_url: bool,
}
/// API 路径模式
struct ApiPathPatterns {
/// 直连模式默认端点
direct_endpoint: &'static str,
/// 代理模式端点(根据 api_format 可能不同)
proxy_endpoint: &'static str,
/// 识别为全链接的路径后缀
full_url_patterns: &'static [&'static str],
}
impl ApiPathPatterns {
fn for_claude(api_format: Option<&str>) -> Self {
// 根据 API 格式决定端点和全链接检测模式
if api_format == Some("openai_chat") {
Self {
direct_endpoint: "/v1/messages",
proxy_endpoint: "/v1/chat/completions",
// 与运行时 ClaudeAdapter 保持一致:同时识别 messages/chat 两类完整路径
full_url_patterns: &[
"/v1/messages",
"/messages",
"/v1/chat/completions",
"/chat/completions",
],
}
} else {
Self {
direct_endpoint: "/v1/messages",
proxy_endpoint: "/v1/messages",
// 与运行时 ClaudeAdapter 保持一致:同时识别 messages/chat 两类完整路径
full_url_patterns: &[
"/v1/messages",
"/messages",
"/v1/chat/completions",
"/chat/completions",
],
}
}
}
fn for_codex(api_format: Option<&str>) -> Self {
let _ = api_format;
Self {
direct_endpoint: "/responses",
proxy_endpoint: "/responses",
full_url_patterns: &["/v1/responses", "/responses"],
}
}
fn for_gemini() -> Self {
Self {
direct_endpoint: "/v1beta/models",
proxy_endpoint: "/v1beta/models",
full_url_patterns: &["/v1beta/models"],
}
}
}
/// 检测 URL 是否以指定的 API 路径结尾
fn url_ends_with_api_path(url: &str, patterns: &[&str]) -> bool {
let (base, _) = split_url_suffix(url);
let path_part = base.trim_end_matches('/').to_lowercase();
patterns
.iter()
.any(|pattern| path_part.ends_with(&pattern.to_lowercase()))
}
/// 硬拼接 URL(用于直连地址)
///
/// 始终将 endpoint 拼接到 base_url 后面,不做任何智能检测或去重。
fn build_direct_url(base_url: &str, endpoint: &str) -> String {
let (base, suffix) = split_url_suffix(base_url);
let base_trimmed = base.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
// 直接拼接,不做任何去重
format!("{base_trimmed}/{endpoint_trimmed}{suffix}")
}
/// 智能构建 URL(用于代理地址)
///
/// 如果 base_url 已经以 API 路径结尾,直接返回;否则追加 endpoint。
pub fn build_smart_url(base_url: &str, endpoint: &str, full_url_patterns: &[&str]) -> String {
let (base, suffix) = split_url_suffix(base_url);
let base_trimmed = base.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
// 检测 base_url 是否已经以 API 路径结尾
if url_ends_with_api_path(base_trimmed, full_url_patterns) {
return format!("{base_trimmed}{suffix}");
}
// 拼接 URL
let url = format!("{base_trimmed}/{endpoint_trimmed}");
let url = dedup_v1_v1_boundary_safe(url);
format!("{url}{suffix}")
}
fn build_runtime_like_url(
app_type: &AppType,
base_url: &str,
endpoint: &str,
is_proxy: bool,
) -> String {
match app_type {
// Claude 代理预览需要展示与运行时一致的 URL 归一化结果
AppType::Claude if is_proxy => ClaudeAdapter::new().build_url(base_url, endpoint),
// Codex/OpenCode 预览复用运行时 /v1 归一化规则
AppType::Codex | AppType::OpenCode | AppType::OpenClaw => {
CodexAdapter::new().build_url(base_url, endpoint)
}
_ => build_direct_url(base_url, endpoint),
}
}
/// 构建 URL 预览
///
/// 根据 app_type、base_url 和 api_format 计算直连和代理模式的请求地址。
/// - 直连地址:始终硬拼接默认后缀
/// - 代理地址:智能检测,如果已包含 API 路径则不重复拼接
pub fn build_url_preview(
app_type: &AppType,
base_url: &str,
api_format: Option<&str>,
) -> UrlPreview {
let patterns = match app_type {
AppType::Claude => ApiPathPatterns::for_claude(api_format),
AppType::Codex => ApiPathPatterns::for_codex(api_format),
AppType::Gemini => ApiPathPatterns::for_gemini(),
AppType::OpenCode => ApiPathPatterns::for_codex(api_format), // OpenCode 使用 Codex 逻辑
AppType::OpenClaw => ApiPathPatterns::for_codex(api_format), // OpenClaw 使用 Codex 逻辑
};
let is_full_url = url_ends_with_api_path(base_url, patterns.full_url_patterns);
// 直连地址:默认硬拼接;Codex/OpenCode 复用运行时规则(含 origin-only /v1 归一化)
let direct_url = build_runtime_like_url(app_type, base_url, patterns.direct_endpoint, false);
// 代理地址:Claude/Codex/OpenCode 复用运行时规则;Gemini 继续使用通用智能拼接
let proxy_url = match app_type {
AppType::Claude | AppType::Codex | AppType::OpenCode | AppType::OpenClaw => {
build_runtime_like_url(app_type, base_url, patterns.proxy_endpoint, true)
}
_ => build_smart_url(
base_url,
patterns.proxy_endpoint,
patterns.full_url_patterns,
),
};
UrlPreview {
direct_url,
proxy_url,
is_full_url,
}
}
/// 检查是否需要代理
///
/// 返回需要代理的原因,None 表示不需要代理
pub fn check_proxy_requirement(
app_type: &AppType,
base_url: &str,
api_format: Option<&str>,
) -> Option<&'static str> {
// Claude OpenAI Chat 格式必须开启代理(需要格式转换)
if matches!(app_type, AppType::Claude) && api_format == Some("openai_chat") {
return Some("openai_chat_format");
}
// base_url 缺失时无法做 full_url / url_mismatch 判断,避免误判
if base_url.trim().is_empty() {
return None;
}
let preview = build_url_preview(app_type, base_url, api_format);
// 如果是全链接且以直连后缀结尾,需要代理
if preview.is_full_url {
// 检查是否以直连后缀结尾
let direct_suffixes: &[&str] = match app_type {
AppType::Claude => &[
"/v1/messages",
"/messages",
"/v1/chat/completions",
"/chat/completions",
],
AppType::Codex | AppType::OpenCode | AppType::OpenClaw => &["/v1/responses", "/responses"],
_ => return None,
};
if url_ends_with_api_path(base_url, direct_suffixes) {
return Some("full_url");
}
}
// 如果直连地址和代理地址路径不同,需要代理(忽略查询参数差异)
let (direct_base, _) = split_url_suffix(&preview.direct_url);
let (proxy_base, _) = split_url_suffix(&preview.proxy_url);
if direct_base != proxy_base {
return Some("url_mismatch");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_url_preview_claude_anthropic() {
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com",
Some("anthropic"),
);
assert_eq!(preview.direct_url, "https://api.example.com/v1/messages");
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
assert!(!preview.is_full_url);
}
#[test]
fn test_build_url_preview_claude_openai_chat() {
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com",
Some("openai_chat"),
);
assert_eq!(preview.direct_url, "https://api.example.com/v1/messages");
assert_eq!(
preview.proxy_url,
"https://api.example.com/v1/chat/completions"
);
assert!(!preview.is_full_url);
}
#[test]
fn test_build_url_preview_claude_full_url() {
// 全链接时:直连会硬拼接后缀,代理保持原地址
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1/messages",
Some("anthropic"),
);
assert_eq!(
preview.direct_url,
"https://api.example.com/v1/messages/v1/messages"
);
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
assert!(preview.is_full_url);
}
#[test]
fn test_build_url_preview_codex_responses() {
let preview = build_url_preview(
&AppType::Codex,
"https://api.openai.com/v1",
Some("responses"),
);
assert_eq!(preview.direct_url, "https://api.openai.com/v1/responses");
assert_eq!(preview.proxy_url, "https://api.openai.com/v1/responses");
assert!(!preview.is_full_url);
}
#[test]
fn test_build_url_preview_codex_origin_normalizes_v1() {
let preview =
build_url_preview(&AppType::Codex, "https://api.openai.com", Some("responses"));
assert_eq!(preview.direct_url, "https://api.openai.com/v1/responses");
assert_eq!(preview.proxy_url, "https://api.openai.com/v1/responses");
assert!(!preview.is_full_url);
}
#[test]
fn test_build_url_preview_codex_full_url() {
// 全链接时:直连/代理均保持原地址(运行时适配器规则)
let preview = build_url_preview(
&AppType::Codex,
"https://api.example.com/v1/responses",
Some("responses"),
);
assert_eq!(preview.direct_url, "https://api.example.com/v1/responses");
assert_eq!(preview.proxy_url, "https://api.example.com/v1/responses");
assert!(preview.is_full_url);
}
#[test]
fn test_check_proxy_requirement_claude_openai_chat() {
let result = check_proxy_requirement(
&AppType::Claude,
"https://api.example.com",
Some("openai_chat"),
);
assert_eq!(result, Some("openai_chat_format"));
}
#[test]
fn test_check_proxy_requirement_claude_full_url() {
let result = check_proxy_requirement(
&AppType::Claude,
"https://api.example.com/v1/messages",
Some("anthropic"),
);
assert_eq!(result, Some("full_url"));
}
#[test]
fn test_check_proxy_requirement_codex_full_url() {
let result = check_proxy_requirement(
&AppType::Codex,
"https://api.example.com/v1/responses",
Some("responses"),
);
assert_eq!(result, Some("full_url"));
}
#[test]
fn test_check_proxy_requirement_codex_origin_none() {
let result =
check_proxy_requirement(&AppType::Codex, "https://api.openai.com", Some("responses"));
assert_eq!(result, None);
}
#[test]
fn test_check_proxy_requirement_none() {
let result = check_proxy_requirement(
&AppType::Claude,
"https://api.example.com",
Some("anthropic"),
);
assert_eq!(result, None);
}
#[test]
fn test_v1_dedup() {
// 代理地址使用 build_smart_url,会去重 /v1/v1
let url = build_smart_url("https://api.example.com/v1", "/v1/messages", &[]);
assert_eq!(url, "https://api.example.com/v1/messages");
}
#[test]
fn test_direct_url_no_dedup() {
// 直连地址硬拼接,不做任何去重
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1",
Some("anthropic"),
);
assert_eq!(preview.direct_url, "https://api.example.com/v1/v1/messages");
// 代理地址按运行时规则构建,会进行路径去重
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
}
#[test]
fn test_query_suffix_preserved_in_preview() {
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com?beta=true",
Some("anthropic"),
);
assert_eq!(
preview.direct_url,
"https://api.example.com/v1/messages?beta=true"
);
assert_eq!(
preview.proxy_url,
"https://api.example.com/v1/messages?beta=true"
);
assert!(!preview.is_full_url);
}
#[test]
fn test_fragment_suffix_preserved_and_full_url_detected() {
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1/messages#frag",
Some("anthropic"),
);
assert_eq!(
preview.proxy_url,
"https://api.example.com/v1/messages#frag"
);
assert!(preview.is_full_url);
}
#[test]
fn test_claude_full_url_detection_is_api_format_agnostic() {
// 与运行时 ClaudeAdapter 一致:/messages 与 /chat/completions 都视为全链接
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1/messages",
Some("anthropic"),
);
assert!(preview.is_full_url);
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1/chat/completions",
Some("anthropic"),
);
assert!(preview.is_full_url);
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1/chat/completions",
Some("openai_chat"),
);
assert!(preview.is_full_url);
let preview = build_url_preview(
&AppType::Claude,
"https://api.example.com/v1/messages",
Some("openai_chat"),
);
assert!(preview.is_full_url);
}
}
-96
View File
@@ -1,96 +0,0 @@
//! URL utilities shared across proxy modules.
//!
//! This module intentionally avoids full URL parsing to keep behavior aligned with
//! existing "stringly-typed" base_url configurations while fixing common edge cases
//! (query/fragment handling and safe path de-duplication).
/// Split a URL-like string into `(base, suffix)` where suffix starts with `?` or `#`.
///
/// Example:
/// - `https://x/v1?token=1` => (`https://x/v1`, `?token=1`)
/// - `https://x/v1#frag` => (`https://x/v1`, `#frag`)
pub(crate) fn split_url_suffix(input: &str) -> (&str, &str) {
match input.find(['?', '#']) {
Some(idx) => (&input[..idx], &input[idx..]),
None => (input, ""),
}
}
/// De-duplicate repeated `/v1/v1` only when it occurs on a segment boundary.
///
/// This avoids corrupting valid paths such as `/v1/v1beta/...`.
pub(crate) fn dedup_v1_v1_boundary_safe(mut url: String) -> String {
const NEEDLE: &str = "/v1/v1";
let mut search_start = 0usize;
loop {
let Some(rel_pos) = url[search_start..].find(NEEDLE) else {
break;
};
let pos = search_start + rel_pos;
let after = pos + NEEDLE.len();
let boundary_ok = after == url.len()
|| matches!(
url.as_bytes().get(after),
Some(b'/') | Some(b'?') | Some(b'#')
);
if boundary_ok {
url.replace_range(pos..after, "/v1");
// Continue searching from the same position in case we created a new boundary match.
search_start = pos;
} else {
// Skip forward to find later occurrences that might be valid boundary matches.
search_start = pos + 1;
}
}
url
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_url_suffix_handles_query() {
let (base, suffix) = split_url_suffix("https://example.com/v1?token=1");
assert_eq!(base, "https://example.com/v1");
assert_eq!(suffix, "?token=1");
}
#[test]
fn split_url_suffix_handles_fragment() {
let (base, suffix) = split_url_suffix("https://example.com/v1#frag");
assert_eq!(base, "https://example.com/v1");
assert_eq!(suffix, "#frag");
}
#[test]
fn dedup_v1_v1_only_on_boundary() {
let url = "https://example.com/v1/v1/messages".to_string();
assert_eq!(
dedup_v1_v1_boundary_safe(url),
"https://example.com/v1/messages"
);
}
#[test]
fn dedup_v1_v1_does_not_corrupt_v1beta() {
let url = "https://example.com/v1/v1beta/models".to_string();
assert_eq!(
dedup_v1_v1_boundary_safe(url.clone()),
"https://example.com/v1/v1beta/models"
);
}
#[test]
fn dedup_v1_v1_skips_v1beta_but_dedups_later_occurrence() {
let url = "https://example.com/v1/v1beta/v1/v1/messages".to_string();
assert_eq!(
dedup_v1_v1_boundary_safe(url),
"https://example.com/v1/v1beta/v1/messages"
);
}
}
+1 -1
View File
@@ -830,7 +830,7 @@ impl ProxyService {
///
/// 代理服务器的路由已经根据 API 端点自动区分应用类型:
/// - `/v1/messages` → Claude
/// - `/v1/responses` → Codex
/// - `/v1/chat/completions`, `/v1/responses` → Codex
/// - `/v1beta/*` → Gemini
///
/// 因此不需要在 URL 中添加应用前缀。
+159 -543
View File
@@ -10,7 +10,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::time::timeout;
@@ -159,154 +159,6 @@ pub struct SkillMetadata {
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 ==========
pub struct SkillService;
@@ -423,25 +275,11 @@ impl SkillService {
) -> Result<InstalledSkill> {
let ssot_dir = Self::get_ssot_dir()?;
// 允许多级目录(如 a/b/c),但必须是安全的相对路径。
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
// 使用目录最后一段作为安装名
let install_name = Path::new(&skill.directory)
.file_name()
.and_then(|name| Self::sanitize_install_name(&name.to_string_lossy()))
.ok_or_else(|| {
anyhow!(format_skill_error(
"INVALID_SKILL_DIRECTORY",
&[("directory", &skill.directory)],
Some("checkZipContent"),
))
})?;
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| skill.directory.clone());
// 检查数据库中是否已有同名 directory 的 skill(来自其他仓库)
let existing_skills = db.get_all_installed_skills()?;
@@ -520,7 +358,7 @@ impl SkillService {
repo_branch = used_branch;
// 复制到 SSOT
let source = temp_dir.join(&source_rel);
let source = temp_dir.join(&skill.directory);
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
@@ -530,24 +368,7 @@ impl SkillService {
)));
}
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)?;
Self::copy_dir_recursive(&source, &dest)?;
let _ = fs::remove_dir_all(&temp_dir);
// 使用实际下载成功的分支,避免 readme_url / repo_branch 与真实分支不一致。
@@ -628,7 +449,12 @@ impl SkillService {
.ok_or_else(|| anyhow!("Skill not found: {id}"))?;
// 从所有应用目录删除
for app in AppType::all() {
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
let _ = Self::remove_from_app(&skill.directory, &app);
}
@@ -685,49 +511,74 @@ impl SkillService {
.map(|s| s.directory.clone())
.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();
for (scan_dir, label) in &scan_sources {
let entries = match fs::read_dir(scan_dir) {
Ok(e) => e,
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
let app_dir = match Self::get_app_skills_dir(&app) {
Ok(d) => d,
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();
if !path.is_dir() {
continue;
}
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;
}
// 跳过已管理的
if managed_dirs.contains(&dir_name) {
continue;
}
// 检查是否有 SKILL.md
let skill_md = path.join("SKILL.md");
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
let (name, description) = if skill_md.exists() {
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
.entry(dir_name.clone())
.and_modify(|s| s.found_in.push(label.clone()))
.and_modify(|s| s.found_in.push(app_str.to_string()))
.or_insert(UnmanagedSkill {
directory: dir_name,
name,
description,
found_in: vec![label.clone()],
path: path.display().to_string(),
found_in: vec![app_str.to_string()],
});
}
}
@@ -743,36 +594,34 @@ impl SkillService {
directories: Vec<String>,
) -> Result<Vec<InstalledSkill>> {
let ssot_dir = Self::get_ssot_dir()?;
let agents_lock = parse_agents_lock();
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 {
// 在所有候选目录中查找
// 找到源目录(从任一应用目录复制)
let mut source_path: Option<PathBuf> = None;
let mut found_in: Vec<String> = Vec::new();
for (base, label) in &search_sources {
let skill_path = base.join(&dir_name);
if skill_path.exists() {
if source_path.is_none() {
source_path = Some(skill_path);
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
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());
}
}
@@ -789,25 +638,40 @@ impl SkillService {
// 解析元数据
let skill_md = dest.join("SKILL.md");
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
let (name, description) = if skill_md.exists() {
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 apps = SkillApps::from_labels(&found_in);
// 从 lock 文件提取仓库信息
let (id, repo_owner, repo_name, repo_branch, readme_url) =
build_repo_info_from_lock(&agents_lock, &dir_name);
let mut apps = SkillApps::default();
for app_str in &found_in {
match app_str.as_str() {
"claude" => apps.claude = true,
"codex" => apps.codex = true,
"gemini" => apps.gemini = true,
"opencode" => apps.opencode = true,
_ => {}
}
}
// 创建记录
let skill = InstalledSkill {
id,
id: format!("local:{dir_name}"),
name,
description,
directory: dir_name,
repo_owner,
repo_name,
repo_branch,
readme_url,
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps,
installed_at: chrono::Utc::now().timestamp(),
};
@@ -1191,79 +1055,6 @@ impl SkillService {
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 分开显示)
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
let mut seen = HashMap::new();
@@ -1287,7 +1078,7 @@ impl SkillService {
let _ = temp_dir.keep();
let mut branches = Vec::new();
if !repo.branch.is_empty() && !repo.branch.eq_ignore_ascii_case("HEAD") {
if !repo.branch.is_empty() {
branches.push(repo.branch.as_str());
}
if !branches.contains(&"main") {
@@ -1352,12 +1143,9 @@ impl SkillService {
)));
};
// 第一遍:解压普通文件和目录,收集 symlink 条目
let mut symlinks: Vec<(PathBuf, String)> = Vec::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let file_path = file.name().to_string();
let file_path = file.name();
let relative_path =
if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) {
@@ -1372,12 +1160,7 @@ impl SkillService {
let outpath = dest.join(relative_path);
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() {
if file.is_dir() {
fs::create_dir_all(&outpath)?;
} else {
if let Some(parent) = outpath.parent() {
@@ -1388,9 +1171,6 @@ impl SkillService {
}
}
// 第二遍:解析 symlink,将目标内容复制到 symlink 位置
Self::resolve_symlinks_in_dir(dest, &symlinks)?;
Ok(())
}
@@ -1413,58 +1193,6 @@ impl SkillService {
Ok(())
}
/// 解析 ZIP 中的符号链接:将目标内容复制到 symlink 位置
///
/// GitHub ZIP 归档保留了 symlink 元数据,解压时可通过 `is_symlink()` 检测。
/// 此方法将 symlink 解析为实际文件/目录内容(而非创建真实 symlink),
/// 以确保跨平台兼容且 skill 内容自包含。
fn resolve_symlinks_in_dir(base_dir: &Path, symlinks: &[(PathBuf, String)]) -> Result<()> {
// 规范化 base_dirmacOS 上 /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 文件安装 Skills
@@ -1497,56 +1225,13 @@ impl SkillService {
let ssot_dir = Self::get_ssot_dir()?;
let mut installed = Vec::new();
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 {
// 解析元数据(提前解析,用于确定安装名)
let skill_md = skill_dir.join("SKILL.md");
let meta = if skill_md.exists() {
Self::parse_skill_metadata_static(&skill_md).ok()
} else {
None
};
// 获取目录名称作为安装名
// 当 SKILL.md 在 ZIP 根目录时,skill_dir == temp_dir
// file_name() 会返回临时目录名(如 .tmpDZKGpF),需要回退到其他来源
let install_name = {
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"),
)));
}
};
let install_name = skill_dir
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
// 检查是否已有同名 directory 的 skill
let conflict = existing_skills
@@ -1562,12 +1247,18 @@ impl SkillService {
continue;
}
let (name, description) = match meta {
Some(m) => (
m.name.unwrap_or_else(|| install_name.clone()),
m.description,
),
None => (install_name.clone(), None),
// 解析元数据
let skill_md = skill_dir.join("SKILL.md");
let (name, description) = if skill_md.exists() {
match Self::parse_skill_metadata_static(&skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| install_name.clone()),
meta.description,
),
Err(_) => (install_name.clone(), None),
}
} else {
(install_name.clone(), None)
};
// 复制到 SSOT
@@ -1631,8 +1322,6 @@ impl SkillService {
let temp_path = temp_dir.path().to_path_buf();
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() {
let mut file = archive.by_index(i)?;
let file_path = match file.enclosed_name() {
@@ -1642,11 +1331,7 @@ impl SkillService {
let outpath = temp_path.join(&file_path);
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() {
if file.is_dir() {
fs::create_dir_all(&outpath)?;
} else {
if let Some(parent) = outpath.parent() {
@@ -1657,9 +1342,6 @@ impl SkillService {
}
}
// 解析 symlink
Self::resolve_symlinks_in_dir(&temp_path, &symlinks)?;
Ok(temp_path)
}
@@ -1732,109 +1414,38 @@ 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> {
let ssot_dir = SkillService::get_ssot_dir()?;
let agents_lock = parse_agents_lock();
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
// 扫描各应用目录
for app in AppType::all() {
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
let app_dir = match SkillService::get_app_skills_dir(&app) {
Ok(d) => d,
Err(_) => continue,
};
let entries = match fs::read_dir(&app_dir) {
Ok(e) => e,
Err(_) => continue,
};
if !app_dir.exists() {
continue;
}
for entry in entries.flatten() {
for entry in fs::read_dir(&app_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
// 跳过隐藏目录(以 . 开头,如 .system)
if dir_name.starts_with('.') {
continue;
}
@@ -1845,6 +1456,7 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
SkillService::copy_dir_recursive(&path, &ssot_path)?;
}
// 记录启用状态
discovered
.entry(dir_name)
.or_default()
@@ -1855,28 +1467,32 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
// 重建数据库
db.clear_skills()?;
// 将 lock 文件中发现的仓库保存到 skill_repos
save_repos_from_lock(db, &agents_lock, discovered.keys());
let mut count = 0;
for (directory, apps) in discovered {
let ssot_path = ssot_dir.join(&directory);
let skill_md = ssot_path.join("SKILL.md");
let (name, description) = SkillService::read_skill_name_desc(&skill_md, &directory);
let (id, repo_owner, repo_name, repo_branch, readme_url) =
build_repo_info_from_lock(&agents_lock, &directory);
let (name, description) = if skill_md.exists() {
match SkillService::parse_skill_metadata_static(&skill_md) {
Ok(meta) => (
meta.name.unwrap_or_else(|| directory.clone()),
meta.description,
),
Err(_) => (directory.clone(), None),
}
} else {
(directory.clone(), None)
};
let skill = InstalledSkill {
id,
id: format!("local:{directory}"),
name,
description,
directory,
repo_owner,
repo_name,
repo_branch,
readme_url,
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps,
installed_at: chrono::Utc::now().timestamp(),
};
+3 -3
View File
@@ -293,11 +293,11 @@ impl StreamCheckService {
timeout: std::time::Duration,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// 健康检查不强制附加查询参数,保持与默认 Claude 路径一致
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
let url = if base.ends_with("/v1") {
format!("{base}/messages")
format!("{base}/messages?beta=true")
} else {
format!("{base}/v1/messages")
format!("{base}/v1/messages?beta=true")
};
let body = json!({
+2 -8
View File
@@ -229,10 +229,7 @@ function App() {
deleteProvider,
saveUsageScript,
setAsDefaultModel,
} = useProviderActions(activeApp, {
isProxyRunning,
isTakeoverActive: isCurrentAppTakeoverActive,
});
} = useProviderActions(activeApp);
const disableOmoMutation = useDisableCurrentOmo();
const handleDisableOmo = () => {
@@ -1025,10 +1022,7 @@ function App() {
<>
{activeApp !== "opencode" && activeApp !== "openclaw" && (
<>
<ProxyToggle
activeApp={activeApp}
currentProvider={providers[currentProviderId] || null}
/>
<ProxyToggle activeApp={activeApp} />
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
@@ -169,8 +169,6 @@ export function ClaudeFormFields({
: t("providerForm.apiHint")
}
onManageClick={() => onEndpointModalToggle(true)}
appType="claude"
apiFormat={apiFormat}
/>
)}
@@ -94,8 +94,6 @@ export function CodexFormFields({
placeholder={t("providerForm.codexApiEndpointPlaceholder")}
hint={t("providerForm.codexApiHint")}
onManageClick={() => onEndpointModalToggle(true)}
appType="codex"
apiFormat="responses"
/>
)}
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { useEffect, useState, useCallback, useMemo } from "react";
import { useEffect, useState } from "react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
@@ -53,81 +53,6 @@ export function CommonConfigEditor({
return () => observer.disconnect();
}, []);
// Mirror value prop to local state so checkbox toggles and JsonEditor stay in sync
// (parent uses form.getValues which doesn't trigger re-renders)
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleLocalChange = useCallback(
(newValue: string) => {
setLocalValue(newValue);
onChange(newValue);
},
[onChange],
);
const toggleStates = useMemo(() => {
try {
const config = JSON.parse(localValue);
return {
hideAttribution:
config?.attribution?.commit === "" && config?.attribution?.pr === "",
alwaysThinking: config?.alwaysThinkingEnabled === true,
teammates:
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1" ||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 1,
};
} catch {
return {
hideAttribution: false,
alwaysThinking: false,
teammates: false,
};
}
}, [localValue]);
const handleToggle = useCallback(
(toggleKey: string, checked: boolean) => {
try {
const config = JSON.parse(localValue || "{}");
switch (toggleKey) {
case "hideAttribution":
if (checked) {
config.attribution = { commit: "", pr: "" };
} else {
delete config.attribution;
}
break;
case "alwaysThinking":
if (checked) {
config.alwaysThinkingEnabled = true;
} else {
delete config.alwaysThinkingEnabled;
}
break;
case "teammates":
if (!config.env) config.env = {};
if (checked) {
config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1";
} else {
delete config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
if (Object.keys(config.env).length === 0) delete config.env;
}
break;
}
handleLocalChange(JSON.stringify(config, null, 2));
} catch {
// Don't modify if JSON is invalid
}
},
[localValue, handleLocalChange],
);
return (
<>
<div className="space-y-2">
@@ -166,40 +91,9 @@ export function CommonConfigEditor({
{commonConfigError}
</p>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.hideAttribution}
onChange={(e) =>
handleToggle("hideAttribution", 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.hideAttribution")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.alwaysThinking}
onChange={(e) => handleToggle("alwaysThinking", 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.alwaysThinking")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.teammates}
onChange={(e) => handleToggle("teammates", 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.enableTeammates")}</span>
</label>
</div>
<JsonEditor
value={localValue}
onChange={handleLocalChange}
value={value}
onChange={onChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
+18 -26
View File
@@ -43,10 +43,7 @@ import {
import { OpenCodeFormFields } from "./OpenCodeFormFields";
import { OpenClawFormFields } from "./OpenClawFormFields";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import {
applyTemplateValues,
setCodexWireApi,
} from "@/utils/providerConfigUtils";
import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import { getCodexCustomTemplate } from "@/config/codexTemplates";
import CodexConfigEditor from "./CodexConfigEditor";
@@ -408,13 +405,14 @@ export function ProviderForm({
onConfigChange: (config) => form.setValue("settingsConfig", config),
});
const [localClaudeApiFormat, setLocalClaudeApiFormat] =
useState<ClaudeApiFormat>(() => {
if (appId !== "claude") return "anthropic";
return initialData?.meta?.apiFormat === "openai_chat"
? "openai_chat"
: "anthropic";
});
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
if (appId !== "claude") return "anthropic";
return initialData?.meta?.apiFormat ?? "anthropic";
});
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
setLocalApiFormat(format);
}, []);
const {
codexAuth,
@@ -442,10 +440,6 @@ export function ProviderForm({
[originalHandleCodexConfigChange, debouncedValidate],
);
const handleClaudeApiFormatChange = useCallback((format: ClaudeApiFormat) => {
setLocalClaudeApiFormat(format);
}, []);
useEffect(() => {
if (appId === "codex" && !initialData && selectedPresetId === "custom") {
const template = getCodexCustomTemplate();
@@ -1367,7 +1361,7 @@ export function ProviderForm({
const authJson = JSON.parse(codexAuth);
const configObj = {
auth: authJson,
config: setCodexWireApi(codexConfig ?? "", "responses"),
config: codexConfig ?? "",
};
settingsConfig = JSON.stringify(configObj);
} catch (err) {
@@ -1502,11 +1496,6 @@ export function ProviderForm({
}
}
const providerApiFormat =
appId === "claude" && category !== "official"
? localClaudeApiFormat
: undefined;
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
payload.meta = {
@@ -1521,7 +1510,10 @@ export function ProviderForm({
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
? pricingConfig.pricingModelSource
: undefined,
apiFormat: providerApiFormat,
apiFormat:
appId === "claude" && category !== "official"
? localApiFormat
: undefined,
};
onSubmit(payload);
@@ -1776,9 +1768,9 @@ export function ProviderForm({
);
if (preset.apiFormat) {
setLocalClaudeApiFormat(preset.apiFormat);
setLocalApiFormat(preset.apiFormat);
} else {
setLocalClaudeApiFormat("anthropic");
setLocalApiFormat("anthropic");
}
form.reset({
@@ -1961,8 +1953,8 @@ export function ProviderForm({
defaultOpusModel={defaultOpusModel}
onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints}
apiFormat={localClaudeApiFormat}
onApiFormatChange={handleClaudeApiFormatChange}
apiFormat={localApiFormat}
onApiFormatChange={handleApiFormatChange}
/>
)}
@@ -1,11 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Zap, AlertTriangle, Server, Unplug } from "lucide-react";
import { proxyApi, type UrlPreview } from "@/lib/api/proxy";
type AppType = "claude" | "codex" | "gemini";
import { Zap } from "lucide-react";
interface EndpointFieldProps {
id: string;
@@ -17,11 +13,6 @@ interface EndpointFieldProps {
showManageButton?: boolean;
onManageClick?: () => void;
manageButtonLabel?: string;
// 应用类型和 API 格式
appType?: AppType;
apiFormat?: string;
// 是否显示请求地址预览
showUrlPreview?: boolean;
}
export function EndpointField({
@@ -34,48 +25,13 @@ export function EndpointField({
showManageButton = true,
onManageClick,
manageButtonLabel,
appType,
apiFormat,
showUrlPreview = true,
}: EndpointFieldProps) {
const { t } = useTranslation();
const [urlPreview, setUrlPreview] = useState<UrlPreview | null>(null);
const lastRequestIdRef = useRef(0);
const defaultManageLabel = t("providerForm.manageAndTest", {
defaultValue: "管理和测速",
});
// 调用后端 API 获取 URL 预览
useEffect(() => {
if (!value || !appType || !showUrlPreview) {
// 标记当前所有已发出的请求为过期,避免旧请求回写
lastRequestIdRef.current += 1;
setUrlPreview(null);
return;
}
// 防抖:延迟 300ms 后请求
const timer = setTimeout(async () => {
const requestId = ++lastRequestIdRef.current;
try {
const preview = await proxyApi.buildUrlPreview(
appType,
value,
apiFormat,
);
if (requestId !== lastRequestIdRef.current) return;
setUrlPreview(preview);
} catch (error) {
console.error("Failed to build URL preview:", error);
if (requestId !== lastRequestIdRef.current) return;
setUrlPreview(null);
}
}, 300);
return () => clearTimeout(timer);
}, [value, appType, apiFormat, showUrlPreview]);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -99,68 +55,6 @@ export function EndpointField({
placeholder={placeholder}
autoComplete="off"
/>
{/* 请求地址预览 */}
{showUrlPreview && urlPreview && (
<div className="p-2 bg-muted/50 border border-border rounded-md space-y-2">
{/* CLI 直连请求地址 */}
<div>
<p className="text-xs text-muted-foreground mb-0.5 flex items-center gap-1">
<Unplug className="h-3 w-3" />
{t("providerForm.directRequestUrl", {
defaultValue: "CLI 直连请求地址:",
})}
</p>
<p className="text-xs font-mono text-foreground break-all pl-4">
{urlPreview.direct_url}
</p>
<p className="text-xs text-muted-foreground/70 mt-0.5 pl-4">
{t("providerForm.directRequestUrlDesc", {
defaultValue: "CLI 直连模式下的实际请求地址",
})}
</p>
</div>
{/* CCS 代理请求地址 */}
<div className="pt-1.5 border-t border-border/50">
<p className="text-xs text-muted-foreground mb-0.5 flex items-center gap-1">
<Server className="h-3 w-3" />
{t("providerForm.proxyRequestUrl", {
defaultValue: "CCS 代理请求地址:",
})}
</p>
<p className="text-xs font-mono text-foreground break-all pl-4">
{urlPreview.proxy_url}
</p>
<p className="text-xs text-muted-foreground/70 mt-0.5 pl-4">
{t("providerForm.proxyRequestUrlDesc", {
defaultValue: "CCS 智能拼接后转发到上游的地址",
})}
</p>
</div>
</div>
)}
{/* 全链接警告 */}
{urlPreview?.is_full_url && (
<div className="flex items-start gap-2 p-2 bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800 rounded-md">
<AlertTriangle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs text-orange-600 dark:text-orange-400 font-medium">
{t("providerForm.fullUrlWarningTitle", {
defaultValue: "检测到完整 API 路径",
})}
</p>
<p className="text-xs text-orange-600/80 dark:text-orange-400/80 mt-0.5">
{t("providerForm.fullUrlWarning", {
defaultValue:
"填写了包含 API 路径的完整地址,此配置仅在代理模式下生效。直连模式下请只填写基础地址。",
})}
</p>
</div>
</div>
)}
{hint ? (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>
+3 -67
View File
@@ -6,91 +6,27 @@
*/
import { Radio, Loader2 } from "lucide-react";
import { useState } from "react";
import { Switch } from "@/components/ui/switch";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import type { AppId } from "@/lib/api";
import { proxyApi } from "@/lib/api/proxy";
import type { Provider } from "@/types";
import { extractProviderBaseUrl } from "@/utils/providerBaseUrl";
interface ProxyToggleProps {
className?: string;
activeApp: AppId;
currentProvider?: Provider | null;
}
export function ProxyToggle({
className,
activeApp,
currentProvider,
}: ProxyToggleProps) {
export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
const { t } = useTranslation();
const [isCheckingRequirement, setIsCheckingRequirement] = useState(false);
const { isRunning, takeoverStatus, setTakeoverForApp, isPending, status } =
useProxyStatus();
const handleToggle = async (checked: boolean) => {
// 关闭代理时,检查当前供应商是否是全链接配置
if (
!checked &&
currentProvider &&
currentProvider.category !== "official"
) {
const baseUrl = extractProviderBaseUrl(currentProvider, activeApp);
const apiFormat = currentProvider.meta?.apiFormat;
setIsCheckingRequirement(true);
try {
let proxyRequirement: string | null = null;
if (baseUrl || apiFormat) {
proxyRequirement = await proxyApi.checkProxyRequirement(
activeApp,
baseUrl || "",
apiFormat,
);
}
if (proxyRequirement) {
const warningKey =
proxyRequirement === "openai_chat_format"
? "notifications.openAIChatFormatWarningOnDisable"
: proxyRequirement === "url_mismatch"
? "notifications.urlMismatchWarningOnDisable"
: "notifications.fullUrlWarningOnDisable";
toast.warning(
t(warningKey, {
defaultValue:
"当前供应商配置可能依赖代理模式,关闭代理后可能无法正常工作。建议更换为基础地址配置或保持代理开启。",
}),
{ duration: 6000, closeButton: true },
);
}
} catch (error) {
console.error("Failed to check proxy requirement:", error);
} finally {
setIsCheckingRequirement(false);
}
}
try {
await setTakeoverForApp({ appType: activeApp, enabled: checked });
} catch (error) {
console.error("[ProxyToggle] Toggle takeover failed:", error);
toast.error(
t("proxy.takeover.toggleFailed", {
defaultValue: "切换接管状态失败",
}),
{
description: t("proxy.takeover.toggleFailedDesc", {
defaultValue: "请检查代理服务状态与权限,然后重试。",
}),
closeButton: true,
},
);
}
};
@@ -125,7 +61,7 @@ export function ProxyToggle({
)}
title={tooltipText}
>
{isPending || isCheckingRequirement ? (
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Radio
@@ -140,7 +76,7 @@ export function ProxyToggle({
<Switch
checked={takeoverEnabled}
onCheckedChange={handleToggle}
disabled={isPending || isCheckingRequirement}
disabled={isPending}
/>
</div>
);
@@ -38,7 +38,7 @@ export function DirectorySettings({
const { t } = useTranslation();
return (
<div className="space-y-6">
<>
{/* CC Switch 配置目录 - 独立区块 */}
<section className="space-y-4">
<header className="space-y-1">
@@ -131,7 +131,7 @@ export function DirectorySettings({
onReset={() => onResetDirectory("opencode")}
/>
</section>
</div>
</>
);
}
@@ -53,7 +53,7 @@ export function ImportExportSection({
</p>
</header>
<div className="space-y-4 rounded-lg border border-border bg-muted/40 p-6">
<div className="space-y-4 rounded-xl glass-card p-6 border border-white/10">
{/* Import and Export Buttons Side by Side */}
<div className="grid grid-cols-2 gap-4 items-stretch">
{/* Import Button */}
+7 -5
View File
@@ -213,12 +213,14 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const query = searchQuery.toLowerCase();
return byStatus.filter((skill) => {
const name = skill.name?.toLowerCase() || "";
const repo =
skill.repoOwner && skill.repoName
? `${skill.repoOwner}/${skill.repoName}`.toLowerCase()
: "";
const description = skill.description?.toLowerCase() || "";
const directory = skill.directory?.toLowerCase() || "";
return name.includes(query) || repo.includes(query);
return (
name.includes(query) ||
description.includes(query) ||
directory.includes(query)
);
});
}, [skills, searchQuery, filterRepo, filterStatus]);
+2 -6
View File
@@ -306,7 +306,6 @@ interface ImportSkillsDialogProps {
name: string;
description?: string;
foundIn: string[];
path: string;
}>;
onImport: (directories: string[]) => void;
onClose: () => void;
@@ -363,11 +362,8 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
{skill.description}
</div>
)}
<div
className="text-xs text-muted-foreground/50 mt-1 truncate"
title={skill.path}
>
{skill.path}
<div className="text-xs text-muted-foreground/70 mt-1">
{t("skills.foundIn")}: {skill.foundIn.join(", ")}
</div>
</div>
</label>
+26 -114
View File
@@ -3,7 +3,6 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { providersApi, settingsApi, openclawApi, type AppId } from "@/lib/api";
import { proxyApi } from "@/lib/api/proxy";
import type {
Provider,
UsageScript,
@@ -18,25 +17,13 @@ import {
useSwitchProviderMutation,
} from "@/lib/query";
import { extractErrorMessage } from "@/utils/errorUtils";
import { extractProviderBaseUrl } from "@/utils/providerBaseUrl";
import { openclawKeys } from "@/hooks/useOpenClaw";
interface UseProviderActionsOptions {
/** 代理服务是否正在运行 */
isProxyRunning?: boolean;
/** 当前应用的代理接管是否激活 */
isTakeoverActive?: boolean;
}
/**
* Hook for managing provider actions (add, update, delete, switch)
* Extracts business logic from App.tsx
*/
export function useProviderActions(
activeApp: AppId,
options: UseProviderActionsOptions = {},
) {
const { isProxyRunning = false, isTakeoverActive = false } = options;
export function useProviderActions(activeApp: AppId) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -146,11 +133,30 @@ export function useProviderActions(
// 切换供应商
const switchProvider = useCallback(
async (provider: Provider) => {
// 官方供应商不需要检查
if (provider.category === "official") {
try {
await switchProviderMutation.mutateAsync(provider.id);
await syncClaudePlugin(provider);
try {
await switchProviderMutation.mutateAsync(provider.id);
await syncClaudePlugin(provider);
// 根据供应商类型显示不同的成功提示
if (
activeApp === "claude" &&
provider.category !== "official" &&
provider.meta?.apiFormat === "openai_chat"
) {
// OpenAI Chat 格式供应商:显示代理提示
toast.info(
t("notifications.openAIChatFormatHint", {
defaultValue:
"此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
}),
{
duration: 5000,
closeButton: true,
},
);
} else {
// 普通供应商:显示切换成功
// OpenCode/OpenClaw: show "added to config" message instead of "switched"
const isMultiProviderApp =
activeApp === "opencode" || activeApp === "openclaw";
const messageKey = isMultiProviderApp
@@ -159,94 +165,7 @@ export function useProviderActions(
const defaultMessage = isMultiProviderApp
? "已添加到配置"
: "切换成功!";
toast.success(
t(messageKey, { defaultValue: defaultMessage }),
{
closeButton: true,
},
);
} catch {
// 错误提示由 mutation 处理
}
return;
}
// 提取 base URL 和 API 格式
const baseUrl = extractProviderBaseUrl(provider, activeApp);
const apiFormat = provider.meta?.apiFormat;
// 调用后端 API 检查是否需要代理(统一由后端控制)
let proxyRequirement: string | null = null;
let proxyRequirementCheckFailed = false;
if (baseUrl || apiFormat) {
try {
proxyRequirement = await proxyApi.checkProxyRequirement(
activeApp,
baseUrl || "",
apiFormat,
);
} catch (error) {
console.error("Failed to check proxy requirement:", error);
proxyRequirementCheckFailed = true;
}
}
// 如果需要代理但代理未激活,阻止切换并提示
if (proxyRequirement && !(isProxyRunning && isTakeoverActive)) {
let message: string;
if (proxyRequirement === "openai_chat_format") {
message = t("notifications.openAIChatFormatRequiresProxy", {
defaultValue:
"此供应商使用 OpenAI Chat 格式,需要开启代理服务进行格式转换才能正常使用。请先开启代理并接管当前应用。",
});
} else if (proxyRequirement === "full_url") {
// 用户填了全链接(如 /v1/messages 结尾)
message = t("notifications.fullUrlRequiresProxy", {
defaultValue:
"此供应商配置了完整 API 路径,直连模式下客户端可能会重复追加路径。请先开启代理并接管当前应用。",
});
} else {
// url_mismatch: 直连地址和代理地址不匹配
message = t("notifications.urlMismatchRequiresProxy", {
defaultValue:
"此供应商的请求地址配置与 API 格式不匹配,直连模式下无法正常工作。请先开启代理并接管当前应用。",
});
}
toast.warning(message, {
duration: 6000,
closeButton: true,
});
return; // 阻止切换
}
try {
await switchProviderMutation.mutateAsync(provider.id);
await syncClaudePlugin(provider);
const isMultiProviderApp =
activeApp === "opencode" || activeApp === "openclaw";
const messageKey = isMultiProviderApp
? "notifications.addToConfigSuccess"
: "notifications.switchSuccess";
const defaultMessage = isMultiProviderApp ? "已添加到配置" : "切换成功!";
if (proxyRequirementCheckFailed && baseUrl) {
toast.success(
t("notifications.switchAppliedUnverified", {
defaultValue: "切换已应用(未验证直连兼容性)",
}),
{
description: t("notifications.switchAppliedUnverifiedDesc", {
defaultValue:
"未能验证该端点是否需要代理。如果切换后无法正常使用,请开启代理并接管当前应用。",
}),
closeButton: true,
duration: 6000,
},
);
} else {
toast.success(t(messageKey, { defaultValue: defaultMessage }), {
closeButton: true,
});
@@ -255,14 +174,7 @@ export function useProviderActions(
// 错误提示由 mutation 处理
}
},
[
switchProviderMutation,
syncClaudePlugin,
activeApp,
t,
isProxyRunning,
isTakeoverActive,
],
[switchProviderMutation, syncClaudePlugin, activeApp, t],
);
// 删除供应商
+2 -23
View File
@@ -58,10 +58,7 @@
"extractFromCurrent": "Extract from Editor",
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
"saveFailed": "Save failed: {{error}}",
"hideAttribution": "Hide AI Attribution",
"alwaysThinking": "Extended Thinking",
"enableTeammates": "Teammates Mode"
"saveFailed": "Save failed: {{error}}"
},
"header": {
"viewOnGithub": "View on GitHub",
@@ -167,14 +164,6 @@
"settingsSaved": "Settings saved",
"settingsSaveFailed": "Failed to save settings: {{error}}",
"openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled",
"openAIChatFormatRequiresProxy": "This provider uses OpenAI Chat format and requires the proxy service for format conversion. Please enable the proxy and takeover the current app first.",
"fullUrlRequiresProxy": "This provider is configured with a full API path. The client may append duplicate paths in direct connection mode. Please enable the proxy and takeover the current app first.",
"urlMismatchRequiresProxy": "This provider's request URL configuration does not match the API format. It cannot work properly in direct connection mode. Please enable the proxy and takeover the current app first.",
"fullUrlWarningOnDisable": "The current provider is configured with a full API path or special format. It may not work properly after disabling the proxy. Consider switching to a base URL configuration or keep the proxy enabled.",
"openAIChatFormatWarningOnDisable": "The current provider uses OpenAI Chat format. It may not work properly after disabling the proxy. Consider keeping the proxy enabled.",
"urlMismatchWarningOnDisable": "The current provider's request URL configuration may rely on the proxy. It may not work properly after disabling the proxy. Consider switching to a base URL configuration or keep the proxy enabled.",
"switchAppliedUnverified": "Switch applied (direct mode not verified)",
"switchAppliedUnverifiedDesc": "Unable to verify whether this endpoint requires the proxy. If it doesn't work after switching, enable the proxy and takeover the current app.",
"openLinkFailed": "Failed to open link",
"openclawModelsRegistered": "Models have been registered to /model list",
"openclawDefaultModelSet": "Set as default model",
@@ -602,12 +591,6 @@
"apiHint": "💡 Fill in Claude API compatible service endpoint, avoid trailing slash",
"apiHintOAI": "💡 Fill in OpenAI Chat Completions compatible service endpoint, avoid trailing slash",
"codexApiHint": "💡 Fill in service endpoint compatible with OpenAI Response format",
"directRequestUrl": "CLI Direct Request URL:",
"directRequestUrlDesc": "Actual request URL after CLI appends default suffix",
"proxyRequestUrl": "CCS Proxy Request URL:",
"proxyRequestUrlDesc": "URL forwarded to upstream after CCS smart path detection",
"fullUrlWarningTitle": "Full API path detected",
"fullUrlWarning": "The URL contains a full API path. This configuration only works in proxy mode. For direct connection mode, please enter only the base URL.",
"fillSupplierName": "Please fill in provider name",
"fillConfigContent": "Please fill in configuration content",
"fillParameter": "Please fill in {{label}}",
@@ -1344,7 +1327,7 @@
"skillCount": "{{count}} skills detected"
},
"search": "Search Skills",
"searchPlaceholder": "Search skill name or repo...",
"searchPlaceholder": "Search skill name or description...",
"filter": {
"placeholder": "Filter by status",
"all": "All",
@@ -1502,10 +1485,6 @@
}
},
"proxy": {
"takeover": {
"toggleFailed": "Failed to toggle takeover",
"toggleFailedDesc": "Check proxy service status and permissions, then try again."
},
"panel": {
"serviceAddress": "Service Address",
"addressCopied": "Address copied",
+2 -23
View File
@@ -58,10 +58,7 @@
"extractFromCurrent": "編集内容から抽出",
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
"extractFailed": "抽出に失敗しました: {{error}}",
"saveFailed": "保存に失敗しました: {{error}}",
"hideAttribution": "AI署名を非表示",
"alwaysThinking": "拡張思考",
"enableTeammates": "Teammates モード"
"saveFailed": "保存に失敗しました: {{error}}"
},
"header": {
"viewOnGithub": "GitHub で見る",
@@ -167,14 +164,6 @@
"settingsSaved": "設定を保存しました",
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です",
"openAIChatFormatRequiresProxy": "このプロバイダーは OpenAI Chat フォーマットを使用しており、フォーマット変換のためにプロキシサービスが必要です。先にプロキシを有効にして、現在のアプリをテイクオーバーしてください。",
"fullUrlRequiresProxy": "このプロバイダーは完全な API パスで構成されています。直接接続モードではクライアントがパスを重複追加する可能性があります。先にプロキシを有効にして、現在のアプリをテイクオーバーしてください。",
"urlMismatchRequiresProxy": "このプロバイダーのリクエスト URL 設定が API フォーマットと一致しません。直接接続モードでは正常に動作しません。先にプロキシを有効にして、現在のアプリをテイクオーバーしてください。",
"fullUrlWarningOnDisable": "現在のプロバイダーは完全な API パスまたは特殊なフォーマットで構成されています。プロキシを無効にすると正常に動作しない可能性があります。ベース URL 設定に変更するか、プロキシを有効のままにすることをお勧めします。",
"openAIChatFormatWarningOnDisable": "現在のプロバイダーは OpenAI Chat フォーマットを使用しています。プロキシを無効にすると正常に動作しない可能性があります。プロキシを有効のままにすることをお勧めします。",
"urlMismatchWarningOnDisable": "現在のプロバイダーのリクエスト URL 設定はプロキシに依存している可能性があります。プロキシを無効にすると正常に動作しない可能性があります。ベース URL 設定に変更するか、プロキシを有効のままにすることをお勧めします。",
"switchAppliedUnverified": "切り替えを適用しました(直接接続は未検証)",
"switchAppliedUnverifiedDesc": "このエンドポイントがプロキシを必要とするか検証できませんでした。切り替え後に動作しない場合は、プロキシを有効にして現在のアプリをテイクオーバーしてください。",
"openLinkFailed": "リンクを開けませんでした",
"openclawModelsRegistered": "モデルが /model リストに登録されました",
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
@@ -602,12 +591,6 @@
"apiHint": "💡 Claude API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください",
"apiHintOAI": "💡 OpenAI Chat Completions 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください",
"codexApiHint": "💡 OpenAI Response 互換のサービスエンドポイントを入力してください",
"directRequestUrl": "CLI 直接リクエスト URL",
"directRequestUrlDesc": "CLI がデフォルトサフィックスを追加した後の実際のリクエスト URL",
"proxyRequestUrl": "CCS プロキシリクエスト URL",
"proxyRequestUrlDesc": "CCS がスマートパス検出後にアップストリームへ転送する URL",
"fullUrlWarningTitle": "完全な API パスを検出",
"fullUrlWarning": "URL には完全な API パスが含まれています。この設定はプロキシモードでのみ有効です。直接接続モードではベース URL のみを入力してください。",
"fillSupplierName": "プロバイダー名を入力してください",
"fillConfigContent": "設定内容を入力してください",
"fillParameter": "{{label}} を入力してください",
@@ -1342,7 +1325,7 @@
"skillCount": "{{count}} 件のスキルを検出"
},
"search": "スキルを検索",
"searchPlaceholder": "スキル名またはリポジトリで検索...",
"searchPlaceholder": "スキル名または説明で検索...",
"filter": {
"placeholder": "状態で絞り込み",
"all": "すべて",
@@ -1500,10 +1483,6 @@
}
},
"proxy": {
"takeover": {
"toggleFailed": "テイクオーバーの切り替えに失敗しました",
"toggleFailedDesc": "プロキシサービスの状態と権限を確認して、もう一度お試しください。"
},
"panel": {
"serviceAddress": "サービスアドレス",
"addressCopied": "アドレスをコピーしました",
+2 -23
View File
@@ -58,10 +58,7 @@
"extractFromCurrent": "从编辑内容提取",
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
"extractFailed": "提取失败: {{error}}",
"saveFailed": "保存失败: {{error}}",
"hideAttribution": "隐藏 AI 署名",
"alwaysThinking": "扩展思考",
"enableTeammates": "Teammates 模式"
"saveFailed": "保存失败: {{error}}"
},
"header": {
"viewOnGithub": "在 GitHub 上查看",
@@ -167,14 +164,6 @@
"settingsSaved": "设置已保存",
"settingsSaveFailed": "保存设置失败:{{error}}",
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
"openAIChatFormatRequiresProxy": "此供应商使用 OpenAI Chat 格式,需要开启代理服务进行格式转换才能正常使用。请先开启代理并接管当前应用。",
"fullUrlRequiresProxy": "此供应商配置了完整 API 路径,直连模式下客户端可能会重复追加路径。请先开启代理并接管当前应用。",
"urlMismatchRequiresProxy": "此供应商的请求地址配置与 API 格式不匹配,直连模式下无法正常工作。请先开启代理并接管当前应用。",
"fullUrlWarningOnDisable": "当前供应商配置了完整 API 路径或特殊格式,关闭代理后可能无法正常工作。建议更换为基础地址配置或保持代理开启。",
"openAIChatFormatWarningOnDisable": "当前供应商使用 OpenAI Chat 格式,关闭代理后可能无法正常工作。建议保持代理开启。",
"urlMismatchWarningOnDisable": "当前供应商的请求地址配置可能依赖代理模式,关闭代理后可能无法正常工作。建议更换为基础地址配置或保持代理开启。",
"switchAppliedUnverified": "切换已应用(未验证直连兼容性)",
"switchAppliedUnverifiedDesc": "未能验证该端点是否需要代理。如果切换后无法正常使用,请开启代理并接管当前应用。",
"openLinkFailed": "链接打开失败",
"openclawModelsRegistered": "模型已注册到 /model 列表",
"openclawDefaultModelSet": "已设为默认模型",
@@ -602,12 +591,6 @@
"apiHint": "💡 填写兼容 Claude API 的服务端点地址,不要以斜杠结尾",
"apiHintOAI": "💡 填写兼容 OpenAI Chat Completions 的服务端点地址,不要以斜杠结尾",
"codexApiHint": "💡 填写兼容 OpenAI Response 格式的服务端点地址",
"directRequestUrl": "CLI 直连请求地址:",
"directRequestUrlDesc": "CLI 硬拼接默认后缀后的实际请求地址",
"proxyRequestUrl": "CCS 代理请求地址:",
"proxyRequestUrlDesc": "CCS 智能拼接后转发到上游的地址",
"fullUrlWarningTitle": "检测到完整 API 路径",
"fullUrlWarning": "填写了包含 API 路径的完整地址,此配置仅在代理模式下生效。直连模式下请只填写基础地址。",
"fillSupplierName": "请填写供应商名称",
"fillConfigContent": "请填写配置内容",
"fillParameter": "请填写 {{label}}",
@@ -1344,7 +1327,7 @@
"skillCount": "识别到 {{count}} 个技能"
},
"search": "搜索技能",
"searchPlaceholder": "搜索技能名称或仓库名称...",
"searchPlaceholder": "搜索技能名称或描述...",
"filter": {
"placeholder": "状态筛选",
"all": "全部",
@@ -1502,10 +1485,6 @@
}
},
"proxy": {
"takeover": {
"toggleFailed": "切换接管状态失败",
"toggleFailedDesc": "请检查代理服务状态与权限,然后重试。"
},
"panel": {
"serviceAddress": "服务地址",
"addressCopied": "地址已复制",
-36
View File
@@ -117,40 +117,4 @@ export const proxyApi = {
async setPricingModelSource(appType: string, value: string): Promise<void> {
return invoke("set_pricing_model_source", { appType, value });
},
// ========== URL 预览 API ==========
/**
* URL
* app_typebase_url api_format
*/
async buildUrlPreview(
appType: string,
baseUrl: string,
apiFormat?: string,
): Promise<UrlPreview> {
return invoke("build_url_preview", { appType, baseUrl, apiFormat });
},
/**
*
* null
*/
async checkProxyRequirement(
appType: string,
baseUrl: string,
apiFormat?: string,
): Promise<string | null> {
return invoke("check_proxy_requirement", { appType, baseUrl, apiFormat });
},
};
/** URL 预览结果 */
export interface UrlPreview {
/** 直连模式请求地址 */
direct_url: string;
/** 代理模式请求地址 */
proxy_url: string;
/** 是否为全链接(base_url 已包含 API 路径) */
is_full_url: boolean;
}
-1
View File
@@ -45,7 +45,6 @@ export interface UnmanagedSkill {
name: string;
description?: string;
foundIn: string[];
path: string;
}
/** 技能对象(兼容旧 API) */
+2 -5
View File
@@ -140,13 +140,10 @@ export interface ProviderMeta {
costMultiplier?: string;
// 供应商计费模式来源
pricingModelSource?: string;
// 供应商 API 格式
// Claude:
// Claude API 格式(仅 Claude 供应商使用)
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
// Codex:
// - "responses": OpenAI Responses API
apiFormat?: "anthropic" | "openai_chat" | "responses";
apiFormat?: "anthropic" | "openai_chat";
}
// Skill 同步方式
-43
View File
@@ -1,43 +0,0 @@
import type { AppId } from "@/lib/api";
import type { Provider } from "@/types";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
/**
* provider base URL/
*/
export function extractProviderBaseUrl(
provider: Provider,
appId: AppId,
): string | null {
try {
const config = provider.settingsConfig;
if (!config) return null;
if (appId === "claude") {
const envUrl = config?.env?.ANTHROPIC_BASE_URL;
return typeof envUrl === "string" ? envUrl.trim() : null;
}
if (appId === "codex" || appId === "opencode") {
const tomlConfig = config?.config;
if (typeof tomlConfig === "string") {
return extractCodexBaseUrl(tomlConfig) ?? null;
}
const baseUrl = config?.base_url;
return typeof baseUrl === "string" ? baseUrl.trim() : null;
}
if (appId === "gemini") {
const envUrl = config?.env?.GOOGLE_GEMINI_BASE_URL;
if (typeof envUrl === "string") return envUrl.trim();
const baseUrl = config?.GEMINI_API_BASE || config?.base_url;
return typeof baseUrl === "string" ? baseUrl.trim() : null;
}
return null;
} catch {
return null;
}
}
-35
View File
@@ -468,41 +468,6 @@ export const setCodexBaseUrl = (
return `${prefix}${replacementLine}\n`;
};
// 在 Codex 的 TOML 配置文本中写入或更新 wire_api 字段(仅保留 responses
export const setCodexWireApi = (
configText: string,
wireApi: "responses" = "responses",
): string => {
const normalizedText = normalizeQuotes(configText);
const replacementLine = `wire_api = "${wireApi}"`;
const pattern = /^wire_api\s*=\s*(["'])(responses|chat)\1/m;
if (pattern.test(normalizedText)) {
return normalizedText.replace(pattern, replacementLine);
}
// 优先插入到 base_url 后,提升可读性
const baseUrlPattern = /^base_url\s*=\s*["'][^"']+["']/m;
const match = normalizedText.match(baseUrlPattern);
if (match && match.index !== undefined) {
const endOfLine = normalizedText.indexOf("\n", match.index);
if (endOfLine !== -1) {
return (
normalizedText.slice(0, endOfLine + 1) +
replacementLine +
"\n" +
normalizedText.slice(endOfLine + 1)
);
}
}
const prefix =
normalizedText && !normalizedText.endsWith("\n")
? `${normalizedText}\n`
: normalizedText;
return `${prefix}${replacementLine}\n`;
};
// ========== Codex model name utils ==========
// 从 Codex 的 TOML 配置文本中提取 model 字段(支持单/双引号)