mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: release v0.83.2 with full pending changes
This commit is contained in:
+30
-10
@@ -1,17 +1,37 @@
|
||||
## ProxyCast v0.83.1
|
||||
## ProxyCast v0.83.2
|
||||
|
||||
### ✨ 新功能
|
||||
- 新增 OpenClaw 命令注册和集成 (dce47534)
|
||||
- 新增 MarkdownRenderer 测试覆盖
|
||||
- 新增视频主题面板渲染器
|
||||
- 新增跨平台应用路径解析模块 `app_paths`,支持 macOS/Windows 目录迁移
|
||||
- Agent 事件转换器增强,支持更多事件类型处理
|
||||
- Agent 请求工具策略扩展,新增策略规则
|
||||
- 流式渲染器增强,新增流诊断工具和 Provider 模型兼容性检测
|
||||
- 终端 AI 模式选择器功能增强
|
||||
- OpenClaw 页面功能扩展
|
||||
- Windows 启动命令模块增强
|
||||
|
||||
### 🐛 修复
|
||||
- 修复 useMemo 依赖缺失导致的 React Hook 警告
|
||||
- 修复 Kiro Provider 凭证处理逻辑
|
||||
- 修复心跳服务适配器和心跳命令的稳定性问题
|
||||
- 修复日志模块和遥测日志的路径处理
|
||||
- 修复数据库模块初始化问题
|
||||
- 修复托盘菜单事件处理逻辑
|
||||
|
||||
### 🔧 优化与重构
|
||||
- 优化视频画布和侧边栏组件
|
||||
- 优化工作台控制器和快捷操作
|
||||
- 优化工作台右侧面板功能
|
||||
- 优化 OpenClaw 页面和类型定义
|
||||
- 优化 Agent 事件转换器
|
||||
- Provider 模型选择器组件重构,提升可维护性
|
||||
- ModelSelector 组件优化,增加测试覆盖
|
||||
- 通用聊天 useProvider Hook 重构
|
||||
- Workbench 页面布局优化
|
||||
- 频道设置页面改进
|
||||
- 终端工作区组件优化
|
||||
- 语音润色模型选择器改进
|
||||
- useProjects Hook 优化
|
||||
|
||||
### 📦 其他
|
||||
- 新增多个组件单元测试(StreamingRenderer、ProviderModelSelector、TerminalAIModeSelector、ModelSelector)
|
||||
- 新增流诊断和 Provider 模型兼容性工具测试
|
||||
- Cargo.lock 依赖更新
|
||||
|
||||
---
|
||||
|
||||
**完整变更**: v0.83.0...v0.83.1
|
||||
**完整变更**: v0.83.1...v0.83.2
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.83.1",
|
||||
"version": "0.83.2",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
Generated
+16
-16
@@ -6952,7 +6952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -7054,7 +7054,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-agent"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-trait",
|
||||
@@ -7079,7 +7079,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-config"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -7095,7 +7095,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-core"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"aster-models",
|
||||
"async-trait",
|
||||
@@ -7135,7 +7135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-credential"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -7170,7 +7170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-gateway"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -7191,7 +7191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-infra"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -7211,7 +7211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-mcp"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -7243,7 +7243,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-processor"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -7262,7 +7262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-providers"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -7316,7 +7316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-stream",
|
||||
@@ -7361,7 +7361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server-utils"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -7376,7 +7376,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-services"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -7417,7 +7417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-skills"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -7433,7 +7433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-terminal"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7460,7 +7460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-websocket"
|
||||
version = "0.83.0"
|
||||
version = "0.83.1"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.83.1"
|
||||
version = "0.83.2"
|
||||
edition = "2021"
|
||||
authors = ["coso"]
|
||||
repository = "https://github.com/aiclientproxy/proxycast"
|
||||
@@ -191,7 +191,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.83.1"
|
||||
version = "0.83.2"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -14,6 +14,9 @@ const TOOL_RESULT_MAX_TEXT_PARTS: usize = 256;
|
||||
const TOOL_RESULT_MAX_OUTPUT_CHARS: usize = 16_000;
|
||||
const TOOL_RESULT_MAX_IMAGES: usize = 12;
|
||||
const TOOL_RESULT_TRUNCATED_NOTICE: &str = "\n\n[event_converter] 工具输出已截断";
|
||||
const TOOL_RESULT_DIAG_WARN_JSON_BYTES: usize = 64 * 1024;
|
||||
const TOOL_RESULT_DIAG_WARN_OUTPUT_CHARS: usize = 8_000;
|
||||
const TOOL_RESULT_DIAG_WARN_IMAGE_COUNT: usize = 4;
|
||||
|
||||
fn enhance_execution_error_text(raw: &str) -> String {
|
||||
if !raw.contains("Execution error: No such file or directory (os error 2)") {
|
||||
@@ -221,6 +224,45 @@ fn maybe_filter_web_content(raw: &str) -> String {
|
||||
struct ExtractedToolResult {
|
||||
output: String,
|
||||
images: Vec<TauriToolImage>,
|
||||
diagnostics: ToolResultDiagnostics,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
struct ToolResultDiagnostics {
|
||||
raw_json_bytes: Option<usize>,
|
||||
output_chars: usize,
|
||||
image_count: usize,
|
||||
text_truncated: bool,
|
||||
images_truncated: bool,
|
||||
}
|
||||
|
||||
fn log_tool_result_diagnostics(tool_id: &str, diagnostics: &ToolResultDiagnostics) {
|
||||
let raw_json_bytes = diagnostics.raw_json_bytes.unwrap_or(0);
|
||||
let should_warn = diagnostics.text_truncated
|
||||
|| diagnostics.images_truncated
|
||||
|| raw_json_bytes >= TOOL_RESULT_DIAG_WARN_JSON_BYTES
|
||||
|| diagnostics.output_chars >= TOOL_RESULT_DIAG_WARN_OUTPUT_CHARS
|
||||
|| diagnostics.image_count >= TOOL_RESULT_DIAG_WARN_IMAGE_COUNT;
|
||||
|
||||
if should_warn {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Diag] tool_end payload summary: tool_id={}, raw_json_bytes={}, output_chars={}, image_count={}, text_truncated={}, images_truncated={}",
|
||||
tool_id,
|
||||
raw_json_bytes,
|
||||
diagnostics.output_chars,
|
||||
diagnostics.image_count,
|
||||
diagnostics.text_truncated,
|
||||
diagnostics.images_truncated
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"[AsterAgent][Diag] tool_end payload summary: tool_id={}, raw_json_bytes={}, output_chars={}, image_count={}",
|
||||
tool_id,
|
||||
raw_json_bytes,
|
||||
diagnostics.output_chars,
|
||||
diagnostics.image_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mime_type_from_data_url(data_url: &str) -> Option<String> {
|
||||
@@ -387,6 +429,8 @@ fn extract_tool_result_data<T: serde::Serialize>(result: &T) -> ExtractedToolRes
|
||||
let output = extract_tool_result_text(result);
|
||||
let mut images = Vec::new();
|
||||
let mut seen_sources = std::collections::HashSet::new();
|
||||
let mut raw_json_bytes = None;
|
||||
let mut images_truncated = false;
|
||||
|
||||
for data_url in extract_data_urls_from_text(&output) {
|
||||
push_tool_image_if_new(
|
||||
@@ -397,10 +441,25 @@ fn extract_tool_result_data<T: serde::Serialize>(result: &T) -> ExtractedToolRes
|
||||
}
|
||||
|
||||
if let Ok(json) = serde_json::to_value(result) {
|
||||
let _ = collect_tool_result_images(&json, &mut images, &mut seen_sources);
|
||||
raw_json_bytes = serde_json::to_vec(&json).ok().map(|bytes| bytes.len());
|
||||
images_truncated = collect_tool_result_images(&json, &mut images, &mut seen_sources);
|
||||
}
|
||||
|
||||
ExtractedToolResult { output, images }
|
||||
let output_chars = output.chars().count();
|
||||
let image_count = images.len();
|
||||
let text_truncated = output.contains(TOOL_RESULT_TRUNCATED_NOTICE);
|
||||
|
||||
ExtractedToolResult {
|
||||
output,
|
||||
images,
|
||||
diagnostics: ToolResultDiagnostics {
|
||||
raw_json_bytes,
|
||||
output_chars,
|
||||
image_count,
|
||||
text_truncated,
|
||||
images_truncated,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri Agent 事件
|
||||
@@ -629,6 +688,7 @@ fn convert_message(message: Message) -> Vec<TauriAgentEvent> {
|
||||
let (success, output, error, images) = match &tool_response.tool_result {
|
||||
Ok(result) => {
|
||||
let extracted = extract_tool_result_data(result);
|
||||
log_tool_result_diagnostics(&tool_response.id, &extracted.diagnostics);
|
||||
(
|
||||
true,
|
||||
extracted.output,
|
||||
@@ -1022,5 +1082,24 @@ mod tests {
|
||||
|
||||
let extracted = extract_tool_result_data(&payload);
|
||||
assert_eq!(extracted.images.len(), TOOL_RESULT_MAX_IMAGES);
|
||||
assert!(extracted.diagnostics.images_truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_result_data_should_record_diagnostics() {
|
||||
let payload = serde_json::json!({
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "hello"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let extracted = extract_tool_result_data(&payload);
|
||||
assert_eq!(extracted.diagnostics.output_chars, 5);
|
||||
assert_eq!(extracted.diagnostics.image_count, 0);
|
||||
assert_eq!(extracted.diagnostics.text_truncated, false);
|
||||
assert!(extracted.diagnostics.raw_json_bytes.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ const WEB_SEARCH_REQUIRED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_REQUIRED_TOOLS
|
||||
const WEB_SEARCH_ALLOWED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_ALLOWED_TOOLS";
|
||||
const WEB_SEARCH_DISALLOWED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_DISALLOWED_TOOLS";
|
||||
const WEB_SEARCH_PREFLIGHT_ENABLED_ENV: &str = "PROXYCAST_WEB_SEARCH_PREFLIGHT_ENABLED";
|
||||
const STREAM_EVENT_DIAG_WARN_TEXT_DELTA_CHARS: usize = 2_000;
|
||||
const STREAM_EVENT_DIAG_WARN_TOOL_OUTPUT_CHARS: usize = 8_000;
|
||||
const STREAM_EVENT_DIAG_WARN_CONTEXT_STEPS: usize = 24;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RequestToolPolicy {
|
||||
@@ -188,6 +191,68 @@ pub struct ReplyAttemptError {
|
||||
pub emitted_any: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct StreamEventDiagnostics {
|
||||
text_delta_count: usize,
|
||||
tool_start_count: usize,
|
||||
tool_end_count: usize,
|
||||
error_count: usize,
|
||||
context_trace_events: usize,
|
||||
max_text_delta_chars: usize,
|
||||
max_tool_output_chars: usize,
|
||||
max_context_trace_steps: usize,
|
||||
}
|
||||
|
||||
fn update_stream_event_diagnostics(
|
||||
diagnostics: &mut StreamEventDiagnostics,
|
||||
event: &TauriAgentEvent,
|
||||
) {
|
||||
match event {
|
||||
TauriAgentEvent::TextDelta { text } => {
|
||||
diagnostics.text_delta_count += 1;
|
||||
let char_count = text.chars().count();
|
||||
diagnostics.max_text_delta_chars = diagnostics.max_text_delta_chars.max(char_count);
|
||||
if char_count >= STREAM_EVENT_DIAG_WARN_TEXT_DELTA_CHARS {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Diag] large text_delta observed: chars={}",
|
||||
char_count
|
||||
);
|
||||
}
|
||||
}
|
||||
TauriAgentEvent::ToolStart { .. } => {
|
||||
diagnostics.tool_start_count += 1;
|
||||
}
|
||||
TauriAgentEvent::ToolEnd { tool_id, result } => {
|
||||
diagnostics.tool_end_count += 1;
|
||||
let output_chars = result.output.chars().count();
|
||||
diagnostics.max_tool_output_chars = diagnostics.max_tool_output_chars.max(output_chars);
|
||||
if output_chars >= STREAM_EVENT_DIAG_WARN_TOOL_OUTPUT_CHARS {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Diag] large tool_end output observed: tool_id={}, output_chars={}, success={}",
|
||||
tool_id,
|
||||
output_chars,
|
||||
result.success
|
||||
);
|
||||
}
|
||||
}
|
||||
TauriAgentEvent::ContextTrace { steps } => {
|
||||
diagnostics.context_trace_events += 1;
|
||||
diagnostics.max_context_trace_steps =
|
||||
diagnostics.max_context_trace_steps.max(steps.len());
|
||||
if steps.len() >= STREAM_EVENT_DIAG_WARN_CONTEXT_STEPS {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Diag] large context_trace observed: steps={}",
|
||||
steps.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
TauriAgentEvent::Error { .. } => {
|
||||
diagnostics.error_count += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StreamReplyExecution {
|
||||
pub text_output: String,
|
||||
@@ -541,6 +606,7 @@ where
|
||||
let mut emitted_any = false;
|
||||
let mut text_chunks: Vec<String> = Vec::new();
|
||||
let mut event_errors: Vec<String> = Vec::new();
|
||||
let mut diagnostics = StreamEventDiagnostics::default();
|
||||
|
||||
while let Some(event_result) = stream.next().await {
|
||||
match event_result {
|
||||
@@ -580,6 +646,7 @@ where
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
update_stream_event_diagnostics(&mut diagnostics, &tauri_event);
|
||||
on_event(&tauri_event);
|
||||
}
|
||||
if let Some(message) = inline_provider_error {
|
||||
@@ -607,6 +674,18 @@ where
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[AsterAgent][Diag] stream summary: text_deltas={}, tool_starts={}, tool_ends={}, context_traces={}, errors={}, max_text_delta_chars={}, max_tool_output_chars={}, max_context_trace_steps={}",
|
||||
diagnostics.text_delta_count,
|
||||
diagnostics.tool_start_count,
|
||||
diagnostics.tool_end_count,
|
||||
diagnostics.context_trace_events,
|
||||
diagnostics.error_count,
|
||||
diagnostics.max_text_delta_chars,
|
||||
diagnostics.max_tool_output_chars,
|
||||
diagnostics.max_context_trace_steps
|
||||
);
|
||||
|
||||
Ok(StreamReplyExecution {
|
||||
text_output: text_chunks.join(""),
|
||||
event_errors,
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
use rusqlite::{Connection, DatabaseName};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const APP_DATA_DIR_NAME: &str = "proxycast";
|
||||
const LEGACY_HOME_DIR_NAME: &str = ".proxycast";
|
||||
const DATABASE_FILE_NAME: &str = "proxycast.db";
|
||||
const MIGRATION_MARKER_FILE: &str = ".migration_completed";
|
||||
const USER_SIGNAL_TABLES: &[&str] = &[
|
||||
"contents",
|
||||
"agent_sessions",
|
||||
"general_chat_sessions",
|
||||
"materials",
|
||||
"api_keys",
|
||||
"heartbeat_executions",
|
||||
];
|
||||
|
||||
pub fn preferred_data_dir() -> Result<PathBuf, String> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or_else(|| "无法获取应用数据目录".to_string())?
|
||||
.join(APP_DATA_DIR_NAME);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("无法创建应用数据目录 {}: {e}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn legacy_home_dir() -> Result<PathBuf, String> {
|
||||
Ok(dirs::home_dir()
|
||||
.ok_or_else(|| "无法获取主目录".to_string())?
|
||||
.join(LEGACY_HOME_DIR_NAME))
|
||||
}
|
||||
|
||||
pub fn preferred_database_path() -> Result<PathBuf, String> {
|
||||
Ok(preferred_data_dir()?.join(DATABASE_FILE_NAME))
|
||||
}
|
||||
|
||||
pub fn legacy_database_path() -> Result<PathBuf, String> {
|
||||
Ok(legacy_home_dir()?.join(DATABASE_FILE_NAME))
|
||||
}
|
||||
|
||||
pub fn resolve_database_path() -> Result<PathBuf, String> {
|
||||
let preferred_root = preferred_data_dir()?;
|
||||
let legacy_root = legacy_home_dir()?;
|
||||
resolve_database_path_from_roots(&preferred_root, &legacy_root)
|
||||
}
|
||||
|
||||
pub fn resolve_logs_dir() -> Result<PathBuf, String> {
|
||||
let preferred_root = preferred_data_dir()?;
|
||||
let legacy_root = legacy_home_dir()?;
|
||||
resolve_subdir_with_legacy_copy_from_roots(&preferred_root, &legacy_root, "logs")
|
||||
}
|
||||
|
||||
pub fn resolve_request_logs_dir() -> Result<PathBuf, String> {
|
||||
let preferred_root = preferred_data_dir()?;
|
||||
let legacy_root = legacy_home_dir()?;
|
||||
resolve_subdir_with_legacy_copy_from_roots(&preferred_root, &legacy_root, "request_logs")
|
||||
}
|
||||
|
||||
fn resolve_database_path_from_roots(
|
||||
preferred_root: &Path,
|
||||
legacy_root: &Path,
|
||||
) -> Result<PathBuf, String> {
|
||||
fs::create_dir_all(preferred_root)
|
||||
.map_err(|e| format!("无法创建数据库目录 {}: {e}", preferred_root.display()))?;
|
||||
|
||||
let preferred_path = preferred_root.join(DATABASE_FILE_NAME);
|
||||
let marker_path = preferred_root.join(MIGRATION_MARKER_FILE);
|
||||
|
||||
// 标记文件存在 → 迁移已完成,直接用 preferred 路径
|
||||
if marker_path.exists() {
|
||||
return Ok(preferred_path);
|
||||
}
|
||||
|
||||
let legacy_path = legacy_root.join(DATABASE_FILE_NAME);
|
||||
|
||||
// 无旧库 → 全新安装,写标记后直接返回
|
||||
if !legacy_path.exists() {
|
||||
write_migration_marker(&marker_path);
|
||||
return Ok(preferred_path);
|
||||
}
|
||||
|
||||
// preferred 库不存在 → 首次迁移
|
||||
if !preferred_path.exists() {
|
||||
let result = migrate_or_fallback_to_legacy(&legacy_path, &preferred_path);
|
||||
if result
|
||||
.as_ref()
|
||||
.map(|p| p == &preferred_path)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
write_migration_marker(&marker_path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 两个库都存在,检查是否需要用旧库覆盖空的新库
|
||||
let preferred_signal = inspect_database_signal(&preferred_path);
|
||||
let legacy_signal = inspect_database_signal(&legacy_path);
|
||||
|
||||
if should_replace_preferred_with_legacy(
|
||||
preferred_path.as_path(),
|
||||
preferred_signal.as_ref(),
|
||||
legacy_path.as_path(),
|
||||
legacy_signal.as_ref(),
|
||||
) {
|
||||
let result = migrate_or_fallback_to_legacy(&legacy_path, &preferred_path);
|
||||
if result
|
||||
.as_ref()
|
||||
.map(|p| p == &preferred_path)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
write_migration_marker(&marker_path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// preferred 库已有用户数据,迁移完成
|
||||
write_migration_marker(&marker_path);
|
||||
Ok(preferred_path)
|
||||
}
|
||||
|
||||
fn write_migration_marker(marker_path: &Path) {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs().to_string())
|
||||
.unwrap_or_default();
|
||||
if let Err(e) = fs::write(marker_path, timestamp) {
|
||||
tracing::warn!(
|
||||
"[路径迁移] 写入迁移标记失败 {}(下次启动会重新检测): {e}",
|
||||
marker_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_or_fallback_to_legacy(
|
||||
legacy_path: &Path,
|
||||
preferred_path: &Path,
|
||||
) -> Result<PathBuf, String> {
|
||||
match migrate_legacy_database(legacy_path, preferred_path) {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
"[路径迁移] 数据库已从旧路径迁移到 {}",
|
||||
preferred_path.display()
|
||||
);
|
||||
Ok(preferred_path.to_path_buf())
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[路径迁移] 数据库迁移失败,回退旧路径 {}: {}",
|
||||
legacy_path.display(),
|
||||
error
|
||||
);
|
||||
Ok(legacy_path.to_path_buf())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_subdir_with_legacy_copy_from_roots(
|
||||
preferred_root: &Path,
|
||||
legacy_root: &Path,
|
||||
subdir: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let preferred_dir = preferred_root.join(subdir);
|
||||
fs::create_dir_all(&preferred_dir)
|
||||
.map_err(|e| format!("无法创建目录 {}: {e}", preferred_dir.display()))?;
|
||||
|
||||
// 标记文件存在 → 迁移已完成,跳过旧目录扫描
|
||||
let marker_path = preferred_root.join(MIGRATION_MARKER_FILE);
|
||||
if marker_path.exists() {
|
||||
return Ok(preferred_dir);
|
||||
}
|
||||
|
||||
let legacy_dir = legacy_root.join(subdir);
|
||||
if legacy_dir.exists() {
|
||||
copy_dir_contents_if_missing(&legacy_dir, &preferred_dir)?;
|
||||
}
|
||||
|
||||
Ok(preferred_dir)
|
||||
}
|
||||
|
||||
fn migrate_legacy_database(legacy_path: &Path, preferred_path: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = preferred_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("无法创建数据库目录 {}: {e}", parent.display()))?;
|
||||
}
|
||||
|
||||
let source = Connection::open(legacy_path)
|
||||
.map_err(|e| format!("打开旧数据库失败 {}: {e}", legacy_path.display()))?;
|
||||
source
|
||||
.busy_timeout(std::time::Duration::from_secs(5))
|
||||
.map_err(|e| format!("设置旧数据库 busy_timeout 失败: {e}"))?;
|
||||
let _ = source.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
|
||||
|
||||
backup_existing_database(preferred_path)?;
|
||||
remove_database_with_sidecars(preferred_path)?;
|
||||
|
||||
match source.backup(DatabaseName::Main, preferred_path, None) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => {
|
||||
let _ = remove_database_with_sidecars(preferred_path);
|
||||
Err(format!(
|
||||
"复制旧数据库 {} -> {} 失败: {error}",
|
||||
legacy_path.display(),
|
||||
preferred_path.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct DatabaseSignal {
|
||||
user_signal: u64,
|
||||
has_schema: bool,
|
||||
}
|
||||
|
||||
fn inspect_database_signal(path: &Path) -> Option<DatabaseSignal> {
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let conn = Connection::open(path).ok()?;
|
||||
let has_schema = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table'",
|
||||
[],
|
||||
|row| row.get::<_, u64>(0),
|
||||
)
|
||||
.ok()
|
||||
.map(|count| count > 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
let user_signal = USER_SIGNAL_TABLES
|
||||
.iter()
|
||||
.map(|table| {
|
||||
let sql = format!("SELECT COUNT(*) FROM {table}");
|
||||
conn.query_row(&sql, [], |row| row.get::<_, u64>(0))
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.sum();
|
||||
|
||||
Some(DatabaseSignal {
|
||||
user_signal,
|
||||
has_schema,
|
||||
})
|
||||
}
|
||||
|
||||
fn should_replace_preferred_with_legacy(
|
||||
preferred_path: &Path,
|
||||
preferred_signal: Option<&DatabaseSignal>,
|
||||
legacy_path: &Path,
|
||||
legacy_signal: Option<&DatabaseSignal>,
|
||||
) -> bool {
|
||||
let Some(legacy_signal) = legacy_signal else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(preferred_signal) = preferred_signal else {
|
||||
return true;
|
||||
};
|
||||
|
||||
if !preferred_signal.has_schema && legacy_signal.has_schema {
|
||||
tracing::warn!(
|
||||
"[路径迁移] 当前数据库 {} 无有效 schema,准备回退旧库 {}",
|
||||
preferred_path.display(),
|
||||
legacy_path.display()
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if preferred_signal.user_signal == 0 && legacy_signal.user_signal > 0 {
|
||||
tracing::warn!(
|
||||
"[路径迁移] 当前数据库 {} 缺少用户数据,检测到旧库 {} 含历史数据,准备自动恢复",
|
||||
preferred_path.display(),
|
||||
legacy_path.display()
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn backup_existing_database(path: &Path) -> Result<(), String> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let suffix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default();
|
||||
let backup_path = path.with_file_name(format!(
|
||||
"{DATABASE_FILE_NAME}.bootstrap-backup-{suffix}.bak"
|
||||
));
|
||||
fs::copy(path, &backup_path).map_err(|e| {
|
||||
format!(
|
||||
"备份当前数据库失败 {} -> {}: {e}",
|
||||
path.display(),
|
||||
backup_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_database_with_sidecars(path: &Path) -> Result<(), String> {
|
||||
if path.exists() {
|
||||
fs::remove_file(path)
|
||||
.map_err(|e| format!("删除旧数据库文件失败 {}: {e}", path.display()))?;
|
||||
}
|
||||
|
||||
for suffix in ["-wal", "-shm"] {
|
||||
let sidecar = PathBuf::from(format!("{}{}", path.display(), suffix));
|
||||
if sidecar.exists() {
|
||||
fs::remove_file(&sidecar)
|
||||
.map_err(|e| format!("删除数据库伴生文件失败 {}: {e}", sidecar.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_contents_if_missing(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let entries =
|
||||
fs::read_dir(from).map_err(|e| format!("读取目录失败 {}: {e}", from.display()))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("读取目录项失败 {}: {e}", from.display()))?;
|
||||
let source_path = entry.path();
|
||||
let target_path = to.join(entry.file_name());
|
||||
|
||||
if source_path.is_dir() {
|
||||
fs::create_dir_all(&target_path)
|
||||
.map_err(|e| format!("创建目录失败 {}: {e}", target_path.display()))?;
|
||||
copy_dir_contents_if_missing(&source_path, &target_path)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if target_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
fs::copy(&source_path, &target_path).map_err(|e| {
|
||||
format!(
|
||||
"复制文件失败 {} -> {}: {e}",
|
||||
source_path.display(),
|
||||
target_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn resolve_database_path_migrates_legacy_database() {
|
||||
let temp = tempdir().unwrap();
|
||||
let preferred_root = temp.path().join("appdata").join("proxycast");
|
||||
let legacy_root = temp.path().join("home").join(".proxycast");
|
||||
fs::create_dir_all(&legacy_root).unwrap();
|
||||
|
||||
let legacy_db = legacy_root.join(DATABASE_FILE_NAME);
|
||||
let conn = Connection::open(&legacy_db).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE sample (id INTEGER PRIMARY KEY, name TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO sample (name) VALUES ('proxycast')", [])
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap();
|
||||
assert_eq!(resolved, preferred_root.join(DATABASE_FILE_NAME));
|
||||
assert!(resolved.exists());
|
||||
|
||||
let migrated = Connection::open(resolved).unwrap();
|
||||
let name: String = migrated
|
||||
.query_row("SELECT name FROM sample LIMIT 1", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(name, "proxycast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_logs_dir_copies_legacy_files() {
|
||||
let temp = tempdir().unwrap();
|
||||
let preferred_root = temp.path().join("appdata").join("proxycast");
|
||||
let legacy_root = temp.path().join("home").join(".proxycast");
|
||||
let legacy_logs = legacy_root.join("logs");
|
||||
fs::create_dir_all(&legacy_logs).unwrap();
|
||||
fs::write(legacy_logs.join("proxycast.log"), "legacy log").unwrap();
|
||||
|
||||
let resolved =
|
||||
resolve_subdir_with_legacy_copy_from_roots(&preferred_root, &legacy_root, "logs")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved, preferred_root.join("logs"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(resolved.join("proxycast.log")).unwrap(),
|
||||
"legacy log"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_database_path_replaces_bootstrap_db_with_legacy_data() {
|
||||
let temp = tempdir().unwrap();
|
||||
let preferred_root = temp.path().join("appdata").join("proxycast");
|
||||
let legacy_root = temp.path().join("home").join(".proxycast");
|
||||
fs::create_dir_all(&preferred_root).unwrap();
|
||||
fs::create_dir_all(&legacy_root).unwrap();
|
||||
|
||||
let preferred_db = preferred_root.join(DATABASE_FILE_NAME);
|
||||
let preferred_conn = Connection::open(&preferred_db).unwrap();
|
||||
preferred_conn
|
||||
.execute(
|
||||
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let legacy_db = legacy_root.join(DATABASE_FILE_NAME);
|
||||
let legacy_conn = Connection::open(&legacy_db).unwrap();
|
||||
legacy_conn
|
||||
.execute(
|
||||
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
legacy_conn
|
||||
.execute(
|
||||
"CREATE TABLE agent_sessions (id INTEGER PRIMARY KEY, name TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
legacy_conn
|
||||
.execute("INSERT INTO contents (title) VALUES ('legacy')", [])
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap();
|
||||
let conn = Connection::open(resolved).unwrap();
|
||||
let count: u64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM contents", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_database_path_keeps_preferred_when_it_has_user_data() {
|
||||
let temp = tempdir().unwrap();
|
||||
let preferred_root = temp.path().join("appdata").join("proxycast");
|
||||
let legacy_root = temp.path().join("home").join(".proxycast");
|
||||
fs::create_dir_all(&preferred_root).unwrap();
|
||||
fs::create_dir_all(&legacy_root).unwrap();
|
||||
|
||||
let preferred_db = preferred_root.join(DATABASE_FILE_NAME);
|
||||
let preferred_conn = Connection::open(&preferred_db).unwrap();
|
||||
preferred_conn
|
||||
.execute(
|
||||
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
preferred_conn
|
||||
.execute("INSERT INTO contents (title) VALUES ('preferred')", [])
|
||||
.unwrap();
|
||||
|
||||
let legacy_db = legacy_root.join(DATABASE_FILE_NAME);
|
||||
let legacy_conn = Connection::open(&legacy_db).unwrap();
|
||||
legacy_conn
|
||||
.execute(
|
||||
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
legacy_conn
|
||||
.execute("INSERT INTO contents (title) VALUES ('legacy')", [])
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap();
|
||||
let conn = Connection::open(resolved).unwrap();
|
||||
let title: String = conn
|
||||
.query_row("SELECT title FROM contents LIMIT 1", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(title, "preferred");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_database_path_skips_migration_when_marker_exists() {
|
||||
let temp = tempdir().unwrap();
|
||||
let preferred_root = temp.path().join("appdata").join("proxycast");
|
||||
let legacy_root = temp.path().join("home").join(".proxycast");
|
||||
fs::create_dir_all(&preferred_root).unwrap();
|
||||
fs::create_dir_all(&legacy_root).unwrap();
|
||||
|
||||
// preferred 库为空(只有 schema)
|
||||
let preferred_db = preferred_root.join(DATABASE_FILE_NAME);
|
||||
let preferred_conn = Connection::open(&preferred_db).unwrap();
|
||||
preferred_conn
|
||||
.execute(
|
||||
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
drop(preferred_conn);
|
||||
|
||||
// 旧库有数据
|
||||
let legacy_db = legacy_root.join(DATABASE_FILE_NAME);
|
||||
let legacy_conn = Connection::open(&legacy_db).unwrap();
|
||||
legacy_conn
|
||||
.execute(
|
||||
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
legacy_conn
|
||||
.execute("INSERT INTO contents (title) VALUES ('legacy')", [])
|
||||
.unwrap();
|
||||
drop(legacy_conn);
|
||||
|
||||
// 写入标记文件 → 模拟已迁移过
|
||||
fs::write(preferred_root.join(MIGRATION_MARKER_FILE), "1700000000").unwrap();
|
||||
|
||||
// 即使旧库有数据、新库为空,也不应触发迁移
|
||||
let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap();
|
||||
assert_eq!(resolved, preferred_db);
|
||||
|
||||
let conn = Connection::open(&resolved).unwrap();
|
||||
let count: u64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM contents", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
// 新库仍为空,说明没有被旧库覆盖
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_database_path_writes_marker_after_successful_migration() {
|
||||
let temp = tempdir().unwrap();
|
||||
let preferred_root = temp.path().join("appdata").join("proxycast");
|
||||
let legacy_root = temp.path().join("home").join(".proxycast");
|
||||
fs::create_dir_all(&legacy_root).unwrap();
|
||||
|
||||
let legacy_db = legacy_root.join(DATABASE_FILE_NAME);
|
||||
let conn = Connection::open(&legacy_db).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO contents (title) VALUES ('data')", [])
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let marker_path = preferred_root.join(MIGRATION_MARKER_FILE);
|
||||
assert!(!marker_path.exists());
|
||||
|
||||
let _ = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap();
|
||||
|
||||
// 迁移成功后标记文件应存在
|
||||
assert!(marker_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_database_path_writes_marker_for_fresh_install() {
|
||||
let temp = tempdir().unwrap();
|
||||
let preferred_root = temp.path().join("appdata").join("proxycast");
|
||||
let legacy_root = temp.path().join("home").join(".proxycast");
|
||||
// 不创建 legacy_root → 模拟全新安装
|
||||
|
||||
let marker_path = preferred_root.join(MIGRATION_MARKER_FILE);
|
||||
assert!(!marker_path.exists());
|
||||
|
||||
let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap();
|
||||
assert_eq!(resolved, preferred_root.join(DATABASE_FILE_NAME));
|
||||
|
||||
// 全新安装也应写标记
|
||||
assert!(marker_path.exists());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod migration_v4;
|
||||
pub mod schema;
|
||||
pub mod system_providers;
|
||||
|
||||
use crate::app_paths;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -26,10 +27,7 @@ pub fn lock_db(db: &DbConnection) -> Result<std::sync::MutexGuard<'_, Connection
|
||||
|
||||
/// 获取数据库文件路径
|
||||
pub fn get_db_path() -> Result<PathBuf, String> {
|
||||
let home = dirs::home_dir().ok_or_else(|| "无法获取主目录".to_string())?;
|
||||
let db_dir = home.join(".proxycast");
|
||||
std::fs::create_dir_all(&db_dir).map_err(|e| format!("无法创建数据库目录 {db_dir:?}: {e}"))?;
|
||||
Ok(db_dir.join("proxycast.db"))
|
||||
app_paths::resolve_database_path()
|
||||
}
|
||||
|
||||
/// 初始化数据库连接
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
//! - `session_files`: 会话文件存储
|
||||
|
||||
pub mod app_bootstrap;
|
||||
pub mod app_paths;
|
||||
pub mod app_utils;
|
||||
pub mod data;
|
||||
pub mod logger;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! 日志管理模块
|
||||
use crate::app_paths;
|
||||
use crate::config::LoggingConfig;
|
||||
use chrono::{Duration, Local, Utc};
|
||||
use regex::Regex;
|
||||
@@ -44,10 +45,12 @@ pub struct LogStore {
|
||||
|
||||
impl Default for LogStore {
|
||||
fn default() -> Self {
|
||||
let log_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".proxycast")
|
||||
.join("logs");
|
||||
let log_dir = app_paths::resolve_logs_dir().unwrap_or_else(|_| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".proxycast")
|
||||
.join("logs")
|
||||
});
|
||||
let _ = fs::create_dir_all(&log_dir);
|
||||
let log_file = log_dir.join("proxycast.log");
|
||||
let config = LogStoreConfig::default();
|
||||
|
||||
@@ -100,15 +100,8 @@ pub struct RequestLogger {
|
||||
impl RequestLogger {
|
||||
/// 创建新的日志记录器
|
||||
pub fn new(config: LogRotationConfig) -> Result<Self, LoggerError> {
|
||||
let log_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".proxycast")
|
||||
.join("request_logs");
|
||||
|
||||
// 创建日志目录
|
||||
fs::create_dir_all(&log_dir).map_err(|e| {
|
||||
LoggerError::DirectoryCreation(format!("无法创建日志目录 {log_dir:?}: {e}"))
|
||||
})?;
|
||||
let log_dir = proxycast_core::app_paths::resolve_request_logs_dir()
|
||||
.map_err(LoggerError::DirectoryCreation)?;
|
||||
|
||||
let logger = Self {
|
||||
logs: RwLock::new(VecDeque::with_capacity(config.max_memory_logs)),
|
||||
|
||||
@@ -1006,10 +1006,8 @@ impl KiroProvider {
|
||||
.unwrap_or(false);
|
||||
if debug_enabled {
|
||||
if let Ok(json_str) = serde_json::to_string_pretty(&cw_request) {
|
||||
let debug_dir = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".proxycast")
|
||||
.join("logs");
|
||||
let debug_dir = proxycast_core::app_paths::resolve_logs_dir()
|
||||
.unwrap_or_else(|_| std::env::temp_dir().join("proxycast").join("logs"));
|
||||
let uuid_prefix = uuid::Uuid::new_v4()
|
||||
.to_string()
|
||||
.split('-')
|
||||
|
||||
@@ -1329,9 +1329,8 @@ pub async fn call_provider_openai(
|
||||
Ok(resp) => {
|
||||
let resp_str = serde_json::to_string_pretty(&resp).unwrap_or_default();
|
||||
if is_proxycast_debug_enabled() {
|
||||
let debug_dir = dirs::home_dir()
|
||||
.map(|h| h.join(".proxycast/logs"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
||||
let debug_dir = proxycast_core::app_paths::resolve_logs_dir()
|
||||
.unwrap_or_else(|_| std::env::temp_dir().join("proxycast").join("logs"));
|
||||
let _ = std::fs::create_dir_all(&debug_dir);
|
||||
let debug_file = debug_dir.join("antigravity_image_response.json");
|
||||
let _ = std::fs::write(&debug_file, &resp_str);
|
||||
@@ -1354,9 +1353,8 @@ pub async fn call_provider_openai(
|
||||
|
||||
let openai_str = serde_json::to_string_pretty(&openai_response).unwrap_or_default();
|
||||
if is_proxycast_debug_enabled() {
|
||||
let debug_dir = dirs::home_dir()
|
||||
.map(|h| h.join(".proxycast/logs"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
||||
let debug_dir = proxycast_core::app_paths::resolve_logs_dir()
|
||||
.unwrap_or_else(|_| std::env::temp_dir().join("proxycast").join("logs"));
|
||||
let _ = std::fs::create_dir_all(&debug_dir);
|
||||
let openai_debug_file =
|
||||
debug_dir.join("antigravity_image_openai_response.json");
|
||||
@@ -2887,9 +2885,9 @@ fn parse_antigravity_accumulated_response(data: &str, model: &str) -> Result<Str
|
||||
);
|
||||
|
||||
let debug_enabled = is_proxycast_debug_enabled();
|
||||
let debug_file = dirs::home_dir()
|
||||
.map(|h| h.join(".proxycast/logs/antigravity_stream_raw.txt"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/antigravity_stream_raw.txt"));
|
||||
let debug_file = proxycast_core::app_paths::resolve_logs_dir()
|
||||
.map(|dir| dir.join("antigravity_stream_raw.txt"))
|
||||
.unwrap_or_else(|_| std::env::temp_dir().join("antigravity_stream_raw.txt"));
|
||||
|
||||
if debug_enabled {
|
||||
if let Some(debug_dir) = debug_file.parent() {
|
||||
|
||||
@@ -11,10 +11,11 @@ use proxycast_agent::tools::heartbeat_tool::{
|
||||
HeartbeatCycleResult, HeartbeatExecutionRecord, HeartbeatService, HeartbeatStatus,
|
||||
HeartbeatTaskPreview, HeartbeatToolError,
|
||||
};
|
||||
use proxycast_core::app_paths;
|
||||
use proxycast_core::database::dao::heartbeat::HeartbeatExecution;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri::AppHandle;
|
||||
|
||||
/// Heartbeat Service 适配器
|
||||
///
|
||||
@@ -35,9 +36,7 @@ impl HeartbeatServiceAdapter {
|
||||
|
||||
/// 获取应用数据目录
|
||||
fn app_data_dir(&self) -> Result<PathBuf, HeartbeatToolError> {
|
||||
self.app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
app_paths::preferred_data_dir()
|
||||
.map_err(|e| HeartbeatToolError::IoError(format!("获取应用数据目录失败: {}", e)))
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::fs;
|
||||
use std::io::{Read, Seek, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri::AppHandle;
|
||||
use zip::write::FileOptions;
|
||||
use zip::{CompressionMethod, ZipWriter};
|
||||
|
||||
@@ -522,9 +522,7 @@ pub async fn export_support_bundle(
|
||||
get_log_storage_diagnostics_from_path(log_file_path.clone(), in_memory_log_count);
|
||||
let persisted_log_tail = read_persisted_logs_tail_from_path(log_file_path, 200)?;
|
||||
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
let app_data_dir = proxycast_core::app_paths::preferred_data_dir()
|
||||
.ok()
|
||||
.or_else(guess_proxycast_app_data_dir);
|
||||
let config_path = guess_proxycast_config_path();
|
||||
@@ -564,7 +562,7 @@ pub async fn export_support_bundle(
|
||||
}
|
||||
|
||||
fn guess_proxycast_app_data_dir() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|dir| dir.join("proxycast"))
|
||||
proxycast_core::app_paths::preferred_data_dir().ok()
|
||||
}
|
||||
|
||||
fn guess_proxycast_config_path() -> Option<PathBuf> {
|
||||
|
||||
@@ -757,7 +757,7 @@ pub fn run() {
|
||||
service.set_app_handle(app_handle.clone());
|
||||
|
||||
if service.get_config().enabled {
|
||||
let app_data_dir = match app_handle.path().app_data_dir() {
|
||||
let app_data_dir = match proxycast_core::app_paths::preferred_data_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
tracing::error!("[启动] 无法获取应用数据目录: {}", e);
|
||||
|
||||
@@ -6,7 +6,7 @@ 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::AppHandle;
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1119,9 +1119,8 @@ fn get_download_directory(app_handle: &AppHandle) -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
// 回退到应用数据目录
|
||||
let app_data_dir = app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
let _ = app_handle;
|
||||
let app_data_dir = proxycast_core::app_paths::preferred_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
|
||||
let download_dir = app_data_dir.join("downloads");
|
||||
|
||||
@@ -12,12 +12,12 @@ use crate::services::heartbeat_service::{
|
||||
CycleResult, HeartbeatServiceState, HeartbeatStatus, HeartbeatTaskPreview,
|
||||
};
|
||||
use crate::AppState;
|
||||
use proxycast_core::app_paths;
|
||||
use proxycast_core::config::{DeliveryConfig, HeartbeatSecurityConfig, TaskSchedule};
|
||||
use proxycast_core::database::dao::heartbeat::HeartbeatExecution;
|
||||
use proxycast_websocket::handlers::{RpcHandler, RpcHandlerState};
|
||||
use proxycast_websocket::protocol::{CronHealthResult, GatewayRpcRequest, RpcMethod};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::Manager;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ========== 配置响应类型 ==========
|
||||
@@ -135,9 +135,7 @@ pub async fn update_heartbeat_config(
|
||||
// 处理启停逻辑
|
||||
if config.enabled && !was_enabled {
|
||||
service.set_app_handle(app.clone());
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
let app_data_dir = app_paths::preferred_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let self_ref = hb_state.0.clone();
|
||||
service.start(app_data_dir, self_ref).await?;
|
||||
@@ -162,12 +160,10 @@ pub async fn get_heartbeat_status(
|
||||
#[tauri::command]
|
||||
pub async fn get_heartbeat_tasks(
|
||||
hb_state: tauri::State<'_, HeartbeatServiceState>,
|
||||
app: tauri::AppHandle,
|
||||
_app: tauri::AppHandle,
|
||||
) -> Result<Vec<HeartbeatTaskPreview>, String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let app_data_dir =
|
||||
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let service = hb_state.0.read().await;
|
||||
service.preview_tasks(&app_data_dir)
|
||||
}
|
||||
@@ -303,11 +299,12 @@ pub async fn get_task_templates() -> Result<Vec<TaskTemplate>, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn apply_task_template(template_id: String, app: tauri::AppHandle) -> Result<(), String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
pub async fn apply_task_template(
|
||||
template_id: String,
|
||||
_app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let app_data_dir =
|
||||
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
|
||||
let template = TaskTemplateRegistry::get_template_by_id(&template_id)
|
||||
.ok_or_else(|| format!("模板不存在: {}", template_id))?;
|
||||
@@ -320,17 +317,15 @@ pub async fn apply_task_template(template_id: String, app: tauri::AppHandle) ->
|
||||
#[tauri::command]
|
||||
pub async fn add_heartbeat_task(
|
||||
hb_state: tauri::State<'_, HeartbeatServiceState>,
|
||||
app: tauri::AppHandle,
|
||||
_app: tauri::AppHandle,
|
||||
description: String,
|
||||
priority: Option<u8>,
|
||||
timeout_secs: Option<u64>,
|
||||
once: Option<bool>,
|
||||
model: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let app_data_dir =
|
||||
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let service = hb_state.0.read().await;
|
||||
service.add_task(
|
||||
&app_data_dir,
|
||||
@@ -345,13 +340,11 @@ pub async fn add_heartbeat_task(
|
||||
#[tauri::command]
|
||||
pub async fn delete_heartbeat_task(
|
||||
hb_state: tauri::State<'_, HeartbeatServiceState>,
|
||||
app: tauri::AppHandle,
|
||||
_app: tauri::AppHandle,
|
||||
index: usize,
|
||||
) -> Result<(), String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let app_data_dir =
|
||||
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let service = hb_state.0.read().await;
|
||||
service.delete_task(&app_data_dir, index)
|
||||
}
|
||||
@@ -359,7 +352,7 @@ pub async fn delete_heartbeat_task(
|
||||
#[tauri::command]
|
||||
pub async fn update_heartbeat_task(
|
||||
hb_state: tauri::State<'_, HeartbeatServiceState>,
|
||||
app: tauri::AppHandle,
|
||||
_app: tauri::AppHandle,
|
||||
index: usize,
|
||||
description: String,
|
||||
priority: Option<u8>,
|
||||
@@ -367,10 +360,8 @@ pub async fn update_heartbeat_task(
|
||||
once: Option<bool>,
|
||||
model: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let app_data_dir =
|
||||
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let service = hb_state.0.read().await;
|
||||
service.update_task(
|
||||
&app_data_dir,
|
||||
@@ -388,7 +379,7 @@ pub async fn update_heartbeat_task(
|
||||
#[tauri::command]
|
||||
pub async fn generate_content_creator_tasks(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: tauri::AppHandle,
|
||||
_app: tauri::AppHandle,
|
||||
) -> Result<usize, String> {
|
||||
use crate::services::heartbeat_service::templates::ContentCreatorTaskGenerator;
|
||||
|
||||
@@ -401,10 +392,8 @@ pub async fn generate_content_creator_tasks(
|
||||
let count = tasks.len();
|
||||
|
||||
if count > 0 {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let app_data_dir =
|
||||
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
ContentCreatorTaskGenerator::append_to_heartbeat(tasks, &app_data_dir)?;
|
||||
}
|
||||
|
||||
@@ -418,10 +407,8 @@ pub async fn trigger_heartbeat_now(
|
||||
hb_state: tauri::State<'_, HeartbeatServiceState>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<CycleResult, String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let app_data_dir =
|
||||
app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
|
||||
let result = {
|
||||
let service = hb_state.0.read().await;
|
||||
|
||||
@@ -1698,17 +1698,15 @@ fn resolve_profile_data_dir_from_base(base_dir: &Path, profile_key: &str) -> Pat
|
||||
}
|
||||
|
||||
fn resolve_profile_data_dir(app: &AppHandle, profile_key: &str) -> Result<PathBuf, String> {
|
||||
let base_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
let _ = app;
|
||||
let base_dir = proxycast_core::app_paths::preferred_data_dir()
|
||||
.map_err(|e| format!("获取应用数据目录失败: {e}"))?;
|
||||
Ok(resolve_profile_data_dir_from_base(&base_dir, profile_key))
|
||||
}
|
||||
|
||||
fn resolve_chrome_profile_data_dir(app: &AppHandle, profile_key: &str) -> Result<PathBuf, String> {
|
||||
let base_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
let _ = app;
|
||||
let base_dir = proxycast_core::app_paths::preferred_data_dir()
|
||||
.map_err(|e| format!("获取应用数据目录失败: {e}"))?;
|
||||
Ok(base_dir
|
||||
.join("chrome_profiles")
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::path::PathBuf;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::io::Write;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::path::Path;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::process::Command;
|
||||
@@ -18,6 +20,9 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
#[cfg(target_os = "windows")]
|
||||
use winreg::{enums::*, RegKey};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct WindowsStartupCheck {
|
||||
pub key: String,
|
||||
@@ -129,7 +134,7 @@ pub fn collect_windows_startup_diagnostics(app: &AppHandle) -> WindowsStartupDia
|
||||
let mut errors = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
let app_data_dir: Option<PathBuf> = app.path().app_data_dir().ok();
|
||||
let app_data_dir: Option<PathBuf> = proxycast_core::app_paths::preferred_data_dir().ok();
|
||||
let home_dir = dirs::home_dir();
|
||||
let legacy_proxycast_dir = home_dir.clone().map(|home| home.join(".proxycast"));
|
||||
let db_path = crate::database::get_db_path().ok();
|
||||
@@ -171,23 +176,27 @@ pub fn collect_windows_startup_diagnostics(app: &AppHandle) -> WindowsStartupDia
|
||||
}
|
||||
|
||||
match &legacy_proxycast_dir {
|
||||
Some(path) => match ensure_dir_writable(path) {
|
||||
Some(path) if path.exists() => match ensure_existing_dir_writable(path) {
|
||||
Ok(()) => checks.push(ok_check(
|
||||
"legacy_proxycast_dir",
|
||||
format!("用户目录数据根可写: {}", path.display()),
|
||||
format!("检测到旧版数据目录且可访问: {}", path.display()),
|
||||
)),
|
||||
Err(error) => {
|
||||
errors.push(format!("用户目录数据根不可写: {}", path.display()));
|
||||
checks.push(error_check(
|
||||
warnings.push(format!("旧版数据目录不可访问: {}", path.display()));
|
||||
checks.push(warn_check(
|
||||
"legacy_proxycast_dir",
|
||||
format!("用户目录数据根不可写: {}", path.display()),
|
||||
format!("旧版数据目录不可访问: {}", path.display()),
|
||||
Some(error),
|
||||
));
|
||||
}
|
||||
},
|
||||
Some(path) => checks.push(ok_check(
|
||||
"legacy_proxycast_dir",
|
||||
format!("未检测到旧版数据目录: {}", path.display()),
|
||||
)),
|
||||
None => {
|
||||
errors.push("无法解析用户 Home 目录".to_string());
|
||||
checks.push(error_check(
|
||||
warnings.push("无法解析用户 Home 目录".to_string());
|
||||
checks.push(warn_check(
|
||||
"legacy_proxycast_dir",
|
||||
"无法解析用户 Home 目录".to_string(),
|
||||
None,
|
||||
@@ -439,6 +448,25 @@ fn ensure_dir_writable(path: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ensure_existing_dir_writable(path: &Path) -> Result<(), String> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let probe = path.join("proxycast-write-test.tmp");
|
||||
let mut file = std::fs::File::create(&probe)
|
||||
.map_err(|e| format!("创建测试文件失败 {}: {e}", probe.display()))?;
|
||||
file.write_all(b"proxycast")
|
||||
.map_err(|e| format!("写入测试文件失败 {}: {e}", probe.display()))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("刷新测试文件失败 {}: {e}", probe.display()))?;
|
||||
std::fs::remove_file(&probe)
|
||||
.map_err(|e| format!("删除测试文件失败 {}: {e}", probe.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_database_file(path: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -447,11 +475,16 @@ fn check_database_file(path: &Path) -> Result<(), String> {
|
||||
|
||||
let conn = rusqlite::Connection::open(path)
|
||||
.map_err(|e| format!("打开数据库失败 {}: {e}", path.display()))?;
|
||||
conn.execute("PRAGMA user_version", [])
|
||||
conn.query_row("PRAGMA user_version", [], |_| Ok(()))
|
||||
.map_err(|e| format!("执行数据库探测失败 {}: {e}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn apply_windows_no_window(command: &mut Command) {
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn detect_webview2_runtime_version() -> Option<String> {
|
||||
const VALUE_NAME: &str = "pv";
|
||||
@@ -579,7 +612,9 @@ fn detect_shell_availability() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
let pwsh_check = Command::new("pwsh").args(["-v"]).output();
|
||||
let mut pwsh_check = Command::new("pwsh");
|
||||
apply_windows_no_window(&mut pwsh_check);
|
||||
let pwsh_check = pwsh_check.args(["-v"]).output();
|
||||
if pwsh_check
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
|
||||
@@ -7,6 +7,8 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::ffi::OsString;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::OnceLock;
|
||||
@@ -28,6 +30,8 @@ const NODE_MIN_VERSION: (u64, u64, u64) = (22, 0, 0);
|
||||
const OPENCLAW_PROGRESS_LOG_LIMIT: usize = 400;
|
||||
const OPENCLAW_INSTALLER_USER_AGENT: &str = "ProxyCast-OpenClaw";
|
||||
const OPENCLAW_TEMP_CARGO_CHECK_DIR: &str = "/tmp/proxycast-cargo-check";
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -1518,9 +1522,8 @@ fn openclaw_proxycast_config_path() -> PathBuf {
|
||||
}
|
||||
|
||||
fn openclaw_installer_download_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
let _ = app;
|
||||
let app_data_dir = proxycast_core::app_paths::preferred_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let dir = app_data_dir.join("downloads").join("openclaw-installers");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("创建 OpenClaw 下载目录失败: {e}"))?;
|
||||
@@ -2200,6 +2203,8 @@ fn prepend_path(dir: &Path) -> Option<OsString> {
|
||||
}
|
||||
|
||||
fn apply_binary_runtime_path(command: &mut Command, binary_path: &str) {
|
||||
apply_windows_no_window(command);
|
||||
|
||||
let Some(bin_dir) = Path::new(binary_path).parent() else {
|
||||
return;
|
||||
};
|
||||
@@ -2208,9 +2213,18 @@ fn apply_binary_runtime_path(command: &mut Command, binary_path: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_windows_no_window(_command: &mut Command) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
_command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_command_in_shell(command_name: &str) -> Result<Option<String>, String> {
|
||||
if cfg!(target_os = "windows") {
|
||||
let output = Command::new("cmd")
|
||||
let mut command = Command::new("cmd");
|
||||
apply_windows_no_window(&mut command);
|
||||
let output = command
|
||||
.arg("/C")
|
||||
.arg("where")
|
||||
.arg(command_name)
|
||||
@@ -2384,7 +2398,9 @@ fn sibling_node_path(command_path: &Path) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
async fn read_binary_semver(path: &Path) -> Option<(u64, u64, u64)> {
|
||||
let output = Command::new(path)
|
||||
let mut command = Command::new(path);
|
||||
apply_windows_no_window(&mut command);
|
||||
let output = command
|
||||
.arg("--version")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -2494,6 +2510,8 @@ fn spawn_shell_command(command_line: &str) -> Result<Child, String> {
|
||||
cmd
|
||||
};
|
||||
|
||||
apply_windows_no_window(&mut command);
|
||||
|
||||
command
|
||||
.env("NO_COLOR", "1")
|
||||
.env("CLICOLOR", "0")
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri::Emitter;
|
||||
|
||||
use proxycast_terminal::emitter::TerminalEventEmit;
|
||||
|
||||
@@ -23,9 +23,7 @@ impl TerminalEventEmit for TauriEmitter {
|
||||
}
|
||||
|
||||
fn app_data_dir(&self) -> Result<PathBuf, String> {
|
||||
self.0
|
||||
.path()
|
||||
.app_data_dir()
|
||||
proxycast_core::app_paths::preferred_data_dir()
|
||||
.map_err(|e| format!("获取应用数据目录失败: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,13 +236,13 @@ fn handle_open_log_dir<R: Runtime>(app: &AppHandle<R>) {
|
||||
info!("[托盘] 用户请求打开日志目录");
|
||||
|
||||
// 获取日志目录路径
|
||||
let log_dir = if let Ok(data_dir) = app.path().app_data_dir() {
|
||||
data_dir.join("logs")
|
||||
} else if let Some(home) = dirs::home_dir() {
|
||||
home.join(".proxycast").join("logs")
|
||||
} else {
|
||||
error!("[托盘] 无法确定日志目录路径");
|
||||
return;
|
||||
let _ = app;
|
||||
let log_dir = match proxycast_core::app_paths::resolve_logs_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(error) => {
|
||||
error!("[托盘] 无法确定日志目录路径: {}", error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 确保目录存在
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.83.1",
|
||||
"version": "0.83.2",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+5
-1
@@ -564,7 +564,10 @@ function AppContent() {
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<TerminalWorkspace onNavigate={handleNavigate} />
|
||||
<TerminalWorkspace
|
||||
onNavigate={handleNavigate}
|
||||
isActive={currentPage === "terminal"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FullscreenWrapper $isActive={currentPage === "sysinfo"}>
|
||||
@@ -637,6 +640,7 @@ function AppContent() {
|
||||
<OpenClawPage
|
||||
onNavigate={handleNavigate}
|
||||
pageParams={pageParams as OpenClawPageParams}
|
||||
isActive={currentPage === "openclaw"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ afterEach(() => {
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -75,6 +76,28 @@ function render(content: string, isStreaming = false): HTMLDivElement {
|
||||
return container;
|
||||
}
|
||||
|
||||
function renderHarness(content: string, isStreaming = false) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const rerender = (nextContent: string, nextIsStreaming = isStreaming) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<MarkdownRenderer
|
||||
content={nextContent}
|
||||
isStreaming={nextIsStreaming}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
rerender(content, isStreaming);
|
||||
|
||||
mountedRoots.push({ container, root });
|
||||
return { container, rerender };
|
||||
}
|
||||
|
||||
describe("MarkdownRenderer", () => {
|
||||
it("非流式时应保留 raw html 渲染能力", () => {
|
||||
const content = [
|
||||
@@ -105,4 +128,21 @@ describe("MarkdownRenderer", () => {
|
||||
expect(container.querySelector(".rendered-html")).toBeNull();
|
||||
expect(container.textContent).toContain("结尾文本");
|
||||
});
|
||||
|
||||
it("流式结束后应立即恢复完整 raw html 渲染", () => {
|
||||
vi.useFakeTimers();
|
||||
const content = [
|
||||
"A".repeat(2_200),
|
||||
"",
|
||||
'<div class="rendered-html">原始 HTML</div>',
|
||||
"",
|
||||
"结尾文本",
|
||||
].join("\n");
|
||||
|
||||
const { container, rerender } = renderHarness(content, true);
|
||||
expect(container.querySelector(".rendered-html")).toBeNull();
|
||||
|
||||
rerender(content, false);
|
||||
expect(container.querySelector(".rendered-html")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,10 +11,13 @@ import { Copy, Check } from "lucide-react";
|
||||
import { parseA2UIJson } from "@/components/content-creator/a2ui/parser";
|
||||
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
|
||||
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
|
||||
import { useDebouncedValue } from "@/lib/artifact/hooks/useDebouncedValue";
|
||||
import { ArtifactPlaceholder } from "./ArtifactPlaceholder";
|
||||
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
|
||||
|
||||
const STREAMING_LIGHT_RENDER_THRESHOLD = 2_000;
|
||||
const STREAMING_LIGHT_RENDER_DEBOUNCE_MS = 48;
|
||||
const STREAMING_STANDARD_RENDER_DEBOUNCE_MS = 24;
|
||||
|
||||
// Custom styles for markdown content to match Cherry Studio
|
||||
const MarkdownContainer = styled.div`
|
||||
@@ -249,6 +252,13 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
|
||||
const [copied, setCopied] = React.useState<string | null>(null);
|
||||
const useLightweightStreamingRender =
|
||||
isStreaming && content.length >= STREAMING_LIGHT_RENDER_THRESHOLD;
|
||||
const debouncedStreamingContent = useDebouncedValue(
|
||||
content,
|
||||
useLightweightStreamingRender
|
||||
? STREAMING_LIGHT_RENDER_DEBOUNCE_MS
|
||||
: STREAMING_STANDARD_RENDER_DEBOUNCE_MS,
|
||||
);
|
||||
const renderContent = isStreaming ? debouncedStreamingContent : content;
|
||||
|
||||
const remarkPlugins = React.useMemo(
|
||||
() =>
|
||||
@@ -272,12 +282,12 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
|
||||
// 匹配 markdown 图片语法中的 base64 data URL
|
||||
const base64ImageRegex =
|
||||
/!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)/g;
|
||||
let result = content;
|
||||
let result = renderContent;
|
||||
const images: { alt: string; src: string; placeholder: string }[] = [];
|
||||
|
||||
let match;
|
||||
let index = 0;
|
||||
while ((match = base64ImageRegex.exec(content)) !== null) {
|
||||
while ((match = base64ImageRegex.exec(renderContent)) !== null) {
|
||||
const placeholder = `__BASE64_IMAGE_${index}__`;
|
||||
images.push({
|
||||
alt: match[1] || "Generated Image",
|
||||
@@ -289,7 +299,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
|
||||
}
|
||||
|
||||
return { text: result, images };
|
||||
}, [content]);
|
||||
}, [renderContent]);
|
||||
|
||||
// 渲染 base64 图片
|
||||
const renderBase64Images = () => {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { StreamingRenderer } from "./StreamingRenderer";
|
||||
import type { ContentPart } from "../types";
|
||||
|
||||
const parseAIResponseMock = vi.fn();
|
||||
|
||||
vi.mock("@/components/content-creator/a2ui/parser", () => ({
|
||||
parseAIResponse: (...args: unknown[]) => parseAIResponseMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/artifact/hooks/useDebouncedValue", () => ({
|
||||
useDebouncedValue: <T,>(value: T) => value,
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownRenderer", () => ({
|
||||
MarkdownRenderer: ({ content }: { content: string }) => (
|
||||
<div data-testid="markdown-renderer">{content}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./A2UITaskCard", () => ({
|
||||
A2UITaskCard: () => <div data-testid="a2ui-card" />,
|
||||
A2UITaskLoadingCard: () => <div data-testid="a2ui-loading-card" />,
|
||||
}));
|
||||
|
||||
vi.mock("./ToolCallDisplay", () => ({
|
||||
ToolCallList: () => <div data-testid="tool-call-list" />,
|
||||
ToolCallItem: () => <div data-testid="tool-call-item" />,
|
||||
}));
|
||||
|
||||
vi.mock("./DecisionPanel", () => ({
|
||||
DecisionPanel: () => <div data-testid="decision-panel" />,
|
||||
}));
|
||||
|
||||
interface MountedHarness {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
}
|
||||
|
||||
const mountedRoots: MountedHarness[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
parseAIResponseMock.mockImplementation((content: string) => ({
|
||||
parts: content.trim() ? [{ type: "text", content: content.trim() }] : [],
|
||||
hasA2UI: false,
|
||||
hasWriteFile: false,
|
||||
hasPending: false,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) break;
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderHarness(props: {
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
contentParts?: ContentPart[];
|
||||
}) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const rerender = (nextProps: typeof props) => {
|
||||
act(() => {
|
||||
root.render(<StreamingRenderer {...nextProps} />);
|
||||
});
|
||||
};
|
||||
|
||||
rerender(props);
|
||||
mountedRoots.push({ container, root });
|
||||
|
||||
return { container, rerender };
|
||||
}
|
||||
|
||||
describe("StreamingRenderer", () => {
|
||||
it("纯文本内容应短路跳过结构化解析", () => {
|
||||
renderHarness({
|
||||
content: "这是普通文本输出,不包含结构化标签。",
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
expect(parseAIResponseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("交错内容重复渲染时应复用已缓存解析结果", () => {
|
||||
const structuredText = '<write_file path="demo.md">hello</write_file>';
|
||||
parseAIResponseMock.mockImplementation((content: string) => {
|
||||
if (content === structuredText) {
|
||||
return {
|
||||
parts: [
|
||||
{
|
||||
type: "write_file",
|
||||
content: "hello",
|
||||
filePath: "demo.md",
|
||||
},
|
||||
],
|
||||
hasA2UI: false,
|
||||
hasWriteFile: true,
|
||||
hasPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
parts: content.trim()
|
||||
? [{ type: "text", content: content.trim() }]
|
||||
: [],
|
||||
hasA2UI: false,
|
||||
hasWriteFile: false,
|
||||
hasPending: false,
|
||||
};
|
||||
});
|
||||
const contentParts: ContentPart[] = [
|
||||
{ type: "text", text: structuredText },
|
||||
{ type: "text", text: "普通文本" },
|
||||
];
|
||||
|
||||
const { rerender } = renderHarness({
|
||||
content: structuredText,
|
||||
contentParts,
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
expect(parseAIResponseMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({
|
||||
content: structuredText,
|
||||
contentParts: [...contentParts],
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
expect(parseAIResponseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -8,16 +8,25 @@
|
||||
import React, { memo, useMemo, useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown, Lightbulb, FileText } from "lucide-react";
|
||||
import { useDebouncedValue } from "@/lib/artifact/hooks/useDebouncedValue";
|
||||
import { MarkdownRenderer } from "./MarkdownRenderer";
|
||||
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
|
||||
import { ToolCallList, ToolCallItem } from "./ToolCallDisplay";
|
||||
import { DecisionPanel } from "./DecisionPanel";
|
||||
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
|
||||
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
|
||||
import type {
|
||||
A2UIFormData,
|
||||
ParseResult,
|
||||
ParsedMessageContent,
|
||||
} from "@/components/content-creator/a2ui/types";
|
||||
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
|
||||
import type { ToolCallState } from "@/lib/api/agent";
|
||||
import type { ContentPart, ActionRequired, ConfirmResponse } from "../types";
|
||||
|
||||
const STRUCTURED_CONTENT_HINT_RE = /<a2ui|```\s*a2ui|<write_file|<document/i;
|
||||
const STRUCTURED_PARSE_CACHE_LIMIT = 64;
|
||||
const STREAMING_STRUCTURED_PARSE_DEBOUNCE_MS = 48;
|
||||
|
||||
// ============ 思考内容组件 ============
|
||||
|
||||
interface ThinkingBlockProps {
|
||||
@@ -65,6 +74,64 @@ const StreamingCursor: React.FC = () => (
|
||||
/>
|
||||
);
|
||||
|
||||
const EMPTY_PARSE_RESULT: ParseResult = {
|
||||
parts: [],
|
||||
hasA2UI: false,
|
||||
hasWriteFile: false,
|
||||
hasPending: false,
|
||||
};
|
||||
|
||||
function hasStructuredContentHint(text: string): boolean {
|
||||
return STRUCTURED_CONTENT_HINT_RE.test(text);
|
||||
}
|
||||
|
||||
function createPlainTextParts(text: string): ParsedMessageContent[] {
|
||||
const trimmed = text.trim();
|
||||
return trimmed ? [{ type: "text", content: trimmed }] : [];
|
||||
}
|
||||
|
||||
function parseStructuredContent(
|
||||
text: string,
|
||||
isStreaming: boolean,
|
||||
): ParseResult {
|
||||
if (!text.trim()) {
|
||||
return EMPTY_PARSE_RESULT;
|
||||
}
|
||||
|
||||
if (!hasStructuredContentHint(text)) {
|
||||
return {
|
||||
parts: createPlainTextParts(text),
|
||||
hasA2UI: false,
|
||||
hasWriteFile: false,
|
||||
hasPending: false,
|
||||
};
|
||||
}
|
||||
|
||||
return parseAIResponse(text, isStreaming);
|
||||
}
|
||||
|
||||
function getCachedStructuredParse(
|
||||
cacheRef: React.MutableRefObject<Map<string, ParseResult>>,
|
||||
text: string,
|
||||
isStreaming: boolean,
|
||||
): ParseResult {
|
||||
const key = `${isStreaming ? "stream" : "static"}:${text}`;
|
||||
const cached = cacheRef.current.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const parsed = parseStructuredContent(text, isStreaming);
|
||||
if (cacheRef.current.size >= STRUCTURED_PARSE_CACHE_LIMIT) {
|
||||
const oldestKey = cacheRef.current.keys().next().value;
|
||||
if (oldestKey) {
|
||||
cacheRef.current.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
cacheRef.current.set(key, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// ============ 流式文本组件(逐字符动画) ============
|
||||
|
||||
interface StreamingTextProps {
|
||||
@@ -113,6 +180,7 @@ const StreamingText: React.FC<StreamingTextProps> = memo(
|
||||
const displayIndexRef = useRef(0);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
const prevTextRef = useRef("");
|
||||
const parseCacheRef = useRef<Map<string, ParseResult>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
// 如果不是流式输出,直接显示完整文本
|
||||
@@ -198,12 +266,27 @@ const StreamingText: React.FC<StreamingTextProps> = memo(
|
||||
|
||||
const shouldShowCursor =
|
||||
isStreaming && showCursor && displayIndexRef.current < text.length;
|
||||
const containsStructuredContent = useMemo(
|
||||
() => hasStructuredContentHint(displayText),
|
||||
[displayText],
|
||||
);
|
||||
const debouncedStructuredText = useDebouncedValue(
|
||||
displayText,
|
||||
isStreaming && containsStructuredContent
|
||||
? STREAMING_STRUCTURED_PARSE_DEBOUNCE_MS
|
||||
: 0,
|
||||
);
|
||||
const parsedSourceText =
|
||||
isStreaming && containsStructuredContent
|
||||
? debouncedStructuredText
|
||||
: displayText;
|
||||
|
||||
// 使用 parseAIResponse 解析内容,以正确处理 a2ui 代码块
|
||||
// 这比依赖 MarkdownRenderer 的 pre 组件更可靠
|
||||
const parsedContent = useMemo(
|
||||
() => parseAIResponse(displayText, isStreaming),
|
||||
[displayText, isStreaming],
|
||||
() =>
|
||||
getCachedStructuredParse(parseCacheRef, parsedSourceText, isStreaming),
|
||||
[parsedSourceText, isStreaming],
|
||||
);
|
||||
|
||||
// 渲染解析后的内容
|
||||
@@ -390,16 +473,38 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
}) => {
|
||||
// 判断是否使用交错显示模式
|
||||
const useInterleavedMode = contentParts && contentParts.length > 0;
|
||||
const parseCacheRef = useRef<Map<string, ParseResult>>(new Map());
|
||||
|
||||
// 解析思考内容(仅在非交错模式下使用)
|
||||
const { visibleText, thinkingText } = useMemo(
|
||||
() => parseThinkingContent(content),
|
||||
[content],
|
||||
);
|
||||
const containsStructuredContent = useMemo(
|
||||
() => hasStructuredContentHint(visibleText),
|
||||
[visibleText],
|
||||
);
|
||||
const debouncedVisibleText = useDebouncedValue(
|
||||
visibleText,
|
||||
isStreaming && containsStructuredContent
|
||||
? STREAMING_STRUCTURED_PARSE_DEBOUNCE_MS
|
||||
: 0,
|
||||
);
|
||||
const parsedVisibleText =
|
||||
isStreaming && containsStructuredContent
|
||||
? debouncedVisibleText
|
||||
: visibleText;
|
||||
|
||||
// 解析 A2UI 和 write_file 内容
|
||||
const parsedContent = useMemo(() => {
|
||||
const result = parseAIResponse(visibleText, isStreaming);
|
||||
if (useInterleavedMode) {
|
||||
return EMPTY_PARSE_RESULT;
|
||||
}
|
||||
const result = getCachedStructuredParse(
|
||||
parseCacheRef,
|
||||
parsedVisibleText,
|
||||
isStreaming,
|
||||
);
|
||||
// 添加调试日志
|
||||
if (result.hasWriteFile) {
|
||||
console.log(
|
||||
@@ -410,7 +515,7 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [visibleText, isStreaming]);
|
||||
}, [parsedVisibleText, isStreaming, useInterleavedMode]);
|
||||
|
||||
// 处理文件写入 - 使用 ref 来追踪已处理的内容
|
||||
const processedWriteFilesRef = useRef<Set<string>>(new Set());
|
||||
@@ -475,7 +580,11 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
if (!partText) return null;
|
||||
|
||||
// 解析 write_file 标签
|
||||
const partParsed = parseAIResponse(partText, isStreaming);
|
||||
const partParsed = getCachedStructuredParse(
|
||||
parseCacheRef,
|
||||
partText,
|
||||
isStreaming,
|
||||
);
|
||||
const isLastPart = index === contentParts.length - 1;
|
||||
|
||||
// 添加调试日志
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
isValidSessionId,
|
||||
resolveRestorableSessionId,
|
||||
} from "../utils/sessionRecovery";
|
||||
import { createStreamDiagnosticsReporter } from "../utils/streamDiagnostics";
|
||||
|
||||
/** 话题(会话)信息 */
|
||||
export interface Topic {
|
||||
@@ -269,6 +270,9 @@ interface UseAgentChatOptions {
|
||||
|
||||
export function useAgentChat(options: UseAgentChatOptions) {
|
||||
const { systemPrompt, onWriteFile, workspaceId } = options;
|
||||
const streamDiagnosticsRef = useRef(
|
||||
createStreamDiagnosticsReporter("useAgentChat"),
|
||||
);
|
||||
|
||||
const getRequiredWorkspaceId = (): string => {
|
||||
const resolvedWorkspaceId = workspaceId?.trim();
|
||||
@@ -686,7 +690,12 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
allProxycastSkills
|
||||
.filter((skill) => skill.installed)
|
||||
.forEach((skill) => {
|
||||
const skillName = (skill.directory || skill.key || skill.name || "").trim();
|
||||
const skillName = (
|
||||
skill.directory ||
|
||||
skill.key ||
|
||||
skill.name ||
|
||||
""
|
||||
).trim();
|
||||
if (!skillName) return;
|
||||
detailsByName.set(skillName, {
|
||||
name: skillName,
|
||||
@@ -984,13 +993,21 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
console.log(
|
||||
`[AgentChat] 设置事件监听器: ${eventName}, sessionId: ${activeSessionId}`,
|
||||
);
|
||||
streamDiagnosticsRef.current.start({
|
||||
sessionId: activeSessionId,
|
||||
eventName,
|
||||
assistantMessageId: assistantMsgId,
|
||||
source: "sendMessage",
|
||||
});
|
||||
unlisten = await safeListen<StreamEvent>(eventName, (event) => {
|
||||
console.log("[AgentChat] 收到事件:", eventName, event.payload);
|
||||
const data = parseStreamEvent(event.payload);
|
||||
if (!data) {
|
||||
console.warn("[AgentChat] 解析事件失败:", event.payload);
|
||||
streamDiagnosticsRef.current.recordInvalidEvent(event.payload);
|
||||
return;
|
||||
}
|
||||
streamDiagnosticsRef.current.record(data);
|
||||
console.log("[AgentChat] 解析后数据:", data);
|
||||
|
||||
switch (data.type) {
|
||||
@@ -1085,6 +1102,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
unlistenRef.current = null;
|
||||
currentAssistantMsgIdRef.current = null;
|
||||
currentStreamingSessionIdRef.current = null;
|
||||
streamDiagnosticsRef.current.markDone();
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
@@ -1133,6 +1151,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
unlistenRef.current = null;
|
||||
currentAssistantMsgIdRef.current = null;
|
||||
currentStreamingSessionIdRef.current = null;
|
||||
streamDiagnosticsRef.current.markError(data.message);
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
@@ -1354,6 +1373,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[AgentChat] Send failed:", error);
|
||||
streamDiagnosticsRef.current.markError(String(error));
|
||||
toast.error(`发送失败: ${error}`, {
|
||||
id: `send-error-${Date.now()}`,
|
||||
duration: 8000,
|
||||
@@ -1505,7 +1525,8 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
})
|
||||
// 过滤仅包含工具协议的空白 assistant 消息,避免历史里出现无意义气泡
|
||||
.filter(
|
||||
(msg) => !(msg.role === "assistant" && msg.content.trim().length === 0),
|
||||
(msg) =>
|
||||
!(msg.role === "assistant" && msg.content.trim().length === 0),
|
||||
);
|
||||
|
||||
if (restoreRequestVersion !== sessionResetVersionRef.current) {
|
||||
@@ -1773,6 +1794,12 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
console.log(
|
||||
`[AgentChat] triggerAIGuide 设置事件监听器: ${eventName}, sessionId: ${activeSessionId}`,
|
||||
);
|
||||
streamDiagnosticsRef.current.start({
|
||||
sessionId: activeSessionId,
|
||||
eventName,
|
||||
assistantMessageId: assistantMsgId,
|
||||
source: "triggerAIGuide",
|
||||
});
|
||||
unlisten = await safeListen<StreamEvent>(eventName, (event) => {
|
||||
console.log(
|
||||
"[AgentChat] triggerAIGuide 收到事件:",
|
||||
@@ -1785,8 +1812,10 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
"[AgentChat] triggerAIGuide 解析事件失败:",
|
||||
event.payload,
|
||||
);
|
||||
streamDiagnosticsRef.current.recordInvalidEvent(event.payload);
|
||||
return;
|
||||
}
|
||||
streamDiagnosticsRef.current.record(data);
|
||||
|
||||
switch (data.type) {
|
||||
case "text_delta":
|
||||
@@ -1858,6 +1887,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
unlistenRef.current = null;
|
||||
currentAssistantMsgIdRef.current = null;
|
||||
currentStreamingSessionIdRef.current = null;
|
||||
streamDiagnosticsRef.current.markDone();
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
@@ -1888,6 +1918,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
unlistenRef.current = null;
|
||||
currentAssistantMsgIdRef.current = null;
|
||||
currentStreamingSessionIdRef.current = null;
|
||||
streamDiagnosticsRef.current.markError(data.message);
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
@@ -2051,6 +2082,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[AgentChat] triggerAIGuide failed:", error);
|
||||
streamDiagnosticsRef.current.markError(String(error));
|
||||
toast.error(`启动引导失败: ${error}`, {
|
||||
id: `guide-error-${Date.now()}`,
|
||||
duration: 8000,
|
||||
|
||||
@@ -1972,6 +1972,44 @@ describe("AgentChatPage 自动引导", () => {
|
||||
"exec-map-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("主题工作台不应把聊天命令 source_ref 当成 Skill 详情去加载", async () => {
|
||||
mockIsContentCreationTheme.mockReturnValue(true);
|
||||
mockUseThemeContextWorkspace.mockReturnValue(
|
||||
createMockThemeContextWorkspaceState({
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({
|
||||
run_state: "auto_running",
|
||||
current_gate_key: "write_mode",
|
||||
queue_items: [
|
||||
{
|
||||
run_id: "run-chat-command",
|
||||
title: "执行主题工作台编排",
|
||||
gate_key: "write_mode",
|
||||
status: "running",
|
||||
source: "chat",
|
||||
source_ref: "aster_agent_chat_stream",
|
||||
started_at: "2026-03-06T04:00:00.000Z",
|
||||
},
|
||||
],
|
||||
latest_terminal: null,
|
||||
updated_at: "2026-03-06T04:00:02.000Z",
|
||||
});
|
||||
|
||||
renderPage({
|
||||
projectId: "project-theme-chat-command",
|
||||
contentId: "content-theme-chat-command",
|
||||
theme: "social-media",
|
||||
lockTheme: true,
|
||||
});
|
||||
await flushEffects(12);
|
||||
|
||||
expect(mockSkillExecutionGetDetail).not.toHaveBeenCalledWith(
|
||||
"aster_agent_chat_stream",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentChatPage 视频主题工作台", () => {
|
||||
|
||||
@@ -108,17 +108,19 @@ import type { Page, PageParams } from "@/types/page";
|
||||
import { SettingsTabs } from "@/types/settings";
|
||||
import { skillsApi, type Skill } from "@/lib/api/skills";
|
||||
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
|
||||
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
|
||||
import { LatestRunStatusBadge } from "@/components/execution/LatestRunStatusBadge";
|
||||
import {
|
||||
executionRunGet,
|
||||
executionRunGetThemeWorkbenchState,
|
||||
type AgentRun,
|
||||
type ThemeWorkbenchRunTodoItem,
|
||||
type ThemeWorkbenchRunTerminalItem,
|
||||
type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState,
|
||||
} from "@/lib/api/executionRun";
|
||||
import { setActiveContentTarget } from "@/lib/activeContentTarget";
|
||||
import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry";
|
||||
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
|
||||
import { resolveProviderModelCompatibility } from "./utils/providerModelCompatibility";
|
||||
import { useProviderModels } from "@/hooks/useProviderModels";
|
||||
import {
|
||||
isReasoningModel,
|
||||
@@ -812,7 +814,7 @@ function resolveThemeWorkbenchQueueItemTitle(
|
||||
item: ThemeWorkbenchRunTodoItem,
|
||||
skillDetailMap: Record<string, SkillDetailInfo | null>,
|
||||
): string {
|
||||
const sourceRef = item.source_ref?.trim();
|
||||
const sourceRef = resolveThemeWorkbenchSkillSourceRef(item);
|
||||
if (sourceRef) {
|
||||
return resolveThemeWorkbenchPrimaryTaskTitle(
|
||||
sourceRef,
|
||||
@@ -823,6 +825,19 @@ function resolveThemeWorkbenchQueueItemTitle(
|
||||
}
|
||||
const THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS = 45 * 1000;
|
||||
|
||||
function resolveThemeWorkbenchSkillSourceRef(
|
||||
item:
|
||||
| ThemeWorkbenchRunTodoItem
|
||||
| ThemeWorkbenchRunTerminalItem
|
||||
| { source?: string | null; source_ref?: string | null },
|
||||
): string | null {
|
||||
if ((item.source || "").trim() !== "skill") {
|
||||
return null;
|
||||
}
|
||||
const sourceRef = item.source_ref?.trim();
|
||||
return sourceRef || null;
|
||||
}
|
||||
|
||||
interface PersistedThemeWorkbenchDocument {
|
||||
versions: DocumentVersion[];
|
||||
currentVersionId: string;
|
||||
@@ -1361,7 +1376,7 @@ function buildThemeWorkbenchWorkflowSteps(
|
||||
if (queueItems.length > 0) {
|
||||
if (queueItems.length === 1) {
|
||||
const item = queueItems[0];
|
||||
const sourceRef = item.source_ref?.trim();
|
||||
const sourceRef = resolveThemeWorkbenchSkillSourceRef(item);
|
||||
const workflowSteps = sourceRef
|
||||
? skillDetailMap[sourceRef]?.workflow_steps || []
|
||||
: [];
|
||||
@@ -2582,13 +2597,14 @@ export function AgentChatPage({
|
||||
}
|
||||
});
|
||||
(themeWorkbenchBackendRunState?.queue_items || []).forEach((item) => {
|
||||
const sourceRef = item.source_ref?.trim();
|
||||
const sourceRef = resolveThemeWorkbenchSkillSourceRef(item);
|
||||
if (sourceRef) {
|
||||
requiredSkillNames.add(sourceRef);
|
||||
}
|
||||
});
|
||||
const terminalSourceRef =
|
||||
themeWorkbenchBackendRunState?.latest_terminal?.source_ref?.trim();
|
||||
const terminalSourceRef = resolveThemeWorkbenchSkillSourceRef(
|
||||
themeWorkbenchBackendRunState?.latest_terminal || {},
|
||||
);
|
||||
if (terminalSourceRef) {
|
||||
requiredSkillNames.add(terminalSourceRef);
|
||||
}
|
||||
@@ -2597,7 +2613,7 @@ export function AgentChatPage({
|
||||
}, [
|
||||
isThemeWorkbench,
|
||||
messages,
|
||||
themeWorkbenchBackendRunState?.latest_terminal?.source_ref,
|
||||
themeWorkbenchBackendRunState?.latest_terminal,
|
||||
themeWorkbenchBackendRunState?.queue_items,
|
||||
]);
|
||||
|
||||
@@ -3625,6 +3641,21 @@ export function AgentChatPage({
|
||||
}
|
||||
}
|
||||
|
||||
const compatibilityResult = resolveProviderModelCompatibility({
|
||||
providerType,
|
||||
configuredProviderType: selectedProvider?.type,
|
||||
model: effectiveModel,
|
||||
});
|
||||
if (compatibilityResult.changed) {
|
||||
effectiveModel = compatibilityResult.model;
|
||||
if (model !== compatibilityResult.model) {
|
||||
setModel(compatibilityResult.model);
|
||||
}
|
||||
if (compatibilityResult.reason) {
|
||||
toast.warning(compatibilityResult.reason);
|
||||
}
|
||||
}
|
||||
|
||||
if (autoContinuePayload) {
|
||||
await sendMessage(
|
||||
text,
|
||||
@@ -3672,6 +3703,7 @@ export function AgentChatPage({
|
||||
providerModels,
|
||||
providerType,
|
||||
runtimeStyleMessagePrompt,
|
||||
selectedProvider?.type,
|
||||
sendMessage,
|
||||
sessionId,
|
||||
setModel,
|
||||
@@ -4026,6 +4058,11 @@ export function AgentChatPage({
|
||||
const toastId = initialSessionName
|
||||
? "openclaw-agent-handoff"
|
||||
: "agent-new-chat";
|
||||
const canCreateFreshSession = Boolean(projectId?.trim());
|
||||
|
||||
if (!canCreateFreshSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const newSessionId = await createFreshSession(initialSessionName);
|
||||
@@ -4048,6 +4085,7 @@ export function AgentChatPage({
|
||||
externalProjectId,
|
||||
initialTheme,
|
||||
initialCreationMode,
|
||||
projectId,
|
||||
]);
|
||||
|
||||
const handleBackHome = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
filterProviderModelsByCompatibility,
|
||||
getProviderModelCompatibilityIssue,
|
||||
resolveProviderModelCompatibility,
|
||||
} from "./providerModelCompatibility";
|
||||
|
||||
describe("resolveProviderModelCompatibility", () => {
|
||||
it("应将 codex provider 的 gpt-5.3-codex 自动降级到 gpt-5.2-codex", () => {
|
||||
const result = resolveProviderModelCompatibility({
|
||||
providerType: "custom-123",
|
||||
configuredProviderType: "codex",
|
||||
model: "gpt-5.3-codex",
|
||||
});
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.model).toBe("gpt-5.2-codex");
|
||||
expect(result.reason).toContain("gpt-5.2-codex");
|
||||
});
|
||||
|
||||
it("非 codex provider 不应调整模型", () => {
|
||||
const result = resolveProviderModelCompatibility({
|
||||
providerType: "anthropic",
|
||||
configuredProviderType: "anthropic",
|
||||
model: "gpt-5.3-codex",
|
||||
});
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.model).toBe("gpt-5.3-codex");
|
||||
});
|
||||
|
||||
it("codex provider 的其他模型不应调整", () => {
|
||||
const result = resolveProviderModelCompatibility({
|
||||
providerType: "codex",
|
||||
configuredProviderType: "codex",
|
||||
model: "gpt-5.2-codex",
|
||||
});
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.model).toBe("gpt-5.2-codex");
|
||||
});
|
||||
|
||||
it("应返回不兼容模型的 UI 提示信息", () => {
|
||||
const issue = getProviderModelCompatibilityIssue({
|
||||
providerType: "custom-123",
|
||||
configuredProviderType: "codex",
|
||||
model: "gpt-5.3-codex",
|
||||
});
|
||||
|
||||
expect(issue).toEqual({
|
||||
code: "codex_chatgpt_account_unsupported",
|
||||
message: "当前 Codex 登录态不支持该模型",
|
||||
suggestedModel: "gpt-5.2-codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("应过滤不兼容模型并保留兼容模型", () => {
|
||||
const result = filterProviderModelsByCompatibility(
|
||||
{
|
||||
providerType: "custom-123",
|
||||
configuredProviderType: "codex",
|
||||
},
|
||||
["gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.2"],
|
||||
);
|
||||
|
||||
expect(result.compatibleModels).toEqual(["gpt-5.2-codex", "gpt-5.2"]);
|
||||
expect(result.incompatibleModels).toEqual([
|
||||
{
|
||||
model: "gpt-5.3-codex",
|
||||
issue: {
|
||||
code: "codex_chatgpt_account_unsupported",
|
||||
message: "当前 Codex 登录态不支持该模型",
|
||||
suggestedModel: "gpt-5.2-codex",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
export interface ProviderModelCompatibilityInput {
|
||||
providerType: string;
|
||||
configuredProviderType?: string | null;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface ProviderModelCompatibilityResult {
|
||||
model: string;
|
||||
changed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ProviderModelCompatibilityIssue {
|
||||
code: "codex_chatgpt_account_unsupported";
|
||||
message: string;
|
||||
suggestedModel?: string;
|
||||
}
|
||||
|
||||
function normalize(value?: string | null): string {
|
||||
return (value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function getProviderModelCompatibilityIssue({
|
||||
providerType,
|
||||
configuredProviderType,
|
||||
model,
|
||||
}: ProviderModelCompatibilityInput): ProviderModelCompatibilityIssue | null {
|
||||
const normalizedProviderType = normalize(providerType);
|
||||
const normalizedConfiguredType = normalize(configuredProviderType);
|
||||
const normalizedModel = normalize(model);
|
||||
|
||||
const isCodexProvider =
|
||||
normalizedProviderType === "codex" || normalizedConfiguredType === "codex";
|
||||
|
||||
if (isCodexProvider && normalizedModel === "gpt-5.3-codex") {
|
||||
return {
|
||||
code: "codex_chatgpt_account_unsupported",
|
||||
message: "当前 Codex 登录态不支持该模型",
|
||||
suggestedModel: "gpt-5.2-codex",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveProviderModelCompatibility({
|
||||
providerType,
|
||||
configuredProviderType,
|
||||
model,
|
||||
}: ProviderModelCompatibilityInput): ProviderModelCompatibilityResult {
|
||||
const issue = getProviderModelCompatibilityIssue({
|
||||
providerType,
|
||||
configuredProviderType,
|
||||
model,
|
||||
});
|
||||
if (issue?.suggestedModel) {
|
||||
return {
|
||||
model: issue.suggestedModel,
|
||||
changed: true,
|
||||
reason: `当前 Codex 登录态与 ${model} 兼容性不足,已自动切换到 ${issue.suggestedModel}。`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterProviderModelsByCompatibility(
|
||||
input: Pick<
|
||||
ProviderModelCompatibilityInput,
|
||||
"providerType" | "configuredProviderType"
|
||||
>,
|
||||
models: string[],
|
||||
): {
|
||||
compatibleModels: string[];
|
||||
incompatibleModels: Array<{
|
||||
model: string;
|
||||
issue: ProviderModelCompatibilityIssue;
|
||||
}>;
|
||||
} {
|
||||
const compatibleModels: string[] = [];
|
||||
const incompatibleModels: Array<{
|
||||
model: string;
|
||||
issue: ProviderModelCompatibilityIssue;
|
||||
}> = [];
|
||||
|
||||
models.forEach((model) => {
|
||||
const issue = getProviderModelCompatibilityIssue({
|
||||
...input,
|
||||
model,
|
||||
});
|
||||
if (issue) {
|
||||
incompatibleModels.push({ model, issue });
|
||||
} else {
|
||||
compatibleModels.push(model);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
compatibleModels,
|
||||
incompatibleModels,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamDiagnosticsReporter } from "./streamDiagnostics";
|
||||
|
||||
const updateCrashContextMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/crashReporting", () => ({
|
||||
updateCrashContext: (context: unknown) => updateCrashContextMock(context),
|
||||
}));
|
||||
|
||||
describe("streamDiagnostics", () => {
|
||||
beforeEach(() => {
|
||||
updateCrashContextMock.mockClear();
|
||||
});
|
||||
|
||||
it("开始流后应写入基础上下文", () => {
|
||||
const reporter = createStreamDiagnosticsReporter("useAgentChat");
|
||||
reporter.start({
|
||||
sessionId: "session-1",
|
||||
eventName: "agent_stream_1",
|
||||
assistantMessageId: "assistant-1",
|
||||
source: "sendMessage",
|
||||
});
|
||||
|
||||
expect(updateCrashContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(updateCrashContextMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
agent_stream_diag: expect.objectContaining({
|
||||
component: "useAgentChat",
|
||||
sessionId: "session-1",
|
||||
eventName: "agent_stream_1",
|
||||
assistantMessageId: "assistant-1",
|
||||
state: "streaming",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("遇到关键事件应立即刷新上下文", () => {
|
||||
const reporter = createStreamDiagnosticsReporter("useAgentChat");
|
||||
reporter.start({
|
||||
sessionId: "session-1",
|
||||
eventName: "agent_stream_1",
|
||||
assistantMessageId: "assistant-1",
|
||||
source: "sendMessage",
|
||||
});
|
||||
|
||||
reporter.record({
|
||||
type: "tool_start",
|
||||
tool_id: "tool-1",
|
||||
tool_name: "WebSearch",
|
||||
});
|
||||
reporter.record({
|
||||
type: "tool_end",
|
||||
tool_id: "tool-1",
|
||||
result: {
|
||||
success: true,
|
||||
output: "ok",
|
||||
},
|
||||
});
|
||||
reporter.record({
|
||||
type: "final_done",
|
||||
});
|
||||
|
||||
const lastCall = updateCrashContextMock.mock.calls.at(-1)?.[0] as {
|
||||
agent_stream_diag: Record<string, unknown>;
|
||||
};
|
||||
expect(lastCall.agent_stream_diag).toMatchObject({
|
||||
state: "done",
|
||||
toolStartCount: 1,
|
||||
toolEndCount: 1,
|
||||
finalDoneCount: 1,
|
||||
lastToolId: "tool-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("解析失败时应记录 invalid 事件", () => {
|
||||
const reporter = createStreamDiagnosticsReporter("useAgentChat");
|
||||
reporter.start({
|
||||
sessionId: "session-1",
|
||||
eventName: "agent_stream_1",
|
||||
assistantMessageId: "assistant-1",
|
||||
source: "sendMessage",
|
||||
});
|
||||
|
||||
reporter.recordInvalidEvent({ foo: "bar" });
|
||||
|
||||
const snapshot = reporter.getSnapshot();
|
||||
expect(snapshot).toMatchObject({
|
||||
invalidEventCount: 1,
|
||||
lastEventType: "invalid",
|
||||
state: "streaming",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { StreamEvent } from "@/lib/api/agent";
|
||||
import { updateCrashContext } from "@/lib/crashReporting";
|
||||
|
||||
const EVENT_PUBLISH_INTERVAL = 20;
|
||||
const PREVIEW_MAX_CHARS = 240;
|
||||
|
||||
export interface StreamDiagnosticsStartPayload {
|
||||
sessionId: string;
|
||||
eventName: string;
|
||||
assistantMessageId: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface StreamDiagnosticsSnapshot {
|
||||
source: string;
|
||||
sessionId: string;
|
||||
eventName: string;
|
||||
assistantMessageId: string;
|
||||
state: "streaming" | "done" | "error";
|
||||
startedAt: string;
|
||||
lastEventAt: string;
|
||||
totalEvents: number;
|
||||
invalidEventCount: number;
|
||||
textDeltaCount: number;
|
||||
thinkingDeltaCount: number;
|
||||
toolStartCount: number;
|
||||
toolEndCount: number;
|
||||
actionRequiredCount: number;
|
||||
contextTraceCount: number;
|
||||
warningCount: number;
|
||||
doneCount: number;
|
||||
finalDoneCount: number;
|
||||
errorCount: number;
|
||||
maxTextDeltaChars: number;
|
||||
maxToolOutputChars: number;
|
||||
maxContextTraceSteps: number;
|
||||
lastEventType?: string;
|
||||
lastToolName?: string;
|
||||
lastToolId?: string;
|
||||
lastWarningCode?: string;
|
||||
lastErrorMessage?: string;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function truncatePreview(value: string | undefined): string | undefined {
|
||||
if (!value) return value;
|
||||
if (value.length <= PREVIEW_MAX_CHARS) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, PREVIEW_MAX_CHARS)}...`;
|
||||
}
|
||||
|
||||
export function createStreamDiagnosticsReporter(componentName: string) {
|
||||
let snapshot: StreamDiagnosticsSnapshot | null = null;
|
||||
let lastPublishedTotalEvents = 0;
|
||||
|
||||
const publish = (force = false) => {
|
||||
if (!snapshot) return;
|
||||
if (
|
||||
!force &&
|
||||
snapshot.totalEvents - lastPublishedTotalEvents < EVENT_PUBLISH_INTERVAL
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastPublishedTotalEvents = snapshot.totalEvents;
|
||||
updateCrashContext({
|
||||
agent_stream_diag: {
|
||||
component: componentName,
|
||||
...snapshot,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
start(payload: StreamDiagnosticsStartPayload) {
|
||||
const startedAt = nowIso();
|
||||
snapshot = {
|
||||
source: payload.source,
|
||||
sessionId: payload.sessionId,
|
||||
eventName: payload.eventName,
|
||||
assistantMessageId: payload.assistantMessageId,
|
||||
state: "streaming",
|
||||
startedAt,
|
||||
lastEventAt: startedAt,
|
||||
totalEvents: 0,
|
||||
invalidEventCount: 0,
|
||||
textDeltaCount: 0,
|
||||
thinkingDeltaCount: 0,
|
||||
toolStartCount: 0,
|
||||
toolEndCount: 0,
|
||||
actionRequiredCount: 0,
|
||||
contextTraceCount: 0,
|
||||
warningCount: 0,
|
||||
doneCount: 0,
|
||||
finalDoneCount: 0,
|
||||
errorCount: 0,
|
||||
maxTextDeltaChars: 0,
|
||||
maxToolOutputChars: 0,
|
||||
maxContextTraceSteps: 0,
|
||||
};
|
||||
lastPublishedTotalEvents = 0;
|
||||
publish(true);
|
||||
},
|
||||
|
||||
recordInvalidEvent(payload: unknown) {
|
||||
if (!snapshot) return;
|
||||
snapshot.totalEvents += 1;
|
||||
snapshot.invalidEventCount += 1;
|
||||
snapshot.lastEventAt = nowIso();
|
||||
snapshot.lastEventType = "invalid";
|
||||
try {
|
||||
snapshot.lastErrorMessage = truncatePreview(JSON.stringify(payload));
|
||||
} catch {
|
||||
snapshot.lastErrorMessage = "[unserializable_payload]";
|
||||
}
|
||||
publish(true);
|
||||
},
|
||||
|
||||
record(event: StreamEvent) {
|
||||
if (!snapshot) return;
|
||||
snapshot.totalEvents += 1;
|
||||
snapshot.lastEventAt = nowIso();
|
||||
snapshot.lastEventType = event.type;
|
||||
|
||||
switch (event.type) {
|
||||
case "text_delta": {
|
||||
snapshot.textDeltaCount += 1;
|
||||
snapshot.maxTextDeltaChars = Math.max(
|
||||
snapshot.maxTextDeltaChars,
|
||||
event.text.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "thinking_delta": {
|
||||
snapshot.thinkingDeltaCount += 1;
|
||||
break;
|
||||
}
|
||||
case "tool_start": {
|
||||
snapshot.toolStartCount += 1;
|
||||
snapshot.lastToolId = event.tool_id;
|
||||
snapshot.lastToolName = truncatePreview(event.tool_name);
|
||||
break;
|
||||
}
|
||||
case "tool_end": {
|
||||
snapshot.toolEndCount += 1;
|
||||
snapshot.lastToolId = event.tool_id;
|
||||
snapshot.maxToolOutputChars = Math.max(
|
||||
snapshot.maxToolOutputChars,
|
||||
event.result.output.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "action_required": {
|
||||
snapshot.actionRequiredCount += 1;
|
||||
break;
|
||||
}
|
||||
case "context_trace": {
|
||||
snapshot.contextTraceCount += 1;
|
||||
snapshot.maxContextTraceSteps = Math.max(
|
||||
snapshot.maxContextTraceSteps,
|
||||
event.steps.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "warning": {
|
||||
snapshot.warningCount += 1;
|
||||
snapshot.lastWarningCode = event.code;
|
||||
snapshot.lastErrorMessage = truncatePreview(event.message);
|
||||
publish(true);
|
||||
return;
|
||||
}
|
||||
case "done": {
|
||||
snapshot.doneCount += 1;
|
||||
publish(true);
|
||||
return;
|
||||
}
|
||||
case "final_done": {
|
||||
snapshot.finalDoneCount += 1;
|
||||
snapshot.state = "done";
|
||||
publish(true);
|
||||
return;
|
||||
}
|
||||
case "error": {
|
||||
snapshot.errorCount += 1;
|
||||
snapshot.state = "error";
|
||||
snapshot.lastErrorMessage = truncatePreview(event.message);
|
||||
publish(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
publish(false);
|
||||
},
|
||||
|
||||
markError(message: string) {
|
||||
if (!snapshot) return;
|
||||
snapshot.state = "error";
|
||||
snapshot.lastEventAt = nowIso();
|
||||
snapshot.lastEventType = "error";
|
||||
snapshot.lastErrorMessage = truncatePreview(message);
|
||||
publish(true);
|
||||
},
|
||||
|
||||
markDone() {
|
||||
if (!snapshot) return;
|
||||
snapshot.state = "done";
|
||||
snapshot.lastEventAt = nowIso();
|
||||
publish(true);
|
||||
},
|
||||
|
||||
getSnapshot(): StreamDiagnosticsSnapshot | null {
|
||||
return snapshot ? { ...snapshot } : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { useProviderModels } from "@/hooks/useProviderModels";
|
||||
import { useGeneralChatStore } from "../store/useGeneralChatStore";
|
||||
import type { ProviderConfig } from "../types";
|
||||
import { filterProviderModelsByCompatibility } from "@/components/agent/chat/utils/providerModelCompatibility";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
@@ -115,6 +116,19 @@ export function useProvider(): UseProviderResult {
|
||||
error: modelsError,
|
||||
} = useProviderModels(selectedProvider);
|
||||
|
||||
const compatibleModelIds = useMemo(() => {
|
||||
if (!selectedProvider) {
|
||||
return availableModelIds;
|
||||
}
|
||||
return filterProviderModelsByCompatibility(
|
||||
{
|
||||
providerType: selectedProvider.key,
|
||||
configuredProviderType: selectedProvider.type,
|
||||
},
|
||||
availableModelIds,
|
||||
).compatibleModels;
|
||||
}, [availableModelIds, selectedProvider]);
|
||||
|
||||
// 计算加载状态
|
||||
// 注意:只有在加载 Provider 列表时才显示加载状态
|
||||
// 模型加载是次要的,不应该阻塞整个界面
|
||||
@@ -178,23 +192,23 @@ export function useProvider(): UseProviderResult {
|
||||
// 如果没有选中的模型,且有可用的模型,自动选择第一个
|
||||
if (
|
||||
!selectedModelId &&
|
||||
availableModelIds.length > 0 &&
|
||||
compatibleModelIds.length > 0 &&
|
||||
!modelInitializedRef.current
|
||||
) {
|
||||
modelInitializedRef.current = true;
|
||||
setSelectedModel(availableModelIds[0]);
|
||||
setSelectedModel(compatibleModelIds[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果选中的模型不在列表中,重新选择
|
||||
if (selectedModelId && !availableModelIds.includes(selectedModelId)) {
|
||||
if (availableModelIds.length > 0) {
|
||||
setSelectedModel(availableModelIds[0]);
|
||||
if (selectedModelId && !compatibleModelIds.includes(selectedModelId)) {
|
||||
if (compatibleModelIds.length > 0) {
|
||||
setSelectedModel(compatibleModelIds[0]);
|
||||
} else {
|
||||
setSelectedModel(null);
|
||||
}
|
||||
}
|
||||
}, [modelsLoading, availableModelIds, selectedModelId, setSelectedModel]);
|
||||
}, [modelsLoading, compatibleModelIds, selectedModelId, setSelectedModel]);
|
||||
|
||||
// ========== 操作方法 ==========
|
||||
|
||||
@@ -218,11 +232,11 @@ export function useProvider(): UseProviderResult {
|
||||
*/
|
||||
const selectModel = useCallback(
|
||||
(modelId: string) => {
|
||||
if (availableModelIds.includes(modelId)) {
|
||||
if (compatibleModelIds.includes(modelId)) {
|
||||
setSelectedModel(modelId);
|
||||
}
|
||||
},
|
||||
[availableModelIds, setSelectedModel],
|
||||
[compatibleModelIds, setSelectedModel],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -278,7 +292,7 @@ export function useProvider(): UseProviderResult {
|
||||
providers,
|
||||
selectedProvider,
|
||||
selectedModelId,
|
||||
availableModelIds,
|
||||
availableModelIds: compatibleModelIds,
|
||||
isLoading,
|
||||
error,
|
||||
hasAvailableProvider,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockUseConfiguredProviders,
|
||||
mockUseProviderModels,
|
||||
mockFilterModelsByTheme,
|
||||
} = vi.hoisted(() => ({
|
||||
mockUseConfiguredProviders: vi.fn(),
|
||||
mockUseProviderModels: vi.fn(),
|
||||
mockFilterModelsByTheme: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useConfiguredProviders", () => ({
|
||||
useConfiguredProviders: () => mockUseConfiguredProviders(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useProviderModels", () => ({
|
||||
useProviderModels: (...args: unknown[]) => mockUseProviderModels(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/agent/chat/utils/modelThemePolicy", () => ({
|
||||
filterModelsByTheme: (...args: unknown[]) => mockFilterModelsByTheme(...args),
|
||||
}));
|
||||
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
|
||||
interface MountedRoot {
|
||||
root: Root;
|
||||
container: HTMLDivElement;
|
||||
}
|
||||
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
function renderModelSelector(
|
||||
props: Partial<React.ComponentProps<typeof ModelSelector>> = {},
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const mergedProps: React.ComponentProps<typeof ModelSelector> = {
|
||||
providerType: "custom-codex",
|
||||
setProviderType: vi.fn(),
|
||||
model: "gpt-5.3-codex",
|
||||
setModel: vi.fn(),
|
||||
activeTheme: "general",
|
||||
...props,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(<ModelSelector {...mergedProps} />);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return { container, props: mergedProps };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockUseConfiguredProviders.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
key: "custom-codex",
|
||||
label: "Codex Custom",
|
||||
registryId: "custom-codex",
|
||||
fallbackRegistryId: "codex",
|
||||
type: "codex",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
mockUseProviderModels.mockReturnValue({
|
||||
modelIds: ["gpt-5.3-codex", "gpt-5.2-codex"],
|
||||
models: [
|
||||
{ id: "gpt-5.3-codex" },
|
||||
{ id: "gpt-5.2-codex" },
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
mockFilterModelsByTheme.mockImplementation((_theme, models) => ({
|
||||
models,
|
||||
usedFallback: false,
|
||||
filteredOutCount: 0,
|
||||
policyName: "none",
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) break;
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("ModelSelector", () => {
|
||||
it("应在 codex 不兼容模型被选中时自动回退到兼容模型", () => {
|
||||
const setModel = vi.fn();
|
||||
|
||||
renderModelSelector({
|
||||
model: "gpt-5.3-codex",
|
||||
setModel,
|
||||
});
|
||||
|
||||
expect(setModel).toHaveBeenCalledWith("gpt-5.2-codex");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { ProviderIcon } from "@/icons/providers";
|
||||
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
|
||||
import { useProviderModels } from "@/hooks/useProviderModels";
|
||||
import { filterModelsByTheme } from "@/components/agent/chat/utils/modelThemePolicy";
|
||||
import { getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility";
|
||||
|
||||
const THEME_LABEL_MAP: Record<string, string> = {
|
||||
general: "通用对话",
|
||||
@@ -80,9 +81,34 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
return filterModelsByTheme(activeTheme, providerModels);
|
||||
}, [activeTheme, providerModels]);
|
||||
|
||||
const currentModels = useMemo(() => {
|
||||
return filteredResult.models.map((item) => item.id);
|
||||
}, [filteredResult.models]);
|
||||
const modelOptions = useMemo(
|
||||
() =>
|
||||
filteredResult.models.map((item) => {
|
||||
const compatibilityIssue = getProviderModelCompatibilityIssue({
|
||||
providerType,
|
||||
configuredProviderType: selectedProvider?.type,
|
||||
model: item.id,
|
||||
});
|
||||
return {
|
||||
id: item.id,
|
||||
compatibilityIssue,
|
||||
};
|
||||
}),
|
||||
[filteredResult.models, providerType, selectedProvider?.type],
|
||||
);
|
||||
|
||||
const currentModels = useMemo(
|
||||
() =>
|
||||
modelOptions
|
||||
.filter((item) => !item.compatibilityIssue)
|
||||
.map((item) => item.id),
|
||||
[modelOptions],
|
||||
);
|
||||
|
||||
const incompatibleModelCount = useMemo(
|
||||
() => modelOptions.filter((item) => item.compatibilityIssue).length,
|
||||
[modelOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current) return;
|
||||
@@ -309,27 +335,39 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
{activeThemeLabel} 未匹配到主题模型,已展示全部模型
|
||||
</div>
|
||||
)}
|
||||
{incompatibleModelCount > 0 && (
|
||||
<div className="text-[11px] text-amber-600 px-2 pb-1">
|
||||
已隐藏 {incompatibleModelCount} 个当前登录态不兼容的模型
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 p-1">
|
||||
{currentModels.length === 0 ? (
|
||||
{modelOptions.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground p-2">
|
||||
暂无可用模型
|
||||
</div>
|
||||
) : (
|
||||
currentModels.map((currentModelItem) => (
|
||||
modelOptions.map((currentModelItem) => (
|
||||
<button
|
||||
key={currentModelItem}
|
||||
key={currentModelItem.id}
|
||||
disabled={Boolean(currentModelItem.compatibilityIssue)}
|
||||
onClick={() => {
|
||||
setModel(currentModelItem);
|
||||
if (currentModelItem.compatibilityIssue) {
|
||||
return;
|
||||
}
|
||||
setModel(currentModelItem.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left group",
|
||||
model === currentModelItem
|
||||
currentModelItem.compatibilityIssue
|
||||
? "cursor-not-allowed opacity-60 text-muted-foreground"
|
||||
: model === currentModelItem.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title={currentModelItem.compatibilityIssue?.message}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{selectedProvider && (
|
||||
@@ -339,11 +377,20 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
size={15}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{currentModelItem}</span>
|
||||
<span className="min-w-0 flex flex-col">
|
||||
<span className="truncate">{currentModelItem.id}</span>
|
||||
{currentModelItem.compatibilityIssue ? (
|
||||
<span className="truncate text-[11px] text-amber-600">
|
||||
{currentModelItem.compatibilityIssue.message}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
{model === currentModelItem && (
|
||||
{currentModelItem.compatibilityIssue ? (
|
||||
<AlertCircle size={14} className="text-amber-500" />
|
||||
) : model === currentModelItem.id ? (
|
||||
<Check size={14} className="text-primary" />
|
||||
)}
|
||||
) : null}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockUseConfiguredProviders,
|
||||
mockUseProviderModels,
|
||||
} = vi.hoisted(() => ({
|
||||
mockUseConfiguredProviders: vi.fn(),
|
||||
mockUseProviderModels: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useConfiguredProviders", () => ({
|
||||
useConfiguredProviders: () => mockUseConfiguredProviders(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useProviderModels", () => ({
|
||||
useProviderModels: (...args: unknown[]) => mockUseProviderModels(...args),
|
||||
}));
|
||||
|
||||
import { ProviderModelSelector } from "./ProviderModelSelector";
|
||||
|
||||
interface MountedRoot {
|
||||
root: Root;
|
||||
container: HTMLDivElement;
|
||||
}
|
||||
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
function renderSelector(
|
||||
props: Partial<React.ComponentProps<typeof ProviderModelSelector>> = {},
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const mergedProps: React.ComponentProps<typeof ProviderModelSelector> = {
|
||||
onSelect: vi.fn(),
|
||||
initialProviderId: "custom-codex",
|
||||
...props,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(<ProviderModelSelector {...mergedProps} />);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return { container };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockUseConfiguredProviders.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
key: "custom-codex",
|
||||
label: "Codex Custom",
|
||||
registryId: "custom-codex",
|
||||
fallbackRegistryId: "codex",
|
||||
type: "codex",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
mockUseProviderModels.mockReturnValue({
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.3-codex",
|
||||
display_name: "GPT-5.3 Codex",
|
||||
capabilities: {
|
||||
vision: true,
|
||||
tools: true,
|
||||
streaming: true,
|
||||
json_mode: true,
|
||||
function_calling: true,
|
||||
reasoning: true,
|
||||
},
|
||||
is_latest: true,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.2-codex",
|
||||
display_name: "GPT-5.2 Codex",
|
||||
capabilities: {
|
||||
vision: true,
|
||||
tools: true,
|
||||
streaming: true,
|
||||
json_mode: true,
|
||||
function_calling: true,
|
||||
reasoning: true,
|
||||
},
|
||||
is_latest: false,
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) break;
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("ProviderModelSelector", () => {
|
||||
it("应隐藏 codex 不兼容模型并展示兼容提示", () => {
|
||||
const { container } = renderSelector();
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"已隐藏 1 个当前登录态不兼容的模型",
|
||||
);
|
||||
expect(container.textContent).not.toContain("GPT-5.3 Codex");
|
||||
expect(container.textContent).toContain("GPT-5.2 Codex");
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { useProviderModels } from "@/hooks/useProviderModels";
|
||||
import { getProviderLabel } from "@/lib/constants/providerMappings";
|
||||
import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry";
|
||||
import { getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
@@ -187,6 +188,24 @@ export const ProviderModelSelector: React.FC<ProviderModelSelectorProps> = ({
|
||||
error: modelsError,
|
||||
} = useProviderModels(selectedProvider, { returnFullMetadata: true });
|
||||
|
||||
const compatibleModels = useMemo(
|
||||
() =>
|
||||
filteredModels.filter(
|
||||
(model) =>
|
||||
!getProviderModelCompatibilityIssue({
|
||||
providerType: selectedProvider?.key || "",
|
||||
configuredProviderType: selectedProvider?.type,
|
||||
model: model.id,
|
||||
}),
|
||||
),
|
||||
[filteredModels, selectedProvider?.key, selectedProvider?.type],
|
||||
);
|
||||
|
||||
const incompatibleModelCount = useMemo(
|
||||
() => filteredModels.length - compatibleModels.length,
|
||||
[compatibleModels.length, filteredModels.length],
|
||||
);
|
||||
|
||||
// 默认选中第一个 Provider
|
||||
useEffect(() => {
|
||||
if (!selectedProviderId && configuredProviders.length > 0) {
|
||||
@@ -279,19 +298,26 @@ export const ProviderModelSelector: React.FC<ProviderModelSelectorProps> = ({
|
||||
<AlertCircle className="h-8 w-8 mb-2" />
|
||||
<p className="text-sm">{modelsError}</p>
|
||||
</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
) : compatibleModels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">暂无模型数据</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredModels.map((model) => (
|
||||
<>
|
||||
{incompatibleModelCount > 0 ? (
|
||||
<div className="px-1 py-1 text-xs text-amber-600">
|
||||
已隐藏 {incompatibleModelCount} 个当前登录态不兼容的模型
|
||||
</div>
|
||||
) : null}
|
||||
{compatibleModels.map((model) => (
|
||||
<ModelItem
|
||||
key={model.id}
|
||||
model={model}
|
||||
isSelected={selectedModelId === model.id}
|
||||
onClick={() => handleSelectModel(model)}
|
||||
/>
|
||||
))
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -269,9 +269,14 @@ function resolveOpenClawSubpage(
|
||||
interface OpenClawPageProps {
|
||||
pageParams?: OpenClawPageParams;
|
||||
onNavigate?: (page: Page, params?: PageParams) => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
|
||||
export function OpenClawPage({
|
||||
pageParams,
|
||||
onNavigate,
|
||||
isActive = false,
|
||||
}: OpenClawPageProps) {
|
||||
const {
|
||||
providers,
|
||||
loading: providersLoading,
|
||||
@@ -562,8 +567,12 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
|
||||
}, [refreshDashboardWindowState, refreshGatewayRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshAll();
|
||||
}, [refreshAll]);
|
||||
}, [isActive, refreshAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!statusResolved || requestedSubpage || operationState.running) {
|
||||
@@ -587,6 +596,10 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (gatewayStatus !== "running" && gatewayStatus !== "starting") {
|
||||
return;
|
||||
}
|
||||
@@ -598,13 +611,23 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) {
|
||||
}, 5000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [gatewayStatus, refreshGatewayRuntime]);
|
||||
}, [gatewayStatus, isActive, refreshGatewayRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSubpage === "dashboard" && gatewayRunning && !dashboardUrl) {
|
||||
void refreshDashboardUrl({ silent: true, showLoading: true });
|
||||
}
|
||||
}, [currentSubpage, dashboardUrl, gatewayRunning, refreshDashboardUrl]);
|
||||
}, [
|
||||
currentSubpage,
|
||||
dashboardUrl,
|
||||
gatewayRunning,
|
||||
isActive,
|
||||
refreshDashboardUrl,
|
||||
]);
|
||||
|
||||
const syncProviderConfig = useCallback(
|
||||
async ({ showSuccessToast = true, trackLoading = true } = {}) => {
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
telegramChannelProbe,
|
||||
} from "@/hooks/useTauri";
|
||||
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
|
||||
import { filterProviderModelsByCompatibility, getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility";
|
||||
import { ChannelLogTailPanel } from "./ChannelLogTailPanel";
|
||||
|
||||
// ============================================================================
|
||||
@@ -108,6 +109,25 @@ function DefaultModelSelect({
|
||||
}) {
|
||||
const { providers, loading: providersLoading } = useConfiguredProviders();
|
||||
|
||||
// 已保存的值如果不兼容,自动回退到 "未指定"
|
||||
useEffect(() => {
|
||||
if (!value || providersLoading) return;
|
||||
const slashIdx = value.indexOf("/");
|
||||
if (slashIdx < 0) return;
|
||||
const providerKey = value.slice(0, slashIdx);
|
||||
const modelName = value.slice(slashIdx + 1);
|
||||
const provider = providers.find((p) => p.key === providerKey);
|
||||
if (!provider) return;
|
||||
const issue = getProviderModelCompatibilityIssue({
|
||||
providerType: provider.type,
|
||||
configuredProviderType: provider.type,
|
||||
model: modelName,
|
||||
});
|
||||
if (issue) {
|
||||
onChange(undefined);
|
||||
}
|
||||
}, [value, providers, providersLoading, onChange]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">默认模型</label>
|
||||
@@ -118,15 +138,23 @@ function DefaultModelSelect({
|
||||
>
|
||||
<option value="">未指定(使用全局默认)</option>
|
||||
{providersLoading && <option disabled>加载中...</option>}
|
||||
{providers.map((p) => (
|
||||
<optgroup key={p.key} label={p.label}>
|
||||
{p.customModels?.map((m) => (
|
||||
<option key={`${p.key}/${m}`} value={`${p.key}/${m}`}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
{providers.map((p) => {
|
||||
const models = p.customModels ?? [];
|
||||
const { compatibleModels } = filterProviderModelsByCompatibility(
|
||||
{ providerType: p.type, configuredProviderType: p.type },
|
||||
models,
|
||||
);
|
||||
if (compatibleModels.length === 0) return null;
|
||||
return (
|
||||
<optgroup key={p.key} label={p.label}>
|
||||
{compatibleModels.map((m) => (
|
||||
<option key={`${p.key}/${m}`} value={`${p.key}/${m}`}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
为此渠道指定默认使用的 AI 模型
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* - AI 面板可控制活动终端
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
import { TerminalPanel } from "./TerminalPanel";
|
||||
import {
|
||||
@@ -210,12 +210,17 @@ const CloseIcon = () => (
|
||||
interface TerminalWorkspaceProps {
|
||||
/** 页面导航回调 */
|
||||
onNavigate: (page: Page) => void;
|
||||
/** 当前页面是否已激活 */
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 终端工作区组件
|
||||
*/
|
||||
export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
|
||||
export function TerminalWorkspace({
|
||||
onNavigate,
|
||||
isActive,
|
||||
}: TerminalWorkspaceProps) {
|
||||
// 面板状态管理 - 初始包含主终端
|
||||
const [panels, setPanels] = useState<SidePanel[]>([
|
||||
{ id: "main-terminal", type: "terminal", title: "Terminal" },
|
||||
@@ -223,6 +228,7 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
|
||||
|
||||
// AI 面板状态
|
||||
const [showAIPanel, setShowAIPanel] = useState(false);
|
||||
const [hasActivated, setHasActivated] = useState(isActive);
|
||||
|
||||
// 活动终端面板 ID(用于 AI 控制)
|
||||
const [activeTerminalPanelId, setActiveTerminalPanelId] =
|
||||
@@ -234,6 +240,12 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
|
||||
// 连接编辑器模态窗口状态
|
||||
const [isConnectionsEditorOpen, setIsConnectionsEditorOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setHasActivated(true);
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
// 获取活动终端的会话 ID
|
||||
const getActiveTerminalSessionId = useCallback((): string | null => {
|
||||
const activePanel = panels.find(
|
||||
@@ -346,6 +358,14 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) {
|
||||
const renderPanelContent = (panel: SidePanel) => {
|
||||
switch (panel.type) {
|
||||
case "terminal":
|
||||
if (!hasActivated) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
终端将在首次打开该页面时初始化
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TerminalPanel
|
||||
panelId={panel.id}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockUseConfiguredProviders,
|
||||
mockUseProviderModels,
|
||||
} = vi.hoisted(() => ({
|
||||
mockUseConfiguredProviders: vi.fn(),
|
||||
mockUseProviderModels: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useConfiguredProviders", () => ({
|
||||
useConfiguredProviders: () => mockUseConfiguredProviders(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useProviderModels", () => ({
|
||||
useProviderModels: (...args: unknown[]) => mockUseProviderModels(...args),
|
||||
}));
|
||||
|
||||
import { TerminalAIModeSelector } from "./TerminalAIModeSelector";
|
||||
|
||||
interface MountedRoot {
|
||||
root: Root;
|
||||
container: HTMLDivElement;
|
||||
}
|
||||
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
function renderSelector(
|
||||
props: Partial<React.ComponentProps<typeof TerminalAIModeSelector>> = {},
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
const mergedProps: React.ComponentProps<typeof TerminalAIModeSelector> = {
|
||||
providerId: "custom-codex",
|
||||
onProviderChange: vi.fn(),
|
||||
modelId: "gpt-5.3-codex",
|
||||
onModelChange: vi.fn(),
|
||||
...props,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(<TerminalAIModeSelector {...mergedProps} />);
|
||||
});
|
||||
|
||||
mountedRoots.push({ root, container });
|
||||
return { container, props: mergedProps };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockUseConfiguredProviders.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
key: "custom-codex",
|
||||
label: "Codex Custom",
|
||||
registryId: "custom-codex",
|
||||
fallbackRegistryId: "codex",
|
||||
type: "codex",
|
||||
credentialType: "codex_oauth",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
mockUseProviderModels.mockReturnValue({
|
||||
modelIds: ["gpt-5.3-codex", "gpt-5.2-codex"],
|
||||
models: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) break;
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("TerminalAIModeSelector", () => {
|
||||
it("应在 codex 不兼容模型被选中时自动回退到兼容模型", () => {
|
||||
const onModelChange = vi.fn();
|
||||
|
||||
renderSelector({
|
||||
modelId: "gpt-5.3-codex",
|
||||
onModelChange,
|
||||
});
|
||||
|
||||
expect(onModelChange).toHaveBeenCalledWith("gpt-5.2-codex");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
|
||||
import { useProviderModels } from "@/hooks/useProviderModels";
|
||||
import { isAliasProvider } from "@/lib/constants/providerMappings";
|
||||
import { filterProviderModelsByCompatibility } from "@/components/agent/chat/utils/providerModelCompatibility";
|
||||
|
||||
// ============================================================================
|
||||
// 常量
|
||||
@@ -229,6 +230,20 @@ export const TerminalAIModeSelector: React.FC<TerminalAIModeSelectorProps> = ({
|
||||
return hookModels;
|
||||
}, [selectedProvider, hookModels]);
|
||||
|
||||
const compatibilityResult = useMemo(() => {
|
||||
if (!selectedProvider) {
|
||||
return { compatibleModels: [], incompatibleModels: [] };
|
||||
}
|
||||
|
||||
return filterProviderModelsByCompatibility(
|
||||
{
|
||||
providerType: selectedProvider.key,
|
||||
configuredProviderType: selectedProvider.type,
|
||||
},
|
||||
currentModels,
|
||||
);
|
||||
}, [currentModels, selectedProvider]);
|
||||
|
||||
// 自动选择第一个模型
|
||||
useEffect(() => {
|
||||
// 等待模型加载完成
|
||||
@@ -240,10 +255,19 @@ export const TerminalAIModeSelector: React.FC<TerminalAIModeSelectorProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentModels.length > 0 && !currentModels.includes(modelId)) {
|
||||
onModelChange(currentModels[0]);
|
||||
if (
|
||||
compatibilityResult.compatibleModels.length > 0 &&
|
||||
!compatibilityResult.compatibleModels.includes(modelId)
|
||||
) {
|
||||
onModelChange(compatibilityResult.compatibleModels[0]);
|
||||
}
|
||||
}, [currentModels, modelId, onModelChange, selectedProvider, modelsLoading]);
|
||||
}, [
|
||||
compatibilityResult.compatibleModels,
|
||||
modelId,
|
||||
onModelChange,
|
||||
selectedProvider,
|
||||
modelsLoading,
|
||||
]);
|
||||
|
||||
// 初始化 Provider
|
||||
useEffect(() => {
|
||||
@@ -308,12 +332,17 @@ export const TerminalAIModeSelector: React.FC<TerminalAIModeSelectorProps> = ({
|
||||
<div className="text-xs font-semibold text-zinc-400 px-2 py-1 mb-1">
|
||||
Models
|
||||
</div>
|
||||
{compatibilityResult.incompatibleModels.length > 0 ? (
|
||||
<div className="px-2 pb-1 text-[11px] text-amber-400">
|
||||
已隐藏 {compatibilityResult.incompatibleModels.length} 个当前登录态不兼容的模型
|
||||
</div>
|
||||
) : null}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-0.5 p-1">
|
||||
{currentModels.length === 0 ? (
|
||||
{compatibilityResult.compatibleModels.length === 0 ? (
|
||||
<div className="text-xs text-zinc-500 p-2">暂无可用模型</div>
|
||||
) : (
|
||||
currentModels.map((m) => (
|
||||
compatibilityResult.compatibleModels.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useConfiguredProviders } from "@/hooks/useConfiguredProviders";
|
||||
import { useProviderModels } from "@/hooks/useProviderModels";
|
||||
import { getProviderLabel } from "@/lib/constants/providerMappings";
|
||||
import { getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility";
|
||||
|
||||
export interface PolishModelSelectorProps {
|
||||
/** 当前选中的模型 ID */
|
||||
@@ -65,6 +66,19 @@ export const PolishModelSelector: React.FC<PolishModelSelectorProps> = ({
|
||||
{ returnFullMetadata: true },
|
||||
);
|
||||
|
||||
const compatibleModels = useMemo(
|
||||
() =>
|
||||
currentModels.filter(
|
||||
(model) =>
|
||||
!getProviderModelCompatibilityIssue({
|
||||
providerType: selectedProvider?.key || "",
|
||||
configuredProviderType: selectedProvider?.type,
|
||||
model: model.id,
|
||||
}),
|
||||
),
|
||||
[currentModels, selectedProvider?.key, selectedProvider?.type],
|
||||
);
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -200,12 +214,12 @@ export const PolishModelSelector: React.FC<PolishModelSelectorProps> = ({
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : currentModels.length === 0 ? (
|
||||
) : compatibleModels.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
|
||||
暂无可用模型
|
||||
</div>
|
||||
) : (
|
||||
currentModels.map((model) => {
|
||||
compatibleModels.map((model) => {
|
||||
const isSelected = value === model.id;
|
||||
// 简化模型名称显示
|
||||
const displayName =
|
||||
|
||||
@@ -141,6 +141,9 @@ export function WorkbenchPage({
|
||||
const shouldHideVideoSidebarInWorkspace =
|
||||
themeModule.capabilities.workspaceKind === "video-canvas" &&
|
||||
workspaceMode === "workspace";
|
||||
const shouldHideVideoRightRailInWorkspace =
|
||||
themeModule.capabilities.workspaceKind === "video-canvas" &&
|
||||
workspaceMode === "workspace";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
@@ -229,24 +232,26 @@ export function WorkbenchPage({
|
||||
/>
|
||||
}
|
||||
rightRail={
|
||||
<WorkbenchRightRail
|
||||
shouldRender={shouldRenderWorkspaceRightRail}
|
||||
isCreateWorkspaceView={isCreateWorkspaceView}
|
||||
projectId={selectedProjectId}
|
||||
theme={theme}
|
||||
creationMode={selectedContentCreationMode}
|
||||
creationType={selectedContentCreationType}
|
||||
initialStyleGuideDialogOpen={pendingStyleGuideDialogOpen}
|
||||
onInitialStyleGuideDialogConsumed={() =>
|
||||
setPendingStyleGuideDialogOpen(false)
|
||||
}
|
||||
initialStyleGuideSourceEntryId={pendingStyleGuideSourceEntryId}
|
||||
onInitialStyleGuideSourceEntryConsumed={() =>
|
||||
setPendingStyleGuideSourceEntryId(null)
|
||||
}
|
||||
onBackToCreateView={() => handleSwitchWorkspaceView("create")}
|
||||
onCreateContentFromPrompt={handleCreateContentFromWorkspacePrompt}
|
||||
/>
|
||||
shouldHideVideoRightRailInWorkspace ? null : (
|
||||
<WorkbenchRightRail
|
||||
shouldRender={shouldRenderWorkspaceRightRail}
|
||||
isCreateWorkspaceView={isCreateWorkspaceView}
|
||||
projectId={selectedProjectId}
|
||||
theme={theme}
|
||||
creationMode={selectedContentCreationMode}
|
||||
creationType={selectedContentCreationType}
|
||||
initialStyleGuideDialogOpen={pendingStyleGuideDialogOpen}
|
||||
onInitialStyleGuideDialogConsumed={() =>
|
||||
setPendingStyleGuideDialogOpen(false)
|
||||
}
|
||||
initialStyleGuideSourceEntryId={pendingStyleGuideSourceEntryId}
|
||||
onInitialStyleGuideSourceEntryConsumed={() =>
|
||||
setPendingStyleGuideSourceEntryId(null)
|
||||
}
|
||||
onBackToCreateView={() => handleSwitchWorkspaceView("create")}
|
||||
onCreateContentFromPrompt={handleCreateContentFromWorkspacePrompt}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
Project,
|
||||
CreateProjectRequest,
|
||||
ProjectUpdate,
|
||||
ProjectFilter,
|
||||
} from "@/types/project";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry";
|
||||
|
||||
interface WorkspaceEnsureResult {
|
||||
@@ -73,12 +73,12 @@ export function useProjects(): UseProjectsReturn {
|
||||
setError(null);
|
||||
|
||||
const [list, defaultProj] = await Promise.all([
|
||||
invoke<Project[]>("workspace_list"),
|
||||
invoke<Project | null>("workspace_get_default"),
|
||||
safeInvoke<Project[]>("workspace_list"),
|
||||
safeInvoke<Project | null>("workspace_get_default"),
|
||||
]);
|
||||
|
||||
if (defaultProj?.id) {
|
||||
const ensureResult = await invoke<WorkspaceEnsureResult>(
|
||||
const ensureResult = await safeInvoke<WorkspaceEnsureResult>(
|
||||
"workspace_ensure_ready",
|
||||
{ id: defaultProj.id },
|
||||
);
|
||||
@@ -139,11 +139,11 @@ export function useProjects(): UseProjectsReturn {
|
||||
/** 创建项目 */
|
||||
const create = useCallback(
|
||||
async (request: CreateProjectRequest): Promise<Project> => {
|
||||
const rootPath = await invoke<string>("workspace_resolve_project_path", {
|
||||
const rootPath = await safeInvoke<string>("workspace_resolve_project_path", {
|
||||
name: request.name,
|
||||
});
|
||||
|
||||
const project = await invoke<Project>("workspace_create", {
|
||||
const project = await safeInvoke<Project>("workspace_create", {
|
||||
request: {
|
||||
name: request.name,
|
||||
rootPath,
|
||||
@@ -159,7 +159,7 @@ export function useProjects(): UseProjectsReturn {
|
||||
/** 更新项目 */
|
||||
const update = useCallback(
|
||||
async (id: string, updateData: ProjectUpdate): Promise<Project> => {
|
||||
const project = await invoke<Project>("workspace_update", {
|
||||
const project = await safeInvoke<Project>("workspace_update", {
|
||||
id,
|
||||
request: updateData,
|
||||
});
|
||||
@@ -172,7 +172,7 @@ export function useProjects(): UseProjectsReturn {
|
||||
/** 删除项目 */
|
||||
const remove = useCallback(
|
||||
async (id: string): Promise<boolean> => {
|
||||
const result = await invoke<boolean>("workspace_delete", { id });
|
||||
const result = await safeInvoke<boolean>("workspace_delete", { id });
|
||||
await refresh();
|
||||
return result;
|
||||
},
|
||||
@@ -181,7 +181,7 @@ export function useProjects(): UseProjectsReturn {
|
||||
|
||||
/** 获取或创建默认项目 */
|
||||
const getOrCreateDefault = useCallback(async (): Promise<Project> => {
|
||||
const project = await invoke<Project>("get_or_create_default_project");
|
||||
const project = await safeInvoke<Project>("get_or_create_default_project");
|
||||
await refresh();
|
||||
return project;
|
||||
}, [refresh]);
|
||||
|
||||
Reference in New Issue
Block a user