mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: improve aster runtime, agent commands, novel parsing, and log retrieval
- Aster runtime: resolve path via ASTER_PATH_ROOT env var, create config/data/state dirs on init - Aster session store: implement commit_session, search_memories, retrieve_context_memories stubs - Aster agent cmd: extract and surface inline agent provider errors from message text - Aster agent cmd: use ensure_workspace_ready_with_auto_relocate for robust workspace path handling - Agent cmd: integrate workspace health auto-repair before launching agent - Config cmd: add ETag-based caching for update checks (10min TTL), add fallback tags URL - Novel service: improve character card parsing with multi-strategy JSON extraction and fallback - Logs cmd: add get_persisted_logs_tail command to read tail of log file for diagnostics - Dev bridge dispatcher: expose get_persisted_logs_tail in browser bridge - Runner: register get_persisted_logs_tail, workspace_ensure_ready, workspace_ensure_default_ready Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
0b2d3caa6c
commit
68e16d4a04
@@ -26,6 +26,7 @@ use aster::agents::{Agent, SessionConfig};
|
||||
use aster::model::ModelConfig;
|
||||
#[cfg(test)]
|
||||
use aster::skills::{global_registry, load_skills_from_directory, SkillSource};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -87,6 +88,50 @@ impl AsterAgentState {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_aster_path_root() -> Result<PathBuf, String> {
|
||||
if let Ok(raw) = std::env::var("ASTER_PATH_ROOT") {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
let home_dir = dirs::home_dir().ok_or_else(|| "无法获取用户目录".to_string())?;
|
||||
Ok(home_dir.join(".proxycast").join("aster"))
|
||||
}
|
||||
|
||||
fn ensure_aster_runtime_dirs() -> Result<PathBuf, String> {
|
||||
let root = Self::resolve_aster_path_root()?;
|
||||
let root_string = root.to_string_lossy().to_string();
|
||||
|
||||
if std::env::var("ASTER_PATH_ROOT")
|
||||
.ok()
|
||||
.map(|value| value.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
std::env::set_var("ASTER_PATH_ROOT", &root_string);
|
||||
}
|
||||
|
||||
let dirs = [
|
||||
root.join("config"),
|
||||
root.join("data"),
|
||||
root.join("state"),
|
||||
root.join("state").join("logs"),
|
||||
];
|
||||
|
||||
for dir in dirs {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| {
|
||||
format!(
|
||||
"初始化 Aster 运行目录失败: {} ({})",
|
||||
dir.to_string_lossy(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// 初始化 Agent(带数据库连接)
|
||||
///
|
||||
/// 创建 Agent 并注入 ProxyCastSessionStore,确保消息存储到 ProxyCast 数据库。
|
||||
@@ -98,6 +143,12 @@ impl AsterAgentState {
|
||||
/// # 参数
|
||||
/// - `db`: 数据库连接,用于创建 SessionStore
|
||||
pub async fn init_agent_with_db(&self, db: &DbConnection) -> Result<(), String> {
|
||||
let runtime_root = Self::ensure_aster_runtime_dirs()?;
|
||||
tracing::info!(
|
||||
"[AsterAgent] Aster 运行目录已准备: {}",
|
||||
runtime_root.to_string_lossy()
|
||||
);
|
||||
|
||||
// 快速路径:检查缓存
|
||||
if self.initialized_cache.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
|
||||
@@ -20,6 +20,20 @@ fn push_non_empty(target: &mut Vec<String>, value: Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn enhance_execution_error_text(raw: &str) -> String {
|
||||
if !raw.contains("Execution error: No such file or directory (os error 2)") {
|
||||
return raw.to_string();
|
||||
}
|
||||
|
||||
if raw.contains("排查建议:") {
|
||||
return raw.to_string();
|
||||
}
|
||||
|
||||
format!(
|
||||
"{raw}\n\n排查建议:\n1) 检查工作区目录是否仍然存在(目录被移动/删除会触发该错误)。\n2) 若使用本地 CLI Provider,请确认对应命令已安装且在 PATH 中。\n3) 重启应用后重试;若仍失败,请复制该错误并附上系统信息。"
|
||||
)
|
||||
}
|
||||
|
||||
fn dedupe_preserve_order(items: Vec<String>) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut deduped = Vec::new();
|
||||
@@ -422,7 +436,7 @@ fn convert_message(message: Message) -> Vec<TauriAgentEvent> {
|
||||
match content {
|
||||
MessageContent::Text(text_content) => {
|
||||
events.push(TauriAgentEvent::TextDelta {
|
||||
text: text_content.text.clone(),
|
||||
text: enhance_execution_error_text(&text_content.text),
|
||||
});
|
||||
}
|
||||
MessageContent::Thinking(thinking) => {
|
||||
|
||||
@@ -12,7 +12,9 @@ use aster::model::ModelConfig;
|
||||
use aster::recipe::Recipe;
|
||||
use aster::session::extension_data::ExtensionData;
|
||||
use aster::session::{
|
||||
ChatHistoryMatch, Session, SessionInsights, SessionStore, SessionType, TokenStatsUpdate,
|
||||
ChatHistoryMatch, CommitOptions, CommitReport, MemoryCategory, MemoryHealth, MemoryRecord,
|
||||
MemorySearchResult, MemoryStats, Session, SessionInsights, SessionStore, SessionType,
|
||||
TokenStatsUpdate,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
@@ -664,6 +666,48 @@ impl SessionStore for ProxyCastSessionStore {
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
async fn commit_session(&self, id: &str, _options: CommitOptions) -> Result<CommitReport> {
|
||||
Ok(CommitReport {
|
||||
session_id: id.to_string(),
|
||||
messages_scanned: 0,
|
||||
memories_created: 0,
|
||||
memories_merged: 0,
|
||||
source_start_ts: None,
|
||||
source_end_ts: None,
|
||||
warnings: vec!["ProxyCastSessionStore: memory commit skipped".to_string()],
|
||||
})
|
||||
}
|
||||
|
||||
async fn search_memories(
|
||||
&self,
|
||||
_query: &str,
|
||||
_limit: Option<usize>,
|
||||
_session_scope: Option<&str>,
|
||||
_categories: Option<Vec<MemoryCategory>>,
|
||||
) -> Result<Vec<MemorySearchResult>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn retrieve_context_memories(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
_query: &str,
|
||||
_limit: usize,
|
||||
) -> Result<Vec<MemoryRecord>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn memory_stats(&self) -> Result<MemoryStats> {
|
||||
Ok(MemoryStats::default())
|
||||
}
|
||||
|
||||
async fn memory_health(&self) -> Result<MemoryHealth> {
|
||||
Ok(MemoryHealth {
|
||||
healthy: true,
|
||||
message: "ProxyCastSessionStore: memory subsystem disabled".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use crate::app::types::LogState;
|
||||
use crate::logger;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -38,6 +39,59 @@ pub async fn clear_logs(logs: tauri::State<'_, LogState>) -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_persisted_log_line(line: &str) -> Option<logger::LogEntry> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some((timestamp, rest)) = trimmed.split_once(" [") {
|
||||
if let Some((level, message)) = rest.split_once("] ") {
|
||||
return Some(logger::LogEntry {
|
||||
timestamp: timestamp.trim().to_string(),
|
||||
level: level.trim().to_lowercase(),
|
||||
message: message.trim().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Some(logger::LogEntry {
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
level: "info".to_string(),
|
||||
message: trimmed.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取持久化日志文件尾部(用于崩溃后恢复诊断)
|
||||
#[tauri::command]
|
||||
pub async fn get_persisted_logs_tail(
|
||||
logs: tauri::State<'_, LogState>,
|
||||
lines: Option<usize>,
|
||||
) -> Result<Vec<logger::LogEntry>, String> {
|
||||
let safe_limit = lines.unwrap_or(200).clamp(20, 1000);
|
||||
let log_file_path = logs.read().await.get_log_file_path();
|
||||
|
||||
let Some(path) = log_file_path else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| format!("读取持久化日志失败: {e}"))?;
|
||||
|
||||
if content.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut parsed: Vec<logger::LogEntry> = content
|
||||
.lines()
|
||||
.rev()
|
||||
.take(safe_limit)
|
||||
.filter_map(parse_persisted_log_line)
|
||||
.collect();
|
||||
|
||||
parsed.reverse();
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// 写入前端异常到本地日志并同步到崩溃上报后端
|
||||
#[tauri::command]
|
||||
pub async fn report_frontend_crash(
|
||||
|
||||
@@ -767,6 +767,7 @@ pub fn run() {
|
||||
app_commands::set_claude_custom_config,
|
||||
// Log commands (from app::commands)
|
||||
app_commands::get_logs,
|
||||
app_commands::get_persisted_logs_tail,
|
||||
app_commands::clear_logs,
|
||||
app_commands::report_frontend_crash,
|
||||
// API test commands (from app::commands)
|
||||
@@ -1272,6 +1273,8 @@ pub fn run() {
|
||||
commands::workspace_cmd::workspace_delete,
|
||||
commands::workspace_cmd::workspace_set_default,
|
||||
commands::workspace_cmd::workspace_get_default,
|
||||
commands::workspace_cmd::workspace_ensure_ready,
|
||||
commands::workspace_cmd::workspace_ensure_default_ready,
|
||||
commands::workspace_cmd::workspace_get_by_path,
|
||||
commands::workspace_cmd::workspace_get_projects_root,
|
||||
commands::workspace_cmd::workspace_resolve_project_path,
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::database::dao::agent::AgentDao;
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile;
|
||||
use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
|
||||
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
|
||||
use crate::workspace::WorkspaceManager;
|
||||
use crate::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -195,7 +196,19 @@ pub async fn agent_create_session(
|
||||
.get(&workspace_id)
|
||||
.map_err(|e| format!("读取 workspace 失败: {e}"))?
|
||||
.ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?;
|
||||
let workspace_root = workspace.root_path.to_string_lossy().to_string();
|
||||
let ensured = ensure_workspace_ready_with_auto_relocate(&workspace_manager, &workspace)?;
|
||||
if ensured.repaired {
|
||||
tracing::warn!(
|
||||
"[Agent] Workspace 路径异常已自动修复: {}{}",
|
||||
ensured.root_path.to_string_lossy(),
|
||||
if ensured.relocated {
|
||||
"(已迁移)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
let workspace_root = ensured.root_path.to_string_lossy().to_string();
|
||||
|
||||
// 初始化 Agent(使用带数据库的版本)
|
||||
agent_state.init_agent_with_db(&db).await?;
|
||||
|
||||
@@ -20,7 +20,9 @@ use crate::services::execution_tracker_service::{ExecutionTracker, RunFinalizeOp
|
||||
use crate::services::heartbeat_service::HeartbeatServiceState;
|
||||
use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile;
|
||||
use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
|
||||
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
|
||||
use crate::workspace::WorkspaceManager;
|
||||
use crate::LogState;
|
||||
use aster::agents::extension::{Envs, ExtensionConfig};
|
||||
use aster::agents::{Agent, AgentEvent};
|
||||
use aster::chrome_mcp::get_chrome_mcp_tools;
|
||||
@@ -56,6 +58,7 @@ const WORKSPACE_SANDBOX_ENABLED_ENV: &str = "PROXYCAST_WORKSPACE_SANDBOX_ENABLED
|
||||
const WORKSPACE_SANDBOX_STRICT_ENV: &str = "PROXYCAST_WORKSPACE_SANDBOX_STRICT";
|
||||
const WORKSPACE_SANDBOX_NOTIFY_ENV: &str = "PROXYCAST_WORKSPACE_SANDBOX_NOTIFY_ON_FALLBACK";
|
||||
const WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE: &str = "workspace_sandbox_fallback";
|
||||
const WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE: &str = "workspace_path_auto_created";
|
||||
|
||||
static SHARED_TASK_MANAGER: OnceLock<Arc<TaskManager>> = OnceLock::new();
|
||||
|
||||
@@ -372,6 +375,29 @@ struct ReplyAttemptError {
|
||||
emitted_any: bool,
|
||||
}
|
||||
|
||||
fn extract_inline_agent_provider_error(message: &Message) -> Option<String> {
|
||||
let text = message.as_concat_text();
|
||||
if !text.contains("Ran into this error:") {
|
||||
return None;
|
||||
}
|
||||
if !text.contains("Please retry if you think this is a transient or recoverable error.") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let after_prefix = text.split_once("Ran into this error:")?.1.trim();
|
||||
let detail = after_prefix
|
||||
.split_once("\n\nPlease retry if you think this is a transient or recoverable error.")
|
||||
.map(|(left, _)| left.trim())
|
||||
.unwrap_or(after_prefix)
|
||||
.trim_end_matches('.');
|
||||
|
||||
if detail.is_empty() {
|
||||
return Some("Agent provider execution failed".to_string());
|
||||
}
|
||||
|
||||
Some(format!("Agent provider execution failed: {detail}"))
|
||||
}
|
||||
|
||||
fn should_use_code_orchestrated_for_message(message: &str) -> bool {
|
||||
let lowered = message.to_lowercase();
|
||||
let keywords = [
|
||||
@@ -427,12 +453,23 @@ async fn stream_reply_once(
|
||||
match event_result {
|
||||
Ok(agent_event) => {
|
||||
emitted_any = true;
|
||||
let inline_provider_error = match &agent_event {
|
||||
AgentEvent::Message(message) => extract_inline_agent_provider_error(message),
|
||||
_ => None,
|
||||
};
|
||||
let tauri_events = convert_agent_event(agent_event);
|
||||
for tauri_event in tauri_events {
|
||||
if let Err(e) = app.emit(event_name, &tauri_event) {
|
||||
tracing::error!("[AsterAgent] 发送事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
if let Some(message) = inline_provider_error {
|
||||
tracing::warn!("[AsterAgent] 捕获到消息级 Provider 错误: {}", message);
|
||||
return Err(ReplyAttemptError {
|
||||
message,
|
||||
emitted_any: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(ReplyAttemptError {
|
||||
@@ -1556,6 +1593,7 @@ pub async fn aster_agent_chat_stream(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
heartbeat_state: State<'_, HeartbeatServiceState>,
|
||||
@@ -1592,15 +1630,59 @@ pub async fn aster_agent_chat_stream(
|
||||
|
||||
let workspace_id = request.workspace_id.trim().to_string();
|
||||
if workspace_id.is_empty() {
|
||||
return Err("workspace_id 必填,请先选择项目工作区".to_string());
|
||||
let message = "workspace_id 必填,请先选择项目工作区".to_string();
|
||||
logs.write()
|
||||
.await
|
||||
.add("error", &format!("[AsterAgent] {}", message));
|
||||
return Err(message);
|
||||
}
|
||||
|
||||
let manager = WorkspaceManager::new(db.inner().clone());
|
||||
let workspace = manager
|
||||
.get(&workspace_id)
|
||||
.map_err(|e| format!("读取 workspace 失败: {e}"))?
|
||||
.ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?;
|
||||
let workspace_root = workspace.root_path.to_string_lossy().to_string();
|
||||
let workspace = match manager.get(&workspace_id) {
|
||||
Ok(Some(workspace)) => workspace,
|
||||
Ok(None) => {
|
||||
let message = format!("Workspace 不存在: {workspace_id}");
|
||||
logs.write()
|
||||
.await
|
||||
.add("error", &format!("[AsterAgent] {}", message));
|
||||
return Err(message);
|
||||
}
|
||||
Err(error) => {
|
||||
let message = format!("读取 workspace 失败: {error}");
|
||||
logs.write()
|
||||
.await
|
||||
.add("error", &format!("[AsterAgent] {}", message));
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let ensured = match ensure_workspace_ready_with_auto_relocate(&manager, &workspace) {
|
||||
Ok(result) => result,
|
||||
Err(message) => {
|
||||
logs.write()
|
||||
.await
|
||||
.add("error", &format!("[AsterAgent] {}", message));
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let workspace_root = ensured.root_path.to_string_lossy().to_string();
|
||||
if ensured.repaired {
|
||||
let warning_message = ensured.warning.unwrap_or_else(|| {
|
||||
format!(
|
||||
"检测到工作区目录缺失,已自动创建并继续执行: {}",
|
||||
workspace_root
|
||||
)
|
||||
});
|
||||
logs.write()
|
||||
.await
|
||||
.add("warn", &format!("[AsterAgent] {}", warning_message));
|
||||
let warning_event = TauriAgentEvent::Warning {
|
||||
code: Some(WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE.to_string()),
|
||||
message: warning_message,
|
||||
};
|
||||
if let Err(error) = app.emit(&request.event_name, &warning_event) {
|
||||
tracing::error!("[AsterAgent] 发送工作区自动恢复提醒失败: {}", error);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let db_conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?;
|
||||
@@ -1962,10 +2044,37 @@ pub async fn aster_session_create(
|
||||
return Err("workspace_id 必填,请先选择项目工作区".to_string());
|
||||
}
|
||||
|
||||
let manager = WorkspaceManager::new(db.inner().clone());
|
||||
let workspace = manager
|
||||
.get(&workspace_id)
|
||||
.map_err(|e| format!("读取 workspace 失败: {e}"))?
|
||||
.ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?;
|
||||
let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?;
|
||||
let workspace_root = ensured.root_path.to_string_lossy().to_string();
|
||||
|
||||
if ensured.repaired {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 会话创建阶段检测到 workspace 目录异常并已修复: {}{}",
|
||||
workspace_root,
|
||||
if ensured.relocated {
|
||||
"(已迁移)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let resolved_working_dir = working_dir
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| Some(workspace_root.clone()));
|
||||
|
||||
AsterAgentWrapper::create_session_sync(
|
||||
&db,
|
||||
name,
|
||||
working_dir,
|
||||
resolved_working_dir,
|
||||
workspace_id,
|
||||
Some(
|
||||
execution_strategy
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::config::{
|
||||
use crate::models::app_type::AppType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
|
||||
@@ -596,6 +597,16 @@ pub struct VersionCheckResult {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
const FALLBACK_TAGS_URL: &str = "https://github.com/aiclientproxy/proxycast/tags";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
struct UpdateCheckCache {
|
||||
latest: Option<String>,
|
||||
download_url: Option<String>,
|
||||
etag: Option<String>,
|
||||
last_checked_unix: u64,
|
||||
}
|
||||
|
||||
/// 检查应用更新
|
||||
///
|
||||
/// 从 GitHub Releases API 获取最新版本信息并与当前版本比较
|
||||
@@ -604,17 +615,60 @@ pub async fn check_for_updates() -> Result<VersionCheckResult, String> {
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const GITHUB_API_URL: &str =
|
||||
"https://api.github.com/repos/aiclientproxy/proxycast/releases/latest";
|
||||
const UPDATE_CHECK_CACHE_TTL_SECS: u64 = 10 * 60;
|
||||
|
||||
let now_unix = current_unix_timestamp();
|
||||
let cache_path = get_update_check_cache_path();
|
||||
let cached = load_update_check_cache(&cache_path);
|
||||
|
||||
if let Some(cache) = &cached {
|
||||
if is_update_cache_fresh(cache, now_unix, UPDATE_CHECK_CACHE_TTL_SECS) {
|
||||
return Ok(build_version_check_result(
|
||||
CURRENT_VERSION,
|
||||
cache.latest.clone(),
|
||||
cache.download_url.clone(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
match client
|
||||
let mut request = client
|
||||
.get(GITHUB_API_URL)
|
||||
.header("User-Agent", "ProxyCast")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
.header("Accept", "application/vnd.github+json");
|
||||
|
||||
if let Some(cache) = &cached {
|
||||
if let Some(etag) = &cache.etag {
|
||||
request = request.header("If-None-Match", etag);
|
||||
}
|
||||
}
|
||||
|
||||
match request.send().await {
|
||||
Ok(response) => {
|
||||
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
|
||||
if let Some(cache) = cached {
|
||||
let refreshed_cache = UpdateCheckCache {
|
||||
last_checked_unix: now_unix,
|
||||
..cache.clone()
|
||||
};
|
||||
let _ = save_update_check_cache(&cache_path, &refreshed_cache);
|
||||
return Ok(build_version_check_result(
|
||||
CURRENT_VERSION,
|
||||
refreshed_cache.latest,
|
||||
refreshed_cache.download_url,
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if response.status().is_success() {
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(reqwest::header::ETAG)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
match response.json::<serde_json::Value>().await {
|
||||
Ok(data) => {
|
||||
let latest_version = data["tag_name"]
|
||||
@@ -624,44 +678,125 @@ pub async fn check_for_updates() -> Result<VersionCheckResult, String> {
|
||||
|
||||
let download_url = data["html_url"].as_str().map(|s| s.to_string());
|
||||
|
||||
let has_update = version_compare(CURRENT_VERSION, latest_version);
|
||||
|
||||
Ok(VersionCheckResult {
|
||||
current: CURRENT_VERSION.to_string(),
|
||||
let new_cache = UpdateCheckCache {
|
||||
latest: Some(latest_version.to_string()),
|
||||
has_update,
|
||||
download_url: download_url.clone(),
|
||||
etag,
|
||||
last_checked_unix: now_unix,
|
||||
};
|
||||
let _ = save_update_check_cache(&cache_path, &new_cache);
|
||||
|
||||
Ok(build_version_check_result(
|
||||
CURRENT_VERSION,
|
||||
Some(latest_version.to_string()),
|
||||
download_url,
|
||||
error: None,
|
||||
})
|
||||
None,
|
||||
))
|
||||
}
|
||||
Err(e) => Ok(VersionCheckResult {
|
||||
current: CURRENT_VERSION.to_string(),
|
||||
latest: None,
|
||||
has_update: false,
|
||||
download_url: None,
|
||||
error: Some(format!("解析响应失败: {e}")),
|
||||
}),
|
||||
Err(e) => Ok(build_version_from_cache_or_default(
|
||||
CURRENT_VERSION,
|
||||
cached.as_ref(),
|
||||
Some(format!("解析更新信息失败,已回退本地缓存: {e}")),
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
Ok(VersionCheckResult {
|
||||
current: CURRENT_VERSION.to_string(),
|
||||
latest: None,
|
||||
has_update: false,
|
||||
download_url: None,
|
||||
error: Some(format!("GitHub API 请求失败: {}", response.status())),
|
||||
})
|
||||
let error_message = match response.status() {
|
||||
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::TOO_MANY_REQUESTS => {
|
||||
"GitHub API 限流,已回退本地缓存,请稍后重试".to_string()
|
||||
}
|
||||
status => format!("GitHub API 请求失败: {status},已回退本地缓存"),
|
||||
};
|
||||
|
||||
Ok(build_version_from_cache_or_default(
|
||||
CURRENT_VERSION,
|
||||
cached.as_ref(),
|
||||
Some(error_message),
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(VersionCheckResult {
|
||||
current: CURRENT_VERSION.to_string(),
|
||||
latest: None,
|
||||
has_update: false,
|
||||
download_url: None,
|
||||
error: Some(format!("网络请求失败: {e}")),
|
||||
}),
|
||||
Err(e) => Ok(build_version_from_cache_or_default(
|
||||
CURRENT_VERSION,
|
||||
cached.as_ref(),
|
||||
Some(format!("网络请求失败,已回退本地缓存: {e}")),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_unix_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn get_update_check_cache_path() -> PathBuf {
|
||||
let base_dir = dirs::cache_dir()
|
||||
.or_else(dirs::config_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
base_dir.join("proxycast").join("update-check-cache.json")
|
||||
}
|
||||
|
||||
fn is_update_cache_fresh(cache: &UpdateCheckCache, now_unix: u64, ttl_secs: u64) -> bool {
|
||||
if cache.latest.is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
now_unix.saturating_sub(cache.last_checked_unix) < ttl_secs
|
||||
}
|
||||
|
||||
fn load_update_check_cache(path: &PathBuf) -> Option<UpdateCheckCache> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str::<UpdateCheckCache>(&content).ok()
|
||||
}
|
||||
|
||||
fn save_update_check_cache(path: &PathBuf, cache: &UpdateCheckCache) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let content = serde_json::to_string(cache).map_err(|e| e.to_string())?;
|
||||
std::fs::write(path, content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn build_version_check_result(
|
||||
current: &str,
|
||||
latest: Option<String>,
|
||||
download_url: Option<String>,
|
||||
error: Option<String>,
|
||||
) -> VersionCheckResult {
|
||||
let resolved_download_url = download_url.or_else(|| Some(FALLBACK_TAGS_URL.to_string()));
|
||||
let has_update = latest
|
||||
.as_deref()
|
||||
.map(|latest_version| version_compare(current, latest_version))
|
||||
.unwrap_or(false);
|
||||
|
||||
VersionCheckResult {
|
||||
current: current.to_string(),
|
||||
latest,
|
||||
has_update,
|
||||
download_url: resolved_download_url,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_version_from_cache_or_default(
|
||||
current: &str,
|
||||
cache: Option<&UpdateCheckCache>,
|
||||
error: Option<String>,
|
||||
) -> VersionCheckResult {
|
||||
if let Some(cached) = cache {
|
||||
return build_version_check_result(
|
||||
current,
|
||||
cached.latest.clone(),
|
||||
cached.download_url.clone(),
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
build_version_check_result(current, None, None, error)
|
||||
}
|
||||
|
||||
/// 简单的版本比较函数
|
||||
/// 返回 true 如果 latest > current
|
||||
fn version_compare(current: &str, latest: &str) -> bool {
|
||||
@@ -745,6 +880,25 @@ mod tests {
|
||||
assert!(patterns.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_update_cache_fresh() {
|
||||
let cache = UpdateCheckCache {
|
||||
latest: Some("0.76.0".to_string()),
|
||||
download_url: Some("https://example.com".to_string()),
|
||||
etag: Some("etag".to_string()),
|
||||
last_checked_unix: 100,
|
||||
};
|
||||
|
||||
assert!(is_update_cache_fresh(&cache, 150, 60));
|
||||
assert!(!is_update_cache_fresh(&cache, 170, 60));
|
||||
|
||||
let cache_without_latest = UpdateCheckCache {
|
||||
latest: None,
|
||||
..cache
|
||||
};
|
||||
assert!(!is_update_cache_fresh(&cache_without_latest, 120, 60));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -39,6 +39,7 @@ fn arb_server_config() -> impl Strategy<Value = ServerConfig> {
|
||||
port,
|
||||
api_key,
|
||||
tls: proxycast_core::config::TlsConfig::default(),
|
||||
response_cache: proxycast_core::config::ResponseCacheSettings::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -371,6 +372,7 @@ fn arb_valid_server_config() -> impl Strategy<Value = ServerConfig> {
|
||||
port,
|
||||
api_key,
|
||||
tls: proxycast_core::config::TlsConfig::default(),
|
||||
response_cache: proxycast_core::config::ResponseCacheSettings::default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,33 @@ pub async fn handle_command(
|
||||
Ok(serde_json::to_value(recent)?)
|
||||
}
|
||||
|
||||
"get_persisted_logs_tail" => {
|
||||
let requested = args
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("lines"))
|
||||
.and_then(|value| value.as_u64())
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(200)
|
||||
.clamp(20, 1000);
|
||||
|
||||
let logs = state.logs.read().await;
|
||||
let entries = logs.get_logs();
|
||||
let limit = entries.len().min(requested);
|
||||
let recent: Vec<_> = entries
|
||||
.into_iter()
|
||||
.rev()
|
||||
.take(limit)
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"timestamp": e.timestamp,
|
||||
"level": e.level,
|
||||
"message": e.message,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::to_value(recent)?)
|
||||
}
|
||||
|
||||
"clear_logs" => {
|
||||
state.logs.write().await.clear();
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
|
||||
@@ -1558,29 +1558,237 @@ fn row_to_run(row: &rusqlite::Row<'_>) -> Result<NovelGenerationRun, rusqlite::E
|
||||
}
|
||||
|
||||
fn parse_character_cards(raw: &str) -> Vec<Value> {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(raw) {
|
||||
if let Some(arr) = value.as_array() {
|
||||
return arr.clone();
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if let Some(cards) = parse_character_cards_from_json_text(trimmed) {
|
||||
return cards;
|
||||
}
|
||||
|
||||
let fenced = extract_first_markdown_code_block(trimmed);
|
||||
if let Some(code) = fenced.as_ref() {
|
||||
if let Some(cards) = parse_character_cards_from_json_text(code) {
|
||||
return cards;
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:按行解析成简单角色卡
|
||||
if let Some(array_text) = extract_json_array_text(trimmed) {
|
||||
if let Some(cards) = parse_character_cards_from_json_text(&array_text) {
|
||||
return cards;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(code) = fenced.as_ref() {
|
||||
if let Some(array_text) = extract_json_array_text(code) {
|
||||
if let Some(cards) = parse_character_cards_from_json_text(&array_text) {
|
||||
return cards;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:按行解析成简单角色卡(过滤 JSON 噪音行)
|
||||
raw.lines()
|
||||
.filter_map(|line| {
|
||||
let name = line.trim().trim_start_matches('-').trim();
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(json!({
|
||||
"name": name,
|
||||
"role_type": "support",
|
||||
"description": ""
|
||||
}))
|
||||
}
|
||||
let name = sanitize_fallback_name(line)?;
|
||||
Some(json!({
|
||||
"name": name,
|
||||
"role_type": "support",
|
||||
"description": ""
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_character_cards_from_json_text(text: &str) -> Option<Vec<Value>> {
|
||||
let value = serde_json::from_str::<Value>(text).ok()?;
|
||||
extract_character_cards_from_value(&value)
|
||||
}
|
||||
|
||||
fn extract_character_cards_from_value(value: &Value) -> Option<Vec<Value>> {
|
||||
if let Some(arr) = value.as_array() {
|
||||
return Some(
|
||||
arr.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, item)| normalize_character_card(item, idx))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
let obj = value.as_object()?;
|
||||
for key in [
|
||||
"characters",
|
||||
"character_cards",
|
||||
"cards",
|
||||
"result",
|
||||
"data",
|
||||
"roles",
|
||||
] {
|
||||
if let Some(arr) = obj.get(key).and_then(Value::as_array) {
|
||||
return Some(
|
||||
arr.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, item)| normalize_character_card(item, idx))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if obj.get("name").is_some() || obj.get("role_type").is_some() {
|
||||
return Some(vec![normalize_character_card(value, 0)]);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_character_card(value: &Value, index: usize) -> Value {
|
||||
let mut obj = value.as_object().cloned().unwrap_or_default();
|
||||
let name = extract_character_name(&obj).unwrap_or_else(|| format!("角色{}", index + 1));
|
||||
let role_type = extract_role_type(&obj);
|
||||
obj.insert("name".to_string(), Value::String(name));
|
||||
obj.insert("role_type".to_string(), Value::String(role_type));
|
||||
Value::Object(obj)
|
||||
}
|
||||
|
||||
fn extract_character_name(obj: &serde_json::Map<String, Value>) -> Option<String> {
|
||||
let candidate_keys = ["name", "character_name", "characterName", "角色名", "角色"];
|
||||
for key in candidate_keys {
|
||||
if let Some(raw_name) = obj.get(key).and_then(Value::as_str) {
|
||||
if let Some(name) = sanitize_candidate_name(raw_name) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_role_type(obj: &serde_json::Map<String, Value>) -> String {
|
||||
let candidate_keys = ["role_type", "roleType", "type", "role", "角色类型"];
|
||||
for key in candidate_keys {
|
||||
if let Some(value) = obj.get(key).and_then(Value::as_str) {
|
||||
return normalize_role_type(value);
|
||||
}
|
||||
}
|
||||
"support".to_string()
|
||||
}
|
||||
|
||||
fn normalize_role_type(raw: &str) -> String {
|
||||
let lowered = raw.trim().to_lowercase();
|
||||
if lowered.contains("main")
|
||||
|| lowered.contains("protagonist")
|
||||
|| lowered.contains("主角")
|
||||
|| lowered.contains("主人公")
|
||||
{
|
||||
return "main".to_string();
|
||||
}
|
||||
if lowered.contains("antagonist")
|
||||
|| lowered.contains("villain")
|
||||
|| lowered.contains("反派")
|
||||
|| lowered.contains("敌人")
|
||||
{
|
||||
return "antagonist".to_string();
|
||||
}
|
||||
"support".to_string()
|
||||
}
|
||||
|
||||
fn extract_first_markdown_code_block(raw: &str) -> Option<String> {
|
||||
let start = raw.find("```")?;
|
||||
let remain = &raw[start + 3..];
|
||||
let content_start = remain.find('\n')?;
|
||||
let content = &remain[content_start + 1..];
|
||||
let end = content.find("```")?;
|
||||
Some(content[..end].trim().to_string())
|
||||
}
|
||||
|
||||
fn extract_json_array_text(raw: &str) -> Option<String> {
|
||||
let mut depth = 0usize;
|
||||
let mut start_index: Option<usize> = None;
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
|
||||
for (idx, ch) in raw.char_indices() {
|
||||
if in_string {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if ch == '"' {
|
||||
in_string = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'"' => in_string = true,
|
||||
'[' => {
|
||||
if depth == 0 {
|
||||
start_index = Some(idx);
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
']' => {
|
||||
if depth == 0 {
|
||||
continue;
|
||||
}
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
if let Some(start) = start_index {
|
||||
return Some(raw[start..idx + 1].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn sanitize_fallback_name(line: &str) -> Option<String> {
|
||||
let mut normalized = line.trim();
|
||||
normalized = normalized
|
||||
.trim_start_matches('-')
|
||||
.trim_start_matches('*')
|
||||
.trim();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
sanitize_candidate_name(normalized)
|
||||
}
|
||||
|
||||
fn sanitize_candidate_name(raw: &str) -> Option<String> {
|
||||
let normalized = raw.trim().trim_matches('"').trim_end_matches(',').trim();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if looks_like_json_noise_line(normalized) {
|
||||
return None;
|
||||
}
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
|
||||
fn looks_like_json_noise_line(line: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if matches!(trimmed, "{" | "}" | "[" | "]" | ",") {
|
||||
return true;
|
||||
}
|
||||
if trimmed.starts_with("//") {
|
||||
return true;
|
||||
}
|
||||
if trimmed.contains("\":") || trimmed.ends_with(':') {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn split_title_and_content(raw: &str, chapter_no: i32) -> (String, String) {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -2405,3 +2613,51 @@ fn value_as_f64(value: Option<&Value>, fallback: f64) -> f64 {
|
||||
.or_else(|| value.as_u64().map(|v| v as f64))
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_character_cards_should_support_markdown_json_block() {
|
||||
let raw = r#"
|
||||
这里是角色卡:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "顾清",
|
||||
"role_type": "main",
|
||||
"relationship": "主角"
|
||||
},
|
||||
{
|
||||
"name": "宿敌",
|
||||
"role_type": "antagonist"
|
||||
}
|
||||
]
|
||||
```
|
||||
"#;
|
||||
|
||||
let cards = parse_character_cards(raw);
|
||||
assert_eq!(cards.len(), 2);
|
||||
assert_eq!(cards[0]["name"], Value::String("顾清".to_string()));
|
||||
assert_eq!(cards[0]["role_type"], Value::String("main".to_string()));
|
||||
assert_eq!(
|
||||
cards[1]["role_type"],
|
||||
Value::String("antagonist".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_character_cards_should_filter_json_noise_in_fallback_lines() {
|
||||
let raw = r#"
|
||||
{
|
||||
"relationship": "上司",
|
||||
"arc": "成长",
|
||||
"abilities": "强大的调查能力",
|
||||
}
|
||||
"#;
|
||||
|
||||
let cards = parse_character_cards(raw);
|
||||
assert!(cards.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user