mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-25 19:32:39 +08:00
Compare commits
10 Commits
style/code
...
fix/skill-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cf9627e8b | ||
|
|
5502c74a79 | ||
|
|
fa7c9514cc | ||
|
|
d82027f107 | ||
|
|
ded9980fbf | ||
|
|
3d91c381d9 | ||
|
|
b8538a6996 | ||
|
|
87b80c66b2 | ||
|
|
14fa749ca9 | ||
|
|
95bc0e38df |
@@ -324,6 +324,7 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
home.join(".local/bin"), // Native install (official recommended)
|
home.join(".local/bin"), // Native install (official recommended)
|
||||||
home.join(".npm-global/bin"),
|
home.join(".npm-global/bin"),
|
||||||
home.join("n/bin"), // n version manager
|
home.join("n/bin"), // n version manager
|
||||||
|
home.join(".volta/bin"), // Volta package manager
|
||||||
];
|
];
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -755,7 +756,7 @@ fn launch_macos_open_app(
|
|||||||
|
|
||||||
let output = cmd
|
let output = cmd
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("启动 {} 失败: {e}", app_name))?;
|
.map_err(|e| format!("启动 {app_name} 失败: {e}"))?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ mod plugin;
|
|||||||
mod prompt;
|
mod prompt;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod settings;
|
|
||||||
mod session_manager;
|
mod session_manager;
|
||||||
|
mod settings;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
mod stream_check;
|
mod stream_check;
|
||||||
mod usage;
|
mod usage;
|
||||||
@@ -30,8 +30,8 @@ pub use plugin::*;
|
|||||||
pub use prompt::*;
|
pub use prompt::*;
|
||||||
pub use provider::*;
|
pub use provider::*;
|
||||||
pub use proxy::*;
|
pub use proxy::*;
|
||||||
pub use settings::*;
|
|
||||||
pub use session_manager::*;
|
pub use session_manager::*;
|
||||||
|
pub use settings::*;
|
||||||
pub use skill::*;
|
pub use skill::*;
|
||||||
pub use stream_check::*;
|
pub use stream_check::*;
|
||||||
pub use usage::*;
|
pub use usage::*;
|
||||||
|
|||||||
@@ -919,7 +919,16 @@ impl Database {
|
|||||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||||
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||||
let pricing_data = [
|
let pricing_data = [
|
||||||
// Claude 4.5 系列 (Latest Models)
|
// Claude 4.6 系列
|
||||||
|
(
|
||||||
|
"claude-opus-4-6-20260206",
|
||||||
|
"Claude Opus 4.6",
|
||||||
|
"5",
|
||||||
|
"25",
|
||||||
|
"0.50",
|
||||||
|
"6.25",
|
||||||
|
),
|
||||||
|
// Claude 4.5 系列
|
||||||
(
|
(
|
||||||
"claude-opus-4-5-20251101",
|
"claude-opus-4-5-20251101",
|
||||||
"Claude Opus 4.5",
|
"Claude Opus 4.5",
|
||||||
@@ -1025,6 +1034,40 @@ impl Database {
|
|||||||
"0.175",
|
"0.175",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
|
// GPT-5.3 Codex 系列
|
||||||
|
("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"),
|
||||||
|
(
|
||||||
|
"gpt-5.3-codex-low",
|
||||||
|
"GPT-5.3 Codex",
|
||||||
|
"1.75",
|
||||||
|
"14",
|
||||||
|
"0.175",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gpt-5.3-codex-medium",
|
||||||
|
"GPT-5.3 Codex",
|
||||||
|
"1.75",
|
||||||
|
"14",
|
||||||
|
"0.175",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gpt-5.3-codex-high",
|
||||||
|
"GPT-5.3 Codex",
|
||||||
|
"1.75",
|
||||||
|
"14",
|
||||||
|
"0.175",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gpt-5.3-codex-xhigh",
|
||||||
|
"GPT-5.3 Codex",
|
||||||
|
"1.75",
|
||||||
|
"14",
|
||||||
|
"0.175",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
// GPT-5.1 系列
|
// GPT-5.1 系列
|
||||||
("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||||
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||||
@@ -1212,7 +1255,7 @@ impl Database {
|
|||||||
|
|
||||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO model_pricing (
|
"INSERT OR IGNORE INTO model_pricing (
|
||||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
@@ -1239,14 +1282,8 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
|
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
|
||||||
let count: i64 = conn
|
// 每次启动都执行 INSERT OR IGNORE,增量追加新模型,已有数据不覆盖
|
||||||
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
|
Self::seed_model_pricing(conn)
|
||||||
.map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?;
|
|
||||||
|
|
||||||
if count == 0 {
|
|
||||||
Self::seed_model_pricing(conn)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 辅助方法 ---
|
// --- 辅助方法 ---
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ mod prompt_files;
|
|||||||
mod provider;
|
mod provider;
|
||||||
mod provider_defaults;
|
mod provider_defaults;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod session_manager;
|
|
||||||
mod services;
|
mod services;
|
||||||
|
mod session_manager;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod store;
|
mod store;
|
||||||
mod tray;
|
mod tray;
|
||||||
|
|||||||
@@ -174,6 +174,29 @@ impl SkillService {
|
|||||||
Self
|
Self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构建 Skill 文档 URL(指向仓库中的 SKILL.md 文件)
|
||||||
|
fn build_skill_doc_url(owner: &str, repo: &str, branch: &str, doc_path: &str) -> String {
|
||||||
|
format!("https://github.com/{owner}/{repo}/blob/{branch}/{doc_path}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从旧 readme_url 中提取仓库内文档路径,兼容 `blob`/`tree` 两种格式
|
||||||
|
fn extract_doc_path_from_url(url: &str) -> Option<String> {
|
||||||
|
let marker = if url.contains("/blob/") {
|
||||||
|
"/blob/"
|
||||||
|
} else if url.contains("/tree/") {
|
||||||
|
"/tree/"
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_, tail) = url.split_once(marker)?;
|
||||||
|
let (_, path) = tail.split_once('/')?;
|
||||||
|
if path.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(path.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 路径管理 ==========
|
// ========== 路径管理 ==========
|
||||||
|
|
||||||
/// 获取 SSOT 目录(~/.cc-switch/skills/)
|
/// 获取 SSOT 目录(~/.cc-switch/skills/)
|
||||||
@@ -298,6 +321,8 @@ impl SkillService {
|
|||||||
|
|
||||||
let dest = ssot_dir.join(&install_name);
|
let dest = ssot_dir.join(&install_name);
|
||||||
|
|
||||||
|
let mut repo_branch = skill.repo_branch.clone();
|
||||||
|
|
||||||
// 如果已存在则跳过下载
|
// 如果已存在则跳过下载
|
||||||
if !dest.exists() {
|
if !dest.exists() {
|
||||||
let repo = SkillRepo {
|
let repo = SkillRepo {
|
||||||
@@ -308,7 +333,7 @@ impl SkillService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 下载仓库
|
// 下载仓库
|
||||||
let temp_dir = timeout(
|
let (temp_dir, used_branch) = timeout(
|
||||||
std::time::Duration::from_secs(60),
|
std::time::Duration::from_secs(60),
|
||||||
self.download_repo(&repo),
|
self.download_repo(&repo),
|
||||||
)
|
)
|
||||||
@@ -324,6 +349,7 @@ impl SkillService {
|
|||||||
Some("checkNetwork"),
|
Some("checkNetwork"),
|
||||||
))
|
))
|
||||||
})??;
|
})??;
|
||||||
|
repo_branch = used_branch;
|
||||||
|
|
||||||
// 复制到 SSOT
|
// 复制到 SSOT
|
||||||
let source = temp_dir.join(&skill.directory);
|
let source = temp_dir.join(&skill.directory);
|
||||||
@@ -338,8 +364,39 @@ impl SkillService {
|
|||||||
|
|
||||||
Self::copy_dir_recursive(&source, &dest)?;
|
Self::copy_dir_recursive(&source, &dest)?;
|
||||||
let _ = fs::remove_dir_all(&temp_dir);
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
|
||||||
|
// 使用实际下载成功的分支,避免 readme_url / repo_branch 与真实分支不一致。
|
||||||
|
if repo_branch != skill.repo_branch {
|
||||||
|
log::info!(
|
||||||
|
"Skill {}/{} 分支自动回退: {} -> {}",
|
||||||
|
skill.repo_owner,
|
||||||
|
skill.repo_name,
|
||||||
|
skill.repo_branch,
|
||||||
|
repo_branch
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let doc_path = skill
|
||||||
|
.readme_url
|
||||||
|
.as_deref()
|
||||||
|
.and_then(Self::extract_doc_path_from_url)
|
||||||
|
.map(|path| {
|
||||||
|
if path.ends_with("/SKILL.md") || path == "SKILL.md" {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
format!("{}/SKILL.md", path.trim_end_matches('/'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| format!("{}/SKILL.md", skill.directory.trim_end_matches('/')));
|
||||||
|
|
||||||
|
let readme_url = Some(Self::build_skill_doc_url(
|
||||||
|
&skill.repo_owner,
|
||||||
|
&skill.repo_name,
|
||||||
|
&repo_branch,
|
||||||
|
&doc_path,
|
||||||
|
));
|
||||||
|
|
||||||
// 创建 InstalledSkill 记录
|
// 创建 InstalledSkill 记录
|
||||||
let installed_skill = InstalledSkill {
|
let installed_skill = InstalledSkill {
|
||||||
id: skill.key.clone(),
|
id: skill.key.clone(),
|
||||||
@@ -352,8 +409,8 @@ impl SkillService {
|
|||||||
directory: install_name.clone(),
|
directory: install_name.clone(),
|
||||||
repo_owner: Some(skill.repo_owner.clone()),
|
repo_owner: Some(skill.repo_owner.clone()),
|
||||||
repo_name: Some(skill.repo_name.clone()),
|
repo_name: Some(skill.repo_name.clone()),
|
||||||
repo_branch: Some(skill.repo_branch.clone()),
|
repo_branch: Some(repo_branch),
|
||||||
readme_url: skill.readme_url.clone(),
|
readme_url,
|
||||||
apps: SkillApps::only(current_app),
|
apps: SkillApps::only(current_app),
|
||||||
installed_at: chrono::Utc::now().timestamp(),
|
installed_at: chrono::Utc::now().timestamp(),
|
||||||
};
|
};
|
||||||
@@ -862,24 +919,26 @@ impl SkillService {
|
|||||||
|
|
||||||
/// 从仓库获取技能列表
|
/// 从仓库获取技能列表
|
||||||
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<DiscoverableSkill>> {
|
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<DiscoverableSkill>> {
|
||||||
let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
|
let (temp_dir, resolved_branch) =
|
||||||
.await
|
timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
|
||||||
.map_err(|_| {
|
.await
|
||||||
anyhow!(format_skill_error(
|
.map_err(|_| {
|
||||||
"DOWNLOAD_TIMEOUT",
|
anyhow!(format_skill_error(
|
||||||
&[
|
"DOWNLOAD_TIMEOUT",
|
||||||
("owner", &repo.owner),
|
&[
|
||||||
("name", &repo.name),
|
("owner", &repo.owner),
|
||||||
("timeout", "60")
|
("name", &repo.name),
|
||||||
],
|
("timeout", "60")
|
||||||
Some("checkNetwork"),
|
],
|
||||||
))
|
Some("checkNetwork"),
|
||||||
})??;
|
))
|
||||||
|
})??;
|
||||||
|
|
||||||
let mut skills = Vec::new();
|
let mut skills = Vec::new();
|
||||||
let scan_dir = temp_dir.clone();
|
let scan_dir = temp_dir.clone();
|
||||||
|
let mut resolved_repo = repo.clone();
|
||||||
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
|
resolved_repo.branch = resolved_branch;
|
||||||
|
self.scan_dir_recursive(&scan_dir, &scan_dir, &resolved_repo, &mut skills)?;
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(&temp_dir);
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
|
||||||
@@ -907,7 +966,15 @@ impl SkillService {
|
|||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Ok(skill) = self.build_skill_from_metadata(&skill_md, &directory, repo) {
|
let doc_path = skill_md
|
||||||
|
.strip_prefix(base_dir)
|
||||||
|
.unwrap_or(skill_md.as_path())
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/");
|
||||||
|
|
||||||
|
if let Ok(skill) =
|
||||||
|
self.build_skill_from_metadata(&skill_md, &directory, &doc_path, repo)
|
||||||
|
{
|
||||||
skills.push(skill);
|
skills.push(skill);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -931,6 +998,7 @@ impl SkillService {
|
|||||||
&self,
|
&self,
|
||||||
skill_md: &Path,
|
skill_md: &Path,
|
||||||
directory: &str,
|
directory: &str,
|
||||||
|
doc_path: &str,
|
||||||
repo: &SkillRepo,
|
repo: &SkillRepo,
|
||||||
) -> Result<DiscoverableSkill> {
|
) -> Result<DiscoverableSkill> {
|
||||||
let meta = self.parse_skill_metadata(skill_md)?;
|
let meta = self.parse_skill_metadata(skill_md)?;
|
||||||
@@ -940,9 +1008,11 @@ impl SkillService {
|
|||||||
name: meta.name.unwrap_or_else(|| directory.to_string()),
|
name: meta.name.unwrap_or_else(|| directory.to_string()),
|
||||||
description: meta.description.unwrap_or_default(),
|
description: meta.description.unwrap_or_default(),
|
||||||
directory: directory.to_string(),
|
directory: directory.to_string(),
|
||||||
readme_url: Some(format!(
|
readme_url: Some(Self::build_skill_doc_url(
|
||||||
"https://github.com/{}/{}/tree/{}/{}",
|
&repo.owner,
|
||||||
repo.owner, repo.name, repo.branch, directory
|
&repo.name,
|
||||||
|
&repo.branch,
|
||||||
|
doc_path,
|
||||||
)),
|
)),
|
||||||
repo_owner: repo.owner.clone(),
|
repo_owner: repo.owner.clone(),
|
||||||
repo_name: repo.name.clone(),
|
repo_name: repo.name.clone(),
|
||||||
@@ -994,16 +1064,21 @@ impl SkillService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 下载仓库
|
/// 下载仓库
|
||||||
async fn download_repo(&self, repo: &SkillRepo) -> Result<PathBuf> {
|
async fn download_repo(&self, repo: &SkillRepo) -> Result<(PathBuf, String)> {
|
||||||
let temp_dir = tempfile::tempdir()?;
|
let temp_dir = tempfile::tempdir()?;
|
||||||
let temp_path = temp_dir.path().to_path_buf();
|
let temp_path = temp_dir.path().to_path_buf();
|
||||||
let _ = temp_dir.keep();
|
let _ = temp_dir.keep();
|
||||||
|
|
||||||
let branches = if repo.branch.is_empty() {
|
let mut branches = Vec::new();
|
||||||
vec!["main", "master"]
|
if !repo.branch.is_empty() {
|
||||||
} else {
|
branches.push(repo.branch.as_str());
|
||||||
vec![repo.branch.as_str(), "main", "master"]
|
}
|
||||||
};
|
if !branches.contains(&"main") {
|
||||||
|
branches.push("main");
|
||||||
|
}
|
||||||
|
if !branches.contains(&"master") {
|
||||||
|
branches.push("master");
|
||||||
|
}
|
||||||
|
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
for branch in branches {
|
for branch in branches {
|
||||||
@@ -1014,7 +1089,7 @@ impl SkillService {
|
|||||||
|
|
||||||
match self.download_and_extract(&url, &temp_path).await {
|
match self.download_and_extract(&url, &temp_path).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
return Ok(temp_path);
|
return Ok((temp_path, branch.to_string()));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
last_error = Some(e);
|
last_error = Some(e);
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ impl Database {
|
|||||||
.single()
|
.single()
|
||||||
.unwrap_or_else(Local::now);
|
.unwrap_or_else(Local::now);
|
||||||
|
|
||||||
let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string();
|
let date = bucket_start.to_rfc3339();
|
||||||
|
|
||||||
if let Some(mut stat) = map.remove(&i) {
|
if let Some(mut stat) = map.remove(&i) {
|
||||||
stat.date = date;
|
stat.date = date;
|
||||||
|
|||||||
@@ -55,17 +55,12 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string();
|
.to_string();
|
||||||
let content = message
|
let content = message.get("content").map(extract_text).unwrap_or_default();
|
||||||
.get("content")
|
|
||||||
.map(extract_text)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if content.trim().is_empty() {
|
if content.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ts = value
|
let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||||
.get("timestamp")
|
|
||||||
.and_then(parse_timestamp_to_ms);
|
|
||||||
|
|
||||||
messages.push(SessionMessage { role, content, ts });
|
messages.push(SessionMessage { role, content, ts });
|
||||||
}
|
}
|
||||||
@@ -127,10 +122,7 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
|||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
let text = message
|
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||||
.get("content")
|
|
||||||
.map(extract_text)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if text.trim().is_empty() {
|
if text.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::fs::File;
|
|||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde_json::Value;
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::codex_config::get_codex_config_dir;
|
use crate::codex_config::get_codex_config_dir;
|
||||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||||
@@ -60,17 +60,12 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string();
|
.to_string();
|
||||||
let content = payload
|
let content = payload.get("content").map(extract_text).unwrap_or_default();
|
||||||
.get("content")
|
|
||||||
.map(extract_text)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if content.trim().is_empty() {
|
if content.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ts = value
|
let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||||
.get("timestamp")
|
|
||||||
.and_then(parse_timestamp_to_ms);
|
|
||||||
|
|
||||||
messages.push(SessionMessage { role, content, ts });
|
messages.push(SessionMessage { role, content, ts });
|
||||||
}
|
}
|
||||||
@@ -139,10 +134,7 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let text = payload
|
let text = payload.get("content").map(extract_text).unwrap_or_default();
|
||||||
.get("content")
|
|
||||||
.map(extract_text)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if text.trim().is_empty() {
|
if text.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -174,10 +166,9 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
|||||||
|
|
||||||
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
|
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
|
||||||
let file_name = path.file_name()?.to_string_lossy();
|
let file_name = path.file_name()?.to_string_lossy();
|
||||||
let re = Regex::new(
|
let re =
|
||||||
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
|
Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
||||||
)
|
.ok()?;
|
||||||
.ok()?;
|
|
||||||
re.find(&file_name).map(|mat| mat.as_str().to_string())
|
re.find(&file_name).map(|mat| mat.as_str().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub fn extract_text(content: &Value) -> String {
|
|||||||
Value::String(text) => text.to_string(),
|
Value::String(text) => text.to_string(),
|
||||||
Value::Array(items) => items
|
Value::Array(items) => items
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| extract_text_from_item(item))
|
.filter_map(extract_text_from_item)
|
||||||
.filter(|text| !text.trim().is_empty())
|
.filter(|text| !text.trim().is_empty())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
@@ -68,10 +68,10 @@ pub fn path_basename(value: &str) -> Option<String> {
|
|||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let normalized = trimmed.trim_end_matches(|c| c == '/' || c == '\\');
|
let normalized = trimmed.trim_end_matches(['/', '\\']);
|
||||||
let last = normalized
|
let last = normalized
|
||||||
.split(['/', '\\'])
|
.split(['/', '\\'])
|
||||||
.last()
|
.next_back()
|
||||||
.filter(|segment| !segment.is_empty())?;
|
.filter(|segment| !segment.is_empty())?;
|
||||||
Some(last.to_string())
|
Some(last.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,8 @@ fn launch_macos_terminal(command: &str, cwd: Option<&str>) -> Result<(), String>
|
|||||||
let script = format!(
|
let script = format!(
|
||||||
r#"tell application "Terminal"
|
r#"tell application "Terminal"
|
||||||
activate
|
activate
|
||||||
do script "{}"
|
do script "{escaped}"
|
||||||
end tell"#,
|
end tell"#
|
||||||
escaped
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let status = Command::new("osascript")
|
let status = Command::new("osascript")
|
||||||
@@ -59,10 +58,9 @@ fn launch_iterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
|||||||
activate
|
activate
|
||||||
create window with default profile
|
create window with default profile
|
||||||
tell current session of current window
|
tell current session of current window
|
||||||
write text "{}"
|
write text "{escaped}"
|
||||||
end tell
|
end tell
|
||||||
end tell"#,
|
end tell"#
|
||||||
escaped
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let status = Command::new("osascript")
|
let status = Command::new("osascript")
|
||||||
@@ -88,7 +86,7 @@ fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> {
|
|||||||
// Note: The user's error output didn't show the working dir arg failure, so we assume flag is okay or we stick to compatible ones.
|
// Note: The user's error output didn't show the working dir arg failure, so we assume flag is okay or we stick to compatible ones.
|
||||||
// Documentation says --working-directory is supported in CLI.
|
// Documentation says --working-directory is supported in CLI.
|
||||||
let work_dir_arg = if let Some(dir) = cwd {
|
let work_dir_arg = if let Some(dir) = cwd {
|
||||||
format!("--working-directory={}", dir)
|
format!("--working-directory={dir}")
|
||||||
} else {
|
} else {
|
||||||
"".to_string()
|
"".to_string()
|
||||||
};
|
};
|
||||||
@@ -251,7 +249,7 @@ fn build_shell_command(command: &str, cwd: Option<&str>) -> String {
|
|||||||
|
|
||||||
fn shell_escape(value: &str) -> String {
|
fn shell_escape(value: &str) -> String {
|
||||||
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
format!("\"{}\"", escaped)
|
format!("\"{escaped}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn escape_osascript(value: &str) -> String {
|
fn escape_osascript(value: &str) -> String {
|
||||||
|
|||||||
44
src/App.tsx
44
src/App.tsx
@@ -175,7 +175,7 @@ function App() {
|
|||||||
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
|
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
|
||||||
const activeProviderId = useMemo(() => {
|
const activeProviderId = useMemo(() => {
|
||||||
const target = proxyStatus?.active_targets?.find(
|
const target = proxyStatus?.active_targets?.find(
|
||||||
(t) => t.app_type === activeApp
|
(t) => t.app_type === activeApp,
|
||||||
);
|
);
|
||||||
return target?.provider_id;
|
return target?.provider_id;
|
||||||
}, [proxyStatus?.active_targets, activeApp]);
|
}, [proxyStatus?.active_targets, activeApp]);
|
||||||
@@ -208,7 +208,7 @@ function App() {
|
|||||||
if (event.appType === activeApp) {
|
if (event.appType === activeApp) {
|
||||||
await refetch();
|
await refetch();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[App] Failed to subscribe provider switch event", error);
|
console.error("[App] Failed to subscribe provider switch event", error);
|
||||||
@@ -242,7 +242,7 @@ function App() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[App] Failed to subscribe universal-provider-synced event",
|
"[App] Failed to subscribe universal-provider-synced event",
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -270,7 +270,7 @@ function App() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[App] Failed to check environment conflicts on startup:",
|
"[App] Failed to check environment conflicts on startup:",
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -286,7 +286,7 @@ function App() {
|
|||||||
if (migrated) {
|
if (migrated) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("migration.success", { defaultValue: "配置迁移成功" }),
|
t("migration.success", { defaultValue: "配置迁移成功" }),
|
||||||
{ closeButton: true }
|
{ closeButton: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -302,7 +302,7 @@ function App() {
|
|||||||
const checkSkillsMigration = async () => {
|
const checkSkillsMigration = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await invoke<{ count: number; error?: string } | null>(
|
const result = await invoke<{ count: number; error?: string } | null>(
|
||||||
"get_skills_migration_result"
|
"get_skills_migration_result",
|
||||||
);
|
);
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
toast.error(t("migration.skillsFailed"), {
|
toast.error(t("migration.skillsFailed"), {
|
||||||
@@ -336,10 +336,10 @@ function App() {
|
|||||||
// 合并新检测到的冲突
|
// 合并新检测到的冲突
|
||||||
setEnvConflicts((prev) => {
|
setEnvConflicts((prev) => {
|
||||||
const existingKeys = new Set(
|
const existingKeys = new Set(
|
||||||
prev.map((c) => `${c.varName}:${c.sourcePath}`)
|
prev.map((c) => `${c.varName}:${c.sourcePath}`),
|
||||||
);
|
);
|
||||||
const newConflicts = conflicts.filter(
|
const newConflicts = conflicts.filter(
|
||||||
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`)
|
(c) => !existingKeys.has(`${c.varName}:${c.sourcePath}`),
|
||||||
);
|
);
|
||||||
return [...prev, ...newConflicts];
|
return [...prev, ...newConflicts];
|
||||||
});
|
});
|
||||||
@@ -351,7 +351,7 @@ function App() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[App] Failed to check environment conflicts on app switch:",
|
"[App] Failed to check environment conflicts on app switch:",
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -433,7 +433,7 @@ function App() {
|
|||||||
t("notifications.removeFromConfigSuccess", {
|
t("notifications.removeFromConfigSuccess", {
|
||||||
defaultValue: "已从配置移除",
|
defaultValue: "已从配置移除",
|
||||||
}),
|
}),
|
||||||
{ closeButton: true }
|
{ closeButton: true },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Delete from database
|
// Delete from database
|
||||||
@@ -445,7 +445,7 @@ function App() {
|
|||||||
// Generate a unique provider key for OpenCode duplication
|
// Generate a unique provider key for OpenCode duplication
|
||||||
const generateUniqueOpencodeKey = (
|
const generateUniqueOpencodeKey = (
|
||||||
originalKey: string,
|
originalKey: string,
|
||||||
existingKeys: string[]
|
existingKeys: string[],
|
||||||
): string => {
|
): string => {
|
||||||
const baseKey = `${originalKey}-copy`;
|
const baseKey = `${originalKey}-copy`;
|
||||||
|
|
||||||
@@ -487,7 +487,7 @@ function App() {
|
|||||||
const existingKeys = Object.keys(providers);
|
const existingKeys = Object.keys(providers);
|
||||||
duplicatedProvider.providerKey = generateUniqueOpencodeKey(
|
duplicatedProvider.providerKey = generateUniqueOpencodeKey(
|
||||||
provider.id,
|
provider.id,
|
||||||
existingKeys
|
existingKeys,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +498,7 @@ function App() {
|
|||||||
(p) =>
|
(p) =>
|
||||||
p.sortIndex !== undefined &&
|
p.sortIndex !== undefined &&
|
||||||
p.sortIndex >= newSortIndex! &&
|
p.sortIndex >= newSortIndex! &&
|
||||||
p.id !== provider.id
|
p.id !== provider.id,
|
||||||
)
|
)
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@@ -514,7 +514,7 @@ function App() {
|
|||||||
toast.error(
|
toast.error(
|
||||||
t("provider.sortUpdateFailed", {
|
t("provider.sortUpdateFailed", {
|
||||||
defaultValue: "排序更新失败",
|
defaultValue: "排序更新失败",
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
return; // 如果排序更新失败,不继续添加
|
return; // 如果排序更新失败,不继续添加
|
||||||
}
|
}
|
||||||
@@ -532,7 +532,7 @@ function App() {
|
|||||||
toast.success(
|
toast.success(
|
||||||
t("provider.terminalOpened", {
|
t("provider.terminalOpened", {
|
||||||
defaultValue: "终端已打开",
|
defaultValue: "终端已打开",
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[App] Failed to open terminal", error);
|
console.error("[App] Failed to open terminal", error);
|
||||||
@@ -540,7 +540,7 @@ function App() {
|
|||||||
toast.error(
|
toast.error(
|
||||||
t("provider.terminalOpenFailed", {
|
t("provider.terminalOpenFailed", {
|
||||||
defaultValue: "打开终端失败",
|
defaultValue: "打开终端失败",
|
||||||
}) + (errorMessage ? `: ${errorMessage}` : "")
|
}) + (errorMessage ? `: ${errorMessage}` : ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -721,7 +721,7 @@ function App() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[App] Failed to re-check conflicts after deletion:",
|
"[App] Failed to re-check conflicts after deletion:",
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -755,7 +755,9 @@ function App() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setCurrentView(
|
setCurrentView(
|
||||||
currentView === "skillsDiscovery" ? "skills" : "providers"
|
currentView === "skillsDiscovery"
|
||||||
|
? "skills"
|
||||||
|
: "providers",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="mr-2 rounded-lg"
|
className="mr-2 rounded-lg"
|
||||||
@@ -788,7 +790,7 @@ function App() {
|
|||||||
"text-xl font-semibold transition-colors",
|
"text-xl font-semibold transition-colors",
|
||||||
isProxyRunning && isCurrentAppTakeoverActive
|
isProxyRunning && isCurrentAppTakeoverActive
|
||||||
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
|
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
|
||||||
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
CC Switch
|
CC Switch
|
||||||
@@ -934,7 +936,7 @@ function App() {
|
|||||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||||
isCurrentAppTakeoverActive
|
isCurrentAppTakeoverActive
|
||||||
? "opacity-100 max-w-[100px] scale-100"
|
? "opacity-100 max-w-[100px] scale-100"
|
||||||
: "opacity-0 max-w-0 scale-75 pointer-events-none"
|
: "opacity-0 max-w-0 scale-75 pointer-events-none",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FailoverToggle activeApp={activeApp} />
|
<FailoverToggle activeApp={activeApp} />
|
||||||
@@ -962,7 +964,7 @@ function App() {
|
|||||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||||
hasSkillsSupport
|
hasSkillsSupport
|
||||||
? "opacity-100 w-8 scale-100 px-2"
|
? "opacity-100 w-8 scale-100 px-2"
|
||||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1"
|
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||||
)}
|
)}
|
||||||
title={t("skills.manage")}
|
title={t("skills.manage")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ interface AppCountBarProps {
|
|||||||
counts: Record<AppId, number>;
|
counts: Record<AppId, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppCountBar: React.FC<AppCountBarProps> = ({ totalLabel, counts }) => {
|
export const AppCountBar: React.FC<AppCountBarProps> = ({
|
||||||
|
totalLabel,
|
||||||
|
counts,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4">
|
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4">
|
||||||
<Badge variant="outline" className="bg-background/50 h-7 px-3">
|
<Badge variant="outline" className="bg-background/50 h-7 px-3">
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ interface AppToggleGroupProps {
|
|||||||
onToggle: (app: AppId, enabled: boolean) => void;
|
onToggle: (app: AppId, enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({ apps, onToggle }) => {
|
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
|
||||||
|
apps,
|
||||||
|
onToggle,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
{APP_IDS.map((app) => {
|
{APP_IDS.map((app) => {
|
||||||
@@ -25,16 +28,17 @@ export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({ apps, onToggle }
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggle(app, !enabled)}
|
onClick={() => onToggle(app, !enabled)}
|
||||||
className={`w-7 h-7 rounded-lg flex items-center justify-center transition-all ${
|
className={`w-7 h-7 rounded-lg flex items-center justify-center transition-all ${
|
||||||
enabled
|
enabled ? activeClass : "opacity-35 hover:opacity-70"
|
||||||
? activeClass
|
|
||||||
: "opacity-35 hover:opacity-70"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
<p>{label}{enabled ? " ✓" : ""}</p>
|
<p>
|
||||||
|
{label}
|
||||||
|
{enabled ? " ✓" : ""}
|
||||||
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ interface ListItemRowProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ListItemRow: React.FC<ListItemRowProps> = ({ isLast, children }) => {
|
export const ListItemRow: React.FC<ListItemRowProps> = ({
|
||||||
|
isLast,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors ${
|
className={`group flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors ${
|
||||||
|
|||||||
@@ -245,7 +245,9 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
<ListItemRow isLast={isLast}>
|
<ListItemRow isLast={isLast}>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-medium text-sm text-foreground truncate">{name}</span>
|
<span className="font-medium text-sm text-foreground truncate">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
{docsUrl && (
|
{docsUrl && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -258,7 +260,10 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-xs text-muted-foreground truncate" title={description}>
|
<p
|
||||||
|
className="text-xs text-muted-foreground truncate"
|
||||||
|
title={description}
|
||||||
|
>
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ export function ModeToggle() {
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = (event: React.MouseEvent) => {
|
||||||
// 如果当前是 dark 或 system(且系统是暗色),切换到 light
|
// 如果当前是 dark 或 system(且系统是暗色),切换到 light
|
||||||
// 否则切换到 dark
|
// 否则切换到 dark
|
||||||
if (theme === "dark") {
|
if (theme === "dark") {
|
||||||
setTheme("light");
|
setTheme("light", event);
|
||||||
} else {
|
} else {
|
||||||
setTheme("dark");
|
setTheme("dark", event);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -746,7 +746,7 @@ export function ProviderForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenCode: validate provider key
|
// OpenCode: validate provider key and models
|
||||||
if (appId === "opencode") {
|
if (appId === "opencode") {
|
||||||
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||||
if (!opencodeProviderKey.trim()) {
|
if (!opencodeProviderKey.trim()) {
|
||||||
@@ -761,6 +761,11 @@ export function ProviderForm({
|
|||||||
toast.error(t("opencode.providerKeyDuplicate"));
|
toast.error(t("opencode.providerKeyDuplicate"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Validate that at least one model is configured
|
||||||
|
if (Object.keys(opencodeModels).length === 0) {
|
||||||
|
toast.error(t("opencode.modelsRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 非官方供应商必填校验:端点和 API Key
|
// 非官方供应商必填校验:端点和 API Key
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function SessionItem({
|
|||||||
"w-full text-left rounded-lg px-3 py-2.5 transition-all group",
|
"w-full text-left rounded-lg px-3 py-2.5 transition-all group",
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-primary/10 border border-primary/30"
|
? "bg-primary/10 border border-primary/30"
|
||||||
: "hover:bg-muted/60 border border-transparent"
|
: "hover:bg-muted/60 border border-transparent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
@@ -62,7 +62,7 @@ export function SessionItem({
|
|||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
"size-4 text-muted-foreground/50 shrink-0 transition-transform",
|
||||||
isSelected && "text-primary rotate-90"
|
isSelected && "text-primary rotate-90",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function SessionManagerPage() {
|
|||||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
const messageRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
const messageRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
const [activeMessageIndex, setActiveMessageIndex] = useState<number | null>(
|
const [activeMessageIndex, setActiveMessageIndex] = useState<number | null>(
|
||||||
null
|
null,
|
||||||
);
|
);
|
||||||
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
const [tocDialogOpen, setTocDialogOpen] = useState(false);
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
@@ -83,7 +83,7 @@ export function SessionManagerPage() {
|
|||||||
}
|
}
|
||||||
const exists = selectedKey
|
const exists = selectedKey
|
||||||
? filteredSessions.some(
|
? filteredSessions.some(
|
||||||
(session) => getSessionKey(session) === selectedKey
|
(session) => getSessionKey(session) === selectedKey,
|
||||||
)
|
)
|
||||||
: false;
|
: false;
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@@ -95,7 +95,7 @@ export function SessionManagerPage() {
|
|||||||
if (!selectedKey) return null;
|
if (!selectedKey) return null;
|
||||||
return (
|
return (
|
||||||
filteredSessions.find(
|
filteredSessions.find(
|
||||||
(session) => getSessionKey(session) === selectedKey
|
(session) => getSessionKey(session) === selectedKey,
|
||||||
) || null
|
) || null
|
||||||
);
|
);
|
||||||
}, [filteredSessions, selectedKey]);
|
}, [filteredSessions, selectedKey]);
|
||||||
@@ -103,7 +103,7 @@ export function SessionManagerPage() {
|
|||||||
const { data: messages = [], isLoading: isLoadingMessages } =
|
const { data: messages = [], isLoading: isLoadingMessages } =
|
||||||
useSessionMessagesQuery(
|
useSessionMessagesQuery(
|
||||||
selectedSession?.providerId,
|
selectedSession?.providerId,
|
||||||
selectedSession?.sourcePath
|
selectedSession?.sourcePath,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 提取用户消息用于目录
|
// 提取用户消息用于目录
|
||||||
@@ -147,7 +147,7 @@ export function SessionManagerPage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
extractErrorMessage(error) ||
|
extractErrorMessage(error) ||
|
||||||
t("common.error", { defaultValue: "Copy failed" })
|
t("common.error", { defaultValue: "Copy failed" }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -158,7 +158,7 @@ export function SessionManagerPage() {
|
|||||||
if (!isMac()) {
|
if (!isMac()) {
|
||||||
await handleCopy(
|
await handleCopy(
|
||||||
selectedSession.resumeCommand,
|
selectedSession.resumeCommand,
|
||||||
t("sessionManager.resumeCommandCopied")
|
t("sessionManager.resumeCommandCopied"),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -240,14 +240,16 @@ export function SessionManagerPage() {
|
|||||||
setIsSearchOpen(true);
|
setIsSearchOpen(true);
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => searchInputRef.current?.focus(),
|
() => searchInputRef.current?.focus(),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Search className="size-3.5" />
|
<Search className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{t("sessionManager.searchSessions")}</TooltipContent>
|
<TooltipContent>
|
||||||
|
{t("sessionManager.searchSessions")}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
@@ -387,7 +389,7 @@ export function SessionManagerPage() {
|
|||||||
<span className="shrink-0">
|
<span className="shrink-0">
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
icon={getProviderIconName(
|
icon={getProviderIconName(
|
||||||
selectedSession.providerId
|
selectedSession.providerId,
|
||||||
)}
|
)}
|
||||||
name={selectedSession.providerId}
|
name={selectedSession.providerId}
|
||||||
size={20}
|
size={20}
|
||||||
@@ -410,7 +412,7 @@ export function SessionManagerPage() {
|
|||||||
<span>
|
<span>
|
||||||
{formatTimestamp(
|
{formatTimestamp(
|
||||||
selectedSession.lastActiveAt ??
|
selectedSession.lastActiveAt ??
|
||||||
selectedSession.createdAt
|
selectedSession.createdAt,
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -422,7 +424,7 @@ export function SessionManagerPage() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
void handleCopy(
|
void handleCopy(
|
||||||
selectedSession.projectDir!,
|
selectedSession.projectDir!,
|
||||||
t("sessionManager.projectDirCopied")
|
t("sessionManager.projectDirCopied"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||||
@@ -497,7 +499,7 @@ export function SessionManagerPage() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
void handleCopy(
|
void handleCopy(
|
||||||
selectedSession.resumeCommand!,
|
selectedSession.resumeCommand!,
|
||||||
t("sessionManager.resumeCommandCopied")
|
t("sessionManager.resumeCommandCopied"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -559,7 +561,7 @@ export function SessionManagerPage() {
|
|||||||
content,
|
content,
|
||||||
t("sessionManager.messageCopied", {
|
t("sessionManager.messageCopied", {
|
||||||
defaultValue: "已复制消息内容",
|
defaultValue: "已复制消息内容",
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function SessionMessageItem({
|
|||||||
: message.role.toLowerCase() === "assistant"
|
: message.role.toLowerCase() === "assistant"
|
||||||
? "bg-blue-500/5 border-blue-500/20 mr-8"
|
? "bg-blue-500/5 border-blue-500/20 mr-8"
|
||||||
: "bg-muted/40 border-border/60",
|
: "bg-muted/40 border-border/60",
|
||||||
isActive && "ring-2 ring-primary ring-offset-2"
|
isActive && "ring-2 ring-primary ring-offset-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function SessionTocSidebar({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-left px-2 py-1.5 rounded text-xs transition-colors",
|
"w-full text-left px-2 py-1.5 rounded text-xs transition-colors",
|
||||||
"hover:bg-muted/80 text-muted-foreground hover:text-foreground",
|
"hover:bg-muted/80 text-muted-foreground hover:text-foreground",
|
||||||
"flex items-start gap-2"
|
"flex items-start gap-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="shrink-0 w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] flex items-center justify-center font-medium">
|
<span className="shrink-0 w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] flex items-center justify-center font-medium">
|
||||||
@@ -118,7 +118,7 @@ export function SessionTocDialog({
|
|||||||
"w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all",
|
"w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all",
|
||||||
"hover:bg-primary/10 text-foreground",
|
"hover:bg-primary/10 text-foreground",
|
||||||
"flex items-start gap-3",
|
"flex items-start gap-3",
|
||||||
"focus:outline-none focus:ring-2 focus:ring-primary focus:ring-inset"
|
"focus:outline-none focus:ring-2 focus:ring-primary focus:ring-inset",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="shrink-0 w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center font-semibold">
|
<span className="shrink-0 w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center font-semibold">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const formatTimestamp = (value?: number) => {
|
|||||||
|
|
||||||
export const formatRelativeTime = (
|
export const formatRelativeTime = (
|
||||||
value: number | undefined,
|
value: number | undefined,
|
||||||
t: (key: string, options?: Record<string, unknown>) => string
|
t: (key: string, options?: Record<string, unknown>) => string,
|
||||||
) => {
|
) => {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -37,7 +37,7 @@ export const formatRelativeTime = (
|
|||||||
|
|
||||||
export const getProviderLabel = (
|
export const getProviderLabel = (
|
||||||
providerId: string,
|
providerId: string,
|
||||||
t: (key: string) => string
|
t: (key: string) => string,
|
||||||
) => {
|
) => {
|
||||||
const key = `apps.${providerId}`;
|
const key = `apps.${providerId}`;
|
||||||
const translated = t(key);
|
const translated = t(key);
|
||||||
@@ -60,10 +60,7 @@ export const getRoleTone = (role: string) => {
|
|||||||
return "text-muted-foreground";
|
return "text-muted-foreground";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRoleLabel = (
|
export const getRoleLabel = (role: string, t: (key: string) => string) => {
|
||||||
role: string,
|
|
||||||
t: (key: string) => string
|
|
||||||
) => {
|
|
||||||
const normalized = role.toLowerCase();
|
const normalized = role.toLowerCase();
|
||||||
if (normalized === "assistant") return "AI";
|
if (normalized === "assistant") return "AI";
|
||||||
if (normalized === "user") return t("sessionManager.roleUser");
|
if (normalized === "user") return t("sessionManager.roleUser");
|
||||||
|
|||||||
@@ -19,21 +19,21 @@ export function ThemeSettings() {
|
|||||||
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
|
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
|
||||||
<ThemeButton
|
<ThemeButton
|
||||||
active={theme === "light"}
|
active={theme === "light"}
|
||||||
onClick={() => setTheme("light")}
|
onClick={(e) => setTheme("light", e)}
|
||||||
icon={Sun}
|
icon={Sun}
|
||||||
>
|
>
|
||||||
{t("settings.themeLight")}
|
{t("settings.themeLight")}
|
||||||
</ThemeButton>
|
</ThemeButton>
|
||||||
<ThemeButton
|
<ThemeButton
|
||||||
active={theme === "dark"}
|
active={theme === "dark"}
|
||||||
onClick={() => setTheme("dark")}
|
onClick={(e) => setTheme("dark", e)}
|
||||||
icon={Moon}
|
icon={Moon}
|
||||||
>
|
>
|
||||||
{t("settings.themeDark")}
|
{t("settings.themeDark")}
|
||||||
</ThemeButton>
|
</ThemeButton>
|
||||||
<ThemeButton
|
<ThemeButton
|
||||||
active={theme === "system"}
|
active={theme === "system"}
|
||||||
onClick={() => setTheme("system")}
|
onClick={(e) => setTheme("system", e)}
|
||||||
icon={Monitor}
|
icon={Monitor}
|
||||||
>
|
>
|
||||||
{t("settings.themeSystem")}
|
{t("settings.themeSystem")}
|
||||||
@@ -45,7 +45,7 @@ export function ThemeSettings() {
|
|||||||
|
|
||||||
interface ThemeButtonProps {
|
interface ThemeButtonProps {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,11 +63,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
|||||||
return counts;
|
return counts;
|
||||||
}, [skills]);
|
}, [skills]);
|
||||||
|
|
||||||
const handleToggleApp = async (
|
const handleToggleApp = async (id: string, app: AppId, enabled: boolean) => {
|
||||||
id: string,
|
|
||||||
app: AppId,
|
|
||||||
enabled: boolean,
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
await toggleAppMutation.mutateAsync({ id, app, enabled });
|
await toggleAppMutation.mutateAsync({ id, app, enabled });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -257,7 +253,9 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
|||||||
<ListItemRow isLast={isLast}>
|
<ListItemRow isLast={isLast}>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-medium text-sm text-foreground truncate">{skill.name}</span>
|
<span className="font-medium text-sm text-foreground truncate">
|
||||||
|
{skill.name}
|
||||||
|
</span>
|
||||||
{skill.readmeUrl && (
|
{skill.readmeUrl && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -267,10 +265,15 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
|||||||
<ExternalLink size={12} />
|
<ExternalLink size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-muted-foreground/50 flex-shrink-0">{sourceLabel}</span>
|
<span className="text-xs text-muted-foreground/50 flex-shrink-0">
|
||||||
|
{sourceLabel}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{skill.description && (
|
{skill.description && (
|
||||||
<p className="text-xs text-muted-foreground truncate" title={skill.description}>
|
<p
|
||||||
|
className="text-xs text-muted-foreground truncate"
|
||||||
|
title={skill.description}
|
||||||
|
>
|
||||||
{skill.description}
|
{skill.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface ThemeProviderProps {
|
|||||||
|
|
||||||
interface ThemeContextValue {
|
interface ThemeContextValue {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme, event?: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeProviderContext = createContext<ThemeContextValue | undefined>(
|
const ThemeProviderContext = createContext<ThemeContextValue | undefined>(
|
||||||
@@ -146,8 +146,30 @@ export function ThemeProvider({
|
|||||||
const value = useMemo<ThemeContextValue>(
|
const value = useMemo<ThemeContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
theme,
|
theme,
|
||||||
setTheme: (nextTheme: Theme) => {
|
setTheme: (nextTheme: Theme, event?: React.MouseEvent) => {
|
||||||
setThemeState(nextTheme);
|
// Skip if same theme
|
||||||
|
if (nextTheme === theme) return;
|
||||||
|
|
||||||
|
// Set transition origin coordinates from click event
|
||||||
|
const x = event?.clientX ?? window.innerWidth / 2;
|
||||||
|
const y = event?.clientY ?? window.innerHeight / 2;
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--theme-transition-x",
|
||||||
|
`${x}px`,
|
||||||
|
);
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--theme-transition-y",
|
||||||
|
`${y}px`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use View Transitions API if available, otherwise fall back to instant change
|
||||||
|
if (document.startViewTransition) {
|
||||||
|
document.startViewTransition(() => {
|
||||||
|
setThemeState(nextTheme);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setThemeState(nextTheme);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[theme],
|
[theme],
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const ScrollBar = React.forwardRef<
|
|||||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
orientation === "horizontal" &&
|
orientation === "horizontal" &&
|
||||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,10 +8,17 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useModelStats } from "@/lib/query/usage";
|
import { useModelStats } from "@/lib/query/usage";
|
||||||
|
import { fmtUsd } from "./format";
|
||||||
|
|
||||||
export function ModelStatsTable() {
|
interface ModelStatsTableProps {
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: stats, isLoading } = useModelStats();
|
const { data: stats, isLoading } = useModelStats({
|
||||||
|
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
||||||
@@ -60,10 +67,10 @@ export function ModelStatsTable() {
|
|||||||
{stat.totalTokens.toLocaleString()}
|
{stat.totalTokens.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
${parseFloat(stat.totalCost).toFixed(4)}
|
{fmtUsd(stat.totalCost, 4)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
${parseFloat(stat.avgCostPerRequest).toFixed(6)}
|
{fmtUsd(stat.avgCostPerRequest, 6)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -8,10 +8,19 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useProviderStats } from "@/lib/query/usage";
|
import { useProviderStats } from "@/lib/query/usage";
|
||||||
|
import { fmtUsd } from "./format";
|
||||||
|
|
||||||
export function ProviderStatsTable() {
|
interface ProviderStatsTableProps {
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderStatsTable({
|
||||||
|
refreshIntervalMs,
|
||||||
|
}: ProviderStatsTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: stats, isLoading } = useProviderStats();
|
const { data: stats, isLoading } = useProviderStats({
|
||||||
|
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
return <div className="h-[400px] animate-pulse rounded bg-gray-100" />;
|
||||||
@@ -63,7 +72,7 @@ export function ProviderStatsTable() {
|
|||||||
{stat.totalTokens.toLocaleString()}
|
{stat.totalTokens.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
${parseFloat(stat.totalCost).toFixed(4)}
|
{fmtUsd(stat.totalCost, 4)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{stat.successRate.toFixed(1)}%
|
{stat.successRate.toFixed(1)}%
|
||||||
|
|||||||
@@ -21,44 +21,122 @@ import { useRequestLogs, usageKeys } from "@/lib/query/usage";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { LogFilters } from "@/types/usage";
|
import type { LogFilters } from "@/types/usage";
|
||||||
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
|
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
fmtInt,
|
||||||
|
fmtUsd,
|
||||||
|
getLocaleFromLanguage,
|
||||||
|
parseFiniteNumber,
|
||||||
|
} from "./format";
|
||||||
|
|
||||||
export function RequestLogTable() {
|
interface RequestLogTableProps {
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ONE_DAY_SECONDS = 24 * 60 * 60;
|
||||||
|
const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;
|
||||||
|
|
||||||
|
type TimeMode = "rolling" | "fixed";
|
||||||
|
|
||||||
|
export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// 默认时间范围:过去24小时
|
const getRollingRange = () => {
|
||||||
const getDefaultFilters = (): LogFilters => {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const oneDayAgo = now - 24 * 60 * 60;
|
const oneDayAgo = now - ONE_DAY_SECONDS;
|
||||||
return { startDate: oneDayAgo, endDate: now };
|
return { startDate: oneDayAgo, endDate: now };
|
||||||
};
|
};
|
||||||
|
|
||||||
const [filters, setFilters] = useState<LogFilters>(getDefaultFilters);
|
const [appliedTimeMode, setAppliedTimeMode] = useState<TimeMode>("rolling");
|
||||||
const [tempFilters, setTempFilters] = useState<LogFilters>(getDefaultFilters);
|
const [draftTimeMode, setDraftTimeMode] = useState<TimeMode>("rolling");
|
||||||
|
|
||||||
|
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
|
||||||
|
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: result, isLoading } = useRequestLogs(filters, page, pageSize);
|
const { data: result, isLoading } = useRequestLogs({
|
||||||
|
filters: appliedFilters,
|
||||||
|
timeMode: appliedTimeMode,
|
||||||
|
rollingWindowSeconds: ONE_DAY_SECONDS,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
options: {
|
||||||
|
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const logs = result?.data ?? [];
|
const logs = result?.data ?? [];
|
||||||
const total = result?.total ?? 0;
|
const total = result?.total ?? 0;
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
setFilters(tempFilters);
|
setValidationError(null);
|
||||||
|
|
||||||
|
if (draftTimeMode === "fixed") {
|
||||||
|
const start = draftFilters.startDate;
|
||||||
|
const end = draftFilters.endDate;
|
||||||
|
|
||||||
|
if (typeof start !== "number" || typeof end !== "number") {
|
||||||
|
setValidationError(
|
||||||
|
t("usage.invalidTimeRange", "请选择完整的开始/结束时间"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start > end) {
|
||||||
|
setValidationError(
|
||||||
|
t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end - start > MAX_FIXED_RANGE_SECONDS) {
|
||||||
|
setValidationError(
|
||||||
|
t("usage.timeRangeTooLarge", "时间范围过大,请缩小范围"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAppliedTimeMode(draftTimeMode);
|
||||||
|
setAppliedFilters((prev) => {
|
||||||
|
const next = { ...prev, ...draftFilters };
|
||||||
|
if (draftTimeMode === "rolling") {
|
||||||
|
delete next.startDate;
|
||||||
|
delete next.endDate;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
const defaults = getDefaultFilters();
|
setValidationError(null);
|
||||||
setTempFilters(defaults);
|
setAppliedTimeMode("rolling");
|
||||||
setFilters(defaults);
|
setDraftTimeMode("rolling");
|
||||||
|
setDraftFilters({});
|
||||||
|
setAppliedFilters({});
|
||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
|
const key = {
|
||||||
|
timeMode: appliedTimeMode,
|
||||||
|
rollingWindowSeconds:
|
||||||
|
appliedTimeMode === "rolling" ? ONE_DAY_SECONDS : undefined,
|
||||||
|
appType: appliedFilters.appType,
|
||||||
|
providerName: appliedFilters.providerName,
|
||||||
|
model: appliedFilters.model,
|
||||||
|
statusCode: appliedFilters.statusCode,
|
||||||
|
startDate:
|
||||||
|
appliedTimeMode === "fixed" ? appliedFilters.startDate : undefined,
|
||||||
|
endDate: appliedTimeMode === "fixed" ? appliedFilters.endDate : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: usageKeys.logs(filters, page, pageSize),
|
queryKey: usageKeys.logs(key, page, pageSize),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,12 +162,11 @@ export function RequestLogTable() {
|
|||||||
return Math.floor(timestamp / 1000);
|
return Math.floor(timestamp / 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dateLocale =
|
const language = i18n.resolvedLanguage || i18n.language || "en";
|
||||||
i18n.language === "zh"
|
const locale = getLocaleFromLanguage(language);
|
||||||
? "zh-CN"
|
|
||||||
: i18n.language === "ja"
|
const rollingRangeForDisplay =
|
||||||
? "ja-JP"
|
draftTimeMode === "rolling" ? getRollingRange() : null;
|
||||||
: "en-US";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -97,10 +174,10 @@ export function RequestLogTable() {
|
|||||||
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
|
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Select
|
<Select
|
||||||
value={tempFilters.appType || "all"}
|
value={draftFilters.appType || "all"}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setTempFilters({
|
setDraftFilters({
|
||||||
...tempFilters,
|
...draftFilters,
|
||||||
appType: v === "all" ? undefined : v,
|
appType: v === "all" ? undefined : v,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -117,11 +194,16 @@ export function RequestLogTable() {
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={tempFilters.statusCode?.toString() || "all"}
|
value={draftFilters.statusCode?.toString() || "all"}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setTempFilters({
|
setDraftFilters({
|
||||||
...tempFilters,
|
...draftFilters,
|
||||||
statusCode: v === "all" ? undefined : parseInt(v),
|
statusCode:
|
||||||
|
v === "all"
|
||||||
|
? undefined
|
||||||
|
: Number.isFinite(Number.parseInt(v, 10))
|
||||||
|
? Number.parseInt(v, 10)
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -144,10 +226,10 @@ export function RequestLogTable() {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("usage.searchProviderPlaceholder")}
|
placeholder={t("usage.searchProviderPlaceholder")}
|
||||||
className="pl-9 bg-background"
|
className="pl-9 bg-background"
|
||||||
value={tempFilters.providerName || ""}
|
value={draftFilters.providerName || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setTempFilters({
|
setDraftFilters({
|
||||||
...tempFilters,
|
...draftFilters,
|
||||||
providerName: e.target.value || undefined,
|
providerName: e.target.value || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -156,10 +238,10 @@ export function RequestLogTable() {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("usage.searchModelPlaceholder")}
|
placeholder={t("usage.searchModelPlaceholder")}
|
||||||
className="w-[180px] bg-background"
|
className="w-[180px] bg-background"
|
||||||
value={tempFilters.model || ""}
|
value={draftFilters.model || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setTempFilters({
|
setDraftFilters({
|
||||||
...tempFilters,
|
...draftFilters,
|
||||||
model: e.target.value || undefined,
|
model: e.target.value || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -174,14 +256,18 @@ export function RequestLogTable() {
|
|||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
className="h-8 w-[200px] bg-background"
|
className="h-8 w-[200px] bg-background"
|
||||||
value={
|
value={
|
||||||
tempFilters.startDate
|
(rollingRangeForDisplay?.startDate ?? draftFilters.startDate)
|
||||||
? timestampToLocalDatetime(tempFilters.startDate)
|
? timestampToLocalDatetime(
|
||||||
|
(rollingRangeForDisplay?.startDate ??
|
||||||
|
draftFilters.startDate) as number,
|
||||||
|
)
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const timestamp = localDatetimeToTimestamp(e.target.value);
|
const timestamp = localDatetimeToTimestamp(e.target.value);
|
||||||
setTempFilters({
|
setDraftTimeMode("fixed");
|
||||||
...tempFilters,
|
setDraftFilters({
|
||||||
|
...draftFilters,
|
||||||
startDate: timestamp,
|
startDate: timestamp,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -191,14 +277,18 @@ export function RequestLogTable() {
|
|||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
className="h-8 w-[200px] bg-background"
|
className="h-8 w-[200px] bg-background"
|
||||||
value={
|
value={
|
||||||
tempFilters.endDate
|
(rollingRangeForDisplay?.endDate ?? draftFilters.endDate)
|
||||||
? timestampToLocalDatetime(tempFilters.endDate)
|
? timestampToLocalDatetime(
|
||||||
|
(rollingRangeForDisplay?.endDate ??
|
||||||
|
draftFilters.endDate) as number,
|
||||||
|
)
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const timestamp = localDatetimeToTimestamp(e.target.value);
|
const timestamp = localDatetimeToTimestamp(e.target.value);
|
||||||
setTempFilters({
|
setDraftTimeMode("fixed");
|
||||||
...tempFilters,
|
setDraftFilters({
|
||||||
|
...draftFilters,
|
||||||
endDate: timestamp,
|
endDate: timestamp,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -234,6 +324,10 @@ export function RequestLogTable() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<div className="text-sm text-red-600">{validationError}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -293,9 +387,7 @@ export function RequestLogTable() {
|
|||||||
logs.map((log) => (
|
logs.map((log) => (
|
||||||
<TableRow key={log.requestId}>
|
<TableRow key={log.requestId}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{new Date(log.createdAt * 1000).toLocaleString(
|
{new Date(log.createdAt * 1000).toLocaleString(locale)}
|
||||||
dateLocale,
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{log.providerName || t("usage.unknownProvider")}
|
{log.providerName || t("usage.unknownProvider")}
|
||||||
@@ -321,19 +413,19 @@ export function RequestLogTable() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{log.inputTokens.toLocaleString()}
|
{fmtInt(log.inputTokens, locale)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{log.outputTokens.toLocaleString()}
|
{fmtInt(log.outputTokens, locale)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{log.cacheReadTokens.toLocaleString()}
|
{fmtInt(log.cacheReadTokens, locale)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{log.cacheCreationTokens.toLocaleString()}
|
{fmtInt(log.cacheCreationTokens, locale)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-mono text-xs">
|
<TableCell className="text-right font-mono text-xs">
|
||||||
{parseFloat(log.costMultiplier) !== 1 ? (
|
{(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? (
|
||||||
<span className="text-orange-600">
|
<span className="text-orange-600">
|
||||||
×{log.costMultiplier}
|
×{log.costMultiplier}
|
||||||
</span>
|
</span>
|
||||||
@@ -342,24 +434,30 @@ export function RequestLogTable() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
${parseFloat(log.totalCostUsd).toFixed(6)}
|
{fmtUsd(log.totalCostUsd, 6)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
{(() => {
|
{(() => {
|
||||||
const durationSec =
|
const durationMs =
|
||||||
(log.durationMs ?? log.latencyMs) / 1000;
|
typeof log.durationMs === "number"
|
||||||
const durationColor =
|
? log.durationMs
|
||||||
durationSec <= 5
|
: log.latencyMs;
|
||||||
|
const durationSec = durationMs / 1000;
|
||||||
|
const durationColor = Number.isFinite(durationSec)
|
||||||
|
? durationSec <= 5
|
||||||
? "bg-green-100 text-green-800"
|
? "bg-green-100 text-green-800"
|
||||||
: durationSec <= 120
|
: durationSec <= 120
|
||||||
? "bg-orange-100 text-orange-800"
|
? "bg-orange-100 text-orange-800"
|
||||||
: "bg-red-200 text-red-900";
|
: "bg-red-200 text-red-900"
|
||||||
|
: "bg-gray-100 text-gray-700";
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${durationColor}`}
|
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${durationColor}`}
|
||||||
>
|
>
|
||||||
{Math.round(durationSec)}s
|
{Number.isFinite(durationSec)
|
||||||
|
? `${Math.round(durationSec)}s`
|
||||||
|
: "--"}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -367,17 +465,20 @@ export function RequestLogTable() {
|
|||||||
log.firstTokenMs != null &&
|
log.firstTokenMs != null &&
|
||||||
(() => {
|
(() => {
|
||||||
const firstSec = log.firstTokenMs / 1000;
|
const firstSec = log.firstTokenMs / 1000;
|
||||||
const firstColor =
|
const firstColor = Number.isFinite(firstSec)
|
||||||
firstSec <= 5
|
? firstSec <= 5
|
||||||
? "bg-green-100 text-green-800"
|
? "bg-green-100 text-green-800"
|
||||||
: firstSec <= 120
|
: firstSec <= 120
|
||||||
? "bg-orange-100 text-orange-800"
|
? "bg-orange-100 text-orange-800"
|
||||||
: "bg-red-200 text-red-900";
|
: "bg-red-200 text-red-900"
|
||||||
|
: "bg-gray-100 text-gray-700";
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${firstColor}`}
|
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${firstColor}`}
|
||||||
>
|
>
|
||||||
{firstSec.toFixed(1)}s
|
{Number.isFinite(firstSec)
|
||||||
|
? `${firstSec.toFixed(1)}s`
|
||||||
|
: "--"}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -8,11 +8,28 @@ import { ProviderStatsTable } from "./ProviderStatsTable";
|
|||||||
import { ModelStatsTable } from "./ModelStatsTable";
|
import { ModelStatsTable } from "./ModelStatsTable";
|
||||||
import type { TimeRange } from "@/types/usage";
|
import type { TimeRange } from "@/types/usage";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { BarChart3, ListFilter, Activity } from "lucide-react";
|
import { BarChart3, ListFilter, Activity, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { usageKeys } from "@/lib/query/usage";
|
||||||
|
|
||||||
export function UsageDashboard() {
|
export function UsageDashboard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
||||||
|
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
|
||||||
|
|
||||||
|
const refreshIntervalOptionsMs = [0, 5000, 10000, 30000, 60000] as const;
|
||||||
|
const changeRefreshInterval = () => {
|
||||||
|
const currentIndex = refreshIntervalOptionsMs.indexOf(
|
||||||
|
refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number],
|
||||||
|
);
|
||||||
|
const safeIndex = currentIndex >= 0 ? currentIndex : 3; // default 30s
|
||||||
|
const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length;
|
||||||
|
const next = refreshIntervalOptionsMs[nextIndex];
|
||||||
|
setRefreshIntervalMs(next);
|
||||||
|
queryClient.invalidateQueries({ queryKey: usageKeys.all });
|
||||||
|
};
|
||||||
|
|
||||||
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
|
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
|
||||||
|
|
||||||
@@ -34,32 +51,45 @@ export function UsageDashboard() {
|
|||||||
onValueChange={(v) => setTimeRange(v as TimeRange)}
|
onValueChange={(v) => setTimeRange(v as TimeRange)}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1">
|
<div className="flex w-full sm:w-auto items-center gap-1">
|
||||||
<TabsTrigger
|
<Button
|
||||||
value="1d"
|
type="button"
|
||||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-10 px-2 text-xs text-muted-foreground"
|
||||||
|
title={t("common.refresh", "刷新")}
|
||||||
|
onClick={changeRefreshInterval}
|
||||||
>
|
>
|
||||||
{t("usage.today")}
|
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||||
</TabsTrigger>
|
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
|
||||||
<TabsTrigger
|
</Button>
|
||||||
value="7d"
|
<TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1">
|
||||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
<TabsTrigger
|
||||||
>
|
value="1d"
|
||||||
{t("usage.last7days")}
|
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||||
</TabsTrigger>
|
>
|
||||||
<TabsTrigger
|
{t("usage.today")}
|
||||||
value="30d"
|
</TabsTrigger>
|
||||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
<TabsTrigger
|
||||||
>
|
value="7d"
|
||||||
{t("usage.last30days")}
|
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||||
</TabsTrigger>
|
>
|
||||||
</TabsList>
|
{t("usage.last7days")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="30d"
|
||||||
|
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("usage.last30days")}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UsageSummaryCards days={days} />
|
<UsageSummaryCards days={days} refreshIntervalMs={refreshIntervalMs} />
|
||||||
|
|
||||||
<UsageTrendChart days={days} />
|
<UsageTrendChart days={days} refreshIntervalMs={refreshIntervalMs} />
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Tabs defaultValue="logs" className="w-full">
|
<Tabs defaultValue="logs" className="w-full">
|
||||||
@@ -86,15 +116,15 @@ export function UsageDashboard() {
|
|||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
>
|
>
|
||||||
<TabsContent value="logs" className="mt-0">
|
<TabsContent value="logs" className="mt-0">
|
||||||
<RequestLogTable />
|
<RequestLogTable refreshIntervalMs={refreshIntervalMs} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="providers" className="mt-0">
|
<TabsContent value="providers" className="mt-0">
|
||||||
<ProviderStatsTable />
|
<ProviderStatsTable refreshIntervalMs={refreshIntervalMs} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="models" className="mt-0">
|
<TabsContent value="models" className="mt-0">
|
||||||
<ModelStatsTable />
|
<ModelStatsTable refreshIntervalMs={refreshIntervalMs} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -4,19 +4,26 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { useUsageSummary } from "@/lib/query/usage";
|
import { useUsageSummary } from "@/lib/query/usage";
|
||||||
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
|
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { fmtUsd, parseFiniteNumber } from "./format";
|
||||||
|
|
||||||
interface UsageSummaryCardsProps {
|
interface UsageSummaryCardsProps {
|
||||||
days: number;
|
days: number;
|
||||||
|
refreshIntervalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
export function UsageSummaryCards({
|
||||||
|
days,
|
||||||
|
refreshIntervalMs,
|
||||||
|
}: UsageSummaryCardsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data: summary, isLoading } = useUsageSummary(days);
|
const { data: summary, isLoading } = useUsageSummary(days, {
|
||||||
|
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||||
|
});
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const totalRequests = summary?.totalRequests ?? 0;
|
const totalRequests = summary?.totalRequests ?? 0;
|
||||||
const totalCost = parseFloat(summary?.totalCost || "0");
|
const totalCost = parseFiniteNumber(summary?.totalCost);
|
||||||
|
|
||||||
const inputTokens = summary?.totalInputTokens ?? 0;
|
const inputTokens = summary?.totalInputTokens ?? 0;
|
||||||
const outputTokens = summary?.totalOutputTokens ?? 0;
|
const outputTokens = summary?.totalOutputTokens ?? 0;
|
||||||
@@ -37,7 +44,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("usage.totalCost"),
|
title: t("usage.totalCost"),
|
||||||
value: `$${totalCost.toFixed(4)}`,
|
value: totalCost == null ? "--" : fmtUsd(totalCost, 4),
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
color: "text-green-500",
|
color: "text-green-500",
|
||||||
bg: "bg-green-500/10",
|
bg: "bg-green-500/10",
|
||||||
|
|||||||
@@ -11,14 +11,26 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useUsageTrends } from "@/lib/query/usage";
|
import { useUsageTrends } from "@/lib/query/usage";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
fmtInt,
|
||||||
|
fmtUsd,
|
||||||
|
getLocaleFromLanguage,
|
||||||
|
parseFiniteNumber,
|
||||||
|
} from "./format";
|
||||||
|
|
||||||
interface UsageTrendChartProps {
|
interface UsageTrendChartProps {
|
||||||
days: number;
|
days: number;
|
||||||
|
refreshIntervalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
export function UsageTrendChart({
|
||||||
|
days,
|
||||||
|
refreshIntervalMs,
|
||||||
|
}: UsageTrendChartProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { data: trends, isLoading } = useUsageTrends(days);
|
const { data: trends, isLoading } = useUsageTrends(days, {
|
||||||
|
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -29,15 +41,12 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isToday = days === 1;
|
const isToday = days === 1;
|
||||||
const dateLocale =
|
const language = i18n.resolvedLanguage || i18n.language || "en";
|
||||||
i18n.language === "zh"
|
const dateLocale = getLocaleFromLanguage(language);
|
||||||
? "zh-CN"
|
|
||||||
: i18n.language === "ja"
|
|
||||||
? "ja-JP"
|
|
||||||
: "en-US";
|
|
||||||
const chartData =
|
const chartData =
|
||||||
trends?.map((stat) => {
|
trends?.map((stat) => {
|
||||||
const pointDate = new Date(stat.date);
|
const pointDate = new Date(stat.date);
|
||||||
|
const cost = parseFiniteNumber(stat.totalCost);
|
||||||
return {
|
return {
|
||||||
rawDate: stat.date,
|
rawDate: stat.date,
|
||||||
label: isToday
|
label: isToday
|
||||||
@@ -56,7 +65,7 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
|||||||
outputTokens: stat.totalOutputTokens,
|
outputTokens: stat.totalOutputTokens,
|
||||||
cacheCreationTokens: stat.totalCacheCreationTokens,
|
cacheCreationTokens: stat.totalCacheCreationTokens,
|
||||||
cacheReadTokens: stat.totalCacheReadTokens,
|
cacheReadTokens: stat.totalCacheReadTokens,
|
||||||
cost: parseFloat(stat.totalCost),
|
cost: cost ?? null,
|
||||||
};
|
};
|
||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
@@ -79,9 +88,9 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
|||||||
/>
|
/>
|
||||||
<span className="font-medium">{entry.name}:</span>
|
<span className="font-medium">{entry.name}:</span>
|
||||||
<span>
|
<span>
|
||||||
{entry.name.includes(t("usage.cost", "成本"))
|
{entry.dataKey === "cost"
|
||||||
? `$${typeof entry.value === "number" ? entry.value.toFixed(6) : entry.value}`
|
? fmtUsd(entry.value, 6)
|
||||||
: entry.value.toLocaleString()}
|
: fmtInt(entry.value, dateLocale)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
39
src/components/usage/format.ts
Normal file
39
src/components/usage/format.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export function parseFiniteNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtInt(
|
||||||
|
value: unknown,
|
||||||
|
locale?: string,
|
||||||
|
fallback: string = "--",
|
||||||
|
): string {
|
||||||
|
const num = parseFiniteNumber(value);
|
||||||
|
if (num == null) return fallback;
|
||||||
|
return new Intl.NumberFormat(locale).format(Math.trunc(num));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtUsd(
|
||||||
|
value: unknown,
|
||||||
|
digits: number,
|
||||||
|
fallback: string = "--",
|
||||||
|
): string {
|
||||||
|
const num = parseFiniteNumber(value);
|
||||||
|
if (num == null) return fallback;
|
||||||
|
return `$${num.toFixed(digits)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocaleFromLanguage(language: string): string {
|
||||||
|
if (!language) return "en-US";
|
||||||
|
if (language.startsWith("zh")) return "zh-CN";
|
||||||
|
if (language.startsWith("ja")) return "ja-JP";
|
||||||
|
return "en-US";
|
||||||
|
}
|
||||||
@@ -16,25 +16,40 @@ export const APP_ICON_MAP: Record<AppId, AppConfig> = {
|
|||||||
claude: {
|
claude: {
|
||||||
label: "Claude",
|
label: "Claude",
|
||||||
icon: <ClaudeIcon size={14} />,
|
icon: <ClaudeIcon size={14} />,
|
||||||
activeClass: "bg-orange-500/10 ring-1 ring-orange-500/20 hover:bg-orange-500/20 text-orange-600 dark:text-orange-400",
|
activeClass:
|
||||||
badgeClass: "bg-orange-500/10 text-orange-700 dark:text-orange-300 hover:bg-orange-500/20 border-0 gap-1.5",
|
"bg-orange-500/10 ring-1 ring-orange-500/20 hover:bg-orange-500/20 text-orange-600 dark:text-orange-400",
|
||||||
|
badgeClass:
|
||||||
|
"bg-orange-500/10 text-orange-700 dark:text-orange-300 hover:bg-orange-500/20 border-0 gap-1.5",
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
label: "Codex",
|
label: "Codex",
|
||||||
icon: <CodexIcon size={14} />,
|
icon: <CodexIcon size={14} />,
|
||||||
activeClass: "bg-green-500/10 ring-1 ring-green-500/20 hover:bg-green-500/20 text-green-600 dark:text-green-400",
|
activeClass:
|
||||||
badgeClass: "bg-green-500/10 text-green-700 dark:text-green-300 hover:bg-green-500/20 border-0 gap-1.5",
|
"bg-green-500/10 ring-1 ring-green-500/20 hover:bg-green-500/20 text-green-600 dark:text-green-400",
|
||||||
|
badgeClass:
|
||||||
|
"bg-green-500/10 text-green-700 dark:text-green-300 hover:bg-green-500/20 border-0 gap-1.5",
|
||||||
},
|
},
|
||||||
gemini: {
|
gemini: {
|
||||||
label: "Gemini",
|
label: "Gemini",
|
||||||
icon: <GeminiIcon size={14} />,
|
icon: <GeminiIcon size={14} />,
|
||||||
activeClass: "bg-blue-500/10 ring-1 ring-blue-500/20 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400",
|
activeClass:
|
||||||
badgeClass: "bg-blue-500/10 text-blue-700 dark:text-blue-300 hover:bg-blue-500/20 border-0 gap-1.5",
|
"bg-blue-500/10 ring-1 ring-blue-500/20 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400",
|
||||||
|
badgeClass:
|
||||||
|
"bg-blue-500/10 text-blue-700 dark:text-blue-300 hover:bg-blue-500/20 border-0 gap-1.5",
|
||||||
},
|
},
|
||||||
opencode: {
|
opencode: {
|
||||||
label: "OpenCode",
|
label: "OpenCode",
|
||||||
icon: <ProviderIcon icon="opencode" name="OpenCode" size={14} showFallback={false} />,
|
icon: (
|
||||||
activeClass: "bg-indigo-500/10 ring-1 ring-indigo-500/20 hover:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400",
|
<ProviderIcon
|
||||||
badgeClass: "bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-500/20 border-0 gap-1.5",
|
icon="opencode"
|
||||||
|
name="OpenCode"
|
||||||
|
size={14}
|
||||||
|
showFallback={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
activeClass:
|
||||||
|
"bg-indigo-500/10 ring-1 ring-indigo-500/20 hover:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400",
|
||||||
|
badgeClass:
|
||||||
|
"bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-500/20 border-0 gap-1.5",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,24 +125,20 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
iconColor: "#0F62FE",
|
iconColor: "#0F62FE",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Qwen Coder",
|
name: "Bailian",
|
||||||
websiteUrl: "https://bailian.console.aliyun.com",
|
websiteUrl: "https://bailian.console.aliyun.com",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
ANTHROPIC_BASE_URL: "https://dashscope.aliyuncs.com/apps/anthropic",
|
ANTHROPIC_BASE_URL: "https://dashscope.aliyuncs.com/apps/anthropic",
|
||||||
ANTHROPIC_AUTH_TOKEN: "",
|
ANTHROPIC_AUTH_TOKEN: "",
|
||||||
ANTHROPIC_MODEL: "qwen3-max",
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "qwen3-max",
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "qwen3-max",
|
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "qwen3-max",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "cn_official",
|
category: "cn_official",
|
||||||
icon: "qwen",
|
icon: "bailian",
|
||||||
iconColor: "#FF6A00",
|
iconColor: "#624AFF",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Kimi k2",
|
name: "Kimi",
|
||||||
websiteUrl: "https://platform.moonshot.cn/console",
|
websiteUrl: "https://platform.moonshot.cn/console",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
@@ -438,7 +434,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
ANTHROPIC_BASE_URL: "https://api.aigocode.com/api",
|
ANTHROPIC_BASE_URL: "https://api.aigocode.com",
|
||||||
ANTHROPIC_AUTH_TOKEN: "",
|
ANTHROPIC_AUTH_TOKEN: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ requires_openai_auth = true`,
|
|||||||
auth: generateThirdPartyAuth(""),
|
auth: generateThirdPartyAuth(""),
|
||||||
config: generateThirdPartyConfig(
|
config: generateThirdPartyConfig(
|
||||||
"aigocode",
|
"aigocode",
|
||||||
"https://api.aigocode.com/openai",
|
"https://api.aigocode.com",
|
||||||
"gpt-5.2",
|
"gpt-5.2",
|
||||||
),
|
),
|
||||||
endpointCandidates: ["https://api.aigocode.com"],
|
endpointCandidates: ["https://api.aigocode.com"],
|
||||||
|
|||||||
@@ -102,17 +102,17 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
|||||||
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.com/gemini",
|
GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.com",
|
||||||
GEMINI_MODEL: "gemini-3-pro",
|
GEMINI_MODEL: "gemini-3-pro",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
baseURL: "https://api.aigocode.com/gemini",
|
baseURL: "https://api.aigocode.com",
|
||||||
model: "gemini-3-pro",
|
model: "gemini-3-pro",
|
||||||
description: "AIGoCode",
|
description: "AIGoCode",
|
||||||
category: "third_party",
|
category: "third_party",
|
||||||
isPartner: true,
|
isPartner: true,
|
||||||
partnerPromotionKey: "aigocode",
|
partnerPromotionKey: "aigocode",
|
||||||
endpointCandidates: ["https://api.aigocode.com/gemini"],
|
endpointCandidates: ["https://api.aigocode.com"],
|
||||||
icon: "aigocode",
|
icon: "aigocode",
|
||||||
iconColor: "#5B7FFF",
|
iconColor: "#5B7FFF",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const iconMappings = {
|
|||||||
zhipu: { icon: "zhipu", iconColor: "#0F62FE" },
|
zhipu: { icon: "zhipu", iconColor: "#0F62FE" },
|
||||||
glm: { icon: "zhipu", iconColor: "#0F62FE" },
|
glm: { icon: "zhipu", iconColor: "#0F62FE" },
|
||||||
qwen: { icon: "qwen", iconColor: "#FF6A00" },
|
qwen: { icon: "qwen", iconColor: "#FF6A00" },
|
||||||
|
bailian: { icon: "bailian", iconColor: "#624AFF" },
|
||||||
alibaba: { icon: "alibaba", iconColor: "#FF6A00" },
|
alibaba: { icon: "alibaba", iconColor: "#FF6A00" },
|
||||||
aliyun: { icon: "alibaba", iconColor: "#FF6A00" },
|
aliyun: { icon: "alibaba", iconColor: "#FF6A00" },
|
||||||
kimi: { icon: "kimi", iconColor: "#6366F1" },
|
kimi: { icon: "kimi", iconColor: "#6366F1" },
|
||||||
|
|||||||
@@ -137,23 +137,21 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Qwen Coder",
|
name: "Bailian",
|
||||||
websiteUrl: "https://bailian.console.aliyun.com",
|
websiteUrl: "https://bailian.console.aliyun.com",
|
||||||
apiKeyUrl: "https://bailian.console.aliyun.com/#/api-key",
|
apiKeyUrl: "https://bailian.console.aliyun.com/#/api-key",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
npm: "@ai-sdk/openai-compatible",
|
npm: "@ai-sdk/openai-compatible",
|
||||||
name: "Qwen Coder",
|
name: "Bailian",
|
||||||
options: {
|
options: {
|
||||||
baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
},
|
},
|
||||||
models: {
|
models: {},
|
||||||
"qwen3-max": { name: "Qwen3 Max" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
category: "cn_official",
|
category: "cn_official",
|
||||||
icon: "qwen",
|
icon: "bailian",
|
||||||
iconColor: "#FF6A00",
|
iconColor: "#624AFF",
|
||||||
templateValues: {
|
templateValues: {
|
||||||
baseURL: {
|
baseURL: {
|
||||||
label: "Base URL",
|
label: "Base URL",
|
||||||
@@ -651,7 +649,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
|||||||
npm: "@ai-sdk/anthropic",
|
npm: "@ai-sdk/anthropic",
|
||||||
name: "AIGoCode",
|
name: "AIGoCode",
|
||||||
options: {
|
options: {
|
||||||
baseURL: "https://api.aigocode.com/v1",
|
baseURL: "https://api.aigocode.com",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
},
|
},
|
||||||
models: {
|
models: {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function useSessionSearch({
|
|||||||
.filter(
|
.filter(
|
||||||
(session) =>
|
(session) =>
|
||||||
session &&
|
session &&
|
||||||
(providerFilter === "all" || session.providerId === providerFilter)
|
(providerFilter === "all" || session.providerId === providerFilter),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 按时间排序
|
// 按时间排序
|
||||||
@@ -127,7 +127,7 @@ export function useSessionSearch({
|
|||||||
return bTs - aTs;
|
return bTs - aTs;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[sessions, providerFilter]
|
[sessions, providerFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
return useMemo(() => ({ search, isIndexing }), [search, isIndexing]);
|
return useMemo(() => ({ search, isIndexing }), [search, isIndexing]);
|
||||||
|
|||||||
@@ -633,6 +633,7 @@
|
|||||||
"modelId": "Model ID",
|
"modelId": "Model ID",
|
||||||
"modelName": "Display Name",
|
"modelName": "Display Name",
|
||||||
"noModels": "No models configured",
|
"noModels": "No models configured",
|
||||||
|
"modelsRequired": "Please add at least one model",
|
||||||
"providerKey": "Provider Key",
|
"providerKey": "Provider Key",
|
||||||
"providerKeyPlaceholder": "my-provider",
|
"providerKeyPlaceholder": "my-provider",
|
||||||
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
|
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
|
||||||
|
|||||||
@@ -633,6 +633,7 @@
|
|||||||
"modelId": "モデル ID",
|
"modelId": "モデル ID",
|
||||||
"modelName": "表示名",
|
"modelName": "表示名",
|
||||||
"noModels": "モデルが設定されていません",
|
"noModels": "モデルが設定されていません",
|
||||||
|
"modelsRequired": "モデルを少なくとも1つ追加してください",
|
||||||
"providerKey": "プロバイダーキー",
|
"providerKey": "プロバイダーキー",
|
||||||
"providerKeyPlaceholder": "my-provider",
|
"providerKeyPlaceholder": "my-provider",
|
||||||
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
|
"providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。",
|
||||||
|
|||||||
@@ -633,6 +633,7 @@
|
|||||||
"modelId": "模型 ID",
|
"modelId": "模型 ID",
|
||||||
"modelName": "显示名称",
|
"modelName": "显示名称",
|
||||||
"noModels": "暂无模型配置",
|
"noModels": "暂无模型配置",
|
||||||
|
"modelsRequired": "请至少添加一个模型配置",
|
||||||
"providerKey": "供应商标识",
|
"providerKey": "供应商标识",
|
||||||
"providerKeyPlaceholder": "my-provider",
|
"providerKeyPlaceholder": "my-provider",
|
||||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||||
|
|||||||
1
src/icons/extracted/bailian.svg
Normal file
1
src/icons/extracted/bailian.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>BaiLian</title><path d="M6.336 8.919v6.162l5.335-3.083L6.337 8.92z" fill-opacity=".4"></path><path d="M21.394 5.288s-.006-.006-.01-.006L17.01 2.754 6.336 8.92l5.335 3.082 9.701-5.6.016-.01a.635.635 0 00.006-1.1v-.003z" fill-opacity=".8"></path><path d="M21.71 12.465a.62.62 0 00-.316.085s-.006 0-.009.003l-4.375 2.528 5.05 2.915h.006a2.06 2.06 0 00.28-1.04v-3.855a.637.637 0 00-.636-.636z"></path><path d="M22.06 17.996l-5.05-2.915L6.34 21.242l4.27 2.465s.016.006.022.012a2.102 2.102 0 002.093 0c.006-.003.016-.006.022-.012l8.538-4.93c.003 0 .006-.003.01-.006.321-.183.589-.45.775-.772h-.006l-.004-.003z" fill-opacity=".8"></path><path d="M11.672 11.998l-5.336 3.083-1.444.832-3.605 2.083H1.28c.173.303.416.555.709.738l.078.044.016.01.02.012 4.232 2.442 10.671-6.161-5.335-3.082z"></path><path d="M12.74.29c-.1-.06-.208-.107-.315-.148-.02-.006-.038-.016-.057-.022a2.121 2.121 0 00-.7-.12c-.233 0-.457.038-.668.11l-.031.01a2.196 2.196 0 00-.372.17L2.068 5.222s-.003 0-.006.003c-.324.183-.592.451-.781.773h.006l5.049 2.918L17.01 2.758 12.74.29z" fill-opacity=".6"></path><path d="M1.287 6.001H1.28A2.06 2.06 0 001 7.041v9.915c0 .378.1.735.28 1.043h.007l5.049-2.918V8.919l-5.05-2.918z" fill-opacity=".3"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -60,6 +60,7 @@ export const icons: Record<string, string> = {
|
|||||||
catcoder: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>KwaiKAT</title><path d="M20.42 19.311h3.418V1l-6.781 4.177-6.778-4.111.026 7.868h3.418l-.026-2.222 3.42 2.035 3.303-2.035v12.6z"></path><path d="M3.064 10.734c2.784-2.07 6.942-2.394 9.941.907l.01.01.01.013 9.16 12.24h-3.84l-7.69-10.217c-1.63-1.737-3.891-1.689-5.515-.638-1.624 1.05-2.563 3.073-1.548 5.28 1.494 3.246 6.152 3.275 7.725.108l.032-.064 2.02 2.629c-2.98 3.968-9.329 3.926-12.165-.552-2.395-3.78-.926-7.645 1.86-9.716z"></path></svg>`,
|
catcoder: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>KwaiKAT</title><path d="M20.42 19.311h3.418V1l-6.781 4.177-6.778-4.111.026 7.868h3.418l-.026-2.222 3.42 2.035 3.303-2.035v12.6z"></path><path d="M3.064 10.734c2.784-2.07 6.942-2.394 9.941.907l.01.01.01.013 9.16 12.24h-3.84l-7.69-10.217c-1.63-1.737-3.891-1.689-5.515-.638-1.624 1.05-2.563 3.073-1.548 5.28 1.494 3.246 6.152 3.275 7.725.108l.032-.064 2.02 2.629c-2.98 3.968-9.329 3.926-12.165-.552-2.395-3.78-.926-7.645 1.86-9.716z"></path></svg>`,
|
||||||
mcp: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>`,
|
mcp: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>`,
|
||||||
nvidia: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Nvidia</title><path d="M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z" fill="#74B71B" fill-rule="nonzero"></path></svg>`,
|
nvidia: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Nvidia</title><path d="M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z" fill="#74B71B" fill-rule="nonzero"></path></svg>`,
|
||||||
|
bailian: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>BaiLian</title><path d="M6.336 8.919v6.162l5.335-3.083L6.337 8.92z" fill-opacity=".4"></path><path d="M21.394 5.288s-.006-.006-.01-.006L17.01 2.754 6.336 8.92l5.335 3.082 9.701-5.6.016-.01a.635.635 0 00.006-1.1v-.003z" fill-opacity=".8"></path><path d="M21.71 12.465a.62.62 0 00-.316.085s-.006 0-.009.003l-4.375 2.528 5.05 2.915h.006a2.06 2.06 0 00.28-1.04v-3.855a.637.637 0 00-.636-.636z"></path><path d="M22.06 17.996l-5.05-2.915L6.34 21.242l4.27 2.465s.016.006.022.012a2.102 2.102 0 002.093 0c.006-.003.016-.006.022-.012l8.538-4.93c.003 0 .006-.003.01-.006.321-.183.589-.45.775-.772h-.006l-.004-.003z" fill-opacity=".8"></path><path d="M11.672 11.998l-5.336 3.083-1.444.832-3.605 2.083H1.28c.173.303.416.555.709.738l.078.044.016.01.02.012 4.232 2.442 10.671-6.161-5.335-3.082z"></path><path d="M12.74.29c-.1-.06-.208-.107-.315-.148-.02-.006-.038-.016-.057-.022a2.121 2.121 0 00-.7-.12c-.233 0-.457.038-.668.11l-.031.01a2.196 2.196 0 00-.372.17L2.068 5.222s-.003 0-.006.003c-.324.183-.592.451-.781.773h.006l5.049 2.918L17.01 2.758 12.74.29z" fill-opacity=".6"></path><path d="M1.287 6.001H1.28A2.06 2.06 0 001 7.041v9.915c0 .378.1.735.28 1.043h.007l5.049-2.918V8.919l-5.05-2.918z" fill-opacity=".3"></path></svg>`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const iconList = Object.keys(icons);
|
export const iconList = Object.keys(icons);
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
|||||||
keywords: ["ernie", "wenxin"],
|
keywords: ["ernie", "wenxin"],
|
||||||
defaultColor: "#2932E1",
|
defaultColor: "#2932E1",
|
||||||
},
|
},
|
||||||
|
bailian: {
|
||||||
|
name: "bailian",
|
||||||
|
displayName: "Bailian",
|
||||||
|
category: "ai-provider",
|
||||||
|
keywords: ["bailian", "dashscope", "aliyun", "alibaba"],
|
||||||
|
defaultColor: "#624AFF",
|
||||||
|
},
|
||||||
bytedance: {
|
bytedance: {
|
||||||
name: "bytedance",
|
name: "bytedance",
|
||||||
displayName: "bytedance",
|
displayName: "bytedance",
|
||||||
|
|||||||
@@ -215,3 +215,42 @@ input[type="password"]::-ms-reveal,
|
|||||||
input[type="password"]::-ms-clear {
|
input[type="password"]::-ms-clear {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Theme transition animation using View Transitions API */
|
||||||
|
::view-transition-old(root),
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: none;
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Old snapshot stays behind, new snapshot animates on top */
|
||||||
|
::view-transition-old(root) {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-new(root) {
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Circular expand animation from click position */
|
||||||
|
@keyframes theme-circle-expand {
|
||||||
|
from {
|
||||||
|
clip-path: circle(0% at var(--theme-transition-x, 50%) var(--theme-transition-y, 50%));
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
clip-path: circle(150% at var(--theme-transition-x, 50%) var(--theme-transition-y, 50%));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply animation to new snapshot - works for both light and dark transitions */
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: theme-circle-expand 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect user preference for reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export const sessionsApi = {
|
|||||||
|
|
||||||
async getMessages(
|
async getMessages(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
sourcePath: string
|
sourcePath: string,
|
||||||
): Promise<SessionMessage[]> {
|
): Promise<SessionMessage[]> {
|
||||||
return await invoke("get_session_messages", { providerId, sourcePath });
|
return await invoke("get_session_messages", { providerId, sourcePath });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -91,11 +91,7 @@ export const skillsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** 切换 Skill 的应用启用状态 */
|
/** 切换 Skill 的应用启用状态 */
|
||||||
async toggleApp(
|
async toggleApp(id: string, app: AppId, enabled: boolean): Promise<boolean> {
|
||||||
id: string,
|
|
||||||
app: AppId,
|
|
||||||
enabled: boolean,
|
|
||||||
): Promise<boolean> {
|
|
||||||
return await invoke("toggle_skill_app", { id, app, enabled });
|
return await invoke("toggle_skill_app", { id, app, enabled });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -133,10 +129,7 @@ export const skillsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** 卸载技能(兼容旧 API) */
|
/** 卸载技能(兼容旧 API) */
|
||||||
async uninstall(
|
async uninstall(directory: string, app: AppId = "claude"): Promise<boolean> {
|
||||||
directory: string,
|
|
||||||
app: AppId = "claude",
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (app === "claude") {
|
if (app === "claude") {
|
||||||
return await invoke("uninstall_skill", { directory });
|
return await invoke("uninstall_skill", { directory });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,35 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { usageApi } from "@/lib/api/usage";
|
import { usageApi } from "@/lib/api/usage";
|
||||||
import type { LogFilters } from "@/types/usage";
|
import type { LogFilters } from "@/types/usage";
|
||||||
|
|
||||||
|
const DEFAULT_REFETCH_INTERVAL_MS = 30000;
|
||||||
|
|
||||||
|
type UsageQueryOptions = {
|
||||||
|
refetchInterval?: number | false;
|
||||||
|
refetchIntervalInBackground?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RequestLogsTimeMode = "rolling" | "fixed";
|
||||||
|
|
||||||
|
type RequestLogsQueryArgs = {
|
||||||
|
filters: LogFilters;
|
||||||
|
timeMode: RequestLogsTimeMode;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
rollingWindowSeconds?: number;
|
||||||
|
options?: UsageQueryOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RequestLogsKey = {
|
||||||
|
timeMode: RequestLogsTimeMode;
|
||||||
|
rollingWindowSeconds?: number;
|
||||||
|
appType?: string;
|
||||||
|
providerName?: string;
|
||||||
|
model?: string;
|
||||||
|
statusCode?: number;
|
||||||
|
startDate?: number;
|
||||||
|
endDate?: number;
|
||||||
|
};
|
||||||
|
|
||||||
// Query keys
|
// Query keys
|
||||||
export const usageKeys = {
|
export const usageKeys = {
|
||||||
all: ["usage"] as const,
|
all: ["usage"] as const,
|
||||||
@@ -9,8 +38,21 @@ export const usageKeys = {
|
|||||||
trends: (days: number) => [...usageKeys.all, "trends", days] as const,
|
trends: (days: number) => [...usageKeys.all, "trends", days] as const,
|
||||||
providerStats: () => [...usageKeys.all, "provider-stats"] as const,
|
providerStats: () => [...usageKeys.all, "provider-stats"] as const,
|
||||||
modelStats: () => [...usageKeys.all, "model-stats"] as const,
|
modelStats: () => [...usageKeys.all, "model-stats"] as const,
|
||||||
logs: (filters: LogFilters, page: number, pageSize: number) =>
|
logs: (key: RequestLogsKey, page: number, pageSize: number) =>
|
||||||
[...usageKeys.all, "logs", filters, page, pageSize] as const,
|
[
|
||||||
|
...usageKeys.all,
|
||||||
|
"logs",
|
||||||
|
key.timeMode,
|
||||||
|
key.rollingWindowSeconds ?? 0,
|
||||||
|
key.appType ?? "",
|
||||||
|
key.providerName ?? "",
|
||||||
|
key.model ?? "",
|
||||||
|
key.statusCode ?? -1,
|
||||||
|
key.startDate ?? 0,
|
||||||
|
key.endDate ?? 0,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
] as const,
|
||||||
detail: (requestId: string) =>
|
detail: (requestId: string) =>
|
||||||
[...usageKeys.all, "detail", requestId] as const,
|
[...usageKeys.all, "detail", requestId] as const,
|
||||||
pricing: () => [...usageKeys.all, "pricing"] as const,
|
pricing: () => [...usageKeys.all, "pricing"] as const,
|
||||||
@@ -25,58 +67,85 @@ const getWindow = (days: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
export function useUsageSummary(days: number) {
|
export function useUsageSummary(days: number, options?: UsageQueryOptions) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: usageKeys.summary(days),
|
queryKey: usageKeys.summary(days),
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const { startDate, endDate } = getWindow(days);
|
const { startDate, endDate } = getWindow(days);
|
||||||
return usageApi.getUsageSummary(startDate, endDate);
|
return usageApi.getUsageSummary(startDate, endDate);
|
||||||
},
|
},
|
||||||
refetchInterval: 30000, // 每30秒自动刷新
|
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
|
||||||
refetchIntervalInBackground: false, // 后台不刷新
|
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, // 后台不刷新
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUsageTrends(days: number) {
|
export function useUsageTrends(days: number, options?: UsageQueryOptions) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: usageKeys.trends(days),
|
queryKey: usageKeys.trends(days),
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const { startDate, endDate } = getWindow(days);
|
const { startDate, endDate } = getWindow(days);
|
||||||
return usageApi.getUsageTrends(startDate, endDate);
|
return usageApi.getUsageTrends(startDate, endDate);
|
||||||
},
|
},
|
||||||
refetchInterval: 30000, // 每30秒自动刷新
|
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProviderStats() {
|
export function useProviderStats(options?: UsageQueryOptions) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: usageKeys.providerStats(),
|
queryKey: usageKeys.providerStats(),
|
||||||
queryFn: usageApi.getProviderStats,
|
queryFn: usageApi.getProviderStats,
|
||||||
refetchInterval: 30000, // 每30秒自动刷新
|
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useModelStats() {
|
export function useModelStats(options?: UsageQueryOptions) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: usageKeys.modelStats(),
|
queryKey: usageKeys.modelStats(),
|
||||||
queryFn: usageApi.getModelStats,
|
queryFn: usageApi.getModelStats,
|
||||||
refetchInterval: 30000, // 每30秒自动刷新
|
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRequestLogs(
|
const getRollingRange = (windowSeconds: number) => {
|
||||||
filters: LogFilters,
|
const endDate = Math.floor(Date.now() / 1000);
|
||||||
page: number = 0,
|
const startDate = endDate - windowSeconds;
|
||||||
pageSize: number = 20,
|
return { startDate, endDate };
|
||||||
) {
|
};
|
||||||
|
|
||||||
|
export function useRequestLogs({
|
||||||
|
filters,
|
||||||
|
timeMode,
|
||||||
|
page = 0,
|
||||||
|
pageSize = 20,
|
||||||
|
rollingWindowSeconds = 24 * 60 * 60,
|
||||||
|
options,
|
||||||
|
}: RequestLogsQueryArgs) {
|
||||||
|
const key: RequestLogsKey = {
|
||||||
|
timeMode,
|
||||||
|
rollingWindowSeconds:
|
||||||
|
timeMode === "rolling" ? rollingWindowSeconds : undefined,
|
||||||
|
appType: filters.appType,
|
||||||
|
providerName: filters.providerName,
|
||||||
|
model: filters.model,
|
||||||
|
statusCode: filters.statusCode,
|
||||||
|
startDate: timeMode === "fixed" ? filters.startDate : undefined,
|
||||||
|
endDate: timeMode === "fixed" ? filters.endDate : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: usageKeys.logs(filters, page, pageSize),
|
queryKey: usageKeys.logs(key, page, pageSize),
|
||||||
queryFn: () => usageApi.getRequestLogs(filters, page, pageSize),
|
queryFn: () => {
|
||||||
refetchInterval: 30000, // 每30秒自动刷新
|
const effectiveFilters =
|
||||||
refetchIntervalInBackground: false,
|
timeMode === "rolling"
|
||||||
|
? { ...filters, ...getRollingRange(rollingWindowSeconds) }
|
||||||
|
: filters;
|
||||||
|
return usageApi.getRequestLogs(effectiveFilters, page, pageSize);
|
||||||
|
},
|
||||||
|
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
|
||||||
|
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user