release: bump version to v0.75.0

This commit is contained in:
coso
2026-02-28 22:42:28 +08:00
parent 672a94536a
commit 5cd3eda653
36 changed files with 7130 additions and 778 deletions
+5 -5
View File
@@ -1,4 +1,4 @@
# Release v0.74.0
# Release v0.75.0
## 📊 变更统计
@@ -176,9 +176,9 @@
### 版本号
已自动同步到:
- `package.json`: 0.74.0
- `src-tauri/Cargo.toml`: 0.74.0
- `src-tauri/tauri.conf.json`: 0.74.0
- `package.json`: 0.75.0
- `src-tauri/Cargo.toml`: 0.75.0
- `src-tauri/tauri.conf.json`: 0.75.0
### 新功能使用
@@ -213,4 +213,4 @@ AI:[自动打开天气网站并读取内容] 今天晴天,20-25°C...
---
**完整变更日志**:https://github.com/aiclientproxy/proxycast/compare/v0.73.0...v0.74.0
**完整变更日志**:https://github.com/aiclientproxy/proxycast/compare/v0.74.0...v0.75.0
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.74.0",
"version": "0.75.0",
"type": "module",
"repository": {
"type": "git",
+15 -15
View File
@@ -6685,7 +6685,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"anyhow",
"arboard",
@@ -6785,7 +6785,7 @@ dependencies = [
[[package]]
name = "proxycast-agent"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"aster-core",
"async-trait",
@@ -6809,7 +6809,7 @@ dependencies = [
[[package]]
name = "proxycast-config"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6825,7 +6825,7 @@ dependencies = [
[[package]]
name = "proxycast-core"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"aster-models",
"async-trait",
@@ -6865,7 +6865,7 @@ dependencies = [
[[package]]
name = "proxycast-credential"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -6900,7 +6900,7 @@ dependencies = [
[[package]]
name = "proxycast-infra"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -6920,7 +6920,7 @@ dependencies = [
[[package]]
name = "proxycast-mcp"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"async-trait",
"glob",
@@ -6951,7 +6951,7 @@ dependencies = [
[[package]]
name = "proxycast-processor"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6970,7 +6970,7 @@ dependencies = [
[[package]]
name = "proxycast-providers"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"anyhow",
"async-stream",
@@ -7022,7 +7022,7 @@ dependencies = [
[[package]]
name = "proxycast-server"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"async-stream",
"axum 0.7.9",
@@ -7065,7 +7065,7 @@ dependencies = [
[[package]]
name = "proxycast-server-utils"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -7080,7 +7080,7 @@ dependencies = [
[[package]]
name = "proxycast-services"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"anyhow",
"aster-core",
@@ -7121,7 +7121,7 @@ dependencies = [
[[package]]
name = "proxycast-skills"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -7137,7 +7137,7 @@ dependencies = [
[[package]]
name = "proxycast-terminal"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7164,7 +7164,7 @@ dependencies = [
[[package]]
name = "proxycast-websocket"
version = "0.74.0"
version = "0.75.0"
dependencies = [
"axum 0.7.9",
"chrono",
+2 -2
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.74.0"
version = "0.75.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/proxycast"
@@ -189,7 +189,7 @@ version = "2.4"
[package]
name = "proxycast"
version = "0.74.0"
version = "0.75.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+181 -53
View File
@@ -5,22 +5,34 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use tokio::sync::RwLock;
use tokio::time::timeout;
use super::loader::PluginLoader;
use super::task::{
PluginQueueStats, PluginTaskPolicy, PluginTaskRecord, PluginTaskState, PluginTaskTracker,
};
use super::types::{
HookResult, PluginConfig, PluginContext, PluginError, PluginInfo, PluginInstance, PluginStatus,
};
use crate::DynEmitter;
/// 插件管理器配置
#[derive(Debug, Clone)]
pub struct PluginManagerConfig {
/// 默认超时时间 (毫秒)
pub default_timeout_ms: u64,
/// 默认重试次数
pub default_max_retries: u32,
/// 默认重试退避基数 (毫秒)
pub default_retry_backoff_ms: u64,
/// 插件级并发上限
pub default_max_concurrency_per_plugin: usize,
/// 插件级队列长度上限
pub default_queue_limit_per_plugin: usize,
/// 任务记录保留数量
pub task_retention_limit: usize,
/// 是否启用插件系统
pub enabled: bool,
/// 最大并发插件数
@@ -31,6 +43,11 @@ impl Default for PluginManagerConfig {
fn default() -> Self {
Self {
default_timeout_ms: 5000,
default_max_retries: 2,
default_retry_backoff_ms: 300,
default_max_concurrency_per_plugin: 4,
default_queue_limit_per_plugin: 100,
task_retention_limit: 2000,
enabled: true,
max_plugins: 50,
}
@@ -47,6 +64,8 @@ pub struct PluginManager {
configs: DashMap<String, PluginConfig>,
/// 管理器配置
config: PluginManagerConfig,
/// 插件任务治理与跟踪
task_tracker: PluginTaskTracker,
}
impl PluginManager {
@@ -56,6 +75,7 @@ impl PluginManager {
loader: PluginLoader::new(plugins_dir),
plugins: DashMap::new(),
configs: DashMap::new(),
task_tracker: PluginTaskTracker::new(config.task_retention_limit),
config,
}
}
@@ -248,6 +268,46 @@ impl PluginManager {
infos
}
/// 设置插件任务事件发射器
pub async fn set_task_emitter(&self, emitter: DynEmitter) {
self.task_tracker.set_emitter(emitter).await;
}
/// 列出插件任务
pub fn list_tasks(
&self,
plugin_id: Option<&str>,
state: Option<PluginTaskState>,
limit: usize,
) -> Vec<PluginTaskRecord> {
self.task_tracker.list_tasks(plugin_id, state, limit)
}
/// 获取插件任务详情
pub fn get_task(&self, task_id: &str) -> Option<PluginTaskRecord> {
self.task_tracker.get_task(task_id)
}
/// 取消插件任务
pub fn cancel_task(&self, task_id: &str) -> bool {
self.task_tracker.cancel_task(task_id)
}
/// 获取插件队列统计
pub fn get_queue_stats(&self, plugin_id: Option<&str>) -> Vec<PluginQueueStats> {
self.task_tracker.queue_stats(plugin_id)
}
fn build_policy(&self, timeout_ms: u64) -> PluginTaskPolicy {
PluginTaskPolicy {
timeout_ms,
max_retries: self.config.default_max_retries,
retry_backoff_ms: self.config.default_retry_backoff_ms,
max_concurrency_per_plugin: self.config.default_max_concurrency_per_plugin,
queue_limit_per_plugin: self.config.default_queue_limit_per_plugin,
}
}
/// 执行请求前钩子 (带隔离)
pub async fn run_on_request(
&self,
@@ -267,24 +327,41 @@ impl PluginManager {
}
let timeout_ms = instance.config.timeout_ms;
let policy = self.build_policy(timeout_ms);
let plugin = instance.plugin.clone();
let plugin_name = plugin.name().to_string();
let base_ctx = ctx.clone();
let base_request = request.clone();
// 带超时执行
let result = match timeout(
Duration::from_millis(timeout_ms),
plugin.on_request(ctx, request),
)
.await
let result = match self
.task_tracker
.execute(&plugin_name, "on_request", policy, move |_attempt| {
let plugin = plugin.clone();
let mut attempt_ctx = base_ctx.clone();
let mut attempt_request = base_request.clone();
async move {
let hook_result = plugin
.on_request(&mut attempt_ctx, &mut attempt_request)
.await?;
Ok((hook_result, attempt_ctx, attempt_request))
}
})
.await
{
Ok(Ok(result)) => result,
Ok(Err(e)) => {
tracing::warn!("插件 {} on_request 执行失败: {}", plugin_name, e);
HookResult::failure(e.to_string(), timeout_ms)
Ok((hook_result, next_ctx, next_request)) => {
*ctx = next_ctx;
*request = next_request;
hook_result
}
Err(_) => {
tracing::warn!("插件 {} on_request 执行超时", plugin_name);
HookResult::failure(format!("执行超时 ({timeout_ms}ms)"), timeout_ms)
Err(failure) => {
tracing::warn!(
"插件 {} on_request 执行失败: {} (state={:?}, attempts={})",
plugin_name,
failure.message,
failure.state,
failure.attempts
);
HookResult::failure(failure.message, timeout_ms)
}
};
@@ -321,24 +398,41 @@ impl PluginManager {
}
let timeout_ms = instance.config.timeout_ms;
let policy = self.build_policy(timeout_ms);
let plugin = instance.plugin.clone();
let plugin_name = plugin.name().to_string();
let base_ctx = ctx.clone();
let base_response = response.clone();
// 带超时执行
let result = match timeout(
Duration::from_millis(timeout_ms),
plugin.on_response(ctx, response),
)
.await
let result = match self
.task_tracker
.execute(&plugin_name, "on_response", policy, move |_attempt| {
let plugin = plugin.clone();
let mut attempt_ctx = base_ctx.clone();
let mut attempt_response = base_response.clone();
async move {
let hook_result = plugin
.on_response(&mut attempt_ctx, &mut attempt_response)
.await?;
Ok((hook_result, attempt_ctx, attempt_response))
}
})
.await
{
Ok(Ok(result)) => result,
Ok(Err(e)) => {
tracing::warn!("插件 {} on_response 执行失败: {}", plugin_name, e);
HookResult::failure(e.to_string(), timeout_ms)
Ok((hook_result, next_ctx, next_response)) => {
*ctx = next_ctx;
*response = next_response;
hook_result
}
Err(_) => {
tracing::warn!("插件 {} on_response 执行超时", plugin_name);
HookResult::failure(format!("执行超时 ({timeout_ms}ms)"), timeout_ms)
Err(failure) => {
tracing::warn!(
"插件 {} on_response 执行失败: {} (state={:?}, attempts={})",
plugin_name,
failure.message,
failure.state,
failure.attempts
);
HookResult::failure(failure.message, timeout_ms)
}
};
@@ -371,24 +465,38 @@ impl PluginManager {
}
let timeout_ms = instance.config.timeout_ms;
let policy = self.build_policy(timeout_ms);
let plugin = instance.plugin.clone();
let plugin_name = plugin.name().to_string();
let base_ctx = ctx.clone();
let error_text = error.to_string();
// 带超时执行
let result = match timeout(
Duration::from_millis(timeout_ms),
plugin.on_error(ctx, error),
)
.await
let result = match self
.task_tracker
.execute(&plugin_name, "on_error", policy, move |_attempt| {
let plugin = plugin.clone();
let mut attempt_ctx = base_ctx.clone();
let error_text = error_text.clone();
async move {
let hook_result = plugin.on_error(&mut attempt_ctx, &error_text).await?;
Ok((hook_result, attempt_ctx))
}
})
.await
{
Ok(Ok(result)) => result,
Ok(Err(e)) => {
tracing::warn!("插件 {} on_error 执行失败: {}", plugin_name, e);
HookResult::failure(e.to_string(), timeout_ms)
Ok((hook_result, next_ctx)) => {
*ctx = next_ctx;
hook_result
}
Err(_) => {
tracing::warn!("插件 {} on_error 执行超时", plugin_name);
HookResult::failure(format!("执行超时 ({timeout_ms}ms)"), timeout_ms)
Err(failure) => {
tracing::warn!(
"插件 {} on_error 执行失败: {} (state={:?}, attempts={})",
plugin_name,
failure.message,
failure.state,
failure.attempts
);
HookResult::failure(failure.message, timeout_ms)
}
};
@@ -452,9 +560,16 @@ impl PluginManager {
.get(plugin_id)
.ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))?;
// TODO: 检查插件是否实现了 PluginUI trait
// 目前返回空列表
Ok(Vec::new())
let policy = self.build_policy(self.config.default_timeout_ms);
self.task_tracker
.execute(plugin_id, "get_plugin_surfaces", policy, |_attempt| async {
Ok::<_, PluginError>(Vec::new())
})
.await
.map_err(|failure| PluginError::ExecutionError {
plugin_name: plugin_id.to_string(),
message: failure.message,
})
}
/// 处理插件 UI 操作
@@ -468,16 +583,29 @@ impl PluginManager {
.get(plugin_id)
.ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))?;
// TODO: 将操作转发给插件的 handle_action 方法
// 目前返回空列表
tracing::debug!(
"收到插件 {} 的 UI 操作: {} (surface: {})",
plugin_id,
action.name,
action.surface_id
);
let action_name = action.name.clone();
let surface_id = action.surface_id.clone();
let policy = self.build_policy(self.config.default_timeout_ms);
Ok(Vec::new())
self.task_tracker
.execute(plugin_id, "handle_plugin_action", policy, move |_attempt| {
let action_name = action_name.clone();
let surface_id = surface_id.clone();
async move {
tracing::debug!(
"收到插件 {} 的 UI 操作: {} (surface: {})",
plugin_id,
action_name,
surface_id
);
Ok::<_, PluginError>(Vec::new())
}
})
.await
.map_err(|failure| PluginError::ExecutionError {
plugin_name: plugin_id.to_string(),
message: failure.message,
})
}
}
+5
View File
@@ -14,6 +14,7 @@ pub mod examples;
pub mod installer;
mod loader;
mod manager;
mod task;
mod types;
pub mod ui_builder;
pub mod ui_trait;
@@ -22,6 +23,10 @@ pub mod ui_types;
pub use binary_downloader::BinaryDownloader;
pub use loader::PluginLoader;
pub use manager::PluginManager;
pub use task::{
PluginQueueStats, PluginTaskError, PluginTaskEventPayload, PluginTaskFailure, PluginTaskPolicy,
PluginTaskRecord, PluginTaskState, PluginTaskTracker,
};
pub use types::{
BinaryComponentStatus, BinaryManifest, HookResult, PlatformBinaries, Plugin, PluginConfig,
PluginContext, PluginError, PluginInfo, PluginManifest, PluginState, PluginStatus, PluginType,
+855
View File
@@ -0,0 +1,855 @@
//! 插件任务执行治理模型
//!
//! 提供统一的任务状态、重试、超时、并发和队列治理能力。
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::future::Future;
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{RwLock, Semaphore};
use tokio::time::{sleep, timeout};
use uuid::Uuid;
use crate::event_emit::DynEmitter;
use super::types::PluginError;
/// 插件任务状态
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginTaskState {
Queued,
Running,
Retrying,
Succeeded,
Failed,
Cancelled,
TimedOut,
}
impl PluginTaskState {
pub fn is_terminal(self) -> bool {
matches!(
self,
PluginTaskState::Succeeded
| PluginTaskState::Failed
| PluginTaskState::Cancelled
| PluginTaskState::TimedOut
)
}
}
impl FromStr for PluginTaskState {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"queued" => Ok(Self::Queued),
"running" => Ok(Self::Running),
"retrying" => Ok(Self::Retrying),
"succeeded" => Ok(Self::Succeeded),
"failed" => Ok(Self::Failed),
"cancelled" => Ok(Self::Cancelled),
"timed_out" => Ok(Self::TimedOut),
_ => Err(format!("未知任务状态: {s}")),
}
}
}
/// 任务错误详情
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginTaskError {
pub code: Option<String>,
pub message: String,
pub retryable: bool,
}
/// 插件任务执行策略
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginTaskPolicy {
pub timeout_ms: u64,
pub max_retries: u32,
pub retry_backoff_ms: u64,
pub max_concurrency_per_plugin: usize,
pub queue_limit_per_plugin: usize,
}
impl Default for PluginTaskPolicy {
fn default() -> Self {
Self {
timeout_ms: 30_000,
max_retries: 2,
retry_backoff_ms: 300,
max_concurrency_per_plugin: 4,
queue_limit_per_plugin: 100,
}
}
}
/// 插件任务记录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginTaskRecord {
pub task_id: String,
pub plugin_id: String,
pub operation: String,
pub state: PluginTaskState,
pub attempt: u32,
pub max_retries: u32,
pub started_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
pub duration_ms: Option<u64>,
pub error: Option<PluginTaskError>,
}
impl PluginTaskRecord {
fn new(task_id: String, plugin_id: String, operation: String, max_retries: u32) -> Self {
Self {
task_id,
plugin_id,
operation,
state: PluginTaskState::Queued,
attempt: 0,
max_retries,
started_at: Utc::now(),
ended_at: None,
duration_ms: None,
error: None,
}
}
fn finish_with_success(&mut self, attempt: u32, started: Instant) {
self.state = PluginTaskState::Succeeded;
self.attempt = attempt;
self.ended_at = Some(Utc::now());
self.duration_ms = Some(started.elapsed().as_millis() as u64);
self.error = None;
}
fn finish_with_failure(
&mut self,
state: PluginTaskState,
attempt: u32,
started: Instant,
error: PluginTaskError,
) {
self.state = state;
self.attempt = attempt;
self.ended_at = Some(Utc::now());
self.duration_ms = Some(started.elapsed().as_millis() as u64);
self.error = Some(error);
}
}
/// 前端消费的任务事件载荷
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginTaskEventPayload {
pub plugin_id: String,
pub task_id: String,
pub operation: String,
pub state: PluginTaskState,
pub attempt: u32,
pub timestamp: String,
pub error: Option<PluginTaskError>,
}
impl PluginTaskEventPayload {
fn from_record(record: &PluginTaskRecord) -> Self {
Self {
plugin_id: record.plugin_id.clone(),
task_id: record.task_id.clone(),
operation: record.operation.clone(),
state: record.state,
attempt: record.attempt,
timestamp: Utc::now().to_rfc3339(),
error: record.error.clone(),
}
}
}
/// 任务失败返回
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginTaskFailure {
pub task_id: String,
pub state: PluginTaskState,
pub attempts: u32,
pub message: String,
pub retryable: bool,
}
impl PluginTaskFailure {
fn new(
task_id: String,
state: PluginTaskState,
attempts: u32,
message: String,
retryable: bool,
) -> Self {
Self {
task_id,
state,
attempts,
message,
retryable,
}
}
}
/// 插件队列统计信息
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginQueueStats {
pub plugin_id: String,
pub running: usize,
pub waiting: usize,
pub rejected: u64,
pub completed: u64,
pub failed: u64,
pub cancelled: u64,
pub timed_out: u64,
}
#[derive(Default)]
struct QueueMetrics {
running: AtomicUsize,
waiting: AtomicUsize,
rejected: AtomicU64,
completed: AtomicU64,
failed: AtomicU64,
cancelled: AtomicU64,
timed_out: AtomicU64,
}
impl QueueMetrics {
fn snapshot(&self, plugin_id: String) -> PluginQueueStats {
PluginQueueStats {
plugin_id,
running: self.running.load(Ordering::SeqCst),
waiting: self.waiting.load(Ordering::SeqCst),
rejected: self.rejected.load(Ordering::SeqCst),
completed: self.completed.load(Ordering::SeqCst),
failed: self.failed.load(Ordering::SeqCst),
cancelled: self.cancelled.load(Ordering::SeqCst),
timed_out: self.timed_out.load(Ordering::SeqCst),
}
}
}
struct RunningGuard {
metrics: Arc<QueueMetrics>,
}
impl RunningGuard {
fn new(metrics: Arc<QueueMetrics>) -> Self {
Self { metrics }
}
}
impl Drop for RunningGuard {
fn drop(&mut self) {
self.metrics.running.fetch_sub(1, Ordering::SeqCst);
}
}
/// 插件任务跟踪器
pub struct PluginTaskTracker {
tasks: DashMap<String, PluginTaskRecord>,
semaphores: DashMap<String, Arc<Semaphore>>,
queue_metrics: DashMap<String, Arc<QueueMetrics>>,
cancel_flags: DashMap<String, Arc<AtomicBool>>,
retention_limit: usize,
emitter: Arc<RwLock<Option<DynEmitter>>>,
}
impl Default for PluginTaskTracker {
fn default() -> Self {
Self::new(2_000)
}
}
impl PluginTaskTracker {
pub fn new(retention_limit: usize) -> Self {
Self {
tasks: DashMap::new(),
semaphores: DashMap::new(),
queue_metrics: DashMap::new(),
cancel_flags: DashMap::new(),
retention_limit: retention_limit.max(100),
emitter: Arc::new(RwLock::new(None)),
}
}
pub async fn set_emitter(&self, emitter: DynEmitter) {
let mut guard = self.emitter.write().await;
*guard = Some(emitter);
}
pub async fn clear_emitter(&self) {
let mut guard = self.emitter.write().await;
*guard = None;
}
pub fn get_task(&self, task_id: &str) -> Option<PluginTaskRecord> {
self.tasks.get(task_id).map(|entry| entry.value().clone())
}
pub fn list_tasks(
&self,
plugin_id: Option<&str>,
state: Option<PluginTaskState>,
limit: usize,
) -> Vec<PluginTaskRecord> {
let mut records: Vec<PluginTaskRecord> = self
.tasks
.iter()
.filter_map(|entry| {
let record = entry.value();
if let Some(plugin_id_filter) = plugin_id {
if record.plugin_id != plugin_id_filter {
return None;
}
}
if let Some(state_filter) = state {
if record.state != state_filter {
return None;
}
}
Some(record.clone())
})
.collect();
records.sort_by(|a, b| b.started_at.cmp(&a.started_at));
records.truncate(limit.max(1));
records
}
pub fn cancel_task(&self, task_id: &str) -> bool {
let Some(flag) = self.cancel_flags.get(task_id) else {
return false;
};
flag.store(true, Ordering::SeqCst);
true
}
pub fn queue_stats(&self, plugin_id: Option<&str>) -> Vec<PluginQueueStats> {
let mut items = Vec::new();
for entry in &self.queue_metrics {
if let Some(plugin_filter) = plugin_id {
if entry.key() != plugin_filter {
continue;
}
}
items.push(entry.value().snapshot(entry.key().clone()));
}
items.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id));
items
}
pub async fn execute<T, F, Fut>(
&self,
plugin_id: &str,
operation: &str,
mut policy: PluginTaskPolicy,
mut operation_fn: F,
) -> Result<T, PluginTaskFailure>
where
T: Send + 'static,
F: FnMut(u32) -> Fut + Send,
Fut: Future<Output = Result<T, PluginError>> + Send,
{
if policy.max_concurrency_per_plugin == 0 {
policy.max_concurrency_per_plugin = 1;
}
if policy.queue_limit_per_plugin == 0 {
policy.queue_limit_per_plugin = 1;
}
if policy.timeout_ms == 0 {
policy.timeout_ms = 1;
}
let task_id = Uuid::new_v4().to_string();
let total_started = Instant::now();
let mut record = PluginTaskRecord::new(
task_id.clone(),
plugin_id.to_string(),
operation.to_string(),
policy.max_retries,
);
self.upsert_task(record.clone());
self.emit_task_event(&record).await;
let cancel_flag = Arc::new(AtomicBool::new(false));
self.cancel_flags
.insert(task_id.clone(), Arc::clone(&cancel_flag));
let semaphore = self
.semaphores
.entry(plugin_id.to_string())
.or_insert_with(|| Arc::new(Semaphore::new(policy.max_concurrency_per_plugin)))
.clone();
let metrics = self
.queue_metrics
.entry(plugin_id.to_string())
.or_insert_with(|| Arc::new(QueueMetrics::default()))
.clone();
let waiting_now = metrics.waiting.fetch_add(1, Ordering::SeqCst) + 1;
if waiting_now > policy.queue_limit_per_plugin {
metrics.waiting.fetch_sub(1, Ordering::SeqCst);
metrics.rejected.fetch_add(1, Ordering::SeqCst);
let error = PluginTaskError {
code: Some("QUEUE_LIMIT_EXCEEDED".to_string()),
message: format!(
"插件 {plugin_id} 队列已满 (limit={})",
policy.queue_limit_per_plugin
),
retryable: false,
};
record.finish_with_failure(PluginTaskState::Failed, 0, total_started, error.clone());
self.upsert_task(record.clone());
self.cancel_flags.remove(&task_id);
self.emit_task_event(&record).await;
return Err(PluginTaskFailure::new(
task_id,
PluginTaskState::Failed,
0,
error.message,
false,
));
}
let permit = match semaphore.acquire_owned().await {
Ok(permit) => permit,
Err(err) => {
metrics.waiting.fetch_sub(1, Ordering::SeqCst);
let error = PluginTaskError {
code: Some("SEMAPHORE_CLOSED".to_string()),
message: format!("无法获取插件执行许可: {err}"),
retryable: true,
};
record.finish_with_failure(
PluginTaskState::Failed,
0,
total_started,
error.clone(),
);
self.upsert_task(record.clone());
self.cancel_flags.remove(&task_id);
self.emit_task_event(&record).await;
return Err(PluginTaskFailure::new(
task_id,
PluginTaskState::Failed,
0,
error.message,
true,
));
}
};
metrics.waiting.fetch_sub(1, Ordering::SeqCst);
metrics.running.fetch_add(1, Ordering::SeqCst);
let running_guard = RunningGuard::new(Arc::clone(&metrics));
if cancel_flag.load(Ordering::SeqCst) {
let error = PluginTaskError {
code: Some("TASK_CANCELLED".to_string()),
message: "任务已取消".to_string(),
retryable: false,
};
record.finish_with_failure(PluginTaskState::Cancelled, 0, total_started, error.clone());
metrics.cancelled.fetch_add(1, Ordering::SeqCst);
self.upsert_task(record.clone());
self.cancel_flags.remove(&task_id);
self.emit_task_event(&record).await;
drop(permit);
drop(running_guard);
return Err(PluginTaskFailure::new(
task_id,
PluginTaskState::Cancelled,
0,
error.message,
false,
));
}
let mut attempt: u32 = 0;
loop {
attempt += 1;
record.state = if attempt == 1 {
PluginTaskState::Running
} else {
PluginTaskState::Retrying
};
record.attempt = attempt;
record.error = None;
self.upsert_task(record.clone());
self.emit_task_event(&record).await;
let timed_result = timeout(
Duration::from_millis(policy.timeout_ms),
operation_fn(attempt),
)
.await;
match timed_result {
Ok(Ok(value)) => {
record.finish_with_success(attempt, total_started);
metrics.completed.fetch_add(1, Ordering::SeqCst);
self.upsert_task(record.clone());
self.cancel_flags.remove(&task_id);
self.emit_task_event(&record).await;
drop(permit);
drop(running_guard);
return Ok(value);
}
Ok(Err(err)) => {
let retryable = is_retryable_error(&err);
let can_retry = retryable
&& attempt <= policy.max_retries
&& !cancel_flag.load(Ordering::SeqCst);
if can_retry {
let backoff = backoff_duration(policy.retry_backoff_ms, attempt);
sleep(backoff).await;
continue;
}
let state = if cancel_flag.load(Ordering::SeqCst) {
PluginTaskState::Cancelled
} else {
PluginTaskState::Failed
};
let error = PluginTaskError {
code: classify_error_code(&err),
message: err.to_string(),
retryable,
};
record.finish_with_failure(state, attempt, total_started, error.clone());
match state {
PluginTaskState::Cancelled => {
metrics.cancelled.fetch_add(1, Ordering::SeqCst);
}
PluginTaskState::Failed => {
metrics.failed.fetch_add(1, Ordering::SeqCst);
}
_ => {}
}
self.upsert_task(record.clone());
self.cancel_flags.remove(&task_id);
self.emit_task_event(&record).await;
drop(permit);
drop(running_guard);
return Err(PluginTaskFailure::new(
task_id,
state,
attempt,
error.message,
retryable,
));
}
Err(_) => {
let can_retry =
attempt <= policy.max_retries && !cancel_flag.load(Ordering::SeqCst);
if can_retry {
let backoff = backoff_duration(policy.retry_backoff_ms, attempt);
sleep(backoff).await;
continue;
}
let state = if cancel_flag.load(Ordering::SeqCst) {
PluginTaskState::Cancelled
} else {
PluginTaskState::TimedOut
};
let error = PluginTaskError {
code: Some(if state == PluginTaskState::TimedOut {
"TIMEOUT".to_string()
} else {
"TASK_CANCELLED".to_string()
}),
message: if state == PluginTaskState::TimedOut {
format!("执行超时: {}ms", policy.timeout_ms)
} else {
"任务已取消".to_string()
},
retryable: state == PluginTaskState::TimedOut,
};
record.finish_with_failure(state, attempt, total_started, error.clone());
match state {
PluginTaskState::TimedOut => {
metrics.timed_out.fetch_add(1, Ordering::SeqCst);
}
PluginTaskState::Cancelled => {
metrics.cancelled.fetch_add(1, Ordering::SeqCst);
}
_ => {}
}
self.upsert_task(record.clone());
self.cancel_flags.remove(&task_id);
self.emit_task_event(&record).await;
drop(permit);
drop(running_guard);
return Err(PluginTaskFailure::new(
task_id,
state,
attempt,
error.message,
state == PluginTaskState::TimedOut,
));
}
}
}
}
fn upsert_task(&self, record: PluginTaskRecord) {
self.tasks.insert(record.task_id.clone(), record);
self.trim_retention();
}
fn trim_retention(&self) {
if self.tasks.len() <= self.retention_limit {
return;
}
while self.tasks.len() > self.retention_limit {
let oldest_id = self
.tasks
.iter()
.min_by_key(|entry| entry.value().started_at)
.map(|entry| entry.key().clone());
let Some(oldest_id) = oldest_id else {
break;
};
self.tasks.remove(&oldest_id);
self.cancel_flags.remove(&oldest_id);
}
}
async fn emit_task_event(&self, record: &PluginTaskRecord) {
let payload = PluginTaskEventPayload::from_record(record);
let Ok(value) = serde_json::to_value(payload) else {
return;
};
let emitter = self.emitter.read().await.clone();
if let Some(emitter) = emitter {
let _ = emitter.emit_event("plugin-task-event", &value);
}
}
}
fn backoff_duration(base_ms: u64, attempt: u32) -> Duration {
let factor = 2_u64.saturating_pow(attempt.saturating_sub(1));
Duration::from_millis(base_ms.max(1).saturating_mul(factor))
}
fn classify_error_code(err: &PluginError) -> Option<String> {
match err {
PluginError::Timeout { .. } => Some("TIMEOUT".to_string()),
PluginError::Disabled(_) => Some("PLUGIN_DISABLED".to_string()),
PluginError::NotFound(_) => Some("PLUGIN_NOT_FOUND".to_string()),
PluginError::ConfigError(_) => Some("CONFIG_ERROR".to_string()),
PluginError::LoadError(_) => Some("LOAD_ERROR".to_string()),
PluginError::InitError(_) => Some("INIT_ERROR".to_string()),
PluginError::ExecutionError { message, .. } => {
if message.contains("401") || message.contains("403") {
Some("AUTH_ERROR".to_string())
} else if message.contains("429") {
Some("RATE_LIMIT".to_string())
} else if message.contains("500")
|| message.contains("502")
|| message.contains("503")
|| message.contains("504")
{
Some("UPSTREAM_5XX".to_string())
} else {
Some("EXECUTION_ERROR".to_string())
}
}
_ => Some("UNKNOWN".to_string()),
}
}
fn is_retryable_error(err: &PluginError) -> bool {
match err {
PluginError::Timeout { .. } => true,
PluginError::ExecutionError { message, .. } => {
let lower = message.to_lowercase();
message.contains("429")
|| message.contains("500")
|| message.contains("502")
|| message.contains("503")
|| message.contains("504")
|| lower.contains("timeout")
|| lower.contains("temporar")
|| lower.contains("connection")
|| lower.contains("network")
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::Mutex;
#[tokio::test]
async fn test_execute_success_and_record_terminal_state() {
let tracker = PluginTaskTracker::new(100);
let policy = PluginTaskPolicy::default();
let result = tracker
.execute("demo-plugin", "on_request", policy, |_attempt| async move {
Ok::<_, PluginError>("ok".to_string())
})
.await
.expect("执行应成功");
assert_eq!(result, "ok");
let tasks = tracker.list_tasks(Some("demo-plugin"), None, 10);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].state, PluginTaskState::Succeeded);
assert_eq!(tasks[0].attempt, 1);
}
#[tokio::test]
async fn test_retry_then_success() {
let tracker = PluginTaskTracker::new(100);
let policy = PluginTaskPolicy {
max_retries: 2,
retry_backoff_ms: 1,
..PluginTaskPolicy::default()
};
let counter = Arc::new(Mutex::new(0_u32));
let result = tracker
.execute("retry-plugin", "on_response", policy, {
let counter = Arc::clone(&counter);
move |_attempt| {
let counter = Arc::clone(&counter);
async move {
let mut lock = counter.lock().await;
*lock += 1;
if *lock < 2 {
Err(PluginError::ExecutionError {
plugin_name: "retry-plugin".to_string(),
message: "503 upstream unavailable".to_string(),
})
} else {
Ok::<_, PluginError>("recovered".to_string())
}
}
}
})
.await
.expect("应在重试后成功");
assert_eq!(result, "recovered");
let tasks = tracker.list_tasks(Some("retry-plugin"), None, 10);
assert_eq!(tasks[0].state, PluginTaskState::Succeeded);
assert_eq!(tasks[0].attempt, 2);
}
#[tokio::test]
async fn test_timeout_to_terminal_state() {
let tracker = PluginTaskTracker::new(100);
let policy = PluginTaskPolicy {
timeout_ms: 30,
max_retries: 0,
..PluginTaskPolicy::default()
};
let result = tracker
.execute(
"timeout-plugin",
"on_error",
policy,
|_attempt| async move {
sleep(Duration::from_millis(80)).await;
Ok::<_, PluginError>("late".to_string())
},
)
.await;
assert!(result.is_err());
let err = result.expect_err("应超时失败");
assert_eq!(err.state, PluginTaskState::TimedOut);
let tasks = tracker.list_tasks(Some("timeout-plugin"), None, 10);
assert_eq!(tasks[0].state, PluginTaskState::TimedOut);
}
#[tokio::test]
async fn test_queue_limit_rejection() {
let tracker = Arc::new(PluginTaskTracker::new(100));
let policy = PluginTaskPolicy {
max_concurrency_per_plugin: 1,
queue_limit_per_plugin: 1,
timeout_ms: 500,
max_retries: 0,
..PluginTaskPolicy::default()
};
let tracker_a = Arc::clone(&tracker);
let policy_a = policy.clone();
let t1 = tokio::spawn(async move {
tracker_a
.execute(
"queue-plugin",
"on_request",
policy_a,
|_attempt| async move {
sleep(Duration::from_millis(150)).await;
Ok::<_, PluginError>("t1".to_string())
},
)
.await
});
sleep(Duration::from_millis(20)).await;
let tracker_b = Arc::clone(&tracker);
let policy_b = policy.clone();
let t2 = tokio::spawn(async move {
tracker_b
.execute(
"queue-plugin",
"on_request",
policy_b,
|_attempt| async move {
sleep(Duration::from_millis(80)).await;
Ok::<_, PluginError>("t2".to_string())
},
)
.await
});
sleep(Duration::from_millis(20)).await;
let t3 = tracker
.execute(
"queue-plugin",
"on_request",
policy,
|_attempt| async move { Ok::<_, PluginError>("t3".to_string()) },
)
.await;
let r1 = t1.await.expect("join t1");
let r2 = t2.await.expect("join t2");
assert!(r1.is_ok());
assert!(r2.is_ok());
assert!(t3.is_err());
let stats = tracker.queue_stats(Some("queue-plugin"));
assert_eq!(stats.len(), 1);
assert!(stats[0].rejected >= 1);
}
}
@@ -266,6 +266,7 @@ mod property_tests {
default_timeout_ms: 1000,
enabled: true,
max_plugins: 10,
..PluginManagerConfig::default()
};
let manager = PluginManager::new(temp_dir.path().to_path_buf(), config);
@@ -297,6 +298,7 @@ mod property_tests {
default_timeout_ms: 1000,
enabled: false, // 禁用插件系统
max_plugins: 10,
..PluginManagerConfig::default()
};
let manager = PluginManager::new(temp_dir.path().to_path_buf(), config);
@@ -744,7 +744,10 @@ impl CodexProvider {
}
// 3. OAuth 刷新流程(标准流程)
let refresh_token = self.credentials.refresh_token.as_ref().unwrap();
let refresh_token =
self.credentials.refresh_token.as_ref().ok_or_else(|| {
create_config_error("OAuth 刷新令牌不可用 (refresh_token is None)")
})?;
tracing::info!("[CODEX] 正在刷新 access token");
@@ -2351,7 +2354,7 @@ pub async fn start_codex_oauth_server_and_get_url() -> Result<
let uuid = Uuid::new_v4().to_string();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_secs();
let filename = format!("codex_{}_{}.json", &uuid[..8], timestamp);
let creds_file_path = creds_dir.join(&filename);
+12 -12
View File
@@ -5,7 +5,7 @@
use super::batch::{BatchTask, BatchTaskStatus};
use super::template::TaskTemplate;
use anyhow::{Context, Result};
use proxycast_core::database::DbConnection;
use proxycast_core::database::{lock_db, DbConnection};
use rusqlite::{params, OptionalExtension};
use uuid::Uuid;
@@ -15,7 +15,7 @@ pub struct BatchTaskDao;
impl BatchTaskDao {
/// 初始化数据库表
pub fn init_tables(db: &DbConnection) -> Result<()> {
let conn = db.lock().unwrap();
let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?;
// 创建批量任务表
conn.execute(
@@ -69,7 +69,7 @@ impl BatchTaskDao {
/// 保存批量任务
pub fn save(db: &DbConnection, batch_task: &BatchTask) -> Result<()> {
let conn = db.lock().unwrap();
let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?;
let options_json = serde_json::to_string(&batch_task.options)?;
let tasks_json = serde_json::to_string(&batch_task.tasks)?;
@@ -104,7 +104,7 @@ impl BatchTaskDao {
/// 根据 ID 查询批量任务
pub fn get_by_id(db: &DbConnection, id: &Uuid) -> Result<Option<BatchTask>> {
let conn = db.lock().unwrap();
let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?;
let mut stmt = conn.prepare(
"SELECT id, name, template_id, status, options_json, tasks_json, results_json,
@@ -185,7 +185,7 @@ impl BatchTaskDao {
/// 查询所有批量任务
pub fn list_all(db: &DbConnection, limit: usize) -> Result<Vec<BatchTask>> {
let conn = db.lock().unwrap();
let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?;
let mut stmt = conn.prepare(
"SELECT id, name, template_id, status, options_json, tasks_json, results_json,
@@ -268,7 +268,7 @@ impl BatchTaskDao {
/// 删除批量任务
pub fn delete(db: &DbConnection, id: &Uuid) -> Result<bool> {
let conn = db.lock().unwrap();
let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?;
let affected = conn.execute(
"DELETE FROM batch_tasks WHERE id = ?1",
@@ -280,7 +280,7 @@ impl BatchTaskDao {
/// 更新批量任务状态
pub fn update_status(db: &DbConnection, id: &Uuid, status: BatchTaskStatus) -> Result<()> {
let conn = db.lock().unwrap();
let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?;
conn.execute(
"UPDATE batch_tasks SET status = ?1 WHERE id = ?2",
@@ -301,7 +301,7 @@ impl BatchTaskDao {
started_at: Option<chrono::DateTime<chrono::Utc>>,
completed_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<()> {
let conn = db.lock().unwrap();
let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?;
let results_json = if results.is_empty() {
None
@@ -330,7 +330,7 @@ pub struct TemplateDao;
impl TemplateDao {
/// 保存模板
pub fn save(db: &DbConnection, template: &TaskTemplate) -> Result<()> {
let conn = db.lock().unwrap();
let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?;
conn.execute(
"INSERT OR REPLACE INTO batch_templates
@@ -357,7 +357,7 @@ impl TemplateDao {
/// 根据 ID 查询模板
pub fn get_by_id(db: &DbConnection, id: &Uuid) -> Result<Option<TaskTemplate>> {
let conn = db.lock().unwrap();
let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?;
let mut stmt = conn.prepare(
"SELECT id, name, description, model, system_prompt, user_message_template,
@@ -391,7 +391,7 @@ impl TemplateDao {
/// 查询所有模板
pub fn list_all(db: &DbConnection) -> Result<Vec<TaskTemplate>> {
let conn = db.lock().unwrap();
let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?;
let mut stmt = conn.prepare(
"SELECT id, name, description, model, system_prompt, user_message_template,
@@ -429,7 +429,7 @@ impl TemplateDao {
/// 删除模板
pub fn delete(db: &DbConnection, id: &Uuid) -> Result<bool> {
let conn = db.lock().unwrap();
let conn = lock_db(db).map_err(|e| anyhow::anyhow!(e))?;
let affected = conn.execute(
"DELETE FROM batch_templates WHERE id = ?1",
+1 -1
View File
@@ -232,7 +232,7 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
// 初始化默认技能仓库
{
let conn = db.lock().expect("Failed to lock database");
let conn = database::lock_db(&db).map_err(|e| format!("Failed to lock database: {e}"))?;
database::dao::skills::SkillDao::init_default_skill_repos(&conn)
.map_err(|e| format!("初始化默认技能仓库失败: {e}"))?;
}
+19
View File
@@ -240,6 +240,21 @@ pub fn run() {
tracing::info!("[启动] MCP Manager 事件发射器已设置");
}
// 设置 PluginManager 的任务事件发射器(用于发送 plugin-task-event)
if let Some(plugin_manager) =
app.try_state::<crate::commands::plugin_cmd::PluginManagerState>()
{
let app_handle = app.handle().clone();
let emitter = proxycast_core::DynEmitter::new(
crate::app::TauriEventEmitter(app_handle),
);
tauri::async_runtime::block_on(async {
let manager = plugin_manager.0.read().await;
manager.set_task_emitter(emitter).await;
});
tracing::info!("[启动] PluginManager 任务事件发射器已设置");
}
// 初始化截图对话模块
// _Requirements: 7.3_
{
@@ -990,6 +1005,10 @@ pub fn run() {
commands::plugin_cmd::reload_plugins,
commands::plugin_cmd::unload_plugin,
commands::plugin_cmd::get_plugins_dir,
commands::plugin_cmd::list_plugin_tasks,
commands::plugin_cmd::get_plugin_task,
commands::plugin_cmd::cancel_plugin_task,
commands::plugin_cmd::get_plugin_queue_stats,
// Plugin Install commands
commands::plugin_install_cmd::install_plugin_from_file,
commands::plugin_install_cmd::install_plugin_from_url,
+1 -1
View File
@@ -75,7 +75,7 @@ pub fn setup_app(
// 初始化默认 skill repos
{
let conn = db.lock().expect("Failed to lock database");
let conn = proxycast_core::database::lock_db(&db)?;
database::dao::skills::SkillDao::init_default_skill_repos(&conn)
.expect("Failed to initialize default skill repos");
}
@@ -1,6 +1,7 @@
//! Memory feedback commands
use crate::database::DbConnection;
use proxycast_core::database::lock_db;
use proxycast_memory::feedback::{
calculate_approval_rate, current_timestamp, generate_feedback_id, get_recent_feedbacks,
record_feedback, FeedbackAction, UserFeedback,
@@ -28,7 +29,7 @@ pub async fn unified_memory_feedback(
created_at: current_timestamp(),
};
let conn = db.lock().unwrap();
let conn = lock_db(&db)?;
record_feedback(&conn, &feedback)?;
Ok(())
@@ -39,7 +40,7 @@ pub async fn get_memory_feedback_stats(
db: State<'_, DbConnection>,
session_id: String,
) -> Result<FeedbackStats, String> {
let conn = db.lock().unwrap();
let conn = lock_db(&db)?;
let feedbacks = get_recent_feedbacks(&conn, &session_id, 50)?;
let approval_rate = calculate_approval_rate(&feedbacks);
+4 -3
View File
@@ -3,6 +3,7 @@
//! Provides Tauri commands for semantic and hybrid search
use crate::database::DbConnection;
use proxycast_core::database::lock_db;
use proxycast_memory::models::{
MemoryCategory, MemoryMetadata, MemorySource, MemoryType, UnifiedMemory,
};
@@ -161,7 +162,7 @@ pub async fn unified_memory_semantic_search(
.map_err(|e| format!("Failed to get embedding: {e}"))?;
let results = {
let conn = db.lock().unwrap();
let conn = lock_db(&db)?;
search::semantic_search(
&conn,
&query_embedding,
@@ -246,7 +247,7 @@ pub async fn unified_memory_hybrid_search(
// Execute semantic search
let semantic_results = {
let conn = db.lock().unwrap();
let conn = lock_db(&db)?;
search::semantic_search(
&conn,
&query_embedding,
@@ -263,7 +264,7 @@ pub async fn unified_memory_hybrid_search(
// Execute keyword search
let keyword_results: Vec<UnifiedMemory> = {
let conn = db.lock().unwrap();
let conn = lock_db(&db)?;
let query_clean = options.query.replace('%', "\\%").replace('_', "\\_");
let search_pattern = format!("%{query_clean}%");
let limit = options.limit.unwrap_or(50) as i64;
+57 -1
View File
@@ -11,7 +11,10 @@
#![allow(dead_code)]
use proxycast_core::plugin::{PluginConfig, PluginInfo, PluginManager, PluginManifest, PluginType};
use proxycast_core::plugin::{
PluginConfig, PluginInfo, PluginManager, PluginManifest, PluginQueueStats, PluginTaskRecord,
PluginTaskState, PluginType,
};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Arc;
@@ -155,6 +158,59 @@ pub async fn get_plugins_dir(
Ok(dir)
}
fn parse_task_state(state: Option<String>) -> Result<Option<PluginTaskState>, String> {
let Some(state) = state else {
return Ok(None);
};
state
.parse::<PluginTaskState>()
.map(Some)
.map_err(|e| format!("解析任务状态失败: {e}"))
}
/// 查询插件任务列表
#[tauri::command]
pub async fn list_plugin_tasks(
state: tauri::State<'_, PluginManagerState>,
plugin_id: Option<String>,
task_state: Option<String>,
limit: Option<usize>,
) -> Result<Vec<PluginTaskRecord>, String> {
let manager = state.0.read().await;
let parsed_state = parse_task_state(task_state)?;
Ok(manager.list_tasks(plugin_id.as_deref(), parsed_state, limit.unwrap_or(100)))
}
/// 获取单个插件任务
#[tauri::command]
pub async fn get_plugin_task(
state: tauri::State<'_, PluginManagerState>,
task_id: String,
) -> Result<Option<PluginTaskRecord>, String> {
let manager = state.0.read().await;
Ok(manager.get_task(&task_id))
}
/// 取消插件任务
#[tauri::command]
pub async fn cancel_plugin_task(
state: tauri::State<'_, PluginManagerState>,
task_id: String,
) -> Result<bool, String> {
let manager = state.0.read().await;
Ok(manager.cancel_task(&task_id))
}
/// 获取插件队列统计
#[tauri::command]
pub async fn get_plugin_queue_stats(
state: tauri::State<'_, PluginManagerState>,
plugin_id: Option<String>,
) -> Result<Vec<PluginQueueStats>, String> {
let manager = state.0.read().await;
Ok(manager.get_queue_stats(plugin_id.as_deref()))
}
// ============================================================================
// 插件 UI 注册系统
// ============================================================================
+2 -2
View File
@@ -67,7 +67,7 @@ fn copy_and_rename_credential_file(
let uuid = Uuid::new_v4().to_string();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_secs();
let new_filename = format!(
@@ -672,7 +672,7 @@ fn create_kiro_credential_from_json(json_content: &str) -> Result<String, String
let uuid = Uuid::new_v4().to_string();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_secs();
let new_filename = format!("kiro_{}_{}_{}.json", &uuid[..8], timestamp, "kiro");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.74.0",
"version": "0.75.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+33 -8
View File
@@ -4,42 +4,67 @@
## 文件索引
| 文件 | 说明 |
|------|------|
| `PluginsPage.tsx` | 插件中心页面,独立的导航栏入口 |
| `PluginManager.tsx` | 插件管理主组件,显示插件列表和状态 |
| `PluginInstallDialog.tsx` | 插件安装对话框,支持本地文件和 URL 安装 |
| `PluginUninstallDialog.tsx` | 插件卸载确认对话框 |
| `PluginUIRenderer.tsx` | 插件 UI 渲染器,根据 pluginId 渲染对应的插件 UI |
| 文件 | 说明 |
| --------------------------- | --------------------------------------------------- |
| `PluginsPage.tsx` | 插件中心页面,独立的导航栏入口 |
| `PluginManager.tsx` | 插件管理主组件,显示插件列表和状态 |
| `PluginInstallDialog.tsx` | 插件安装对话框,支持本地文件和 URL 安装 |
| `PluginUninstallDialog.tsx` | 插件卸载确认对话框 |
| `PluginUIRenderer.tsx` | 插件 UI 渲染器,根据 pluginId 渲染对应的插件 UI |
| `PluginItemContextMenu.tsx` | 插件项右键菜单,支持启用/禁用、打开目录、卸载等操作 |
| `index.ts` | 模块导出 |
| `index.ts` | 模块导出 |
## 功能说明
### PluginManager
- 显示插件系统状态概览
- 列出已加载的插件和已安装的插件包
- 提供安装/卸载入口
- 支持启用/禁用插件
- 提供运行诊断面板(任务筛选、搜索、分页、详情、取消)
- 支持时间范围筛选(1h / 24h / 7d / 自定义区间)与一键重置筛选
- 自定义区间支持快捷按钮(近 15/30/60 分钟)与区间提示文案
- 自定义区间支持记忆最近一次有效区间并一键应用
- 支持显示上次区间最近使用时间,并可一键复制区间
- 复制区间具备降级兜底(Clipboard API 不可用时自动回退)
- 支持一键复制结构化区间 JSON(start/end/updatedAt)
- 支持保存最近 5 条自定义区间历史并快速回填
- 支持删除单条历史区间与一键清空历史
- 支持为历史区间命名标签,便于快速识别与复用
- 支持收藏历史区间并在列表/下拉中置顶展示
- 历史区间支持按标签搜索与排序(最近使用/标签)
- 支持“仅收藏”快速筛选,便于聚焦常用区间
- 支持“收藏置顶”开关,允许切换为纯时间/标签排序
- 支持历史区间“视图模式”菜单(默认置顶/纯排序/仅收藏)
- 仅收藏模式下提供专属空态提示,提升筛选反馈清晰度
- 仅收藏模式下统计文案会突出“仅收藏匹配数”
- 支持历史区间 JSON 导入/导出(剪贴板)
- 支持将当前筛选任务导出为 CSV
- 自动持久化运行诊断筛选条件(localStorage)
### PluginInstallDialog
- 支持从本地文件安装(.zip, .tar.gz)
- 支持从 URL 下载安装(GitHub Releases 等)
- 显示安装进度(下载、验证、解压、安装、注册)
- 显示安装结果
### PluginUninstallDialog
- 显示插件信息确认
- 调用后端卸载命令
- 刷新插件列表
### PluginUIRenderer
- 根据 pluginId 渲染对应的插件 UI 组件
- 支持内置插件组件映射 (machine-id-tool -> MachineIdTool)
- 显示友好的错误提示(插件未找到、加载失败)
- 导出 Page 类型定义,支持动态插件路由
### PluginItemContextMenu
- 为已安装插件列表提供右键菜单
- 支持启用/禁用插件
- 支持打开插件目录
+137 -288
View File
@@ -1,7 +1,28 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { ComponentProps } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useWorkbenchStore } from "@/stores/useWorkbenchStore";
import {
clickButtonByText,
clickButtonByTitle,
clickElement,
cleanupMountedRoots,
findAsideByClassFragment,
findButtonByText,
findButtonByTitle,
findInputById,
findInputByPlaceholder,
fillTextInput,
flushEffects as flushAsyncEffects,
mountHarness,
setupReactActEnvironment,
triggerKeyboardShortcut,
type MountedRoot,
} from "./hooks/testUtils";
import {
createWorkspaceContentFixture,
createWorkspaceProjectFixture,
DEFAULT_WORKSPACE_PAGE_PROPS,
} from "./testFixtures";
const {
mockListProjects,
@@ -72,81 +93,90 @@ vi.mock("@/lib/api/project", () => ({
import { WorkbenchPage } from "./WorkbenchPage";
interface RenderResult {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = [];
const mountedRoots: MountedRoot[] = [];
function renderPage(
props: Partial<ComponentProps<typeof WorkbenchPage>> = {},
): RenderResult {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
) {
return mountHarness(
WorkbenchPage,
{ theme: "social-media", ...props },
mountedRoots,
);
}
act(() => {
root.render(<WorkbenchPage theme="social-media" {...props} />);
function renderDefaultWorkspacePage(
props: Partial<ComponentProps<typeof WorkbenchPage>> = {},
) {
return renderPage({
...DEFAULT_WORKSPACE_PAGE_PROPS,
...props,
});
mountedRoots.push({ container, root });
return { container, root };
}
async function flushEffects(times = 3): Promise<void> {
for (let i = 0; i < times; i += 1) {
await act(async () => {
await Promise.resolve();
});
}
await flushAsyncEffects(times);
}
function getLeftSidebar(container: HTMLElement): HTMLElement | null {
const matched = Array.from(container.querySelectorAll("aside")).find((aside) =>
aside.className.includes("bg-muted/20"),
);
return (matched as HTMLElement | undefined) ?? null;
async function enterDefaultWorkspace(options?: {
expandSidebar?: boolean;
}): Promise<{ container: HTMLDivElement }> {
const rendered = renderDefaultWorkspacePage();
await flushEffects();
if (options?.expandSidebar) {
triggerKeyboardShortcut(window, "b", { ctrlKey: true });
await flushEffects();
}
return { container: rendered.container };
}
function expectAgentWorkspaceVisible(container: HTMLElement): void {
expect(container.querySelector("[data-testid='agent-chat-page']")).not.toBeNull();
}
function expectWorkspaceNavigationVisible(container: HTMLElement): void {
expect(container.textContent).toContain("创作");
expect(container.textContent).toContain("发布");
}
async function enterProjectManagementFromWorkspace(
container: HTMLElement,
): Promise<void> {
const managementButton = findButtonByText(container, "项目管理");
expect(managementButton).toBeDefined();
clickButtonByText(container, "项目管理");
await flushEffects();
}
function expectProjectManagementLandingVisible(container: HTMLElement): void {
expect(container.textContent).toContain("统一创作工作区");
expect(container.textContent).toContain("进入创作");
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
setupReactActEnvironment();
localStorage.clear();
vi.clearAllMocks();
useWorkbenchStore.getState().setLeftSidebarCollapsed(true);
mockListProjects.mockResolvedValue([
{
createWorkspaceProjectFixture({
id: "project-1",
name: "社媒项目A",
workspaceType: "social-media",
rootPath: "/tmp/workspace/project-1",
isDefault: false,
createdAt: Date.now(),
updatedAt: Date.now(),
isFavorite: false,
isArchived: false,
tags: [],
},
}),
]);
mockListContents.mockResolvedValue([
{
createWorkspaceContentFixture({
id: "content-1",
project_id: "project-1",
title: "文稿A",
content_type: "post",
status: "draft",
order: 0,
word_count: 0,
created_at: Date.now(),
updated_at: Date.now(),
},
}),
]);
mockGetContent.mockResolvedValue({
@@ -156,16 +186,7 @@ beforeEach(() => {
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
cleanupMountedRoots(mountedRoots);
localStorage.clear();
});
@@ -174,7 +195,7 @@ describe("WorkbenchPage 左侧栏模式行为", () => {
const { container } = renderPage({ viewMode: "project-management" });
await flushEffects();
const leftSidebar = getLeftSidebar(container);
const leftSidebar = findAsideByClassFragment(container, "bg-muted/20");
expect(leftSidebar).not.toBeNull();
expect(leftSidebar?.className).toContain("w-[260px]");
expect(container.textContent).toContain("主题项目管理");
@@ -184,118 +205,54 @@ describe("WorkbenchPage 左侧栏模式行为", () => {
const { container } = renderPage({ viewMode: "project-management" });
await flushEffects();
const projectButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("社媒项目A"),
);
const projectButton = findButtonByText(container, "社媒项目A");
expect(projectButton).toBeDefined();
act(() => {
projectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
clickButtonByText(container, "社媒项目A");
await flushEffects();
expect(container.querySelector("[data-testid='agent-chat-page']")).not.toBeNull();
expect(container.textContent).toContain("创作");
expect(container.textContent).toContain("发布");
expectAgentWorkspaceVisible(container);
expectWorkspaceNavigationVisible(container);
});
it("作业模式默认收起左侧栏", async () => {
const { container } = renderPage({
viewMode: "workspace",
projectId: "project-1",
contentId: "content-1",
});
await flushEffects();
const { container } = await enterDefaultWorkspace();
expect(getLeftSidebar(container)).toBeNull();
expect(findAsideByClassFragment(container, "bg-muted/20")).toBeNull();
expect(container.textContent).not.toContain("主题项目管理");
});
it("作业模式展开侧栏后点击项目保持在统一工作区", async () => {
const { container } = renderPage({
viewMode: "workspace",
projectId: "project-1",
contentId: "content-1",
});
await flushEffects();
const { container } = await enterDefaultWorkspace({ expandSidebar: true });
act(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "b",
ctrlKey: true,
bubbles: true,
}),
);
});
await flushEffects();
const projectButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("社媒项目A"),
);
const projectButton = findButtonByText(container, "社媒项目A");
expect(projectButton).toBeDefined();
act(() => {
projectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
clickButtonByText(container, "社媒项目A");
await flushEffects();
expect(container.querySelector("[data-testid='agent-chat-page']")).not.toBeNull();
expectAgentWorkspaceVisible(container);
});
it("工作区点击项目管理后回到项目管理态", async () => {
const { container } = renderPage({
viewMode: "workspace",
projectId: "project-1",
contentId: "content-1",
});
await flushEffects();
const { container } = await enterDefaultWorkspace();
const managementButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("项目管理"),
);
expect(managementButton).toBeDefined();
act(() => {
managementButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushEffects();
expect(container.textContent).toContain("统一创作工作区");
expect(container.textContent).toContain("进入创作");
await enterProjectManagementFromWorkspace(container);
expectProjectManagementLandingVisible(container);
});
it("工作区点击项目管理后自动展开左侧栏", async () => {
const { container } = renderPage({
viewMode: "workspace",
projectId: "project-1",
contentId: "content-1",
});
await flushEffects();
const { container } = await enterDefaultWorkspace();
const managementButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("项目管理"),
);
expect(managementButton).not.toBeUndefined();
await enterProjectManagementFromWorkspace(container);
act(() => {
managementButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushEffects();
const leftSidebar = getLeftSidebar(container);
const leftSidebar = findAsideByClassFragment(container, "bg-muted/20");
expect(leftSidebar).not.toBeNull();
expect(leftSidebar?.className).toContain("w-[260px]");
expectProjectManagementLandingVisible(container);
expect(container.textContent).toContain("主题项目管理");
});
it("统一工作区中的聊天页隐藏内部顶部栏,避免双导航", async () => {
const { container } = renderPage({
viewMode: "workspace",
projectId: "project-1",
contentId: "content-1",
});
await flushEffects();
const { container } = await enterDefaultWorkspace();
const chat = container.querySelector("[data-testid='agent-chat-page']");
expect(chat).not.toBeNull();
@@ -304,18 +261,12 @@ describe("WorkbenchPage 左侧栏模式行为", () => {
it("视频主题在作业模式渲染主题工作区而非对话工作区", async () => {
mockListProjects.mockResolvedValueOnce([
{
createWorkspaceProjectFixture({
id: "video-project-1",
name: "视频项目A",
workspaceType: "video",
rootPath: "/tmp/workspace/video-project-1",
isDefault: false,
createdAt: Date.now(),
updatedAt: Date.now(),
isFavorite: false,
isArchived: false,
tags: [],
},
}),
]);
const { container } = renderPage({
@@ -333,193 +284,91 @@ describe("WorkbenchPage 左侧栏模式行为", () => {
});
it("切换到非创作视图时左侧显示紧凑提示并可返回创作视图", async () => {
const { container } = renderPage({
viewMode: "workspace",
projectId: "project-1",
contentId: "content-1",
});
await flushEffects();
const { container } = await enterDefaultWorkspace({ expandSidebar: true });
act(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "b",
ctrlKey: true,
bubbles: true,
}),
);
});
await flushEffects();
const publishButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "发布",
);
const publishButton = findButtonByText(container, "发布", { exact: true });
expect(publishButton).toBeDefined();
act(() => {
publishButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
clickButtonByText(container, "发布", { exact: true });
await flushEffects();
expect(container.textContent).toContain("当前处于「发布」视图");
expect(container.textContent).toContain("当前文稿:文稿A");
expect(container.textContent).toContain("返回创作视图");
expect(container.querySelector("input[placeholder='搜索文稿...']")).toBeNull();
expect(findInputByPlaceholder(container, "搜索文稿...")).toBeNull();
const openViewActionsButton = container.querySelector(
"button[title='展开视图动作']",
);
const openViewActionsButton = findButtonByTitle(container, "展开视图动作");
expect(openViewActionsButton).not.toBeNull();
act(() => {
openViewActionsButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
clickElement(openViewActionsButton);
await flushEffects();
expect(container.textContent).toContain("视图动作");
expect(container.textContent).toContain("前往设置视图");
const backToCreateButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "返回创作视图",
);
expect(backToCreateButton).toBeDefined();
act(() => {
backToCreateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const backToCreateButton = findButtonByText(container, "返回创作视图", {
exact: true,
});
expect(backToCreateButton).toBeDefined();
clickButtonByText(container, "返回创作视图", { exact: true });
await flushEffects();
expect(container.querySelector("input[placeholder='搜索文稿...']")).not.toBeNull();
expect(findInputByPlaceholder(container, "搜索文稿...")).not.toBeNull();
});
it("创建项目后保持选中新项目且重置项目搜索", async () => {
mockListProjects
.mockResolvedValueOnce([
{
id: "project-1",
name: "社媒项目A",
workspaceType: "social-media",
rootPath: "/tmp/workspace/project-1",
isDefault: false,
createdAt: Date.now(),
updatedAt: Date.now(),
isFavorite: false,
isArchived: false,
tags: [],
},
])
.mockResolvedValueOnce([
{
id: "project-1",
name: "社媒项目A",
workspaceType: "social-media",
rootPath: "/tmp/workspace/project-1",
isDefault: false,
createdAt: Date.now(),
updatedAt: Date.now(),
isFavorite: false,
isArchived: false,
tags: [],
},
{
id: "project-2",
name: "新项目B",
workspaceType: "social-media",
rootPath: "/tmp/workspace/新项目B",
isDefault: false,
createdAt: Date.now(),
updatedAt: Date.now(),
isFavorite: false,
isArchived: false,
tags: [],
},
]);
mockCreateProject.mockResolvedValue({
const baseProject = createWorkspaceProjectFixture({
id: "project-1",
name: "社媒项目A",
workspaceType: "social-media",
rootPath: "/tmp/workspace/project-1",
});
const createdProject = createWorkspaceProjectFixture({
id: "project-2",
name: "新项目B",
workspaceType: "social-media",
rootPath: "/tmp/workspace/新项目B",
isDefault: false,
createdAt: Date.now(),
updatedAt: Date.now(),
isFavorite: false,
isArchived: false,
tags: [],
});
const { container } = renderPage({
viewMode: "workspace",
projectId: "project-1",
contentId: "content-1",
});
await flushEffects();
mockListProjects
.mockResolvedValueOnce([baseProject])
.mockResolvedValueOnce([baseProject, createdProject]);
mockCreateProject.mockResolvedValue(createdProject);
act(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "b",
ctrlKey: true,
bubbles: true,
}),
);
});
await flushEffects();
const { container } = await enterDefaultWorkspace({ expandSidebar: true });
const projectSearchInput = container.querySelector(
"input[placeholder='搜索项目...']",
const projectSearchInput = findInputByPlaceholder(
container,
"搜索项目...",
) as HTMLInputElement | null;
expect(projectSearchInput).not.toBeNull();
act(() => {
if (!projectSearchInput) {
return;
}
projectSearchInput.value = "关键字";
projectSearchInput.dispatchEvent(new Event("input", { bubbles: true }));
});
fillTextInput(projectSearchInput, "关键字");
await flushEffects();
expect(projectSearchInput?.value).toBe("关键字");
const createProjectButton = container.querySelector("button[title='新建项目']");
const createProjectButton = findButtonByTitle(container, "新建项目");
expect(createProjectButton).not.toBeNull();
act(() => {
createProjectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
clickButtonByTitle(container, "新建项目");
await flushEffects();
const projectNameInput = document.querySelector(
"#workspace-project-name",
const projectNameInput = findInputById(
document,
"workspace-project-name",
) as HTMLInputElement | null;
expect(projectNameInput).not.toBeNull();
act(() => {
if (!projectNameInput) {
return;
}
projectNameInput.value = "新项目B";
projectNameInput.dispatchEvent(new Event("input", { bubbles: true }));
});
fillTextInput(projectNameInput, "新项目B");
await flushEffects();
const createButton = Array.from(document.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "创建项目",
);
const createButton = findButtonByText(document, "创建项目", { exact: true });
expect(createButton).toBeDefined();
act(() => {
createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
clickButtonByText(document, "创建项目", { exact: true });
await flushEffects(5);
expect(mockCreateProject).toHaveBeenCalled();
expect(mockListContents).toHaveBeenCalledWith("project-2");
expect(projectSearchInput?.value).toBe("");
const newProjectEntry = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("新项目B"),
);
const oldProjectEntry = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("社媒项目A"),
);
const newProjectEntry = findButtonByText(container, "新项目B");
const oldProjectEntry = findButtonByText(container, "社媒项目A");
expect(newProjectEntry).toBeDefined();
expect(newProjectEntry?.className).toContain("bg-accent text-accent-foreground");
expect(oldProjectEntry).toBeDefined();
@@ -1,101 +1,83 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { ComponentProps } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkbenchCreateContentDialog } from "./WorkbenchCreateContentDialog";
import {
clickButtonByText,
findButtonByText,
cleanupMountedRoots,
findInputById,
fillTextInput,
mountHarness,
setupReactActEnvironment,
type MountedRoot,
} from "../hooks/testUtils";
interface RenderResult {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: MountedRoot[] = [];
const mountedRoots: RenderResult[] = [];
type ContentDialogProps = ComponentProps<typeof WorkbenchCreateContentDialog>;
function setInputValue(input: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
function createDialogProps(
overrides: Partial<ContentDialogProps> = {},
): ContentDialogProps {
return {
open: true,
creatingContent: false,
step: "mode",
selectedProjectId: "project-1",
creationModeOptions: [
{ value: "guided", label: "引导模式", description: "分步骤提问" },
{ value: "fast", label: "快速模式", description: "快速起稿" },
],
selectedCreationMode: "guided",
onCreationModeChange: () => {},
currentCreationIntentFields: [
{
key: "topic",
label: "创作主题",
placeholder: "请输入主题",
},
],
creationIntentValues: {
topic: "",
targetAudience: "",
goal: "",
constraints: "",
contentType: "",
length: "",
corePoints: "",
tone: "",
outline: "",
mustInclude: "",
extraRequirements: "",
},
onCreationIntentValueChange: () => {},
currentIntentLength: 0,
minCreationIntentLength: 10,
creationIntentError: "",
onOpenChange: () => {},
onBackOrCancel: () => {},
onGoToIntentStep: () => {},
onCreateContent: () => {},
...overrides,
};
}
function renderDialog(
overrides: Partial<ComponentProps<typeof WorkbenchCreateContentDialog>> = {},
): RenderResult {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<WorkbenchCreateContentDialog
open={true}
creatingContent={false}
step="mode"
selectedProjectId="project-1"
creationModeOptions={[
{ value: "guided", label: "引导模式", description: "分步骤提问" },
{ value: "fast", label: "快速模式", description: "快速起稿" },
]}
selectedCreationMode="guided"
onCreationModeChange={() => {}}
currentCreationIntentFields={[
{
key: "topic",
label: "创作主题",
placeholder: "请输入主题",
},
]}
creationIntentValues={{
topic: "",
targetAudience: "",
goal: "",
constraints: "",
contentType: "",
length: "",
corePoints: "",
tone: "",
outline: "",
mustInclude: "",
extraRequirements: "",
}}
onCreationIntentValueChange={() => {}}
currentIntentLength={0}
minCreationIntentLength={10}
creationIntentError=""
onOpenChange={() => {}}
onBackOrCancel={() => {}}
onGoToIntentStep={() => {}}
onCreateContent={() => {}}
{...overrides}
/>,
);
});
const rendered = { container, root };
mountedRoots.push(rendered);
return rendered;
overrides: Partial<ContentDialogProps> = {},
) {
return mountHarness(
WorkbenchCreateContentDialog,
createDialogProps(overrides),
mountedRoots,
);
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
setupReactActEnvironment();
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
cleanupMountedRoots(mountedRoots);
});
describe("WorkbenchCreateContentDialog", () => {
@@ -107,21 +89,13 @@ describe("WorkbenchCreateContentDialog", () => {
expect(document.body.textContent).toContain("步骤 1/2");
expect(document.body.textContent).toContain("引导模式");
const fastModeButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.includes("快速模式"),
);
const nextButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "下一步",
);
const fastModeButton = findButtonByText(document.body, "快速模式");
const nextButton = findButtonByText(document.body, "下一步", { exact: true });
expect(fastModeButton).toBeDefined();
expect(nextButton).toBeDefined();
act(() => {
fastModeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
act(() => {
nextButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
clickButtonByText(document.body, "快速模式");
clickButtonByText(document.body, "下一步", { exact: true });
expect(onCreationModeChange).toHaveBeenCalledWith("fast");
expect(onGoToIntentStep).toHaveBeenCalledTimes(1);
@@ -139,9 +113,9 @@ describe("WorkbenchCreateContentDialog", () => {
expect(document.body.textContent).toContain("创作意图字数:6/10");
expect(document.body.textContent).toContain("创作意图至少需要 10 个字");
const createButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "创建并进入作业",
);
const createButton = findButtonByText(document.body, "创建并进入作业", {
exact: true,
});
expect(createButton).toBeDefined();
expect(createButton).toHaveProperty("disabled", true);
});
@@ -158,33 +132,22 @@ describe("WorkbenchCreateContentDialog", () => {
onCreateContent,
});
const topicInput = document.body.querySelector(
"input#creation-intent-topic",
const topicInput = findInputById(
document.body,
"creation-intent-topic",
) as HTMLInputElement | null;
expect(topicInput).not.toBeNull();
fillTextInput(topicInput, "新的主题");
act(() => {
if (!topicInput) {
return;
}
setInputValue(topicInput, "新的主题");
const backButton = findButtonByText(document.body, "上一步", { exact: true });
const createButton = findButtonByText(document.body, "创建并进入作业", {
exact: true,
});
const backButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "上一步",
);
const createButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "创建并进入作业",
);
expect(backButton).toBeDefined();
expect(createButton).toBeDefined();
act(() => {
backButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
act(() => {
createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
clickButtonByText(document.body, "上一步", { exact: true });
clickButtonByText(document.body, "创建并进入作业", { exact: true });
expect(onCreationIntentValueChange).toHaveBeenCalledWith("topic", "新的主题");
expect(onBackOrCancel).toHaveBeenCalledTimes(1);
@@ -1,74 +1,56 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { ComponentProps } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkbenchCreateProjectDialog } from "./WorkbenchCreateProjectDialog";
import {
clickButtonByText,
findButtonByText,
cleanupMountedRoots,
findInputById,
fillTextInput,
mountHarness,
setupReactActEnvironment,
type MountedRoot,
} from "../hooks/testUtils";
interface RenderResult {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: MountedRoot[] = [];
const mountedRoots: RenderResult[] = [];
type ProjectDialogProps = ComponentProps<typeof WorkbenchCreateProjectDialog>;
function setInputValue(input: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
function createDialogProps(
overrides: Partial<ProjectDialogProps> = {},
): ProjectDialogProps {
return {
open: true,
creatingProject: false,
newProjectName: "小说项目A",
projectTypeLabel: "小说创作",
workspaceProjectsRoot: "/tmp/workspace",
resolvedProjectPath: "/tmp/workspace/小说项目A",
pathChecking: false,
pathConflictMessage: "",
onOpenChange: () => {},
onProjectNameChange: () => {},
onCreateProject: () => {},
...overrides,
};
}
function renderDialog(
overrides: Partial<ComponentProps<typeof WorkbenchCreateProjectDialog>> = {},
): RenderResult {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<WorkbenchCreateProjectDialog
open={true}
creatingProject={false}
newProjectName="小说项目A"
projectTypeLabel="小说创作"
workspaceProjectsRoot="/tmp/workspace"
resolvedProjectPath="/tmp/workspace/小说项目A"
pathChecking={false}
pathConflictMessage=""
onOpenChange={() => {}}
onProjectNameChange={() => {}}
onCreateProject={() => {}}
{...overrides}
/>,
);
});
const rendered = { container, root };
mountedRoots.push(rendered);
return rendered;
overrides: Partial<ProjectDialogProps> = {},
) {
return mountHarness(
WorkbenchCreateProjectDialog,
createDialogProps(overrides),
mountedRoots,
);
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
setupReactActEnvironment();
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
cleanupMountedRoots(mountedRoots);
});
describe("WorkbenchCreateProjectDialog", () => {
@@ -79,22 +61,18 @@ describe("WorkbenchCreateProjectDialog", () => {
expect(document.body.textContent).toContain("新建项目");
expect(document.body.textContent).toContain("/tmp/workspace/小说项目A");
const projectTypeInput = document.body.querySelector(
"input#workspace-project-type",
const projectTypeInput = findInputById(
document.body,
"workspace-project-type",
) as HTMLInputElement | null;
expect(projectTypeInput?.value).toBe("小说创作");
const projectNameInput = document.body.querySelector(
"input#workspace-project-name",
const projectNameInput = findInputById(
document.body,
"workspace-project-name",
) as HTMLInputElement | null;
expect(projectNameInput).not.toBeNull();
act(() => {
if (!projectNameInput) {
return;
}
setInputValue(projectNameInput, "小说项目B");
});
fillTextInput(projectNameInput, "小说项目B");
expect(onProjectNameChange).toHaveBeenCalledWith("小说项目B");
});
@@ -102,9 +80,9 @@ describe("WorkbenchCreateProjectDialog", () => {
it("路径冲突时禁用创建按钮", () => {
renderDialog({ pathConflictMessage: "路径已存在项目:冲突项目" });
const createButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "创建项目",
);
const createButton = findButtonByText(document.body, "创建项目", {
exact: true,
});
expect(createButton).toBeDefined();
expect(createButton).toHaveProperty("disabled", true);
expect(document.body.textContent).toContain("路径已存在项目:冲突项目");
@@ -115,21 +93,15 @@ describe("WorkbenchCreateProjectDialog", () => {
const onCreateProject = vi.fn();
renderDialog({ onOpenChange, onCreateProject });
const cancelButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "取消",
);
const createButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "创建项目",
);
const cancelButton = findButtonByText(document.body, "取消", { exact: true });
const createButton = findButtonByText(document.body, "创建项目", {
exact: true,
});
expect(cancelButton).toBeDefined();
expect(createButton).toBeDefined();
act(() => {
cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
act(() => {
createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
clickButtonByText(document.body, "取消", { exact: true });
clickButtonByText(document.body, "创建项目", { exact: true });
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onCreateProject).toHaveBeenCalledTimes(1);
@@ -0,0 +1,253 @@
import { act, createElement, type ComponentType } from "react";
import { createRoot, type Root } from "react-dom/client";
export interface MountedRoot {
container: HTMLDivElement;
root: Root;
}
export interface MountedRenderResult<TProps> extends MountedRoot {
rerender: (props: TProps) => void;
}
export function setupReactActEnvironment(): void {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
}
export function mountHarness<TProps>(
Component: ComponentType<TProps>,
initialProps: TProps,
mountedRoots: MountedRoot[],
): MountedRenderResult<TProps> {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const rerender = (props: TProps) => {
act(() => {
root.render(
createElement(
Component as ComponentType<Record<string, unknown>>,
props as Record<string, unknown>,
),
);
});
};
rerender(initialProps);
mountedRoots.push({ container, root });
return {
container,
root,
rerender,
};
}
export function cleanupMountedRoots(mountedRoots: MountedRoot[]): void {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
}
export function clickElement(element: Element | null): void {
act(() => {
element?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
type QueryScope = {
querySelectorAll: (selectors: string) => ArrayLike<Element>;
};
export function findButtonByText(
scope: QueryScope,
text: string,
options?: {
exact?: boolean;
},
): HTMLButtonElement | undefined {
const exact = options?.exact ?? false;
return Array.from(scope.querySelectorAll("button")).find((button) => {
const content = button.textContent?.trim() ?? "";
return exact ? content === text : content.includes(text);
}) as HTMLButtonElement | undefined;
}
export function clickButtonByText(
scope: QueryScope,
text: string,
options?: {
exact?: boolean;
},
): HTMLButtonElement | undefined {
const button = findButtonByText(scope, text, options);
clickElement(button ?? null);
return button;
}
export function clickByTestId(
container: HTMLElement,
testId: string,
): HTMLButtonElement | null {
const button = container.querySelector(
`button[data-testid='${testId}']`,
) as HTMLButtonElement | null;
clickElement(button);
return button;
}
export function getRootElement(container: HTMLElement): HTMLElement | null {
return container.firstElementChild as HTMLElement | null;
}
export function findInputByPlaceholder(
scope: QueryScope,
placeholder: string,
): HTMLInputElement | HTMLTextAreaElement | null {
const field = Array.from(scope.querySelectorAll("input,textarea")).find(
(element) =>
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement
? element.placeholder === placeholder
: false,
);
if (
field instanceof HTMLInputElement ||
field instanceof HTMLTextAreaElement
) {
return field;
}
return null;
}
export function findInputById(
scope: {
querySelector: (selectors: string) => Element | null;
},
id: string,
): HTMLInputElement | HTMLTextAreaElement | null {
const element = scope.querySelector(`#${id}`);
if (
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement
) {
return element;
}
return null;
}
export function findButtonByTitle(
scope: {
querySelector: (selectors: string) => Element | null;
},
title: string,
): HTMLButtonElement | null {
const element = scope.querySelector(`button[title='${title}']`);
if (element instanceof HTMLButtonElement) {
return element;
}
return null;
}
export function clickButtonByTitle(
scope: {
querySelector: (selectors: string) => Element | null;
},
title: string,
): HTMLButtonElement | null {
const button = findButtonByTitle(scope, title);
clickElement(button);
return button;
}
export function findAsideByClassFragment(
scope: QueryScope,
classFragment: string,
): HTMLElement | null {
const matched = Array.from(scope.querySelectorAll("aside")).find((aside) =>
aside.className.includes(classFragment),
);
if (matched instanceof HTMLElement) {
return matched;
}
return null;
}
export function setTextInputValue(
element: HTMLInputElement | HTMLTextAreaElement,
value: string,
): void {
const prototype =
element instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
setter?.call(element, value);
element.dispatchEvent(new Event("input", { bubbles: true }));
}
export function fillTextInput(
element: HTMLInputElement | HTMLTextAreaElement | null,
value: string,
): void {
act(() => {
if (!element) {
return;
}
setTextInputValue(element, value);
});
}
export function triggerKeyboardShortcut(
target: EventTarget,
key: string,
options?: {
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
type?: "keydown" | "keyup";
bubbles?: boolean;
},
): void {
const {
type = "keydown",
bubbles = true,
ctrlKey,
metaKey,
altKey,
shiftKey,
} = options ?? {};
act(() => {
target.dispatchEvent(
new KeyboardEvent(type, {
key,
bubbles,
ctrlKey,
metaKey,
altKey,
shiftKey,
}),
);
});
}
export async function flushEffects(times = 4): Promise<void> {
for (let i = 0; i < times; i += 1) {
await act(async () => {
await Promise.resolve();
});
}
}
@@ -0,0 +1,375 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type UseCreationDialogsParams,
useCreationDialogs,
} from "./useCreationDialogs";
import {
cleanupMountedRoots,
clickByTestId,
flushEffects,
getRootElement,
mountHarness,
setupReactActEnvironment,
type MountedRoot,
} from "./testUtils";
const {
mockCreateContent,
mockCreateProject,
mockExtractErrorMessage,
mockGetContent,
mockGetContentTypeLabel,
mockGetCreateProjectErrorMessage,
mockGetDefaultContentTypeForProject,
mockGetProjectByRootPath,
mockGetProjectTypeLabel,
mockGetWorkspaceProjectsRoot,
mockResolveProjectRootPath,
mockToastError,
mockToastSuccess,
} = vi.hoisted(() => ({
mockCreateContent: vi.fn(),
mockCreateProject: vi.fn(),
mockExtractErrorMessage: vi.fn(),
mockGetContent: vi.fn(),
mockGetContentTypeLabel: vi.fn(),
mockGetCreateProjectErrorMessage: vi.fn(),
mockGetDefaultContentTypeForProject: vi.fn(),
mockGetProjectByRootPath: vi.fn(),
mockGetProjectTypeLabel: vi.fn(),
mockGetWorkspaceProjectsRoot: vi.fn(),
mockResolveProjectRootPath: vi.fn(),
mockToastError: vi.fn(),
mockToastSuccess: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}));
vi.mock("@/lib/api/project", () => ({
createContent: mockCreateContent,
createProject: mockCreateProject,
extractErrorMessage: mockExtractErrorMessage,
getContent: mockGetContent,
getContentTypeLabel: mockGetContentTypeLabel,
getCreateProjectErrorMessage: mockGetCreateProjectErrorMessage,
getDefaultContentTypeForProject: mockGetDefaultContentTypeForProject,
getProjectByRootPath: mockGetProjectByRootPath,
getProjectTypeLabel: mockGetProjectTypeLabel,
getWorkspaceProjectsRoot: mockGetWorkspaceProjectsRoot,
resolveProjectRootPath: mockResolveProjectRootPath,
}));
type HarnessProps = UseCreationDialogsParams;
function CreationDialogsHarness(props: HarnessProps) {
const dialogs = useCreationDialogs(props);
return (
<div
data-create-project-open={String(dialogs.createProjectDialogOpen)}
data-create-content-open={String(dialogs.createContentDialogOpen)}
data-create-content-step={dialogs.createContentDialogStep}
data-new-project-name={dialogs.newProjectName}
data-workspace-root={dialogs.workspaceProjectsRoot}
data-resolved-project-path={dialogs.resolvedProjectPath}
data-path-checking={String(dialogs.pathChecking)}
data-path-conflict-message={dialogs.pathConflictMessage}
data-creating-project={String(dialogs.creatingProject)}
data-creating-content={String(dialogs.creatingContent)}
data-creation-intent-error={dialogs.creationIntentError}
data-current-intent-length={String(dialogs.currentIntentLength)}
data-pending-prompts={JSON.stringify(dialogs.pendingInitialPromptsByContentId)}
data-content-modes={JSON.stringify(dialogs.contentCreationModes)}
>
<button
data-testid="open-project-dialog"
onClick={dialogs.handleOpenCreateProjectDialog}
/>
<button
data-testid="close-project-dialog"
onClick={() => dialogs.setCreateProjectDialogOpen(false)}
/>
<button
data-testid="set-project-name-new"
onClick={() => dialogs.setNewProjectName("新项目A")}
/>
<button
data-testid="set-project-name-conflict"
onClick={() => dialogs.setNewProjectName("冲突项目")}
/>
<button
data-testid="create-project"
onClick={() => {
void dialogs.handleCreateProject();
}}
/>
<button
data-testid="open-content-dialog"
onClick={dialogs.handleOpenCreateContentDialog}
/>
<button data-testid="goto-intent" onClick={dialogs.handleGoToIntentStep} />
<button
data-testid="fill-intent-topic"
onClick={() =>
dialogs.handleCreationIntentValueChange("topic", "这是一个足够详细的创作主题")
}
/>
<button
data-testid="create-content"
onClick={() => {
void dialogs.handleCreateContent();
}}
/>
<button
data-testid="consume-prompt"
onClick={() => dialogs.consumePendingInitialPrompt("content-new")}
/>
</div>
);
}
const mountedRoots: MountedRoot[] = [];
function createHarnessProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
return {
theme: "social-media",
selectedProjectId: null,
selectedContentId: null,
loadProjects: vi.fn(async () => undefined),
loadContents: vi.fn(async () => undefined),
onEnterWorkspace: vi.fn(),
onProjectCreated: vi.fn(),
defaultCreationMode: "guided",
minCreationIntentLength: 10,
...overrides,
};
}
function renderHarness(props: Partial<HarnessProps> = {}) {
return mountHarness(
CreationDialogsHarness,
createHarnessProps(props),
mountedRoots,
);
}
function click(container: HTMLElement, testId: string): void {
const button = clickByTestId(container, testId);
expect(button).not.toBeNull();
}
async function openContentIntentStep(container: HTMLElement): Promise<void> {
click(container, "open-content-dialog");
await flushEffects();
click(container, "goto-intent");
await flushEffects();
}
function parseRootDatasetRecord(
root: HTMLElement | null,
key: string,
): Record<string, string> {
const value = root?.dataset[key] ?? "{}";
return JSON.parse(value) as Record<string, string>;
}
beforeEach(() => {
setupReactActEnvironment();
vi.clearAllMocks();
mockGetWorkspaceProjectsRoot.mockResolvedValue("/tmp/workspace");
mockGetProjectTypeLabel.mockReturnValue("社媒内容");
mockResolveProjectRootPath.mockImplementation(
async (name: string) => `/tmp/workspace/${name}`,
);
mockGetProjectByRootPath.mockResolvedValue(null);
mockCreateProject.mockResolvedValue({
id: "project-new",
name: "新项目A",
});
mockExtractErrorMessage.mockReturnValue("mock-error");
mockGetCreateProjectErrorMessage.mockReturnValue("mock-friendly-error");
mockGetDefaultContentTypeForProject.mockReturnValue("post");
mockGetContentTypeLabel.mockReturnValue("文稿");
mockCreateContent.mockResolvedValue({
id: "content-new",
});
mockGetContent.mockResolvedValue(null);
});
afterEach(() => {
cleanupMountedRoots(mountedRoots);
});
describe("useCreationDialogs", () => {
it("创建项目成功后应关闭弹窗并触发回调", async () => {
const loadProjects = vi.fn(async () => undefined);
const onProjectCreated = vi.fn();
const { container } = renderHarness({
loadProjects,
onProjectCreated,
});
await flushEffects();
click(container, "open-project-dialog");
await flushEffects();
click(container, "set-project-name-new");
await flushEffects();
click(container, "create-project");
await flushEffects(6);
expect(mockCreateProject).toHaveBeenCalledWith({
name: "新项目A",
rootPath: "/tmp/workspace/新项目A",
workspaceType: "social-media",
});
expect(onProjectCreated).toHaveBeenCalledWith("project-new");
expect(loadProjects).toHaveBeenCalledTimes(1);
expect(mockToastSuccess).toHaveBeenCalledWith("已创建新项目");
const root = getRootElement(container);
expect(root?.dataset.createProjectOpen).toBe("false");
expect(root?.dataset.creatingProject).toBe("false");
});
it("打开项目弹窗后应检测路径冲突", async () => {
mockGetProjectByRootPath.mockImplementation(async (rootPath: string) => {
if (rootPath.endsWith("/冲突项目")) {
return { id: "project-existing", name: "历史项目" };
}
return null;
});
const { container } = renderHarness();
await flushEffects();
click(container, "open-project-dialog");
await flushEffects();
click(container, "set-project-name-conflict");
await flushEffects(6);
const root = getRootElement(container);
expect(root?.dataset.resolvedProjectPath).toBe("/tmp/workspace/冲突项目");
expect(root?.dataset.pathChecking).toBe("false");
expect(root?.dataset.pathConflictMessage).toBe("路径已存在项目:历史项目");
expect(mockGetProjectByRootPath).toHaveBeenCalledWith("/tmp/workspace/冲突项目");
});
it("关闭项目弹窗后应重置路径状态", async () => {
mockGetProjectByRootPath.mockImplementation(async (rootPath: string) => {
if (rootPath.endsWith("/冲突项目")) {
return { id: "project-existing", name: "历史项目" };
}
return null;
});
const { container } = renderHarness();
await flushEffects();
click(container, "open-project-dialog");
await flushEffects();
click(container, "set-project-name-conflict");
await flushEffects(6);
let root = getRootElement(container);
expect(root?.dataset.pathConflictMessage).toBe("路径已存在项目:历史项目");
expect(root?.dataset.resolvedProjectPath).toBe("/tmp/workspace/冲突项目");
click(container, "close-project-dialog");
await flushEffects();
root = getRootElement(container);
expect(root?.dataset.createProjectOpen).toBe("false");
expect(root?.dataset.pathConflictMessage).toBe("");
expect(root?.dataset.resolvedProjectPath).toBe("");
expect(root?.dataset.pathChecking).toBe("false");
});
it("选中文稿且 metadata 含 creationMode 时应回填模式缓存", async () => {
mockGetContent.mockResolvedValueOnce({
id: "content-existing",
metadata: {
creationMode: "framework",
},
});
const { container } = renderHarness({
selectedContentId: "content-existing",
});
await flushEffects(5);
expect(mockGetContent).toHaveBeenCalledWith("content-existing");
const root = getRootElement(container);
const contentModes = parseRootDatasetRecord(root, "contentModes");
expect(contentModes["content-existing"]).toBe("framework");
});
it("创作意图不足时创建文稿应被阻止并提示错误", async () => {
const { container } = renderHarness({
selectedProjectId: "project-1",
});
await flushEffects();
await openContentIntentStep(container);
click(container, "create-content");
await flushEffects();
const root = getRootElement(container);
expect(root?.dataset.createContentOpen).toBe("true");
expect(root?.dataset.createContentStep).toBe("intent");
expect(root?.dataset.creationIntentError).toContain("创作意图至少需要 10 个字");
expect(mockCreateContent).not.toHaveBeenCalled();
});
it("创作意图通过后创建文稿应写入待发送提示并进入工作区", async () => {
const loadContents = vi.fn(async () => undefined);
const onEnterWorkspace = vi.fn();
const { container } = renderHarness({
selectedProjectId: "project-1",
loadContents,
onEnterWorkspace,
});
await flushEffects();
await openContentIntentStep(container);
click(container, "fill-intent-topic");
await flushEffects();
click(container, "create-content");
await flushEffects(6);
expect(mockCreateContent).toHaveBeenCalledTimes(1);
expect(loadContents).toHaveBeenCalledWith("project-1");
expect(onEnterWorkspace).toHaveBeenCalledWith("content-new", {
showChatPanel: true,
});
expect(mockToastSuccess).toHaveBeenCalledWith("已创建新文稿");
const root = getRootElement(container);
expect(root?.dataset.createContentOpen).toBe("false");
expect(root?.dataset.createContentStep).toBe("mode");
const pendingPrompts = parseRootDatasetRecord(root, "pendingPrompts");
const contentModes = parseRootDatasetRecord(root, "contentModes");
expect(Object.keys(pendingPrompts)).toContain("content-new");
expect(contentModes["content-new"]).toBe("guided");
click(container, "consume-prompt");
await flushEffects();
const rootAfterConsume = getRootElement(container);
const pendingPromptsAfterConsume = parseRootDatasetRecord(
rootAfterConsume,
"pendingPrompts",
);
expect(pendingPromptsAfterConsume["content-new"]).toBeUndefined();
});
});
@@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useState,
type Dispatch,
type SetStateAction,
} from "react";
import { toast } from "sonner";
import {
createContent,
@@ -41,6 +48,191 @@ function parseCreationMode(value: unknown): CreationMode | null {
return null;
}
function parseCreationModeFromMetadata(metadata: unknown): CreationMode | null {
if (!metadata || typeof metadata !== "object") {
return null;
}
return parseCreationMode((metadata as Record<string, unknown>).creationMode);
}
function useWorkspaceProjectsRootLoader(
setWorkspaceProjectsRoot: Dispatch<SetStateAction<string>>,
): void {
useEffect(() => {
let mounted = true;
const loadWorkspaceRoot = async () => {
try {
const root = await getWorkspaceProjectsRoot();
if (mounted) {
setWorkspaceProjectsRoot(root);
}
} catch (error) {
console.error("加载 workspace 目录失败:", error);
}
};
void loadWorkspaceRoot();
return () => {
mounted = false;
};
}, [setWorkspaceProjectsRoot]);
}
interface UseProjectPathResolverParams {
createProjectDialogOpen: boolean;
newProjectName: string;
resetProjectPathState: () => void;
setResolvedProjectPath: Dispatch<SetStateAction<string>>;
}
function useProjectPathResolver({
createProjectDialogOpen,
newProjectName,
resetProjectPathState,
setResolvedProjectPath,
}: UseProjectPathResolverParams): void {
useEffect(() => {
if (!createProjectDialogOpen) {
resetProjectPathState();
return;
}
const projectName = newProjectName.trim();
if (!projectName) {
resetProjectPathState();
return;
}
let mounted = true;
const resolvePath = async () => {
try {
const path = await resolveProjectRootPath(projectName);
if (mounted) {
setResolvedProjectPath(path);
}
} catch (error) {
console.error("解析项目目录失败:", error);
if (mounted) {
resetProjectPathState();
}
}
};
void resolvePath();
return () => {
mounted = false;
};
}, [
createProjectDialogOpen,
newProjectName,
resetProjectPathState,
setResolvedProjectPath,
]);
}
interface UseProjectPathConflictCheckerParams {
createProjectDialogOpen: boolean;
resolvedProjectPath: string;
setPathChecking: Dispatch<SetStateAction<boolean>>;
setPathConflictMessage: Dispatch<SetStateAction<string>>;
}
function useProjectPathConflictChecker({
createProjectDialogOpen,
resolvedProjectPath,
setPathChecking,
setPathConflictMessage,
}: UseProjectPathConflictCheckerParams): void {
useEffect(() => {
if (!createProjectDialogOpen || !resolvedProjectPath) {
setPathChecking(false);
setPathConflictMessage("");
return;
}
let mounted = true;
setPathChecking(true);
const checkPathConflict = async () => {
try {
const existingProject = await getProjectByRootPath(resolvedProjectPath);
if (!mounted) {
return;
}
if (existingProject) {
setPathConflictMessage(`路径已存在项目:${existingProject.name}`);
} else {
setPathConflictMessage("");
}
} catch (error) {
console.error("检查项目路径冲突失败:", error);
if (mounted) {
setPathConflictMessage("");
}
} finally {
if (mounted) {
setPathChecking(false);
}
}
};
void checkPathConflict();
return () => {
mounted = false;
};
}, [
createProjectDialogOpen,
resolvedProjectPath,
setPathChecking,
setPathConflictMessage,
]);
}
interface UseContentCreationModeLoaderParams {
selectedContentId: string | null;
contentCreationModes: Record<string, CreationMode>;
setContentCreationModes: Dispatch<SetStateAction<Record<string, CreationMode>>>;
}
function useContentCreationModeLoader({
selectedContentId,
contentCreationModes,
setContentCreationModes,
}: UseContentCreationModeLoaderParams): void {
useEffect(() => {
if (!selectedContentId || contentCreationModes[selectedContentId]) {
return;
}
let mounted = true;
const loadCreationMode = async () => {
try {
const content = await getContent(selectedContentId);
const mode = parseCreationModeFromMetadata(content?.metadata);
if (mounted && mode) {
setContentCreationModes((previous) => ({
...previous,
[selectedContentId]: mode,
}));
}
} catch (error) {
console.error("读取文稿创作模式失败:", error);
}
};
void loadCreationMode();
return () => {
mounted = false;
};
}, [contentCreationModes, selectedContentId, setContentCreationModes]);
}
export interface UseCreationDialogsParams {
theme: WorkspaceTheme;
selectedProjectId: string | null;
@@ -91,6 +283,12 @@ export function useCreationDialogs({
Record<string, CreationMode>
>({});
const resetProjectPathState = useCallback(() => {
setResolvedProjectPath("");
setPathChecking(false);
setPathConflictMessage("");
}, []);
const creationIntentInput = useMemo<CreationIntentInput>(
() => ({
creationMode: selectedCreationMode,
@@ -119,11 +317,9 @@ export function useCreationDialogs({
const handleOpenCreateProjectDialog = useCallback(() => {
setNewProjectName(`${getProjectTypeLabel(theme as ProjectType)}项目`);
setResolvedProjectPath("");
setPathConflictMessage("");
setPathChecking(false);
resetProjectPathState();
setCreateProjectDialogOpen(true);
}, [theme]);
}, [resetProjectPathState, theme]);
const handleCreateProject = useCallback(async () => {
const name = newProjectName.trim();
@@ -253,140 +449,24 @@ export function useCreationDialogs({
});
}, []);
useEffect(() => {
let mounted = true;
const loadWorkspaceRoot = async () => {
try {
const root = await getWorkspaceProjectsRoot();
if (mounted) {
setWorkspaceProjectsRoot(root);
}
} catch (error) {
console.error("加载 workspace 目录失败:", error);
}
};
void loadWorkspaceRoot();
return () => {
mounted = false;
};
}, []);
useEffect(() => {
if (!createProjectDialogOpen) {
setResolvedProjectPath("");
setPathChecking(false);
setPathConflictMessage("");
return;
}
const projectName = newProjectName.trim();
if (!projectName) {
setResolvedProjectPath("");
setPathChecking(false);
setPathConflictMessage("");
return;
}
let mounted = true;
const resolvePath = async () => {
try {
const path = await resolveProjectRootPath(projectName);
if (mounted) {
setResolvedProjectPath(path);
}
} catch (error) {
console.error("解析项目目录失败:", error);
if (mounted) {
setResolvedProjectPath("");
setPathConflictMessage("");
setPathChecking(false);
}
}
};
void resolvePath();
return () => {
mounted = false;
};
}, [createProjectDialogOpen, newProjectName]);
useEffect(() => {
if (!createProjectDialogOpen || !resolvedProjectPath) {
setPathChecking(false);
setPathConflictMessage("");
return;
}
let mounted = true;
setPathChecking(true);
const checkPathConflict = async () => {
try {
const existingProject = await getProjectByRootPath(resolvedProjectPath);
if (!mounted) {
return;
}
if (existingProject) {
setPathConflictMessage(`路径已存在项目:${existingProject.name}`);
} else {
setPathConflictMessage("");
}
} catch (error) {
console.error("检查项目路径冲突失败:", error);
if (mounted) {
setPathConflictMessage("");
}
} finally {
if (mounted) {
setPathChecking(false);
}
}
};
void checkPathConflict();
return () => {
mounted = false;
};
}, [createProjectDialogOpen, resolvedProjectPath]);
useEffect(() => {
if (!selectedContentId || contentCreationModes[selectedContentId]) {
return;
}
let mounted = true;
const loadCreationMode = async () => {
try {
const content = await getContent(selectedContentId);
const metadata = content?.metadata;
const mode = parseCreationMode(
metadata && typeof metadata === "object"
? (metadata as Record<string, unknown>).creationMode
: null,
);
if (mounted && mode) {
setContentCreationModes((previous) => ({
...previous,
[selectedContentId]: mode,
}));
}
} catch (error) {
console.error("读取文稿创作模式失败:", error);
}
};
void loadCreationMode();
return () => {
mounted = false;
};
}, [contentCreationModes, selectedContentId]);
useWorkspaceProjectsRootLoader(setWorkspaceProjectsRoot);
useProjectPathResolver({
createProjectDialogOpen,
newProjectName,
resetProjectPathState,
setResolvedProjectPath,
});
useProjectPathConflictChecker({
createProjectDialogOpen,
resolvedProjectPath,
setPathChecking,
setPathConflictMessage,
});
useContentCreationModeLoader({
selectedContentId,
contentCreationModes,
setContentCreationModes,
});
return {
createProjectDialogOpen,
@@ -0,0 +1,413 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupMountedRoots,
clickByTestId,
flushEffects,
mountHarness,
setupReactActEnvironment,
type MountedRoot,
} from "./testUtils";
import {
type UseWorkbenchControllerParams,
useWorkbenchController,
} from "./useWorkbenchController";
const {
mockGetProjectTypeLabel,
mockGetThemeModule,
mockToastError,
mockToastSuccess,
mockUpdateContent,
mockUseCreationDialogs,
mockUseWorkbenchNavigation,
mockUseWorkbenchPanelRenderer,
mockUseWorkbenchProjectData,
mockUseWorkbenchQuickActions,
mockUseWorkbenchStore,
} = vi.hoisted(() => ({
mockGetProjectTypeLabel: vi.fn(),
mockGetThemeModule: vi.fn(),
mockToastError: vi.fn(),
mockToastSuccess: vi.fn(),
mockUpdateContent: vi.fn(),
mockUseCreationDialogs: vi.fn(),
mockUseWorkbenchNavigation: vi.fn(),
mockUseWorkbenchPanelRenderer: vi.fn(),
mockUseWorkbenchProjectData: vi.fn(),
mockUseWorkbenchQuickActions: vi.fn(),
mockUseWorkbenchStore: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}));
vi.mock("@/stores/useWorkbenchStore", () => ({
useWorkbenchStore: mockUseWorkbenchStore,
}));
vi.mock("@/lib/api/project", () => ({
updateContent: mockUpdateContent,
getProjectTypeLabel: mockGetProjectTypeLabel,
}));
vi.mock("@/features/themes", () => ({
getThemeModule: mockGetThemeModule,
}));
vi.mock("./useWorkbenchProjectData", () => ({
useWorkbenchProjectData: mockUseWorkbenchProjectData,
}));
vi.mock("./useWorkbenchNavigation", () => ({
useWorkbenchNavigation: mockUseWorkbenchNavigation,
}));
vi.mock("./useCreationDialogs", () => ({
useCreationDialogs: mockUseCreationDialogs,
}));
vi.mock("./useWorkbenchPanelRenderer", () => ({
useWorkbenchPanelRenderer: mockUseWorkbenchPanelRenderer,
}));
vi.mock("./useWorkbenchQuickActions", () => ({
useWorkbenchQuickActions: mockUseWorkbenchQuickActions,
}));
type ControllerHarnessProps = UseWorkbenchControllerParams;
function createProjectDataHookValue(
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
projects: [{ id: "project-1", name: "项目A" }],
projectsLoading: false,
selectedProjectId: "project-1",
setSelectedProjectId: vi.fn(),
contents: [{ id: "content-1", title: "文稿A" }],
contentsLoading: false,
selectedContentId: "content-1",
setSelectedContentId: vi.fn(),
projectQuery: "",
setProjectQuery: vi.fn(),
contentQuery: "",
setContentQuery: vi.fn(),
selectedProject: { id: "project-1", name: "项目A" },
filteredProjects: [{ id: "project-1", name: "项目A" }],
filteredContents: [{ id: "content-1", title: "文稿A" }],
loadProjects: vi.fn(async () => undefined),
loadContents: vi.fn(async () => undefined),
resetProjectAndContentQueries: vi.fn(),
clearContentsSelection: vi.fn(),
...overrides,
};
}
function WorkbenchControllerHarness(props: ControllerHarnessProps) {
const controller = useWorkbenchController(props);
return (
<div
data-current-content-title={controller.currentContentTitle ?? ""}
data-project-type-label={controller.projectTypeLabel}
>
<button
data-testid="quick-save"
onClick={() => {
void controller.handleQuickSaveCurrent();
}}
/>
<button
data-testid="enter-workspace"
onClick={() =>
controller.handleEnterWorkspace("content-new", { showChatPanel: false })
}
/>
</div>
);
}
const mountedRoots: MountedRoot[] = [];
function renderHarness(props: Partial<ControllerHarnessProps> = {}) {
return mountHarness(
WorkbenchControllerHarness,
{
theme: "social-media",
initialProjectId: "project-1",
initialContentId: "content-1",
initialViewMode: "workspace",
...props,
},
mountedRoots,
);
}
function click(container: HTMLElement, testId: string): void {
const button = clickByTestId(container, testId);
expect(button).not.toBeNull();
}
beforeEach(() => {
setupReactActEnvironment();
vi.clearAllMocks();
mockUseWorkbenchStore.mockReturnValue({
leftSidebarCollapsed: true,
toggleLeftSidebar: vi.fn(),
setLeftSidebarCollapsed: vi.fn(),
});
mockGetThemeModule.mockReturnValue({
navigation: {
defaultView: "create",
items: [{ key: "create", label: "创作" }],
},
capabilities: {
workspaceKind: "agent-chat",
},
panelRenderers: {},
workspaceRenderer: () => null,
primaryWorkspaceRenderer: undefined,
});
mockGetProjectTypeLabel.mockReturnValue("社媒内容");
mockUseWorkbenchProjectData.mockReturnValue(createProjectDataHookValue());
mockUseWorkbenchNavigation.mockReturnValue({
activeRightDrawer: null,
setActiveRightDrawer: vi.fn(),
showChatPanel: true,
setShowChatPanel: vi.fn(),
workflowProgress: null,
setWorkflowProgress: vi.fn(),
showWorkflowRail: false,
setShowWorkflowRail: vi.fn(),
workspaceMode: "workspace",
setWorkspaceMode: vi.fn(),
activeWorkspaceView: "create",
setActiveWorkspaceView: vi.fn(),
shouldRenderLeftSidebar: false,
isCreateWorkspaceView: true,
shouldRenderWorkspaceRightRail: true,
activeWorkspaceViewLabel: "创作",
hasWorkflowWorkspaceView: true,
hasPublishWorkspaceView: true,
hasSettingsWorkspaceView: true,
applyInitialNavigationState: vi.fn(),
handleOpenWorkflowView: vi.fn(),
handleBackToProjectManagement: vi.fn(),
handleEnterWorkspaceView: vi.fn(),
handleSwitchWorkspaceView: vi.fn(),
});
mockUseCreationDialogs.mockReturnValue({
createProjectDialogOpen: false,
setCreateProjectDialogOpen: vi.fn(),
createContentDialogOpen: false,
setCreateContentDialogOpen: vi.fn(),
createContentDialogStep: "mode",
setCreateContentDialogStep: vi.fn(),
newProjectName: "",
setNewProjectName: vi.fn(),
workspaceProjectsRoot: "/tmp/workspace",
creatingProject: false,
creatingContent: false,
selectedCreationMode: "guided",
setSelectedCreationMode: vi.fn(),
creationIntentValues: {},
creationIntentError: "",
setCreationIntentError: vi.fn(),
currentCreationIntentFields: [],
currentIntentLength: 0,
pendingInitialPromptsByContentId: {},
contentCreationModes: {},
resolvedProjectPath: "",
pathChecking: false,
pathConflictMessage: "",
resetCreateContentDialogState: vi.fn(),
handleOpenCreateProjectDialog: vi.fn(),
handleCreateProject: vi.fn(),
handleOpenCreateContentDialog: vi.fn(),
handleCreationIntentValueChange: vi.fn(),
handleGoToIntentStep: vi.fn(),
handleCreateContent: vi.fn(),
consumePendingInitialPrompt: vi.fn(),
});
mockUseWorkbenchPanelRenderer.mockReturnValue({
activePanelRenderer: null,
});
mockUseWorkbenchQuickActions.mockReturnValue({
nonCreateQuickActions: [],
});
});
afterEach(() => {
cleanupMountedRoots(mountedRoots);
});
describe("useWorkbenchController", () => {
it("应在初始化时触发项目加载与导航初始化", async () => {
renderHarness();
await flushEffects(5);
const projectData = mockUseWorkbenchProjectData.mock.results[0]
.value as Record<string, unknown>;
const navigation = mockUseWorkbenchNavigation.mock.results[0].value as Record<
string,
unknown
>;
const resetQueries = projectData.resetProjectAndContentQueries as ReturnType<
typeof vi.fn
>;
const setSelectedProjectId = projectData.setSelectedProjectId as ReturnType<
typeof vi.fn
>;
const setSelectedContentId = projectData.setSelectedContentId as ReturnType<
typeof vi.fn
>;
const loadProjects = projectData.loadProjects as ReturnType<typeof vi.fn>;
const applyInitialNavigationState =
navigation.applyInitialNavigationState as ReturnType<typeof vi.fn>;
expect(resetQueries).toHaveBeenCalledTimes(1);
expect(setSelectedProjectId).toHaveBeenCalledWith("project-1");
expect(setSelectedContentId).toHaveBeenCalledWith("content-1");
expect(applyInitialNavigationState).toHaveBeenCalledWith("workspace", "content-1");
expect(loadProjects).toHaveBeenCalledTimes(1);
});
it("handleEnterWorkspace 应同步更新工作区关键状态", async () => {
const { container } = renderHarness();
await flushEffects();
click(container, "enter-workspace");
await flushEffects();
const projectData = mockUseWorkbenchProjectData.mock.results[0]
.value as Record<string, unknown>;
const navigation = mockUseWorkbenchNavigation.mock.results[0].value as Record<
string,
unknown
>;
const store = mockUseWorkbenchStore.mock.results[0].value as Record<
string,
unknown
>;
const setSelectedContentId = projectData.setSelectedContentId as ReturnType<
typeof vi.fn
>;
const setWorkspaceMode = navigation.setWorkspaceMode as ReturnType<typeof vi.fn>;
const setActiveWorkspaceView =
navigation.setActiveWorkspaceView as ReturnType<typeof vi.fn>;
const setShowChatPanel = navigation.setShowChatPanel as ReturnType<typeof vi.fn>;
const setActiveRightDrawer =
navigation.setActiveRightDrawer as ReturnType<typeof vi.fn>;
const setLeftSidebarCollapsed =
store.setLeftSidebarCollapsed as ReturnType<typeof vi.fn>;
expect(setSelectedContentId).toHaveBeenCalledWith("content-new");
expect(setWorkspaceMode).toHaveBeenCalledWith("workspace");
expect(setActiveWorkspaceView).toHaveBeenCalledWith("create");
expect(setShowChatPanel).toHaveBeenCalledWith(false);
expect(setActiveRightDrawer).toHaveBeenCalledWith(null);
expect(setLeftSidebarCollapsed).toHaveBeenCalledWith(true);
});
it("handleQuickSaveCurrent 成功时应保存并刷新文稿列表", async () => {
mockUpdateContent.mockResolvedValueOnce(undefined);
const { container } = renderHarness();
await flushEffects(5);
const projectData = mockUseWorkbenchProjectData.mock.results[0]
.value as Record<string, unknown>;
const loadContents = projectData.loadContents as ReturnType<typeof vi.fn>;
const callsBeforeSave = loadContents.mock.calls.length;
click(container, "quick-save");
await flushEffects(5);
expect(mockUpdateContent).toHaveBeenCalledWith("content-1", {
metadata: {
saved_from: "theme-workspace",
saved_at: expect.any(Number),
},
});
expect(loadContents.mock.calls.length).toBe(callsBeforeSave + 1);
expect(loadContents).toHaveBeenLastCalledWith("project-1");
expect(mockToastSuccess).toHaveBeenCalledWith("已保存当前文稿");
});
it("handleQuickSaveCurrent 在未选中项目或文稿时应直接返回", async () => {
mockUseWorkbenchProjectData.mockReturnValueOnce(
createProjectDataHookValue({
selectedProjectId: null,
selectedContentId: null,
}),
);
const { container } = renderHarness();
await flushEffects(3);
click(container, "quick-save");
await flushEffects(3);
expect(mockUpdateContent).not.toHaveBeenCalled();
expect(mockToastSuccess).not.toHaveBeenCalled();
expect(mockToastError).not.toHaveBeenCalled();
});
it("handleQuickSaveCurrent 失败时应提示错误", async () => {
mockUpdateContent.mockRejectedValueOnce(new Error("save-failed"));
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
try {
const { container } = renderHarness();
await flushEffects(5);
click(container, "quick-save");
await flushEffects(5);
expect(mockUpdateContent).toHaveBeenCalledTimes(1);
expect(mockToastError).toHaveBeenCalledWith("保存失败");
expect(consoleErrorSpy).toHaveBeenCalled();
} finally {
consoleErrorSpy.mockRestore();
}
});
it("快捷键 Ctrl/Cmd+B 应触发左侧栏切换", async () => {
const toggleLeftSidebar = vi.fn();
mockUseWorkbenchStore.mockReturnValueOnce({
leftSidebarCollapsed: true,
toggleLeftSidebar,
setLeftSidebarCollapsed: vi.fn(),
});
renderHarness();
await flushEffects();
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "b",
ctrlKey: true,
bubbles: true,
}),
);
await flushEffects();
expect(toggleLeftSidebar).toHaveBeenCalledTimes(1);
});
});
@@ -78,6 +78,96 @@ export interface UseWorkbenchControllerParams {
resetAt?: number;
}
interface UseWorkbenchBootstrapParams {
applyInitialNavigationState: (
initialViewMode: WorkspaceViewMode | undefined,
initialContentId: string | undefined,
) => void;
clearContentsSelection: () => void;
initialContentId?: string;
initialProjectId?: string;
initialViewMode?: WorkspaceViewMode;
loadProjects: () => Promise<void>;
resetProjectAndContentQueries: () => void;
resetAt?: number;
setSelectedContentId: (contentId: string | null) => void;
setSelectedProjectId: (projectId: string | null) => void;
theme: WorkspaceTheme;
}
function useWorkbenchBootstrap({
applyInitialNavigationState,
clearContentsSelection,
initialContentId,
initialProjectId,
initialViewMode,
loadProjects,
resetProjectAndContentQueries,
resetAt,
setSelectedContentId,
setSelectedProjectId,
theme,
}: UseWorkbenchBootstrapParams): void {
useEffect(() => {
resetProjectAndContentQueries();
setSelectedProjectId(initialProjectId ?? null);
setSelectedContentId(initialContentId ?? null);
applyInitialNavigationState(initialViewMode, initialContentId);
clearContentsSelection();
void loadProjects();
}, [
applyInitialNavigationState,
clearContentsSelection,
initialContentId,
initialProjectId,
initialViewMode,
loadProjects,
resetProjectAndContentQueries,
resetAt,
setSelectedContentId,
setSelectedProjectId,
theme,
]);
}
interface UseSelectedProjectContentsLoaderParams {
clearContentsSelection: () => void;
loadContents: (projectId: string) => Promise<void>;
projects: Array<unknown>;
selectedProjectId: string | null;
}
function useSelectedProjectContentsLoader({
clearContentsSelection,
loadContents,
projects,
selectedProjectId,
}: UseSelectedProjectContentsLoaderParams): void {
useEffect(() => {
if (!selectedProjectId) {
clearContentsSelection();
return;
}
void loadContents(selectedProjectId);
}, [clearContentsSelection, loadContents, selectedProjectId, projects]);
}
function useSidebarToggleHotkey(toggleLeftSidebar: () => void): void {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
event.preventDefault();
toggleLeftSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [toggleLeftSidebar]);
}
export function useWorkbenchController({
onNavigate,
initialProjectId,
@@ -268,14 +358,7 @@ export function useWorkbenchController({
}
}, [loadContents, selectedContentId, selectedProjectId]);
useEffect(() => {
resetProjectAndContentQueries();
setSelectedProjectId(initialProjectId ?? null);
setSelectedContentId(initialContentId ?? null);
applyInitialNavigationState(initialViewMode, initialContentId);
clearContentsSelection();
void loadProjects();
}, [
useWorkbenchBootstrap({
applyInitialNavigationState,
clearContentsSelection,
initialContentId,
@@ -287,33 +370,19 @@ export function useWorkbenchController({
setSelectedContentId,
setSelectedProjectId,
theme,
]);
useEffect(() => {
if (!selectedProjectId) {
clearContentsSelection();
return;
}
void loadContents(selectedProjectId);
}, [clearContentsSelection, loadContents, selectedProjectId, projects]);
});
useSelectedProjectContentsLoader({
clearContentsSelection,
loadContents,
projects,
selectedProjectId,
});
const handleBackHome = useCallback(() => {
onNavigate?.("agent", buildHomeAgentParams());
}, [onNavigate]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
event.preventDefault();
toggleLeftSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [toggleLeftSidebar]);
useSidebarToggleHotkey(toggleLeftSidebar);
const currentContentTitle = selectedContentId
? contents.find((item) => item.id === selectedContentId)?.title || "已选文稿"
@@ -0,0 +1,209 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type UseWorkbenchNavigationParams,
useWorkbenchNavigation,
} from "./useWorkbenchNavigation";
import {
cleanupMountedRoots,
clickElement,
mountHarness,
setupReactActEnvironment,
type MountedRoot,
} from "./testUtils";
type NavigationHarnessProps = UseWorkbenchNavigationParams;
function NavigationHarness(props: NavigationHarnessProps) {
const navigation = useWorkbenchNavigation(props);
return (
<div
data-mode={navigation.workspaceMode}
data-view={navigation.activeWorkspaceView}
data-drawer={navigation.activeRightDrawer ?? "none"}
data-show-workflow-rail={String(navigation.showWorkflowRail)}
data-show-chat={String(navigation.showChatPanel)}
data-should-render-left-sidebar={String(navigation.shouldRenderLeftSidebar)}
data-is-create-view={String(navigation.isCreateWorkspaceView)}
data-right-rail={String(navigation.shouldRenderWorkspaceRightRail)}
data-view-label={navigation.activeWorkspaceViewLabel}
>
<button
data-testid="apply-project-detail"
onClick={() => navigation.applyInitialNavigationState("project-detail")}
/>
<button
data-testid="open-workflow"
onClick={navigation.handleOpenWorkflowView}
/>
<button
data-testid="back-project-management"
onClick={navigation.handleBackToProjectManagement}
/>
<button
data-testid="enter-publish"
onClick={() => navigation.handleEnterWorkspaceView("publish")}
/>
<button
data-testid="switch-publish"
onClick={() => navigation.handleSwitchWorkspaceView("publish")}
/>
<button
data-testid="switch-create"
onClick={() => navigation.handleSwitchWorkspaceView("create")}
/>
<button
data-testid="prepare-tools-state"
onClick={() => {
navigation.setActiveRightDrawer("tools");
navigation.setShowWorkflowRail(true);
}}
/>
</div>
);
}
const mountedRoots: MountedRoot[] = [];
function createHarnessProps(
overrides: Partial<NavigationHarnessProps> = {},
): NavigationHarnessProps {
return {
initialViewMode: "workspace",
initialContentId: "content-1",
defaultWorkspaceView: "create",
navigationItems: [
{ key: "create", label: "创作" },
{ key: "workflow", label: "流程" },
{ key: "settings", label: "设置" },
],
leftSidebarCollapsed: true,
setLeftSidebarCollapsed: vi.fn(),
isAgentChatWorkspace: true,
hasPrimaryWorkspaceRenderer: false,
...overrides,
};
}
function renderHarness(initialProps: Partial<NavigationHarnessProps> = {}) {
return mountHarness(
NavigationHarness,
createHarnessProps(initialProps),
mountedRoots,
);
}
afterEach(() => {
cleanupMountedRoots(mountedRoots);
});
beforeEach(() => {
setupReactActEnvironment();
});
describe("useWorkbenchNavigation", () => {
it("可按 project-detail 规则应用初始化导航状态", () => {
const setLeftSidebarCollapsed = vi.fn();
const { container } = renderHarness({
initialViewMode: "project-management",
initialContentId: undefined,
leftSidebarCollapsed: false,
setLeftSidebarCollapsed,
});
const applyButton = container.querySelector(
"button[data-testid='apply-project-detail']",
);
expect(applyButton).not.toBeNull();
clickElement(applyButton);
const root = container.firstElementChild as HTMLElement | null;
expect(root?.dataset.mode).toBe("workspace");
expect(root?.dataset.view).toBe("workflow");
expect(setLeftSidebarCollapsed).toHaveBeenLastCalledWith(true);
});
it("无 workflow 导航时打开流程动作会回退到 settings", () => {
const setLeftSidebarCollapsed = vi.fn();
const { container } = renderHarness({
navigationItems: [
{ key: "create", label: "创作" },
{ key: "publish", label: "发布" },
{ key: "settings", label: "设置" },
],
setLeftSidebarCollapsed,
});
const openWorkflowButton = container.querySelector(
"button[data-testid='open-workflow']",
);
expect(openWorkflowButton).not.toBeNull();
clickElement(openWorkflowButton);
const root = container.firstElementChild as HTMLElement | null;
expect(root?.dataset.view).toBe("settings");
expect(root?.dataset.viewLabel).toBe("设置");
});
it("切换到非 create 视图时收起工具抽屉与流程轨", () => {
const setLeftSidebarCollapsed = vi.fn();
const { container } = renderHarness({
navigationItems: [
{ key: "create", label: "创作" },
{ key: "publish", label: "发布" },
{ key: "settings", label: "设置" },
],
setLeftSidebarCollapsed,
});
const prepareButton = container.querySelector(
"button[data-testid='prepare-tools-state']",
);
expect(prepareButton).not.toBeNull();
clickElement(prepareButton);
let root = container.firstElementChild as HTMLElement | null;
expect(root?.dataset.drawer).toBe("tools");
expect(root?.dataset.showWorkflowRail).toBe("true");
const switchPublishButton = container.querySelector(
"button[data-testid='switch-publish']",
);
expect(switchPublishButton).not.toBeNull();
clickElement(switchPublishButton);
root = container.firstElementChild as HTMLElement | null;
expect(root?.dataset.drawer).toBe("none");
expect(root?.dataset.showWorkflowRail).toBe("false");
});
it("返回项目管理时恢复项目管理模式并展开左栏", () => {
const setLeftSidebarCollapsed = vi.fn();
const { container } = renderHarness({
navigationItems: [
{ key: "create", label: "创作" },
{ key: "workflow", label: "流程" },
],
setLeftSidebarCollapsed,
});
const backButton = container.querySelector(
"button[data-testid='back-project-management']",
);
expect(backButton).not.toBeNull();
clickElement(backButton);
const root = container.firstElementChild as HTMLElement | null;
expect(root?.dataset.mode).toBe("project-management");
expect(root?.dataset.showChat).toBe("true");
expect(root?.dataset.shouldRenderLeftSidebar).toBe("true");
expect(setLeftSidebarCollapsed).toHaveBeenLastCalledWith(false);
});
});
@@ -0,0 +1,271 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type UseWorkbenchProjectDataParams,
useWorkbenchProjectData,
} from "./useWorkbenchProjectData";
import {
cleanupMountedRoots,
clickByTestId,
flushEffects,
getRootElement,
mountHarness,
setupReactActEnvironment,
type MountedRoot,
} from "./testUtils";
import {
createWorkspaceContentFixture,
createWorkspaceProjectFixture,
} from "../testFixtures";
const { mockListContents, mockListProjects, mockToastError } = vi.hoisted(() => ({
mockListContents: vi.fn(),
mockListProjects: vi.fn(),
mockToastError: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: {
error: mockToastError,
},
}));
vi.mock("@/lib/api/project", () => ({
listProjects: mockListProjects,
listContents: mockListContents,
}));
type HarnessProps = UseWorkbenchProjectDataParams;
function WorkbenchProjectDataHarness(props: HarnessProps) {
const data = useWorkbenchProjectData(props);
return (
<div
data-selected-project-id={data.selectedProjectId ?? ""}
data-selected-content-id={data.selectedContentId ?? ""}
data-project-query={data.projectQuery}
data-content-query={data.contentQuery}
data-project-ids={data.projects.map((item) => item.id).join(",")}
data-filtered-project-ids={data.filteredProjects.map((item) => item.id).join(",")}
data-content-ids={data.contents.map((item) => item.id).join(",")}
data-filtered-content-ids={data.filteredContents.map((item) => item.id).join(",")}
>
<button
data-testid="load-projects"
onClick={() => {
void data.loadProjects();
}}
/>
<button
data-testid="load-contents-project-a"
onClick={() => {
void data.loadContents("project-a");
}}
/>
<button
data-testid="set-selected-project-manual"
onClick={() => data.setSelectedProjectId("project-manual")}
/>
<button
data-testid="set-selected-content-manual"
onClick={() => data.setSelectedContentId("content-manual")}
/>
<button
data-testid="set-project-query-manual"
onClick={() => data.setProjectQuery("manual")}
/>
<button
data-testid="set-content-query-manual"
onClick={() => data.setContentQuery("manual")}
/>
<button
data-testid="reset-queries"
onClick={data.resetProjectAndContentQueries}
/>
<button
data-testid="clear-contents-selection"
onClick={data.clearContentsSelection}
/>
</div>
);
}
const mountedRoots: MountedRoot[] = [];
function renderHarness(props: Partial<HarnessProps> = {}) {
const baseProps: HarnessProps = {
theme: "social-media",
initialProjectId: undefined,
initialContentId: undefined,
};
return mountHarness(
WorkbenchProjectDataHarness,
{ ...baseProps, ...props },
mountedRoots,
);
}
function click(container: HTMLElement, testId: string): void {
const button = clickByTestId(container, testId);
expect(button).not.toBeNull();
}
beforeEach(() => {
setupReactActEnvironment();
vi.clearAllMocks();
});
afterEach(() => {
cleanupMountedRoots(mountedRoots);
});
describe("useWorkbenchProjectData", () => {
it("loadProjects 仅保留当前主题且未归档项目,并使用 initialProjectId", async () => {
mockListProjects.mockResolvedValue([
createWorkspaceProjectFixture({
id: "project-init",
name: "初始化项目",
workspaceType: "social-media",
rootPath: "/tmp/workspace/project-init",
isArchived: false,
tags: [],
}),
createWorkspaceProjectFixture({
id: "project-other-theme",
name: "视频项目",
workspaceType: "video",
rootPath: "/tmp/workspace/project-other-theme",
isArchived: false,
tags: [],
}),
createWorkspaceProjectFixture({
id: "project-archived",
name: "归档项目",
workspaceType: "social-media",
rootPath: "/tmp/workspace/project-archived",
isArchived: true,
tags: [],
}),
createWorkspaceProjectFixture({
id: "project-manual",
name: "Manual 标签项目",
workspaceType: "social-media",
rootPath: "/tmp/workspace/project-manual",
isArchived: false,
tags: ["manual"],
}),
]);
const { container } = renderHarness({
initialProjectId: "project-init",
});
click(container, "load-projects");
await flushEffects();
const root = getRootElement(container);
expect(root?.dataset.projectIds).toBe("project-init,project-manual");
expect(root?.dataset.selectedProjectId).toBe("project-init");
});
it("项目选择优先级应为 previousId > initialProjectId", async () => {
mockListProjects.mockResolvedValue([
createWorkspaceProjectFixture({
id: "project-init",
name: "初始化项目",
workspaceType: "social-media",
rootPath: "/tmp/workspace/project-init",
isArchived: false,
tags: [],
}),
createWorkspaceProjectFixture({
id: "project-manual",
name: "Manual 标签项目",
workspaceType: "social-media",
rootPath: "/tmp/workspace/project-manual",
isArchived: false,
tags: ["manual"],
}),
]);
const { container } = renderHarness({
initialProjectId: "project-init",
});
click(container, "load-projects");
await flushEffects();
click(container, "set-selected-project-manual");
await flushEffects();
click(container, "load-projects");
await flushEffects();
click(container, "set-project-query-manual");
await flushEffects();
const root = getRootElement(container);
expect(root?.dataset.selectedProjectId).toBe("project-manual");
expect(root?.dataset.filteredProjectIds).toBe("project-manual");
});
it("文稿选择优先级应为 previousId > initialContentId,且支持筛选", async () => {
mockListContents.mockResolvedValue([
createWorkspaceContentFixture({
id: "content-init",
project_id: "project-a",
title: "初始化文稿",
}),
createWorkspaceContentFixture({
id: "content-manual",
project_id: "project-a",
title: "manual 文稿",
}),
]);
const { container } = renderHarness({
initialContentId: "content-init",
});
click(container, "load-contents-project-a");
await flushEffects();
click(container, "set-selected-content-manual");
await flushEffects();
click(container, "load-contents-project-a");
await flushEffects();
click(container, "set-content-query-manual");
await flushEffects();
const root = getRootElement(container);
expect(root?.dataset.selectedContentId).toBe("content-manual");
expect(root?.dataset.filteredContentIds).toBe("content-manual");
});
it("应支持重置查询与清空文稿选择", async () => {
mockListContents.mockResolvedValue([
createWorkspaceContentFixture({
id: "content-manual",
project_id: "project-a",
title: "manual 文稿",
}),
]);
const { container } = renderHarness();
click(container, "load-contents-project-a");
await flushEffects();
click(container, "set-selected-content-manual");
click(container, "set-project-query-manual");
click(container, "set-content-query-manual");
await flushEffects();
click(container, "reset-queries");
click(container, "clear-contents-selection");
await flushEffects();
const root = getRootElement(container);
expect(root?.dataset.projectQuery).toBe("");
expect(root?.dataset.contentQuery).toBe("");
expect(root?.dataset.selectedContentId).toBe("");
expect(root?.dataset.contentIds).toBe("");
});
});
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type UseWorkbenchQuickActionsParams,
useWorkbenchQuickActions,
} from "./useWorkbenchQuickActions";
import {
cleanupMountedRoots,
clickElement,
mountHarness,
setupReactActEnvironment,
type MountedRoot,
} from "./testUtils";
type QuickActionsHarnessProps = UseWorkbenchQuickActionsParams;
function QuickActionsHarness(props: QuickActionsHarnessProps) {
const { nonCreateQuickActions } = useWorkbenchQuickActions(props);
return (
<div>
<div data-testid="action-count">{nonCreateQuickActions.length}</div>
<div data-testid="action-keys">
{nonCreateQuickActions.map((action) => action.key).join(",")}
</div>
<div data-testid="action-labels">
{nonCreateQuickActions.map((action) => action.label).join("|")}
</div>
{nonCreateQuickActions.map((action) => (
<button key={action.key} data-key={action.key} onClick={action.onClick}>
{action.label}
</button>
))}
</div>
);
}
const mountedRoots: MountedRoot[] = [];
function createHarnessProps(
overrides: Partial<QuickActionsHarnessProps> = {},
): QuickActionsHarnessProps {
return {
workspaceMode: "workspace",
activeWorkspaceView: "publish",
hasWorkflowWorkspaceView: true,
hasPublishWorkspaceView: true,
hasSettingsWorkspaceView: true,
selectedContentId: "content-1",
onSwitchWorkspaceView: vi.fn(),
onQuickSaveCurrent: vi.fn(),
...overrides,
};
}
function renderHarness(initialProps: Partial<QuickActionsHarnessProps> = {}) {
return mountHarness(
QuickActionsHarness,
createHarnessProps(initialProps),
mountedRoots,
);
}
afterEach(() => {
cleanupMountedRoots(mountedRoots);
});
beforeEach(() => {
setupReactActEnvironment();
});
describe("useWorkbenchQuickActions", () => {
it("非工作区或创作视图时不返回动作", () => {
const onSwitchWorkspaceView = vi.fn();
const onQuickSaveCurrent = vi.fn();
const { container, rerender } = renderHarness({
workspaceMode: "project-management",
onSwitchWorkspaceView,
onQuickSaveCurrent,
});
expect(
container.querySelector("[data-testid='action-count']")?.textContent,
).toBe("0");
rerender(
createHarnessProps({
workspaceMode: "workspace",
activeWorkspaceView: "create",
onSwitchWorkspaceView,
onQuickSaveCurrent,
}),
);
expect(
container.querySelector("[data-testid='action-count']")?.textContent,
).toBe("0");
});
it("发布视图返回正确动作,并可触发回调", () => {
const onSwitchWorkspaceView = vi.fn();
const onQuickSaveCurrent = vi.fn();
const { container } = renderHarness({
onSwitchWorkspaceView,
onQuickSaveCurrent,
});
const labels =
container.querySelector("[data-testid='action-labels']")?.textContent ?? "";
expect(labels).toContain("返回创作视图");
expect(labels).toContain("前往流程视图");
expect(labels).toContain("前往设置视图");
expect(labels).toContain("快速保存当前文稿");
expect(labels).not.toContain("前往发布视图");
const workflowButton = container.querySelector(
"button[data-key='to-workflow']",
);
expect(workflowButton).not.toBeNull();
clickElement(workflowButton);
expect(onSwitchWorkspaceView).toHaveBeenCalledWith("workflow");
const saveButton = container.querySelector("button[data-key='quick-save']");
expect(saveButton).not.toBeNull();
clickElement(saveButton);
expect(onQuickSaveCurrent).toHaveBeenCalledTimes(1);
});
it("在流程视图时包含前往发布动作", () => {
const onSwitchWorkspaceView = vi.fn();
const onQuickSaveCurrent = vi.fn();
const { container } = renderHarness({
activeWorkspaceView: "workflow",
selectedContentId: null,
onSwitchWorkspaceView,
onQuickSaveCurrent,
});
const labels =
container.querySelector("[data-testid='action-labels']")?.textContent ?? "";
expect(labels).toContain("前往发布视图");
expect(labels).not.toContain("快速保存当前文稿");
});
});
+44
View File
@@ -0,0 +1,44 @@
import type { ContentListItem, Project } from "@/lib/api/project";
export const WORKSPACE_FIXTURE_TIMESTAMP = 1_700_000_000_000;
export const DEFAULT_WORKSPACE_PAGE_PROPS = {
viewMode: "workspace",
projectId: "project-1",
contentId: "content-1",
} as const;
type ProjectFixtureRequired = Pick<
Project,
"id" | "name" | "workspaceType" | "rootPath"
>;
export function createWorkspaceProjectFixture(
data: ProjectFixtureRequired & Partial<Project>,
): Project {
return {
isDefault: false,
createdAt: WORKSPACE_FIXTURE_TIMESTAMP,
updatedAt: WORKSPACE_FIXTURE_TIMESTAMP,
isFavorite: false,
isArchived: false,
tags: [],
...data,
};
}
type ContentFixtureRequired = Pick<ContentListItem, "id" | "project_id" | "title">;
export function createWorkspaceContentFixture(
data: ContentFixtureRequired & Partial<ContentListItem>,
): ContentListItem {
return {
content_type: "post",
status: "draft",
order: 0,
word_count: 0,
created_at: WORKSPACE_FIXTURE_TIMESTAMP,
updated_at: WORKSPACE_FIXTURE_TIMESTAMP,
...data,
};
}
+42
View File
@@ -421,6 +421,48 @@ export type ServerMessage =
| { beginRendering: BeginRendering }
| { deleteSurface: DeleteSurface };
/** 插件任务状态 */
export type PluginTaskState =
| "queued"
| "running"
| "retrying"
| "succeeded"
| "failed"
| "cancelled"
| "timed_out";
/** 插件任务错误 */
export interface PluginTaskError {
code?: string;
message: string;
retryable: boolean;
}
/** 插件任务事件 */
export interface PluginTaskEventPayload {
pluginId: PluginId;
taskId: string;
operation: string;
state: PluginTaskState;
attempt: number;
timestamp: string;
error?: PluginTaskError;
}
/** 插件任务记录 */
export interface PluginTaskRecord {
taskId: string;
pluginId: PluginId;
operation: string;
state: PluginTaskState;
attempt: number;
maxRetries: number;
startedAt: string;
endedAt?: string;
durationMs?: number;
error?: PluginTaskError;
}
// ============================================================================
// 消息类型 (Client → Server)
// ============================================================================
+40
View File
@@ -11,6 +11,7 @@ import { surfaceManager, SurfaceManager } from "./SurfaceManager";
import { initPluginUI } from "./index";
import type {
PluginId,
PluginTaskEventPayload,
SurfaceId,
SurfaceState,
ServerMessage,
@@ -31,6 +32,8 @@ interface UsePluginUIOptions {
interface UsePluginUIResult {
/** 插件的所有 Surface */
surfaces: SurfaceState[];
/** 插件任务事件(用于状态可观测) */
taskEvents: PluginTaskEventPayload[];
/** 是否正在加载 */
loading: boolean;
/** 错误信息 */
@@ -49,6 +52,7 @@ export function usePluginUI(options: UsePluginUIOptions): UsePluginUIResult {
const { pluginId, autoInit = true, manager = surfaceManager } = options;
const [surfaces, setSurfaces] = useState<SurfaceState[]>([]);
const [taskEvents, setTaskEvents] = useState<PluginTaskEventPayload[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const initializedRef = useRef(false);
@@ -73,6 +77,41 @@ export function usePluginUI(options: UsePluginUIOptions): UsePluginUIResult {
return unsubscribe;
}, [pluginId, manager]);
// 监听插件任务事件
useEffect(() => {
let unlisten: UnlistenFn | null = null;
const setupTaskListener = async () => {
try {
unlisten = await safeListen<PluginTaskEventPayload>(
"plugin-task-event",
(event) => {
if (event.payload.pluginId !== pluginId) {
return;
}
setTaskEvents((prev) => {
const next = [...prev, event.payload];
if (next.length > 100) {
return next.slice(next.length - 100);
}
return next;
});
},
);
} catch (err) {
console.error("[usePluginUI] 监听任务事件失败:", err);
}
};
setupTaskListener();
return () => {
if (unlisten) {
unlisten();
}
};
}, [pluginId]);
// 监听来自 Rust 的 UI 消息
useEffect(() => {
let unlisten: UnlistenFn | null = null;
@@ -178,6 +217,7 @@ export function usePluginUI(options: UsePluginUIOptions): UsePluginUIResult {
return {
surfaces,
taskEvents,
loading,
error,
handleAction,
+11 -7
View File
@@ -131,7 +131,8 @@ const defaultMocks: Record<string, any> = {
success: true,
reused: false,
browser_source: "system",
browser_path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
browser_path:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
profile_dir: "/tmp/proxycast/chrome_profiles/search_google",
remote_debugging_port: 13001,
pid: 12345,
@@ -172,12 +173,11 @@ const defaultMocks: Record<string, any> = {
auto_fallback: true,
}),
set_browser_backend_policy: (args: any) => ({
priority:
args?.policy?.priority ?? [
"aster_compat",
"proxycast_extension_bridge",
"cdp_direct",
],
priority: args?.policy?.priority ?? [
"aster_compat",
"proxycast_extension_bridge",
"cdp_direct",
],
auto_fallback: args?.policy?.auto_fallback ?? true,
}),
get_browser_backends_status: () => ({
@@ -321,6 +321,10 @@ const defaultMocks: Record<string, any> = {
unload_plugin: () => ({ success: true }),
uninstall_plugin: () => ({ success: true }),
launch_plugin_ui: () => ({}),
list_plugin_tasks: () => [],
get_plugin_task: () => null,
cancel_plugin_task: () => true,
get_plugin_queue_stats: () => [],
// 凭证池相关
get_relay_providers: () => [],