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:
coso
2026-03-02 17:52:00 +08:00
co-authored by Claude Sonnet 4.6
parent 0b2d3caa6c
commit 68e16d4a04
11 changed files with 784 additions and 57 deletions
+51
View File
@@ -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(());
+15 -1
View File
@@ -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(),
})
}
}
// ============================================================================
+54
View File
@@ -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(
+3
View File
@@ -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,
+14 -1
View File
@@ -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?;
+116 -7
View File
@@ -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
+187 -33
View File
@@ -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)]
+2
View File
@@ -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(),
})
}
+27
View File
@@ -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 }))
+270 -14
View File
@@ -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());
}
}