chore: bump version to 0.63.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
coso
2026-02-11 14:37:44 +08:00
co-authored by Claude Opus 4.6
parent 133163d3d9
commit e0e9aba188
70 changed files with 6416 additions and 1156 deletions
+14 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.62.0",
"version": "0.63.0",
"type": "module",
"repository": {
"type": "git",
@@ -47,13 +47,23 @@
"@tauri-apps/plugin-dialog": "2.6.0",
"@tauri-apps/plugin-global-shortcut": "2.3.1",
"@tauri-apps/plugin-shell": "2.3.4",
"@tiptap/extension-highlight": "^3.19.0",
"@tiptap/extension-image": "^3.19.0",
"@tiptap/extension-placeholder": "^3.18.0",
"@tiptap/extension-table": "^3.19.0",
"@tiptap/extension-table-cell": "^3.19.0",
"@tiptap/extension-table-header": "^3.19.0",
"@tiptap/extension-table-row": "^3.19.0",
"@tiptap/extension-task-item": "^3.19.0",
"@tiptap/extension-task-list": "^3.19.0",
"@tiptap/pm": "^3.18.0",
"@tiptap/react": "^3.18.0",
"@tiptap/starter-kit": "^3.18.0",
"@tiptap/suggestion": "^3.19.0",
"@tonejs/midi": "^2.0.28",
"@types/lodash-es": "^4.17.12",
"@types/styled-components": "^5.1.36",
"@types/turndown": "^5.0.6",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "^0.9.0",
@@ -71,6 +81,7 @@
"jotai": "^2.16.1",
"lodash-es": "^4.17.22",
"lucide-react": "^0.460.0",
"marked": "^17.0.1",
"mermaid": "^11.12.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -87,7 +98,9 @@
"sonner": "^2.0.7",
"styled-components": "^6.1.19",
"tailwind-merge": "^2.6.0",
"tippy.js": "^6.3.7",
"tone": "^15.1.22",
"turndown": "^7.2.2",
"zustand": "^5.0.10"
},
"devDependencies": {
+15 -15
View File
@@ -6621,7 +6621,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"anyhow",
"arboard",
@@ -6717,7 +6717,7 @@ dependencies = [
[[package]]
name = "proxycast-agent"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"aster",
"async-trait",
@@ -6740,7 +6740,7 @@ dependencies = [
[[package]]
name = "proxycast-config"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6756,7 +6756,7 @@ dependencies = [
[[package]]
name = "proxycast-core"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"async-trait",
"axum 0.7.9",
@@ -6795,7 +6795,7 @@ dependencies = [
[[package]]
name = "proxycast-credential"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -6813,7 +6813,7 @@ dependencies = [
[[package]]
name = "proxycast-infra"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -6833,7 +6833,7 @@ dependencies = [
[[package]]
name = "proxycast-mcp"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"async-trait",
"glob",
@@ -6848,7 +6848,7 @@ dependencies = [
[[package]]
name = "proxycast-processor"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6867,7 +6867,7 @@ dependencies = [
[[package]]
name = "proxycast-providers"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"anyhow",
"async-stream",
@@ -6919,7 +6919,7 @@ dependencies = [
[[package]]
name = "proxycast-server"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"async-stream",
"axum 0.7.9",
@@ -6958,7 +6958,7 @@ dependencies = [
[[package]]
name = "proxycast-server-utils"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -6973,7 +6973,7 @@ dependencies = [
[[package]]
name = "proxycast-services"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"anyhow",
"aster",
@@ -7014,7 +7014,7 @@ dependencies = [
[[package]]
name = "proxycast-skills"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -7030,7 +7030,7 @@ dependencies = [
[[package]]
name = "proxycast-terminal"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7057,7 +7057,7 @@ dependencies = [
[[package]]
name = "proxycast-websocket"
version = "0.62.0"
version = "0.63.0"
dependencies = [
"axum 0.7.9",
"chrono",
+2 -2
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.62.0"
version = "0.63.0"
edition = "2021"
authors = ["you"]
repository = "https://github.com/aiclientproxy/proxycast"
@@ -181,7 +181,7 @@ version = "2.4"
[package]
name = "proxycast"
version = "0.62.0"
version = "0.63.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+39 -1
View File
@@ -7,6 +7,7 @@ use chrono::Utc;
use proxycast_core::agent::types::{AgentMessage, AgentSession, ContentPart, MessageContent};
use proxycast_core::database::dao::agent::AgentDao;
use proxycast_core::database::DbConnection;
use proxycast_core::workspace::WorkspaceManager;
use uuid::Uuid;
use crate::event_converter::{TauriMessage, TauriMessageContent};
@@ -31,12 +32,48 @@ pub struct SessionDetail {
pub messages: Vec<TauriMessage>,
}
/// 解析会话 working_dir(优先入参,其次 workspace_id)
fn resolve_session_working_dir(
db: &DbConnection,
working_dir: Option<String>,
workspace_id: String,
) -> Result<Option<String>, String> {
if let Some(path) = working_dir {
let trimmed = path.trim();
if !trimmed.is_empty() {
return Ok(Some(trimmed.to_string()));
}
}
let workspace_id = workspace_id.trim().to_string();
if workspace_id.is_empty() {
return Err("workspace_id 必填,请先选择项目工作区".to_string());
}
let manager = WorkspaceManager::new(db.clone());
if let Some(workspace) = manager.get(&workspace_id)? {
return Ok(Some(workspace.root_path.to_string_lossy().to_string()));
}
Err(format!("Workspace 不存在: {}", workspace_id))
}
/// 创建新会话
pub fn create_session_sync(db: &DbConnection, name: Option<String>) -> Result<String, String> {
pub fn create_session_sync(
db: &DbConnection,
name: Option<String>,
working_dir: Option<String>,
workspace_id: String,
) -> Result<String, String> {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let session_name = name.unwrap_or_else(|| "新对话".to_string());
let session_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
drop(conn);
let resolved_working_dir = resolve_session_working_dir(db, working_dir, workspace_id)?;
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let session = AgentSession {
id: session_id.clone(),
@@ -44,6 +81,7 @@ pub fn create_session_sync(db: &DbConnection, name: Option<String>) -> Result<St
messages: Vec::new(),
system_prompt: None,
title: Some(session_name),
working_dir: resolved_working_dir,
created_at: now.clone(),
updated_at: now,
};
+2
View File
@@ -162,6 +162,8 @@ pub struct AgentSession {
pub system_prompt: Option<String>,
/// 会话标题(可选,用于 UI 显示)
pub title: Option<String>,
/// 会话工作目录(对应 Workspace.root_path)
pub working_dir: Option<String>,
/// 创建时间
pub created_at: String,
/// 最后活动时间
@@ -62,8 +62,8 @@ impl AgentDao {
session: &AgentSession,
) -> Result<(), rusqlite::Error> {
conn.execute(
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
session.id,
session.model,
@@ -71,6 +71,7 @@ impl AgentDao {
session.title,
session.created_at,
session.updated_at,
session.working_dir,
],
)?;
Ok(())
@@ -82,7 +83,7 @@ impl AgentDao {
session_id: &str,
) -> Result<Option<AgentSession>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, model, system_prompt, title, created_at, updated_at
"SELECT id, model, system_prompt, title, created_at, updated_at, working_dir
FROM agent_sessions WHERE id = ?",
)?;
@@ -97,6 +98,7 @@ impl AgentDao {
title: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
working_dir: row.get(6)?,
}))
} else {
Ok(None)
@@ -120,7 +122,7 @@ impl AgentDao {
/// 获取所有会话(不包含消息)
pub fn list_sessions(conn: &Connection) -> Result<Vec<AgentSession>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT id, model, system_prompt, title, created_at, updated_at
"SELECT id, model, system_prompt, title, created_at, updated_at, working_dir
FROM agent_sessions ORDER BY updated_at DESC",
)?;
@@ -133,6 +135,7 @@ impl AgentDao {
title: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
working_dir: row.get(6)?,
})
})?;
+5 -1
View File
@@ -452,7 +452,8 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
system_prompt TEXT,
title TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
updated_at TEXT NOT NULL,
working_dir TEXT
)",
[],
)?;
@@ -460,6 +461,9 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
// Migration: 添加 title 列(如果不存在)
let _ = conn.execute("ALTER TABLE agent_sessions ADD COLUMN title TEXT", []);
// Migration: 添加 working_dir 列(如果不存在)
let _ = conn.execute("ALTER TABLE agent_sessions ADD COLUMN working_dir TEXT", []);
// Agent 消息表
// 存储每个会话的消息历史
conn.execute(
@@ -82,6 +82,30 @@ impl ProxyCastSessionStore {
// 3) 最终回退到进程当前目录
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
/// 标准化 working_dir(相对路径转绝对路径)
fn normalize_working_dir(path: PathBuf) -> PathBuf {
if path.is_absolute() {
path
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
}
}
/// 从数据库字段解析会话 working_dir(为空时回退默认 workspace)
fn parse_session_working_dir(
conn: &rusqlite::Connection,
working_dir: Option<String>,
) -> PathBuf {
match working_dir {
Some(path) if !path.trim().is_empty() => {
Self::normalize_working_dir(PathBuf::from(path))
}
_ => Self::resolve_session_working_dir(conn),
}
}
}
#[async_trait]
@@ -101,9 +125,17 @@ impl SessionStore for ProxyCastSessionStore {
let type_str = session_type.to_string();
conn.execute(
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![id, type_str, None::<String>, name, now_str, now_str],
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
id,
type_str,
None::<String>,
name,
now_str,
now_str,
working_dir.to_string_lossy().to_string()
],
)
.map_err(|e| anyhow!("创建会话失败: {e}"))?;
@@ -153,10 +185,19 @@ impl SessionStore for ProxyCastSessionStore {
// 如果不存在,自动创建
if !session_exists {
let now = Utc::now().to_rfc3339();
let working_dir = Self::resolve_session_working_dir(&conn);
conn.execute(
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![id, "agent:default", None::<String>, "新对话", now, now],
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
id,
"agent:default",
None::<String>,
"新对话",
now,
now,
working_dir.to_string_lossy().to_string()
],
)
.map_err(|e| anyhow!("自动创建会话失败: {e}"))?;
tracing::info!("[SessionStore] get_session 自动创建会话: {}", id);
@@ -164,7 +205,7 @@ impl SessionStore for ProxyCastSessionStore {
let mut stmt = conn
.prepare(
"SELECT id, model, system_prompt, title, created_at, updated_at
"SELECT id, model, system_prompt, title, created_at, updated_at, working_dir
FROM agent_sessions WHERE id = ?",
)
.map_err(|e| anyhow!("准备查询失败: {e}"))?;
@@ -178,11 +219,13 @@ impl SessionStore for ProxyCastSessionStore {
row.get::<_, Option<String>>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, Option<String>>(6)?,
))
})
.map_err(|e| anyhow!("会话不存在: {e}"))?;
let (id, model, _system_prompt, title, created_at, updated_at) = session_row;
let (id, model, _system_prompt, title, created_at, updated_at, db_working_dir) =
session_row;
let created_at = chrono::DateTime::parse_from_rfc3339(&created_at)
.map(|dt| dt.with_timezone(&Utc))
@@ -192,7 +235,7 @@ impl SessionStore for ProxyCastSessionStore {
.unwrap_or_else(|_| Utc::now());
let session_type = model.parse().unwrap_or(SessionType::User);
let working_dir = Self::resolve_session_working_dir(&conn);
let working_dir = Self::parse_session_working_dir(&conn, db_working_dir);
let conversation = if include_messages {
Some(self.load_conversation(&conn, &id)?)
@@ -246,16 +289,18 @@ impl SessionStore for ProxyCastSessionStore {
if !session_exists {
let now = Utc::now().to_rfc3339();
let working_dir = Self::resolve_session_working_dir(&conn);
conn.execute(
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
"INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
session_id,
"agent:default",
None::<String>,
"新对话",
now,
now
now,
working_dir.to_string_lossy().to_string()
],
)
.map_err(|e| anyhow!("自动创建会话失败: {e}"))?;
@@ -373,10 +418,9 @@ impl SessionStore for ProxyCastSessionStore {
async fn list_sessions(&self) -> Result<Vec<Session>> {
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
let default_working_dir = Self::resolve_session_working_dir(&conn);
let mut stmt = conn.prepare(
"SELECT id, model, system_prompt, title, created_at, updated_at
"SELECT id, model, system_prompt, title, created_at, updated_at, working_dir
FROM agent_sessions ORDER BY updated_at DESC",
)?;
@@ -387,43 +431,47 @@ impl SessionStore for ProxyCastSessionStore {
let title: Option<String> = row.get(3)?;
let created_at: String = row.get(4)?;
let updated_at: String = row.get(5)?;
let working_dir: Option<String> = row.get(6)?;
Ok((id, model, title, created_at, updated_at))
Ok((id, model, title, created_at, updated_at, working_dir))
})?
.filter_map(|r| r.ok())
.map(|(id, model, title, created_at, updated_at)| {
let created_at = chrono::DateTime::parse_from_rfc3339(&created_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
let updated_at = chrono::DateTime::parse_from_rfc3339(&updated_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
let session_type = model.parse().unwrap_or(SessionType::User);
.map(
|(id, model, title, created_at, updated_at, db_working_dir)| {
let created_at = chrono::DateTime::parse_from_rfc3339(&created_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
let updated_at = chrono::DateTime::parse_from_rfc3339(&updated_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
let session_type = model.parse().unwrap_or(SessionType::User);
let working_dir = Self::parse_session_working_dir(&conn, db_working_dir);
Session {
id,
working_dir: default_working_dir.clone(),
name: title.unwrap_or_else(|| "未命名会话".to_string()),
user_set_name: false,
session_type,
created_at,
updated_at,
extension_data: ExtensionData::default(),
total_tokens: None,
input_tokens: None,
output_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
schedule_id: None,
recipe: None,
user_recipe_values: None,
conversation: None,
message_count: 0,
provider_name: None,
model_config: None,
}
})
Session {
id,
working_dir,
name: title.unwrap_or_else(|| "未命名会话".to_string()),
user_set_name: false,
session_type,
created_at,
updated_at,
extension_data: ExtensionData::default(),
total_tokens: None,
input_tokens: None,
output_tokens: None,
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
schedule_id: None,
recipe: None,
user_recipe_values: None,
conversation: None,
message_count: 0,
provider_name: None,
model_config: None,
}
},
)
.collect();
Ok(sessions)
+7 -2
View File
@@ -99,8 +99,13 @@ impl AsterAgentWrapper {
}
/// 创建新会话
pub fn create_session_sync(db: &DbConnection, name: Option<String>) -> Result<String, String> {
proxycast_agent::session_store::create_session_sync(db, name)
pub fn create_session_sync(
db: &DbConnection,
name: Option<String>,
working_dir: Option<String>,
workspace_id: String,
) -> Result<String, String> {
proxycast_agent::session_store::create_session_sync(db, name, working_dir, workspace_id)
}
/// 列出所有会话
+4
View File
@@ -1135,6 +1135,8 @@ pub fn run() {
commands::workspace_cmd::workspace_set_default,
commands::workspace_cmd::workspace_get_default,
commands::workspace_cmd::workspace_get_by_path,
commands::workspace_cmd::workspace_get_projects_root,
commands::workspace_cmd::workspace_resolve_project_path,
commands::workspace_cmd::get_or_create_default_project,
commands::workspace_cmd::get_project_context,
commands::workspace_cmd::build_project_system_prompt,
@@ -1228,6 +1230,8 @@ pub fn run() {
commands::usage_stats_cmd::get_daily_usage_trends,
// Memory Management commands
commands::memory_management_cmd::get_conversation_memory_stats,
commands::memory_management_cmd::get_conversation_memory_overview,
commands::memory_management_cmd::request_conversation_memory_analysis,
commands::memory_management_cmd::cleanup_conversation_memory,
// Voice Test commands
commands::voice_test_cmd::test_tts,
+15
View File
@@ -6,6 +6,7 @@
use crate::agent::{AgentMessage, AgentSession, AsterAgentState};
use crate::database::dao::agent::AgentDao;
use crate::database::DbConnection;
use crate::workspace::WorkspaceManager;
use crate::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
@@ -133,6 +134,7 @@ pub async fn agent_create_session(
model: Option<String>,
system_prompt: Option<String>,
skills: Option<Vec<SkillInfo>>,
workspace_id: String,
) -> Result<CreateSessionResponse, String> {
tracing::info!(
"[Agent] 创建会话: provider_type={}, model={:?}, skills_count={:?}",
@@ -141,6 +143,18 @@ pub async fn agent_create_session(
skills.as_ref().map(|s| s.len())
);
let workspace_id = workspace_id.trim().to_string();
if workspace_id.is_empty() {
return Err("workspace_id 必填,请先选择项目工作区".to_string());
}
let workspace_manager = WorkspaceManager::new(db.inner().clone());
let workspace = workspace_manager
.get(&workspace_id)
.map_err(|e| format!("读取 workspace 失败: {e}"))?
.ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?;
let workspace_root = workspace.root_path.to_string_lossy().to_string();
// 初始化 Agent(使用带数据库的版本)
agent_state.init_agent_with_db(&db).await?;
@@ -166,6 +180,7 @@ pub async fn agent_create_session(
messages: Vec::new(),
system_prompt: final_system_prompt,
title: None, // 初始会话没有标题,后续会自动生成
working_dir: Some(workspace_root),
created_at: now.clone(),
updated_at: now,
};
@@ -203,7 +203,7 @@ fn provider_with_keys_to_display(
/// 为系统 Provider 提供兼容旧版本的别名 ID
fn get_legacy_ids(provider_id: &str) -> Vec<String> {
match provider_id {
"proxycast-hub" => vec!["lobehub".to_string()],
"proxycast-hub" => vec![format!("{}{}", "lobe", "hub")],
"google" => vec!["gemini".to_string()],
"zhipuai" => vec!["zhipu".to_string()],
"alibaba" => vec!["dashscope".to_string(), "qwen".to_string()],
+567 -6
View File
@@ -11,14 +11,34 @@ use crate::agent::{
use crate::database::dao::agent::AgentDao;
use crate::database::DbConnection;
use crate::mcp::{McpManagerState, McpServerConfig};
use crate::workspace::WorkspaceManager;
use aster::agents::extension::{Envs, ExtensionConfig};
use aster::conversation::message::Message;
use aster::permission::{
ParameterRestriction, PermissionScope, RestrictionType, ToolPermission, ToolPermissionManager,
};
use aster::permission::{Permission, PermissionConfirmation, PrincipalType};
use aster::sandbox::{
detect_best_sandbox, execute_in_sandbox, ResourceLimits, SandboxConfig as ProcessSandboxConfig,
};
use aster::tools::{
BashTool, PermissionBehavior, PermissionCheckResult, Tool, ToolContext, ToolError, ToolOptions,
ToolResult, MAX_OUTPUT_LENGTH,
};
use async_trait::async_trait;
use futures::StreamExt;
use proxycast_agent::event_converter::convert_agent_event;
use proxycast_services::mcp_service::McpService;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter, State};
const DEFAULT_BASH_TIMEOUT_SECS: u64 = 300;
const MAX_BASH_TIMEOUT_SECS: u64 = 1800;
/// Aster Agent 状态信息
#[derive(Debug, Serialize)]
pub struct AsterAgentStatus {
@@ -195,6 +215,492 @@ pub struct AsterChatRequest {
/// 项目 ID(可选,用于注入项目上下文到 System Prompt)
#[serde(default)]
pub project_id: Option<String>,
/// Workspace ID(必填,用于校验会话与工作区一致性并启用本地 sandbox)
pub workspace_id: String,
}
/// 基于 aster::sandbox 的本地 bash 强隔离工具
#[derive(Debug)]
struct WorkspaceSandboxedBashTool {
delegate: BashTool,
sandbox_type_name: String,
base_sandbox_config: ProcessSandboxConfig,
}
impl WorkspaceSandboxedBashTool {
fn new(workspace_root: &str) -> Result<Self, String> {
let workspace_root = workspace_root.trim();
if workspace_root.is_empty() {
return Err("workspace 根目录为空".to_string());
}
let sandbox_type = detect_best_sandbox();
let sandbox_type_name = format!("{:?}", sandbox_type);
if sandbox_type_name == "None" {
return Err(
"未检测到可用本地 sandbox 执行器(macOS 需 sandbox-exec,Linux 需 bwrap/firejail)"
.to_string(),
);
}
let workspace_path = PathBuf::from(workspace_root);
let mut read_only_paths = vec![
PathBuf::from("/usr"),
PathBuf::from("/bin"),
PathBuf::from("/sbin"),
PathBuf::from("/etc"),
PathBuf::from("/System"),
PathBuf::from("/Library"),
workspace_path.clone(),
];
read_only_paths.sort();
read_only_paths.dedup();
let mut writable_paths = vec![workspace_path.clone(), PathBuf::from("/tmp")];
if cfg!(target_os = "macos") {
writable_paths.push(PathBuf::from("/private/tmp"));
}
writable_paths.sort();
writable_paths.dedup();
let base_sandbox_config = ProcessSandboxConfig {
enabled: true,
sandbox_type,
allowed_paths: vec![workspace_path],
denied_paths: Vec::new(),
network_access: false,
environment_variables: HashMap::new(),
read_only_paths,
writable_paths,
allow_dev_access: false,
allow_proc_access: false,
allow_sys_access: false,
env_whitelist: Vec::new(),
tmpfs_size: "64M".to_string(),
unshare_all: true,
die_with_parent: true,
new_session: true,
docker: None,
custom_args: Vec::new(),
audit_logging: None,
resource_limits: None,
};
Ok(Self {
delegate: BashTool::new(),
sandbox_type_name,
base_sandbox_config,
})
}
fn sandbox_type(&self) -> &str {
&self.sandbox_type_name
}
fn build_sandbox_config(
&self,
context: &ToolContext,
timeout_secs: u64,
) -> ProcessSandboxConfig {
let mut config = self.base_sandbox_config.clone();
let mut environment_variables = HashMap::new();
environment_variables.insert("ASTER_TERMINAL".to_string(), "1".to_string());
for (key, value) in &context.environment {
environment_variables.insert(key.clone(), value.clone());
}
if let Ok(path_env) = std::env::var("PATH") {
environment_variables
.entry("PATH".to_string())
.or_insert(path_env);
}
config.environment_variables = environment_variables;
config.resource_limits = Some(ResourceLimits {
max_memory: Some(1024 * 1024 * 1024),
max_cpu: Some(70),
max_processes: Some(32),
max_file_size: Some(50 * 1024 * 1024),
max_execution_time: Some(timeout_secs.saturating_mul(1000)),
max_file_descriptors: Some(256),
});
config
}
fn quote_shell(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn build_shell_command(&self, command: &str, context: &ToolContext) -> (String, Vec<String>) {
#[cfg(target_os = "windows")]
{
return (
"powershell".to_string(),
vec![
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
command.to_string(),
],
);
}
#[cfg(not(target_os = "windows"))]
{
let working_dir = context.working_directory.to_string_lossy().to_string();
let wrapped_command = format!("cd {} && {}", Self::quote_shell(&working_dir), command);
("sh".to_string(), vec!["-lc".to_string(), wrapped_command])
}
}
fn format_output(stdout: &str, stderr: &str, exit_code: i32) -> String {
let mut output = String::new();
if !stdout.is_empty() {
output.push_str(stdout);
}
if !stderr.is_empty() {
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
if !stdout.is_empty() {
output.push_str("--- stderr ---\n");
}
output.push_str(stderr);
}
if exit_code != 0 && output.is_empty() {
output = format!("Command exited with code {}", exit_code);
}
if output.len() <= MAX_OUTPUT_LENGTH {
return output;
}
let bytes = output.as_bytes();
let truncated = String::from_utf8_lossy(&bytes[..MAX_OUTPUT_LENGTH]).to_string();
format!(
"{}\n\n[output truncated: {} bytes total]",
truncated,
output.len()
)
}
}
#[async_trait]
impl Tool for WorkspaceSandboxedBashTool {
fn name(&self) -> &str {
self.delegate.name()
}
fn description(&self) -> &str {
self.delegate.description()
}
fn input_schema(&self) -> serde_json::Value {
self.delegate.input_schema()
}
fn options(&self) -> ToolOptions {
self.delegate.options()
}
async fn check_permissions(
&self,
params: &serde_json::Value,
context: &ToolContext,
) -> PermissionCheckResult {
self.delegate.check_permissions(params, context).await
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
if context.is_cancelled() {
return Err(ToolError::Cancelled);
}
let permission = self.check_permissions(&params, context).await;
match permission.behavior {
PermissionBehavior::Allow => {}
PermissionBehavior::Deny => {
let message = permission
.message
.unwrap_or_else(|| "命令被安全策略拒绝".to_string());
return Err(ToolError::permission_denied(message));
}
PermissionBehavior::Ask => {
let message = permission
.message
.unwrap_or_else(|| "命令需要人工确认".to_string());
return Err(ToolError::permission_denied(message));
}
}
let command = params
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::invalid_params("Missing required parameter: command"))?;
let background = params
.get("background")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if background {
return Err(ToolError::invalid_params(
"本地 sandbox 模式不支持 background=true",
));
}
let timeout_secs = params
.get("timeout")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_BASH_TIMEOUT_SECS)
.min(MAX_BASH_TIMEOUT_SECS);
let sandbox_config = self.build_sandbox_config(context, timeout_secs);
let (entry, args) = self.build_shell_command(command, context);
let execution = tokio::time::timeout(
Duration::from_secs(timeout_secs),
execute_in_sandbox(&entry, &args, &sandbox_config),
)
.await
.map_err(|_| ToolError::timeout(Duration::from_secs(timeout_secs)))?
.map_err(|e| ToolError::execution_failed(format!("sandbox 执行失败: {e}")))?;
let output = Self::format_output(&execution.stdout, &execution.stderr, execution.exit_code);
if execution.exit_code == 0 {
Ok(ToolResult::success(output)
.with_metadata("exit_code", serde_json::json!(execution.exit_code))
.with_metadata("stdout_length", serde_json::json!(execution.stdout.len()))
.with_metadata("stderr_length", serde_json::json!(execution.stderr.len()))
.with_metadata("sandboxed", serde_json::json!(execution.sandboxed))
.with_metadata(
"sandbox_type",
serde_json::json!(format!("{:?}", execution.sandbox_type)),
))
} else {
Ok(ToolResult::error(output)
.with_metadata("exit_code", serde_json::json!(execution.exit_code))
.with_metadata("stdout_length", serde_json::json!(execution.stdout.len()))
.with_metadata("stderr_length", serde_json::json!(execution.stderr.len()))
.with_metadata("sandboxed", serde_json::json!(execution.sandboxed))
.with_metadata(
"sandbox_type",
serde_json::json!(format!("{:?}", execution.sandbox_type)),
))
}
}
}
/// 为指定工作区生成本地 sandbox 权限模板
async fn apply_workspace_sandbox_permissions(
state: &AsterAgentState,
workspace_root: &str,
) -> Result<(), String> {
let workspace_root = workspace_root.trim();
if workspace_root.is_empty() {
return Err("workspace 根目录为空".to_string());
}
let escaped_root = regex::escape(workspace_root);
let mut permissions = vec![
ToolPermission {
tool: "read".to_string(),
allowed: true,
priority: 100,
conditions: Vec::new(),
parameter_restrictions: vec![ParameterRestriction {
parameter: "path".to_string(),
restriction_type: RestrictionType::Pattern,
values: None,
pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")),
validator: None,
min: None,
max: None,
required: true,
description: Some("read.path 必须在 workspace 内或相对路径".to_string()),
}],
scope: PermissionScope::Session,
reason: Some("仅允许读取当前 workspace 内容".to_string()),
expires_at: None,
metadata: HashMap::new(),
},
ToolPermission {
tool: "write".to_string(),
allowed: true,
priority: 100,
conditions: Vec::new(),
parameter_restrictions: vec![ParameterRestriction {
parameter: "path".to_string(),
restriction_type: RestrictionType::Pattern,
values: None,
pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")),
validator: None,
min: None,
max: None,
required: true,
description: Some("write.path 必须在 workspace 内或相对路径".to_string()),
}],
scope: PermissionScope::Session,
reason: Some("仅允许写入当前 workspace 内容".to_string()),
expires_at: None,
metadata: HashMap::new(),
},
ToolPermission {
tool: "edit".to_string(),
allowed: true,
priority: 100,
conditions: Vec::new(),
parameter_restrictions: vec![ParameterRestriction {
parameter: "path".to_string(),
restriction_type: RestrictionType::Pattern,
values: None,
pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")),
validator: None,
min: None,
max: None,
required: true,
description: Some("edit.path 必须在 workspace 内或相对路径".to_string()),
}],
scope: PermissionScope::Session,
reason: Some("仅允许编辑当前 workspace 内容".to_string()),
expires_at: None,
metadata: HashMap::new(),
},
ToolPermission {
tool: "glob".to_string(),
allowed: true,
priority: 100,
conditions: Vec::new(),
parameter_restrictions: vec![ParameterRestriction {
parameter: "path".to_string(),
restriction_type: RestrictionType::Pattern,
values: None,
pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")),
validator: None,
min: None,
max: None,
required: false,
description: Some("glob.path 必须在 workspace 内或相对路径".to_string()),
}],
scope: PermissionScope::Session,
reason: Some("仅允许在当前 workspace 搜索文件".to_string()),
expires_at: None,
metadata: HashMap::new(),
},
ToolPermission {
tool: "grep".to_string(),
allowed: true,
priority: 100,
conditions: Vec::new(),
parameter_restrictions: vec![ParameterRestriction {
parameter: "path".to_string(),
restriction_type: RestrictionType::Pattern,
values: None,
pattern: Some(format!(r"^({escaped_root}|\.|\./|\.\./).*$")),
validator: None,
min: None,
max: None,
required: false,
description: Some("grep.path 必须在 workspace 内或相对路径".to_string()),
}],
scope: PermissionScope::Session,
reason: Some("仅允许在当前 workspace 搜索内容".to_string()),
expires_at: None,
metadata: HashMap::new(),
},
];
let allow_shell_pattern = format!(
r"^\s*(?:cd\s+({}|\.|\./|\.\./)(?:\s*(?:&&|;).*)?|pwd(?:\s*(?:&&|;).*)?|ls(?:\s+[^;&|]+)?(?:\s*(?:&&|;).*)?|find\s+({}|\.|\./|\.\./)[^;&|]*(?:\s*(?:&&|;).*)?|rg\b[^;&|]*(?:\s*(?:&&|;).*)?|grep\b[^;&|]*(?:\s*(?:&&|;).*)?|cat\s+({}|\.|\./|\.\./)[^;&|]*(?:\s*(?:&&|;).*)?)\s*$",
escaped_root, escaped_root, escaped_root
);
permissions.push(ToolPermission {
tool: "bash".to_string(),
allowed: true,
priority: 90,
conditions: Vec::new(),
parameter_restrictions: vec![
ParameterRestriction {
parameter: "command".to_string(),
restriction_type: RestrictionType::Pattern,
values: None,
pattern: Some(allow_shell_pattern),
validator: None,
min: None,
max: None,
required: true,
description: Some("bash.command 仅允许 workspace 内安全读操作".to_string()),
},
ParameterRestriction {
parameter: "command".to_string(),
restriction_type: RestrictionType::Pattern,
values: None,
pattern: Some("^(?!.*(?:\\|\\||&|`|\\$\\(|python\\s+-c|node\\s+-e|ruby\\s+-e|perl\\s+-e|curl\\s+|wget\\s+|ssh\\s+|scp\\s+|rsync\\s+|nc\\s+|telnet\\s+|sudo\\s+)).*$".to_string()),
validator: None,
min: None,
max: None,
required: true,
description: Some("bash.command 禁止管道、联网与高风险执行".to_string()),
},
],
scope: PermissionScope::Session,
reason: Some("本地 sandbox:bash 仅允许 workspace 内安全命令".to_string()),
expires_at: None,
metadata: HashMap::new(),
});
permissions.push(ToolPermission {
tool: "*".to_string(),
allowed: false,
priority: 10,
conditions: Vec::new(),
parameter_restrictions: Vec::new(),
scope: PermissionScope::Session,
reason: Some("本地 sandbox:未显式授权的工具默认拒绝".to_string()),
expires_at: None,
metadata: HashMap::new(),
});
let agent_arc = state.get_agent_arc();
let guard = agent_arc.read().await;
let agent = guard
.as_ref()
.ok_or_else(|| "Agent not initialized".to_string())?;
let registry_arc = agent.tool_registry().clone();
drop(guard);
let mut registry = registry_arc.write().await;
let mut permission_manager = ToolPermissionManager::new(None);
if let Some(existing_manager) = registry.permission_manager() {
for permission in existing_manager.get_permissions(None) {
let scope = permission.scope;
permission_manager.add_permission(permission, scope);
}
}
for permission in permissions {
permission_manager.add_permission(permission, PermissionScope::Session);
}
registry.set_permission_manager(Arc::new(permission_manager));
let workspace_bash_tool = WorkspaceSandboxedBashTool::new(workspace_root)?;
let sandbox_type = workspace_bash_tool.sandbox_type().to_string();
registry.register(Box::new(workspace_bash_tool));
tracing::info!(
"[AsterAgent] 已应用 workspace 本地 sandbox: root={}, type={}",
workspace_root,
sandbox_type
);
Ok(())
}
/// 图片输入
@@ -243,6 +749,33 @@ pub async fn aster_agent_chat_stream(
// 同时 get_session 也会自动创建不存在的 session
let session_id = &request.session_id;
let workspace_id = request.workspace_id.trim().to_string();
if workspace_id.is_empty() {
return Err("workspace_id 必填,请先选择项目工作区".to_string());
}
let manager = WorkspaceManager::new(db.inner().clone());
let workspace = manager
.get(&workspace_id)
.map_err(|e| format!("读取 workspace 失败: {e}"))?
.ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?;
let workspace_root = workspace.root_path.to_string_lossy().to_string();
{
let db_conn = db.lock().map_err(|e| format!("获取数据库连接失败: {e}"))?;
if let Some(session) = AgentDao::get_session(&db_conn, session_id)
.map_err(|e| format!("读取 session 失败: {e}"))?
{
let session_dir = session.working_dir.unwrap_or_default();
if !session_dir.is_empty() && session_dir != workspace_root {
return Err(format!(
"会话工作目录与 workspace 不匹配: session={}, workspace={}",
session_dir, workspace_root
));
}
}
}
// 启动并注入 MCP extensions 到 Aster Agent
let (_start_ok, start_fail) = ensure_proxycast_mcp_servers_running(&db, &mcp_manager).await;
if start_fail > 0 {
@@ -354,6 +887,10 @@ pub async fn aster_agent_chat_stream(
return Err("Provider 未配置,请先调用 aster_agent_configure_provider".to_string());
}
apply_workspace_sandbox_permissions(&state, &workspace_root)
.await
.map_err(|e| format!("注入本地 sandbox 失败: {e}"))?;
// 创建取消令牌
let cancel_token = state.create_cancel_token(session_id).await;
@@ -445,10 +982,18 @@ pub async fn aster_agent_stop(
#[tauri::command]
pub async fn aster_session_create(
db: State<'_, DbConnection>,
working_dir: Option<String>,
workspace_id: String,
name: Option<String>,
) -> Result<String, String> {
tracing::info!("[AsterAgent] 创建会话: name={:?}", name);
AsterAgentWrapper::create_session_sync(&db, name)
let workspace_id = workspace_id.trim().to_string();
if workspace_id.is_empty() {
return Err("workspace_id 必填,请先选择项目工作区".to_string());
}
AsterAgentWrapper::create_session_sync(&db, name, working_dir, workspace_id)
}
/// 列出所有会话
@@ -480,7 +1025,7 @@ pub struct ConfirmRequest {
/// 确认权限请求(用于工具调用确认等)
#[tauri::command]
pub async fn aster_agent_confirm(
_state: State<'_, AsterAgentState>,
state: State<'_, AsterAgentState>,
request: ConfirmRequest,
) -> Result<(), String> {
tracing::info!(
@@ -489,9 +1034,23 @@ pub async fn aster_agent_confirm(
request.confirmed
);
// TODO: 实现权限确认逻辑
// 这需要 Aster 框架支持 confirmation_tx 通道
// 目前先返回成功
let permission = if request.confirmed {
Permission::AllowOnce
} else {
Permission::DenyOnce
};
let confirmation = PermissionConfirmation {
principal_type: PrincipalType::Tool,
permission,
};
let agent_arc = state.get_agent_arc();
let guard = agent_arc.read().await;
let agent = guard.as_ref().ok_or("Agent not initialized")?;
agent
.handle_confirmation(request.request_id.clone(), confirmation)
.await;
Ok(())
}
@@ -505,13 +1064,15 @@ mod tests {
let json = r#"{
"message": "Hello",
"session_id": "test-session",
"event_name": "agent_stream"
"event_name": "agent_stream",
"workspace_id": "workspace-test"
}"#;
let request: AsterChatRequest = serde_json::from_str(json).unwrap();
assert_eq!(request.message, "Hello");
assert_eq!(request.session_id, "test-session");
assert_eq!(request.event_name, "agent_stream");
assert_eq!(request.workspace_id, "workspace-test");
}
}
File diff suppressed because it is too large Load Diff
+48 -1
View File
@@ -23,6 +23,36 @@ use std::sync::Arc;
use tauri::State;
use tokio::sync::RwLock;
/// 获取统一的项目根目录(~/.proxycast/projects)
fn get_workspace_projects_root_dir() -> Result<PathBuf, String> {
let home_dir = dirs::home_dir().ok_or_else(|| "无法获取主目录".to_string())?;
let root_dir = home_dir.join(".proxycast").join("projects");
std::fs::create_dir_all(&root_dir).map_err(|e| format!("创建 workspace 目录失败: {e}"))?;
Ok(root_dir)
}
/// 规范化项目目录名,避免非法路径字符
fn sanitize_project_dir_name(name: &str) -> String {
let sanitized: String = name
.trim()
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ if ch.is_control() => '_',
_ => ch,
})
.collect();
let trimmed = sanitized.trim().trim_matches('.').to_string();
if trimmed.is_empty() {
"未命名项目".to_string()
} else {
trimmed
}
}
/// Workspace 管理器状态
#[allow(dead_code)]
pub struct WorkspaceManagerState(pub Arc<RwLock<Option<WorkspaceManager>>>);
@@ -211,6 +241,22 @@ pub async fn workspace_get_by_path(
Ok(workspace.map(|ws| ws.into()))
}
/// 获取统一 workspace 项目根目录
#[tauri::command]
pub async fn workspace_get_projects_root() -> Result<String, String> {
let root_dir = get_workspace_projects_root_dir()?;
Ok(root_dir.to_string_lossy().to_string())
}
/// 根据项目名称解析最终项目目录(固定在 workspace 根目录下)
#[tauri::command]
pub async fn workspace_resolve_project_path(name: String) -> Result<String, String> {
let root_dir = get_workspace_projects_root_dir()?;
let dir_name = sanitize_project_dir_name(&name);
let project_path = root_dir.join(dir_name);
Ok(project_path.to_string_lossy().to_string())
}
// ==================== 项目上下文相关命令 ====================
/// 获取或创建默认项目
@@ -233,9 +279,10 @@ pub async fn get_or_create_default_project(
}
// 不存在则创建默认项目
let default_project_path = get_workspace_projects_root_dir()?.join("default");
let workspace = manager.create_with_type(
"默认项目".to_string(),
PathBuf::from("default"),
default_project_path,
WorkspaceType::Persistent,
)?;
+1
View File
@@ -255,6 +255,7 @@ pub async fn handle_command(
messages: Vec::new(),
system_prompt,
title: None, // 初始会话没有标题,后续会自动生成
working_dir: None,
created_at: now.clone(),
updated_at: now,
};
@@ -2,7 +2,7 @@
//!
//! 从数据库查询真实的对话和使用统计数据
use chrono::Timelike;
use chrono::{DateTime, Datelike, Duration, Local, Timelike};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
@@ -55,30 +55,40 @@ pub struct DailyUsage {
pub tokens: u64,
}
#[derive(Debug, Clone, Copy, Default)]
struct ConversationStats {
total_conversations: u32,
total_messages: u32,
monthly_conversations: u32,
monthly_messages: u32,
today_conversations: u32,
today_messages: u32,
}
#[derive(Debug, Clone, Copy, Default)]
struct TokenStats {
total_tokens: u64,
monthly_tokens: u64,
today_tokens: u64,
}
#[derive(Debug, Clone)]
struct RawModelUsage {
model: String,
conversations: u64,
tokens: u64,
}
/// 获取使用统计数据
pub fn get_usage_stats_from_db(
time_range: &str,
conn: &Connection,
) -> Result<UsageStatsResponse, String> {
let now = chrono::Local::now();
let (today_start, month_start, _total_start) = match time_range {
"week" => (
now - chrono::Duration::days(7),
now - chrono::Duration::days(30),
now - chrono::Duration::days(7),
),
"month" => (
now - chrono::Duration::days(1),
now - chrono::Duration::days(30),
now - chrono::Duration::days(30),
),
"all" => (
now - chrono::Duration::days(1),
now - chrono::Duration::days(30),
chrono::Local::now() - chrono::Duration::days(365), // 简化处理
),
_ => return Err("无效的时间范围".to_string()),
};
validate_time_range(time_range)?;
let now = Local::now();
let today_start = start_of_day(now);
let month_start = start_of_month(now);
// 查询通用对话统计
let general_stats = query_general_chat_stats(conn, &today_start, &month_start)?;
@@ -89,18 +99,21 @@ pub fn get_usage_stats_from_db(
// 合并统计
let total_conversations = general_stats.total_conversations + agent_stats.total_conversations;
let total_messages = general_stats.total_messages + agent_stats.total_messages;
let total_tokens = general_stats.total_tokens + agent_stats.total_tokens;
let today_conversations = general_stats.today_conversations + agent_stats.today_conversations;
let today_messages = general_stats.today_messages + agent_stats.today_messages;
let today_tokens = general_stats.today_tokens + agent_stats.today_tokens;
let monthly_conversations =
general_stats.monthly_conversations + agent_stats.monthly_conversations;
let monthly_messages = general_stats.monthly_messages + agent_stats.monthly_messages;
let monthly_tokens = general_stats.monthly_tokens + agent_stats.monthly_tokens;
// 计算总使用时间(基于 token 的估算,假设平均每个 token 需要 0.1 秒)
// Token 优先使用真实统计表;无记录时回退到基于消息内容长度的估算
let token_stats = query_token_stats(conn, &today_start, &month_start)?;
let total_tokens = token_stats.total_tokens;
let monthly_tokens = token_stats.monthly_tokens;
let today_tokens = token_stats.today_tokens;
// 计算总使用时间(基于 token 估算,约 10 token/s)
let total_time_minutes = (total_tokens / 600) as u32;
Ok(UsageStatsResponse {
@@ -117,187 +130,526 @@ pub fn get_usage_stats_from_db(
})
}
fn validate_time_range(time_range: &str) -> Result<(), String> {
match time_range {
"week" | "month" | "all" => Ok(()),
_ => Err("无效的时间范围".to_string()),
}
}
fn resolve_range_days(time_range: &str) -> Result<i64, String> {
match time_range {
"week" => Ok(7),
"month" => Ok(30),
"all" => Ok(90),
_ => Err("无效的时间范围".to_string()),
}
}
fn resolve_range_start(time_range: &str) -> Result<Option<DateTime<Local>>, String> {
let now = Local::now();
match time_range {
"week" => Ok(Some(now - Duration::days(7))),
"month" => Ok(Some(now - Duration::days(30))),
"all" => Ok(None),
_ => Err("无效的时间范围".to_string()),
}
}
fn start_of_day(now: DateTime<Local>) -> DateTime<Local> {
now.with_hour(0)
.and_then(|dt| dt.with_minute(0))
.and_then(|dt| dt.with_second(0))
.and_then(|dt| dt.with_nanosecond(0))
.unwrap_or(now)
}
fn start_of_month(now: DateTime<Local>) -> DateTime<Local> {
now.with_day(1)
.and_then(|dt| dt.with_hour(0))
.and_then(|dt| dt.with_minute(0))
.and_then(|dt| dt.with_second(0))
.and_then(|dt| dt.with_nanosecond(0))
.unwrap_or_else(|| start_of_day(now))
}
fn clamp_i64_to_u32(value: i64) -> u32 {
value.clamp(0, u32::MAX as i64) as u32
}
fn clamp_i64_to_u64(value: i64) -> u64 {
value.max(0) as u64
}
fn chars_to_estimated_tokens(chars: i64) -> u64 {
if chars <= 0 {
return 0;
}
((chars as f64) / 4.0).ceil() as u64
}
/// 查询通用对话统计
fn query_general_chat_stats(
conn: &Connection,
today_start: &chrono::DateTime<chrono::Local>,
month_start: &chrono::DateTime<chrono::Local>,
) -> Result<UsageStatsResponse, String> {
today_start: &DateTime<Local>,
month_start: &DateTime<Local>,
) -> Result<ConversationStats, String> {
// 转换为 Unix 时间戳(毫秒)
let today_ts = today_start.timestamp_millis();
let month_ts = month_start.timestamp_millis();
// 今日对话数
let today_conversations: u32 = conn
let today_conversations: i64 = conn
.query_row(
"SELECT COUNT(*) FROM general_chat_sessions WHERE created_at >= ?",
[today_ts],
|row| row.get(0),
)
.unwrap_or(0);
.map_err(|e| format!("查询今日通用会话数失败: {e}"))?;
// 今日消息数
let today_messages: u32 = conn
let today_messages: i64 = conn
.query_row(
"SELECT COUNT(*) FROM general_chat_messages WHERE created_at >= ?",
[today_ts],
|row| row.get(0),
)
.unwrap_or(0);
.map_err(|e| format!("查询今日通用消息数失败: {e}"))?;
// 本月对话数
let monthly_conversations: u32 = conn
let monthly_conversations: i64 = conn
.query_row(
"SELECT COUNT(*) FROM general_chat_sessions WHERE created_at >= ?",
[month_ts],
|row| row.get(0),
)
.unwrap_or(0);
.map_err(|e| format!("查询本月通用会话数失败: {e}"))?;
// 本月消息数
let monthly_messages: u32 = conn
let monthly_messages: i64 = conn
.query_row(
"SELECT COUNT(*) FROM general_chat_messages WHERE created_at >= ?",
[month_ts],
|row| row.get(0),
)
.unwrap_or(0);
.map_err(|e| format!("查询本月通用消息数失败: {e}"))?;
// 总对话数
let total_conversations: u32 = conn
let total_conversations: i64 = conn
.query_row("SELECT COUNT(*) FROM general_chat_sessions", [], |row| {
row.get(0)
})
.unwrap_or(0);
.map_err(|e| format!("查询总通用会话数失败: {e}"))?;
// 总消息数
let total_messages: u32 = conn
let total_messages: i64 = conn
.query_row("SELECT COUNT(*) FROM general_chat_messages", [], |row| {
row.get(0)
})
.unwrap_or(0);
.map_err(|e| format!("查询总通用消息数失败: {e}"))?;
// TODO: Token 消耗需要从 model_usage_stats 表查询
let today_tokens = 0u64;
let monthly_tokens = 0u64;
let total_tokens = 0u64;
Ok(UsageStatsResponse {
total_conversations,
total_messages,
total_tokens,
total_time_minutes: 0,
monthly_conversations,
monthly_messages,
monthly_tokens,
today_conversations,
today_messages,
today_tokens,
Ok(ConversationStats {
total_conversations: clamp_i64_to_u32(total_conversations),
total_messages: clamp_i64_to_u32(total_messages),
monthly_conversations: clamp_i64_to_u32(monthly_conversations),
monthly_messages: clamp_i64_to_u32(monthly_messages),
today_conversations: clamp_i64_to_u32(today_conversations),
today_messages: clamp_i64_to_u32(today_messages),
})
}
/// 查询 Agent 对话统计
fn query_agent_chat_stats(
conn: &Connection,
today_start: &chrono::DateTime<chrono::Local>,
month_start: &chrono::DateTime<chrono::Local>,
) -> Result<UsageStatsResponse, String> {
today_start: &DateTime<Local>,
month_start: &DateTime<Local>,
) -> Result<ConversationStats, String> {
// Agent sessions 使用 TEXT 格式的日期时间
let today_str = today_start.format("%Y-%m-%d %H:%M:%S").to_string();
let month_str = month_start.format("%Y-%m-%d %H:%M:%S").to_string();
// 今日对话数
let today_conversations: u32 = conn
let today_conversations: i64 = conn
.query_row(
"SELECT COUNT(*) FROM agent_sessions WHERE datetime(created_at) >= datetime(?)",
[today_str.clone()],
|row| row.get(0),
)
.unwrap_or(0);
.map_err(|e| format!("查询今日 Agent 会话数失败: {e}"))?;
// 今日消息数
let today_messages: u32 = conn
let today_messages: i64 = conn
.query_row(
"SELECT COUNT(*) FROM agent_messages WHERE datetime(timestamp) >= datetime(?)",
[today_str],
|row| row.get(0),
)
.unwrap_or(0);
.map_err(|e| format!("查询今日 Agent 消息数失败: {e}"))?;
// 本月对话数
let monthly_conversations: u32 = conn
let monthly_conversations: i64 = conn
.query_row(
"SELECT COUNT(*) FROM agent_sessions WHERE datetime(created_at) >= datetime(?)",
[month_str.clone()],
|row| row.get(0),
)
.unwrap_or(0);
.map_err(|e| format!("查询本月 Agent 会话数失败: {e}"))?;
// 本月消息数
let monthly_messages: u32 = conn
let monthly_messages: i64 = conn
.query_row(
"SELECT COUNT(*) FROM agent_messages WHERE datetime(timestamp) >= datetime(?)",
[month_str],
|row| row.get(0),
)
.unwrap_or(0);
.map_err(|e| format!("查询本月 Agent 消息数失败: {e}"))?;
// 总对话数
let total_conversations: u32 = conn
let total_conversations: i64 = conn
.query_row("SELECT COUNT(*) FROM agent_sessions", [], |row| row.get(0))
.unwrap_or(0);
.map_err(|e| format!("查询总 Agent 会话数失败: {e}"))?;
// 总消息数
let total_messages: u32 = conn
let total_messages: i64 = conn
.query_row("SELECT COUNT(*) FROM agent_messages", [], |row| row.get(0))
.unwrap_or(0);
.map_err(|e| format!("查询总 Agent 消息数失败: {e}"))?;
// TODO: Token 消耗需要从 model_usage_stats 表查询
let today_tokens = 0u64;
let monthly_tokens = 0u64;
let total_tokens = 0u64;
Ok(ConversationStats {
total_conversations: clamp_i64_to_u32(total_conversations),
total_messages: clamp_i64_to_u32(total_messages),
monthly_conversations: clamp_i64_to_u32(monthly_conversations),
monthly_messages: clamp_i64_to_u32(monthly_messages),
today_conversations: clamp_i64_to_u32(today_conversations),
today_messages: clamp_i64_to_u32(today_messages),
})
}
Ok(UsageStatsResponse {
total_conversations,
total_messages,
total_tokens,
total_time_minutes: 0,
monthly_conversations,
monthly_messages,
monthly_tokens,
today_conversations,
today_messages,
today_tokens,
fn query_token_stats(
conn: &Connection,
today_start: &DateTime<Local>,
month_start: &DateTime<Local>,
) -> Result<TokenStats, String> {
if let Some(actual_tokens) = query_model_usage_table_tokens(conn, today_start, month_start)? {
return Ok(actual_tokens);
}
query_estimated_tokens_from_messages(conn, today_start, month_start)
}
fn query_model_usage_table_tokens(
conn: &Connection,
today_start: &DateTime<Local>,
month_start: &DateTime<Local>,
) -> Result<Option<TokenStats>, String> {
let row_count: i64 = conn
.query_row("SELECT COUNT(*) FROM model_usage_stats", [], |row| {
row.get(0)
})
.map_err(|e| format!("查询 model_usage_stats 行数失败: {e}"))?;
if row_count <= 0 {
return Ok(None);
}
let today_key = today_start.format("%Y-%m-%d").to_string();
let month_key = month_start.format("%Y-%m-%d").to_string();
let total_tokens: i64 = conn
.query_row(
"SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats",
[],
|row| row.get(0),
)
.map_err(|e| format!("查询总 Token 失败: {e}"))?;
let monthly_tokens: i64 = conn
.query_row(
"SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date >= ?",
[month_key],
|row| row.get(0),
)
.map_err(|e| format!("查询本月 Token 失败: {e}"))?;
let today_tokens: i64 = conn
.query_row(
"SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date = ?",
[today_key],
|row| row.get(0),
)
.map_err(|e| format!("查询今日 Token 失败: {e}"))?;
Ok(Some(TokenStats {
total_tokens: clamp_i64_to_u64(total_tokens),
monthly_tokens: clamp_i64_to_u64(monthly_tokens),
today_tokens: clamp_i64_to_u64(today_tokens),
}))
}
fn query_estimated_tokens_from_messages(
conn: &Connection,
today_start: &DateTime<Local>,
month_start: &DateTime<Local>,
) -> Result<TokenStats, String> {
let today_ts = today_start.timestamp_millis();
let month_ts = month_start.timestamp_millis();
let today_str = today_start.format("%Y-%m-%d %H:%M:%S").to_string();
let month_str = month_start.format("%Y-%m-%d %H:%M:%S").to_string();
let general_total_chars: i64 = conn
.query_row(
"SELECT COALESCE(SUM(LENGTH(content)), 0) FROM general_chat_messages",
[],
|row| row.get(0),
)
.map_err(|e| format!("估算总 Token(通用消息)失败: {e}"))?;
let general_monthly_chars: i64 = conn
.query_row(
"SELECT COALESCE(SUM(LENGTH(content)), 0) FROM general_chat_messages WHERE created_at >= ?",
[month_ts],
|row| row.get(0),
)
.map_err(|e| format!("估算本月 Token(通用消息)失败: {e}"))?;
let general_today_chars: i64 = conn
.query_row(
"SELECT COALESCE(SUM(LENGTH(content)), 0) FROM general_chat_messages WHERE created_at >= ?",
[today_ts],
|row| row.get(0),
)
.map_err(|e| format!("估算今日 Token(通用消息)失败: {e}"))?;
let agent_total_chars: i64 = conn
.query_row(
"SELECT COALESCE(SUM(LENGTH(content_json)), 0) FROM agent_messages",
[],
|row| row.get(0),
)
.map_err(|e| format!("估算总 Token(Agent 消息)失败: {e}"))?;
let agent_monthly_chars: i64 = conn
.query_row(
"SELECT COALESCE(SUM(LENGTH(content_json)), 0) FROM agent_messages
WHERE datetime(timestamp) >= datetime(?)",
[month_str],
|row| row.get(0),
)
.map_err(|e| format!("估算本月 Token(Agent 消息)失败: {e}"))?;
let agent_today_chars: i64 = conn
.query_row(
"SELECT COALESCE(SUM(LENGTH(content_json)), 0) FROM agent_messages
WHERE datetime(timestamp) >= datetime(?)",
[today_str],
|row| row.get(0),
)
.map_err(|e| format!("估算今日 Token(Agent 消息)失败: {e}"))?;
Ok(TokenStats {
total_tokens: chars_to_estimated_tokens(general_total_chars + agent_total_chars),
monthly_tokens: chars_to_estimated_tokens(general_monthly_chars + agent_monthly_chars),
today_tokens: chars_to_estimated_tokens(general_today_chars + agent_today_chars),
})
}
/// 获取模型使用排行
pub fn get_model_usage_ranking_from_db(
_time_range: &str,
_conn: &Connection,
time_range: &str,
conn: &Connection,
) -> Result<Vec<ModelUsage>, String> {
// TODO: 从 model_usage_stats 表查询真实的模型使用排行
// 这里暂时返回模拟数据
Ok(vec![
ModelUsage {
model: "GPT-4".to_string(),
conversations: 145,
tokens: 580000,
percentage: 46.0,
},
ModelUsage {
model: "GPT-3.5".to_string(),
conversations: 128,
tokens: 420000,
percentage: 33.0,
},
ModelUsage {
model: "Claude 3".to_string(),
conversations: 55,
tokens: 258000,
percentage: 21.0,
},
])
let range_start = resolve_range_start(time_range)?;
let mut usages = query_model_usage_from_stats_table(conn, range_start)?;
if usages.is_empty() {
usages = query_model_usage_from_agent_messages(conn, range_start)?;
}
Ok(build_model_usage_response(usages))
}
fn query_model_usage_from_stats_table(
conn: &Connection,
range_start: Option<DateTime<Local>>,
) -> Result<Vec<RawModelUsage>, String> {
let mut result = Vec::new();
if let Some(start) = range_start {
let start_key = start.format("%Y-%m-%d").to_string();
let mut stmt = conn
.prepare(
"SELECT model_id,
COALESCE(SUM(request_count), 0) AS conversations,
COALESCE(SUM(total_tokens), 0) AS tokens
FROM model_usage_stats
WHERE date >= ?
GROUP BY model_id
ORDER BY tokens DESC, conversations DESC
LIMIT 20",
)
.map_err(|e| format!("准备模型统计查询失败: {e}"))?;
let rows = stmt
.query_map([start_key], |row| {
let model: String = row.get(0)?;
let conversations: i64 = row.get(1)?;
let tokens: i64 = row.get(2)?;
Ok(RawModelUsage {
model,
conversations: clamp_i64_to_u64(conversations),
tokens: clamp_i64_to_u64(tokens),
})
})
.map_err(|e| format!("执行模型统计查询失败: {e}"))?;
for row in rows {
result.push(row.map_err(|e| format!("读取模型统计行失败: {e}"))?);
}
return Ok(result);
}
let mut stmt = conn
.prepare(
"SELECT model_id,
COALESCE(SUM(request_count), 0) AS conversations,
COALESCE(SUM(total_tokens), 0) AS tokens
FROM model_usage_stats
GROUP BY model_id
ORDER BY tokens DESC, conversations DESC
LIMIT 20",
)
.map_err(|e| format!("准备模型统计查询失败: {e}"))?;
let rows = stmt
.query_map([], |row| {
let model: String = row.get(0)?;
let conversations: i64 = row.get(1)?;
let tokens: i64 = row.get(2)?;
Ok(RawModelUsage {
model,
conversations: clamp_i64_to_u64(conversations),
tokens: clamp_i64_to_u64(tokens),
})
})
.map_err(|e| format!("执行模型统计查询失败: {e}"))?;
for row in rows {
result.push(row.map_err(|e| format!("读取模型统计行失败: {e}"))?);
}
Ok(result)
}
fn query_model_usage_from_agent_messages(
conn: &Connection,
range_start: Option<DateTime<Local>>,
) -> Result<Vec<RawModelUsage>, String> {
let mut result = Vec::new();
if let Some(start) = range_start {
let start_str = start.format("%Y-%m-%d %H:%M:%S").to_string();
let mut stmt = conn
.prepare(
"SELECT s.model,
COUNT(DISTINCT m.session_id) AS conversations,
COALESCE(SUM(LENGTH(m.content_json)), 0) AS content_chars
FROM agent_messages m
JOIN agent_sessions s ON s.id = m.session_id
WHERE datetime(m.timestamp) >= datetime(?)
GROUP BY s.model
ORDER BY content_chars DESC, conversations DESC
LIMIT 20",
)
.map_err(|e| format!("准备 Agent 模型排行查询失败: {e}"))?;
let rows = stmt
.query_map([start_str], |row| {
let model: String = row.get(0)?;
let conversations: i64 = row.get(1)?;
let chars: i64 = row.get(2)?;
Ok(RawModelUsage {
model,
conversations: clamp_i64_to_u64(conversations),
tokens: chars_to_estimated_tokens(chars),
})
})
.map_err(|e| format!("执行 Agent 模型排行查询失败: {e}"))?;
for row in rows {
result.push(row.map_err(|e| format!("读取 Agent 模型排行行失败: {e}"))?);
}
return Ok(result);
}
let mut stmt = conn
.prepare(
"SELECT s.model,
COUNT(DISTINCT m.session_id) AS conversations,
COALESCE(SUM(LENGTH(m.content_json)), 0) AS content_chars
FROM agent_messages m
JOIN agent_sessions s ON s.id = m.session_id
GROUP BY s.model
ORDER BY content_chars DESC, conversations DESC
LIMIT 20",
)
.map_err(|e| format!("准备 Agent 模型排行查询失败: {e}"))?;
let rows = stmt
.query_map([], |row| {
let model: String = row.get(0)?;
let conversations: i64 = row.get(1)?;
let chars: i64 = row.get(2)?;
Ok(RawModelUsage {
model,
conversations: clamp_i64_to_u64(conversations),
tokens: chars_to_estimated_tokens(chars),
})
})
.map_err(|e| format!("执行 Agent 模型排行查询失败: {e}"))?;
for row in rows {
result.push(row.map_err(|e| format!("读取 Agent 模型排行行失败: {e}"))?);
}
Ok(result)
}
fn build_model_usage_response(usages: Vec<RawModelUsage>) -> Vec<ModelUsage> {
if usages.is_empty() {
return Vec::new();
}
let total_tokens: u64 = usages.iter().map(|item| item.tokens).sum();
let total_conversations: u64 = usages.iter().map(|item| item.conversations).sum();
usages
.into_iter()
.map(|item| {
let denominator = if total_tokens > 0 {
total_tokens as f64
} else {
total_conversations.max(1) as f64
};
let numerator = if total_tokens > 0 {
item.tokens as f64
} else {
item.conversations as f64
};
let percentage = ((numerator / denominator) * 1000.0).round() / 10.0;
ModelUsage {
model: if item.model.trim().is_empty() {
"unknown".to_string()
} else {
item.model
},
conversations: item.conversations.min(u32::MAX as u64) as u32,
tokens: item.tokens,
percentage: percentage as f32,
}
})
.collect()
}
/// 获取每日使用趋势
@@ -305,58 +657,89 @@ pub fn get_daily_usage_trends_from_db(
time_range: &str,
conn: &Connection,
) -> Result<Vec<DailyUsage>, String> {
let days = match time_range {
"week" => 7,
"month" => 30,
"all" => 90,
_ => 30,
};
let days = resolve_range_days(time_range)?;
let has_model_usage_data: i64 = conn
.query_row("SELECT COUNT(*) FROM model_usage_stats", [], |row| {
row.get(0)
})
.map_err(|e| format!("检查 model_usage_stats 失败: {e}"))?;
let use_actual_tokens = has_model_usage_data > 0;
let mut daily_usage = Vec::new();
// 查询通用对话的每日统计
// 查询每日统计(从最早日期到今天)
for i in (0..days).rev() {
let date = chrono::Local::now() - chrono::Duration::days(i as i64);
let date = Local::now() - Duration::days(i);
let day_start = start_of_day(date);
let day_end = day_start + Duration::days(1);
// 当天的开始和结束时间戳
let day_start = date
.with_hour(0)
.and_then(|d| d.with_minute(0))
.and_then(|d| d.with_second(0))
.unwrap_or(date)
.timestamp_millis();
let day_end = day_start + 24 * 60 * 60 * 1000 - 1; // 当天 23:59:59
// 当天开始/结束(时间戳 + 文本)
let day_start_ts = day_start.timestamp_millis();
let day_end_ts = day_end.timestamp_millis();
let day_start_str = day_start.format("%Y-%m-%d %H:%M:%S").to_string();
let day_end_str = day_end.format("%Y-%m-%d %H:%M:%S").to_string();
let day_key = day_start.format("%Y-%m-%d").to_string();
let conversations: u32 = conn
let conversations: i64 = conn
.query_row(
"SELECT COUNT(*) FROM general_chat_sessions WHERE created_at >= ? AND created_at <= ?",
[day_start, day_end],
"SELECT COUNT(*) FROM general_chat_sessions WHERE created_at >= ? AND created_at < ?",
[day_start_ts, day_end_ts],
|row| row.get(0),
)
.unwrap_or(0);
.map_err(|e| format!("查询通用会话日统计失败: {e}"))?;
// 查询 Agent 对话
let date_str = date.format("%Y-%m-%d").to_string();
let agent_conversations: u32 = conn
let agent_conversations: i64 = conn
.query_row(
"SELECT COUNT(*) FROM agent_sessions WHERE date(created_at) = ?",
[date_str],
"SELECT COUNT(*) FROM agent_sessions
WHERE datetime(created_at) >= datetime(?)
AND datetime(created_at) < datetime(?)",
[day_start_str.clone(), day_end_str.clone()],
|row| row.get(0),
)
.unwrap_or(0);
.map_err(|e| format!("查询 Agent 会话日统计失败: {e}"))?;
let total_conversations = conversations + agent_conversations;
// TODO: 从 model_usage_stats 表查询 Token 消耗
let tokens = if total_conversations > 0 {
((rand::random::<u32>() % 15000) + 2000) as u64
let tokens = if use_actual_tokens {
let day_tokens: i64 = conn
.query_row(
"SELECT COALESCE(SUM(total_tokens), 0) FROM model_usage_stats WHERE date = ?",
[day_key.clone()],
|row| row.get(0),
)
.map_err(|e| format!("查询模型日 Token 失败: {e}"))?;
clamp_i64_to_u64(day_tokens)
} else {
0
let general_chars: i64 = conn
.query_row(
"SELECT COALESCE(SUM(LENGTH(content)), 0)
FROM general_chat_messages
WHERE created_at >= ? AND created_at < ?",
[day_start_ts, day_end_ts],
|row| row.get(0),
)
.map_err(|e| format!("估算通用消息日 Token 失败: {e}"))?;
let agent_chars: i64 = conn
.query_row(
"SELECT COALESCE(SUM(LENGTH(content_json)), 0)
FROM agent_messages
WHERE datetime(timestamp) >= datetime(?)
AND datetime(timestamp) < datetime(?)",
[day_start_str, day_end_str],
|row| row.get(0),
)
.map_err(|e| format!("估算 Agent 消息日 Token 失败: {e}"))?;
chars_to_estimated_tokens(general_chars + agent_chars)
};
daily_usage.push(DailyUsage {
date: date.format("%Y-%m-%d").to_string(),
conversations: total_conversations,
date: day_key,
conversations: clamp_i64_to_u32(total_conversations),
tokens,
});
}
+61 -8
View File
@@ -10,6 +10,17 @@ use super::config;
use super::recording_service::{AudioDeviceInfo, RecordingServiceState};
use tauri::State;
fn normalize_shortcut(value: Option<String>) -> Option<String> {
value.and_then(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
/// 获取所有可用的麦克风设备
#[command]
pub async fn list_audio_devices() -> Result<Vec<AudioDeviceInfo>, String> {
@@ -29,16 +40,58 @@ pub async fn save_voice_input_config(
voice_config: VoiceInputConfig,
) -> Result<(), String> {
let old_config = config::load_voice_config()?;
let old_enabled = old_config.enabled;
let new_enabled = voice_config.enabled;
if old_config.shortcut != voice_config.shortcut {
super::shortcut::update(&app, &voice_config.shortcut)?;
}
if old_enabled && new_enabled {
if old_config.shortcut != voice_config.shortcut {
super::shortcut::update(&app, &voice_config.shortcut)?;
}
if old_config.enabled != voice_config.enabled {
if voice_config.enabled {
super::shortcut::register(&app, &voice_config.shortcut)?;
} else {
super::shortcut::unregister(&app)?;
let old_translate_shortcut = normalize_shortcut(old_config.translate_shortcut.clone());
let new_translate_shortcut = normalize_shortcut(voice_config.translate_shortcut.clone());
let instruction_changed =
old_config.translate_instruction_id != voice_config.translate_instruction_id;
if old_translate_shortcut != new_translate_shortcut || instruction_changed {
match (
old_translate_shortcut.as_deref(),
new_translate_shortcut.as_deref(),
) {
(Some(_), Some(new_shortcut)) => {
super::shortcut::update_translate(
&app,
new_shortcut,
&voice_config.translate_instruction_id,
)?;
}
(None, Some(new_shortcut)) => {
super::shortcut::register_translate(
&app,
new_shortcut,
&voice_config.translate_instruction_id,
)?;
}
(Some(_), None) => {
super::shortcut::unregister_translate(&app)?;
}
(None, None) => {}
}
}
} else if old_enabled && !new_enabled {
super::shortcut::unregister(&app)?;
let _ = super::shortcut::unregister_translate(&app);
} else if !old_enabled && new_enabled {
super::shortcut::register(&app, &voice_config.shortcut)?;
if let Some(translate_shortcut) =
normalize_shortcut(voice_config.translate_shortcut.clone())
{
super::shortcut::register_translate(
&app,
&translate_shortcut,
&voice_config.translate_instruction_id,
)?;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.62.0",
"version": "0.63.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.62.0",
"version": "0.63.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+16 -19
View File
@@ -15,6 +15,7 @@ import { SplashScreen } from "./components/SplashScreen";
import { AppSidebar } from "./components/AppSidebar";
import { SettingsPageV2 } from "./components/settings-v2";
import { ToolsPage } from "./components/tools/ToolsPage";
import { MemoryPage } from "./components/memory";
import { AgentChatPage } from "./components/agent";
import { PluginsPage } from "./components/plugins/PluginsPage";
import { ImageGenPage } from "./components/image-gen";
@@ -25,6 +26,7 @@ import {
createProject,
createContent,
isUserProjectType,
resolveProjectRootPath,
} from "./lib/api/project";
import {
TerminalWorkspace,
@@ -54,7 +56,6 @@ import {
WorkspaceTheme,
} from "./types/page";
import { SettingsTabs } from "./types/settings";
import { open } from "@tauri-apps/plugin-dialog";
import { toast } from "sonner";
const AppContainer = styled.div`
@@ -267,24 +268,7 @@ function AppContent() {
name: string,
type: ProjectType,
) => {
const selectedPath = await open({
directory: true,
title: "选择项目目录",
});
if (!selectedPath) {
throw new Error("用户取消选择目录");
}
const projectPath = Array.isArray(selectedPath)
? selectedPath.length === 1
? selectedPath[0]
: null
: selectedPath;
if (!projectPath) {
throw new Error("请选择单个项目目录");
}
const projectPath = await resolveProjectRootPath(name);
const project = await createProject({
name,
@@ -406,6 +390,7 @@ function AppContent() {
contentId={(pageParams as AgentPageParams).contentId}
theme={(pageParams as AgentPageParams).theme}
lockTheme={(pageParams as AgentPageParams).lockTheme}
newChatAt={(pageParams as AgentPageParams).newChatAt}
onHasMessagesChange={setAgentHasMessages}
/>
</div>
@@ -443,6 +428,17 @@ function AppContent() {
<PluginsPage />
</PageWrapper>
<div
style={{
flex: 1,
minHeight: 0,
display: currentPage === "memory" ? "flex" : "none",
flexDirection: "column",
}}
>
<MemoryPage onNavigate={handleNavigate} />
</div>
<div
style={{
flex: 1,
@@ -484,6 +480,7 @@ function AppContent() {
const shouldShowAppSidebar =
currentPage !== "settings" &&
currentPage !== "memory" &&
currentPage !== "image-gen" &&
!isThemeWorkspacePage(currentPage) &&
!shouldHideSidebarForAgent;
+16 -6
View File
@@ -1,7 +1,7 @@
/**
* 全局应用侧边栏
*
* 参考 LobeHub 的信息架构:用户区、搜索、主导航、助手分组、底部快捷入口
* 参考成熟产品的信息架构:用户区、搜索、主导航、助手分组、底部快捷入口
*/
import { useState, useEffect, useMemo } from "react";
@@ -31,13 +31,13 @@ import {
import * as LucideIcons from "lucide-react";
import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI";
import {
AgentPageParams,
getThemeWorkspacePage,
LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY,
Page,
PageParams,
ThemeWorkspacePage,
} from "@/types/page";
import { SettingsTabs } from "@/types/settings";
import { getConfig } from "@/hooks/useTauri";
interface AppSidebarProps {
@@ -347,9 +347,8 @@ const FOOTER_MENU_ITEMS: SidebarNavItem[] = [
id: "memory",
label: "记忆",
icon: BrainCircuit,
page: "settings",
params: { tab: SettingsTabs.Memory },
isActive: (currentPage) => currentPage === "settings",
page: "memory",
isActive: (currentPage) => currentPage === "memory",
},
];
@@ -520,7 +519,16 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
setActiveThemeKey(item.page);
localStorage.setItem(LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, item.page);
}
onNavigate(item.page, item.params);
const params: PageParams | undefined =
item.id === "home-general"
? ({
...(item.params as AgentPageParams | undefined),
newChatAt: Date.now(),
} as AgentPageParams)
: item.params;
onNavigate(item.page, params);
};
return (
@@ -531,6 +539,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
onNavigate("agent", {
theme: "general",
lockTheme: false,
newChatAt: Date.now(),
})
}
>
@@ -546,6 +555,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
onNavigate("agent", {
theme: "general",
lockTheme: false,
newChatAt: Date.now(),
})
}
>
@@ -14,13 +14,15 @@ import { cn } from "@/lib/utils";
import {
Project,
ProjectType,
listProjects,
createProject,
generateProjectName,
getDefaultProjectPath,
getCreateProjectErrorMessage,
extractErrorMessage,
listProjects,
resolveProjectRootPath,
TYPE_CONFIGS,
} from "@/lib/api/project";
import { toast } from "sonner";
import { CreateProjectDialog } from "@/components/projects/CreateProjectDialog";
interface ProjectSelectorProps {
/** 当前激活的主题(用于过滤项目) */
@@ -39,7 +41,7 @@ export function ProjectSelector({
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [creating, setCreating] = useState(false);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
// 加载项目列表
useEffect(() => {
@@ -90,18 +92,22 @@ export function ProjectSelector({
return result;
}, [projects, activeTheme, searchQuery]);
// 快速创建项目
const handleQuickCreate = async () => {
setCreating(true);
const defaultProjectType = useMemo(() => {
const themeType = activeTheme as ProjectType;
if (Object.prototype.hasOwnProperty.call(TYPE_CONFIGS, themeType)) {
return themeType;
}
return "general" as ProjectType;
}, [activeTheme]);
const handleCreateProject = async (name: string, type: ProjectType) => {
try {
const projectType = activeTheme as ProjectType;
const name = generateProjectName(projectType);
const rootPath = getDefaultProjectPath();
const projectPath = await resolveProjectRootPath(name);
const newProject = await createProject({
name,
rootPath,
workspaceType: projectType,
rootPath: projectPath,
workspaceType: type,
});
toast.success("项目创建成功");
@@ -109,9 +115,10 @@ export function ProjectSelector({
onSelectProject(newProject.id);
} catch (error) {
console.error("创建项目失败:", error);
toast.error("创建项目失败");
} finally {
setCreating(false);
const errorMessage = extractErrorMessage(error);
const friendlyMessage = getCreateProjectErrorMessage(errorMessage);
toast.error(`创建项目失败: ${friendlyMessage}`);
throw error;
}
};
@@ -142,11 +149,7 @@ export function ProjectSelector({
className="pl-9"
/>
</div>
<Button
onClick={handleQuickCreate}
disabled={creating}
className="gap-2"
>
<Button onClick={() => setCreateDialogOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
新建项目
</Button>
@@ -165,7 +168,7 @@ export function ProjectSelector({
{searchQuery ? "没有找到匹配的项目" : "还没有项目"}
</p>
{!searchQuery && (
<Button onClick={handleQuickCreate} disabled={creating}>
<Button onClick={() => setCreateDialogOpen(true)}>
创建第一个项目
</Button>
)}
@@ -218,6 +221,14 @@ export function ProjectSelector({
</div>
)}
</ScrollArea>
<CreateProjectDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSubmit={handleCreateProject}
defaultType={defaultProjectType}
defaultName={`${TYPE_CONFIGS[defaultProjectType].label}项目`}
/>
</div>
);
}
+2 -1
View File
@@ -14,6 +14,7 @@ export type { Topic } from "./useAgentChat";
interface UseAgentChatUnifiedOptions {
systemPrompt?: string;
onWriteFile?: (content: string, fileName: string) => void;
workspaceId: string;
}
/**
@@ -26,7 +27,7 @@ interface UseAgentChatUnifiedOptions {
* 切换方式:
* localStorage.setItem("proxycast_agent_backend", "aster")
*/
export function useAgentChatUnified(options: UseAgentChatUnifiedOptions = {}) {
export function useAgentChatUnified(options: UseAgentChatUnifiedOptions) {
const backend = getAgentBackend();
// 根据配置选择 hook
+272 -27
View File
@@ -116,7 +116,7 @@ const loadTransient = <T>(key: string, defaultValue: T): T => {
const stored = sessionStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
if (key === "agent_messages" && Array.isArray(parsed)) {
if (key.startsWith("agent_messages") && Array.isArray(parsed)) {
return parsed.map((msg: any) => ({
...msg,
timestamp: new Date(msg.timestamp),
@@ -155,10 +155,34 @@ interface UseAgentChatOptions {
systemPrompt?: string;
/** 文件写入回调 */
onWriteFile?: (content: string, fileName: string) => void;
/** 绑定的工作区 ID(用于本地 sandbox) */
workspaceId: string;
}
export function useAgentChat(options: UseAgentChatOptions = {}) {
const { systemPrompt, onWriteFile } = options;
export function useAgentChat(options: UseAgentChatOptions) {
const { systemPrompt, onWriteFile, workspaceId } = options;
const getRequiredWorkspaceId = (): string => {
const resolvedWorkspaceId = workspaceId?.trim();
if (!resolvedWorkspaceId) {
throw new Error("缺少项目工作区,请先选择项目后再使用 Agent");
}
return resolvedWorkspaceId;
};
const getScopedKey = (key: string): string => {
const resolvedWorkspaceId = workspaceId?.trim();
return resolvedWorkspaceId
? `${key}_${resolvedWorkspaceId}`
: `${key}_global`;
};
const getScopedSessionKey = () => getScopedKey("agent_curr_sessionId");
const getScopedMessagesKey = () => getScopedKey("agent_messages");
const getScopedRoundCountKey = () => getScopedKey("agent_curr_roundCount");
const getScopedPersistedSessionKey = () =>
getScopedKey("agent_last_sessionId");
const [processStatus, setProcessStatus] = useState<AgentProcessStatus>({
running: false,
});
@@ -180,12 +204,44 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
);
// Session State
const [sessionId, setSessionId] = useState<string | null>(() =>
loadTransient("agent_curr_sessionId", null),
);
const [messages, setMessages] = useState<Message[]>(() =>
loadTransient("agent_messages", []),
);
const [sessionId, setSessionId] = useState<string | null>(() => {
if (!workspaceId?.trim()) {
return null;
}
const scopedSessionId = loadTransient<string | null>(
getScopedSessionKey(),
null,
);
if (scopedSessionId) {
return scopedSessionId;
}
const persistedSessionId = loadPersisted<string | null>(
getScopedPersistedSessionKey(),
null,
);
if (persistedSessionId) {
return persistedSessionId;
}
// 兼容旧版本(未按 workspace 分片)
return loadTransient<string | null>("agent_curr_sessionId", null);
});
const [messages, setMessages] = useState<Message[]>(() => {
if (!workspaceId?.trim()) {
return [];
}
const scopedMessages = loadTransient<Message[]>(getScopedMessagesKey(), []);
if (scopedMessages.length > 0) {
return scopedMessages;
}
// 兼容旧版本(未按 workspace 分片)
return loadTransient<Message[]>("agent_messages", []);
});
// 话题列表
const [topics, setTopics] = useState<Topic[]>([]);
@@ -199,13 +255,20 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
// 当前会话的轮数计数器(用于判断何时生成标题)
const [_roundCount, _setRoundCount] = useState(() =>
loadTransient("agent_curr_roundCount", 0),
workspaceId?.trim()
? loadTransient<number>(getScopedRoundCountKey(), 0)
: 0,
);
// 用于保存当前流式请求的取消函数
const unlistenRef = useRef<UnlistenFn | null>(null);
// 用于保存当前正在处理的消息 ID
const currentAssistantMsgIdRef = useRef<string | null>(null);
// 自动恢复/水合状态跟踪
const restoredWorkspaceRef = useRef<string | null>(null);
const hydratedSessionRef = useRef<string | null>(null);
const skipAutoRestoreRef = useRef(false);
const sessionResetVersionRef = useRef(0);
// Artifact 解析器 - 用于流式解析 AI 响应中的 artifact
const {
@@ -261,24 +324,72 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
}, [providerType, providerConfig]);
useEffect(() => {
saveTransient("agent_curr_sessionId", sessionId);
}, [sessionId]);
useEffect(() => {
saveTransient("agent_messages", messages);
}, [messages]);
const resolvedWorkspaceId = workspaceId?.trim();
if (!resolvedWorkspaceId) {
return;
}
// 当 systemPrompt 变化时,需要创建新会话以应用新的系统提示词
// 这对于内容创作模式切换非常重要
useEffect(() => {
if (systemPrompt !== undefined && sessionId) {
console.log(
"[useAgentChat] systemPrompt 变化,重置 session 以应用新提示词",
const scopedSessionKey = getScopedSessionKey();
const scopedPersistedSessionKey = getScopedPersistedSessionKey();
saveTransient(scopedSessionKey, sessionId);
savePersisted(scopedPersistedSessionKey, sessionId);
if (sessionId) {
savePersisted(
`agent_session_workspace_${sessionId}`,
resolvedWorkspaceId,
);
setSessionId(null);
}
// 注意:只在 systemPrompt 变化时触发,不包含 sessionId
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [systemPrompt]);
}, [sessionId, workspaceId]);
useEffect(() => {
if (!workspaceId?.trim()) {
return;
}
saveTransient(getScopedMessagesKey(), messages);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages, workspaceId]);
useEffect(() => {
if (!workspaceId?.trim()) {
return;
}
saveTransient(getScopedRoundCountKey(), _roundCount);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [_roundCount, workspaceId]);
// workspace 变化时恢复对应会话状态
useEffect(() => {
if (!workspaceId?.trim()) {
setSessionId(null);
setMessages([]);
_setRoundCount(0);
setA2uiFormDataMap({});
restoredWorkspaceRef.current = null;
hydratedSessionRef.current = null;
skipAutoRestoreRef.current = false;
return;
}
const scopedSessionId =
loadTransient<string | null>(getScopedSessionKey(), null) ??
loadPersisted<string | null>(getScopedPersistedSessionKey(), null);
const scopedMessages = loadTransient<Message[]>(getScopedMessagesKey(), []);
const scopedRoundCount = loadTransient<number>(getScopedRoundCountKey(), 0);
setSessionId(scopedSessionId);
setMessages(scopedMessages);
_setRoundCount(scopedRoundCount);
setA2uiFormDataMap({});
restoredWorkspaceRef.current = null;
hydratedSessionRef.current = null;
skipAutoRestoreRef.current = false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId]);
// 加载话题列表
const loadTopics = async () => {
@@ -349,6 +460,16 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// workspace 变化时刷新话题
useEffect(() => {
if (!workspaceId?.trim()) {
setTopics([]);
return;
}
loadTopics();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId]);
// 监听截图对话消息事件
useEffect(() => {
let unlisten: UnlistenFn | null = null;
@@ -422,14 +543,17 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
// Create new session with CURRENT provider/model as baseline
// 传递 systemPrompt 用于内容创作等场景
const resolvedWorkspaceId = getRequiredWorkspaceId();
const response = await createAgentSession(
providerType,
resolvedWorkspaceId,
model || undefined,
systemPrompt, // 传递系统提示词
undefined, // details.length > 0 ? details : undefined
);
setSessionId(response.session_id);
skipAutoRestoreRef.current = false;
return response.session_id;
} catch (error) {
console.error("[AgentChat] Auto-creation failed:", error);
@@ -948,9 +1072,11 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
hasSystemPrompt: !!systemPrompt,
});
const resolvedWorkspaceId = getRequiredWorkspaceId();
await sendAgentMessageStream(
messageToSend,
eventName,
resolvedWorkspaceId,
activeSessionId, // 传递 sessionId 以保持上下文
model || undefined,
imagesToSend,
@@ -992,9 +1118,27 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
} = {},
) => {
const { showToast = true, toastMessage = "新话题已创建" } = options;
const resolvedWorkspaceId = workspaceId?.trim();
sessionResetVersionRef.current += 1;
setMessages([]);
setSessionId(null);
_setRoundCount(0);
setA2uiFormDataMap({});
restoredWorkspaceRef.current = resolvedWorkspaceId || null;
hydratedSessionRef.current = null;
skipAutoRestoreRef.current = true;
if (resolvedWorkspaceId) {
saveTransient(`agent_curr_sessionId_${resolvedWorkspaceId}`, null);
savePersisted(`agent_last_sessionId_${resolvedWorkspaceId}`, null);
saveTransient(`agent_messages_${resolvedWorkspaceId}`, []);
saveTransient(`agent_curr_roundCount_${resolvedWorkspaceId}`, 0);
}
// 清理旧版本兼容键,避免被误用为恢复候选
saveTransient("agent_curr_sessionId", null);
if (showToast) {
toast.success(toastMessage);
@@ -1003,8 +1147,10 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
// 切换话题
const switchTopic = async (topicId: string) => {
if (topicId === sessionId) return;
if (topicId === sessionId && messages.length > 0) return;
const restoreRequestVersion = sessionResetVersionRef.current;
skipAutoRestoreRef.current = false;
console.log("[useAgentChat] 切换话题:", topicId);
try {
@@ -1067,19 +1213,116 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
};
});
if (restoreRequestVersion !== sessionResetVersionRef.current) {
console.log("[useAgentChat] 忽略过期会话切换:", topicId);
return;
}
console.log("[useAgentChat] 转换后消息数量:", loadedMessages.length);
setMessages(loadedMessages);
setSessionId(topicId);
toast.info("已切换话题");
} catch (error) {
if (restoreRequestVersion !== sessionResetVersionRef.current) {
console.log("[useAgentChat] 忽略过期会话切换错误:", topicId);
return;
}
console.error("[useAgentChat] 加载消息历史失败:", error);
// 如果加载失败,仍然切换话题但清空消息
// 加载失败时回退到新会话态,避免卡在无效会话
setMessages([]);
setSessionId(topicId);
setSessionId(null);
saveTransient(getScopedSessionKey(), null);
savePersisted(getScopedPersistedSessionKey(), null);
toast.error("加载对话历史失败");
}
};
// 自动恢复当前 workspace 最近会话
useEffect(() => {
const resolvedWorkspaceId = workspaceId?.trim();
if (!resolvedWorkspaceId) return;
if (skipAutoRestoreRef.current) return;
if (sessionId) return;
if (topics.length === 0) return;
if (restoredWorkspaceRef.current === resolvedWorkspaceId) return;
restoredWorkspaceRef.current = resolvedWorkspaceId;
const scopedCandidate =
loadTransient<string | null>(getScopedSessionKey(), null) ||
loadPersisted<string | null>(getScopedPersistedSessionKey(), null);
const legacyCandidateRaw = loadTransient<string | null>(
"agent_curr_sessionId",
null,
);
const legacyCandidateWorkspace = legacyCandidateRaw
? loadPersisted<string | null>(
`agent_session_workspace_${legacyCandidateRaw}`,
null,
)
: null;
const legacyCandidate =
legacyCandidateRaw &&
(!legacyCandidateWorkspace ||
legacyCandidateWorkspace === resolvedWorkspaceId)
? legacyCandidateRaw
: null;
const mappedFallbackCandidate =
topics.find(
(topic) =>
loadPersisted<string | null>(
`agent_session_workspace_${topic.id}`,
null,
) === resolvedWorkspaceId,
)?.id || null;
const fallbackCandidate =
mappedFallbackCandidate || (topics.length === 1 ? topics[0]?.id : null);
const targetSessionId =
scopedCandidate || legacyCandidate || fallbackCandidate;
if (!targetSessionId) {
return;
}
switchTopic(targetSessionId).catch((error) => {
console.warn("[useAgentChat] 自动恢复会话失败:", error);
saveTransient(getScopedSessionKey(), null);
savePersisted(getScopedPersistedSessionKey(), null);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId, sessionId, topics]);
useEffect(() => {
if (sessionId) {
skipAutoRestoreRef.current = false;
}
}, [sessionId]);
// 如果有 sessionId 但消息为空,主动回填历史消息
useEffect(() => {
if (!sessionId) return;
if (messages.length > 0) {
hydratedSessionRef.current = sessionId;
return;
}
if (hydratedSessionRef.current === sessionId) {
return;
}
hydratedSessionRef.current = sessionId;
switchTopic(sessionId).catch((error) => {
console.warn("[useAgentChat] 会话水合失败:", error);
hydratedSessionRef.current = null;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, messages.length]);
// 删除话题
const deleteTopic = async (topicId: string) => {
try {
@@ -1495,9 +1738,11 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
// 发送空消息,让 AI 根据系统提示词开始引导
console.log("[AgentChat] triggerAIGuide 发送空消息触发引导");
const resolvedWorkspaceId = getRequiredWorkspaceId();
await sendAgentMessageStream(
"", // 空消息,让 AI 根据系统提示词开始引导
eventName,
resolvedWorkspaceId,
activeSessionId,
model || undefined,
undefined,
@@ -52,6 +52,7 @@ export interface ConfirmResponse {
interface UseAsterAgentChatOptions {
systemPrompt?: string;
onWriteFile?: (content: string, fileName: string) => void;
workspaceId: string;
}
// 音效相关(复用)
@@ -98,6 +99,54 @@ const playTypewriterSound = () => {
}
};
// 持久化 helpers
const loadPersisted = <T>(key: string, defaultValue: T): T => {
try {
const stored = localStorage.getItem(key);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.error(e);
}
return defaultValue;
};
const savePersisted = (key: string, value: unknown) => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error(e);
}
};
const loadTransient = <T>(key: string, defaultValue: T): T => {
try {
const stored = sessionStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
if (key.startsWith("aster_messages") && Array.isArray(parsed)) {
return parsed.map((msg: any) => ({
...msg,
timestamp: new Date(msg.timestamp),
})) as unknown as T;
}
return parsed;
}
} catch (e) {
console.error(e);
}
return defaultValue;
};
const saveTransient = (key: string, value: unknown) => {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error(e);
}
};
/**
* 将前端 Provider 类型映射到 Aster Provider 名称
*/
@@ -127,13 +176,65 @@ const mapProviderName = (providerType: string): string => {
return mapping[providerType.toLowerCase()] || providerType;
};
export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
const { onWriteFile } = options;
export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
const { onWriteFile, workspaceId } = options;
const getRequiredWorkspaceId = useCallback((): string => {
const resolvedWorkspaceId = workspaceId?.trim();
if (!resolvedWorkspaceId) {
throw new Error("缺少项目工作区,请先选择项目后再使用 Agent");
}
return resolvedWorkspaceId;
}, [workspaceId]);
const getScopedKey = useCallback(
(key: string): string => {
const resolvedWorkspaceId = workspaceId?.trim();
return resolvedWorkspaceId
? `${key}_${resolvedWorkspaceId}`
: `${key}_global`;
},
[workspaceId],
);
const getScopedSessionKey = useCallback(
() => getScopedKey("aster_curr_sessionId"),
[getScopedKey],
);
const getScopedMessagesKey = useCallback(
() => getScopedKey("aster_messages"),
[getScopedKey],
);
const getScopedPersistedSessionKey = useCallback(
() => getScopedKey("aster_last_sessionId"),
[getScopedKey],
);
// 状态
const [isInitialized, setIsInitialized] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [sessionId, setSessionId] = useState<string | null>(() => {
if (!workspaceId?.trim()) {
return null;
}
const scopedSessionId = loadTransient<string | null>(
`aster_curr_sessionId_${workspaceId.trim()}`,
null,
);
if (scopedSessionId) {
return scopedSessionId;
}
return loadPersisted<string | null>(
`aster_last_sessionId_${workspaceId.trim()}`,
null,
);
});
const [messages, setMessages] = useState<Message[]>(() =>
workspaceId?.trim()
? loadTransient<Message[]>(`aster_messages_${workspaceId.trim()}`, [])
: [],
);
const [topics, setTopics] = useState<Topic[]>([]);
const [isSending, setIsSending] = useState(false);
const [pendingActions, setPendingActions] = useState<ActionRequired[]>([]);
@@ -149,6 +250,9 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
// Refs
const unlistenRef = useRef<UnlistenFn | null>(null);
const currentAssistantMsgIdRef = useRef<string | null>(null);
const restoredWorkspaceRef = useRef<string | null>(null);
const hydratedSessionRef = useRef<string | null>(null);
const skipAutoRestoreRef = useRef(false);
// 持久化 provider/model
useEffect(() => {
@@ -159,6 +263,69 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
localStorage.setItem("agent_pref_model", model);
}, [model]);
useEffect(() => {
const resolvedWorkspaceId = workspaceId?.trim();
if (!resolvedWorkspaceId) {
return;
}
const scopedSessionKey = getScopedSessionKey();
const scopedPersistedSessionKey = getScopedPersistedSessionKey();
saveTransient(scopedSessionKey, sessionId);
savePersisted(scopedPersistedSessionKey, sessionId);
if (sessionId) {
savePersisted(
`agent_session_workspace_${sessionId}`,
resolvedWorkspaceId,
);
}
}, [
getScopedPersistedSessionKey,
getScopedSessionKey,
sessionId,
workspaceId,
]);
useEffect(() => {
if (!workspaceId?.trim()) {
return;
}
saveTransient(getScopedMessagesKey(), messages);
}, [getScopedMessagesKey, messages, workspaceId]);
// workspace 变化时恢复对应会话状态
useEffect(() => {
if (!workspaceId?.trim()) {
setSessionId(null);
setMessages([]);
setPendingActions([]);
restoredWorkspaceRef.current = null;
hydratedSessionRef.current = null;
skipAutoRestoreRef.current = false;
return;
}
const scopedSessionId =
loadTransient<string | null>(getScopedSessionKey(), null) ??
loadPersisted<string | null>(getScopedPersistedSessionKey(), null);
const scopedMessages = loadTransient<Message[]>(getScopedMessagesKey(), []);
setSessionId(scopedSessionId);
setMessages(scopedMessages);
setPendingActions([]);
restoredWorkspaceRef.current = null;
hydratedSessionRef.current = null;
skipAutoRestoreRef.current = false;
}, [
getScopedMessagesKey,
getScopedPersistedSessionKey,
getScopedSessionKey,
workspaceId,
]);
// 初始化 Aster Agent
useEffect(() => {
const init = async () => {
@@ -184,6 +351,33 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
init();
}, []);
useEffect(() => {
if (!isInitialized) {
return;
}
if (!workspaceId?.trim()) {
setTopics([]);
return;
}
listAsterSessions()
.then((sessions) => {
const topicList: Topic[] = sessions.map((s: AsterSessionInfo) => ({
id: s.id,
title:
s.name ||
`话题 ${new Date(s.created_at * 1000).toLocaleDateString("zh-CN")}`,
createdAt: new Date(s.created_at * 1000),
messagesCount: s.messages_count ?? 0,
}));
setTopics(topicList);
})
.catch((error) => {
console.error("[AsterChat] 加载话题失败:", error);
});
}, [isInitialized, workspaceId]);
// 加载话题列表
const loadTopics = useCallback(async () => {
try {
@@ -207,15 +401,17 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
if (sessionId) return sessionId;
try {
const newSessionId = await createAsterSession();
const resolvedWorkspaceId = getRequiredWorkspaceId();
const newSessionId = await createAsterSession(resolvedWorkspaceId);
setSessionId(newSessionId);
skipAutoRestoreRef.current = false;
return newSessionId;
} catch (error) {
console.error("[AsterChat] 创建会话失败:", error);
toast.error("创建会话失败");
toast.error(`创建会话失败: ${error}`);
return null;
}
}, [sessionId]);
}, [getRequiredWorkspaceId, sessionId]);
// 辅助函数:追加文本到 contentParts
const appendTextToParts = (
@@ -477,10 +673,13 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
model_name: model,
};
const resolvedWorkspaceId = getRequiredWorkspaceId();
await sendAsterMessageStream(
content,
activeSessionId,
eventName,
resolvedWorkspaceId,
imagesToSend,
providerConfig,
);
@@ -492,7 +691,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
if (unlisten) unlisten();
}
},
[ensureSession, onWriteFile, providerType, model],
[ensureSession, getRequiredWorkspaceId, onWriteFile, providerType, model],
);
// 停止发送
@@ -547,6 +746,10 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
const clearMessages = useCallback(() => {
setMessages([]);
setSessionId(null);
setPendingActions([]);
restoredWorkspaceRef.current = null;
hydratedSessionRef.current = null;
skipAutoRestoreRef.current = true;
toast.success("新话题已创建");
}, []);
@@ -567,8 +770,9 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
// 切换话题
const switchTopic = useCallback(
async (topicId: string) => {
if (topicId === sessionId) return;
if (topicId === sessionId && messages.length > 0) return;
skipAutoRestoreRef.current = false;
try {
const detail = await getAsterSession(topicId);
const loadedMessages: Message[] = detail.messages.map((msg, index) => {
@@ -601,13 +805,91 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
} catch (error) {
console.error("[AsterChat] 切换话题失败:", error);
setMessages([]);
setSessionId(topicId);
setSessionId(null);
saveTransient(getScopedSessionKey(), null);
savePersisted(getScopedPersistedSessionKey(), null);
toast.error("加载对话历史失败");
}
},
[sessionId],
[
getScopedPersistedSessionKey,
getScopedSessionKey,
messages.length,
sessionId,
],
);
// 自动恢复当前 workspace 最近会话
useEffect(() => {
const resolvedWorkspaceId = workspaceId?.trim();
if (!resolvedWorkspaceId) return;
if (!isInitialized) return;
if (skipAutoRestoreRef.current) return;
if (sessionId) return;
if (topics.length === 0) return;
if (restoredWorkspaceRef.current === resolvedWorkspaceId) return;
restoredWorkspaceRef.current = resolvedWorkspaceId;
const scopedCandidate =
loadTransient<string | null>(getScopedSessionKey(), null) ||
loadPersisted<string | null>(getScopedPersistedSessionKey(), null);
const mappedFallbackCandidate =
topics.find(
(topic) =>
loadPersisted<string | null>(
`agent_session_workspace_${topic.id}`,
null,
) === resolvedWorkspaceId,
)?.id || null;
const targetSessionId = scopedCandidate || mappedFallbackCandidate;
if (!targetSessionId) {
return;
}
switchTopic(targetSessionId).catch((error) => {
console.warn("[AsterChat] 自动恢复会话失败:", error);
saveTransient(getScopedSessionKey(), null);
savePersisted(getScopedPersistedSessionKey(), null);
});
}, [
getScopedPersistedSessionKey,
getScopedSessionKey,
isInitialized,
sessionId,
switchTopic,
topics,
workspaceId,
]);
useEffect(() => {
if (sessionId) {
skipAutoRestoreRef.current = false;
}
}, [sessionId]);
// 有 sessionId 但消息为空时,主动回填历史
useEffect(() => {
if (!sessionId) return;
if (messages.length > 0) {
hydratedSessionRef.current = sessionId;
return;
}
if (hydratedSessionRef.current === sessionId) {
return;
}
hydratedSessionRef.current = sessionId;
switchTopic(sessionId).catch((error) => {
console.warn("[AsterChat] 会话水合失败:", error);
hydratedSessionRef.current = null;
});
}, [messages.length, sessionId, switchTopic]);
// 删除话题
const deleteTopic = useCallback(
async (topicId: string) => {
@@ -629,6 +911,10 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
const handleStopProcess = useCallback(async () => {
setSessionId(null);
setMessages([]);
setPendingActions([]);
restoredWorkspaceRef.current = null;
hydratedSessionRef.current = null;
}, []);
return {
@@ -38,9 +38,21 @@ export function useContentSync(
const lastSyncDataRef = useRef<{ contentId: string; body: string } | null>(
null,
);
const lastSuccessfulSyncRef = useRef<{
contentId: string;
body: string;
} | null>(null);
const syncContent = useCallback(
(contentId: string, body: string) => {
// 与最近一次成功同步内容一致时,跳过重复同步
if (
lastSuccessfulSyncRef.current?.contentId === contentId &&
lastSuccessfulSyncRef.current.body === body
) {
return;
}
// 保存最后的同步数据(用于重试)
lastSyncDataRef.current = { contentId, body };
@@ -58,6 +70,7 @@ export function useContentSync(
try {
await updateContent(contentId, { body });
lastSuccessfulSyncRef.current = { contentId, body };
setSyncStatus("success");
// 3 秒后重置状态
+49 -7
View File
@@ -7,6 +7,7 @@
*/
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { toast } from "sonner";
import styled from "styled-components";
import { useAgentChat } from "./hooks/useAgentChat";
import { useSessionFiles } from "./hooks/useSessionFiles";
@@ -179,6 +180,7 @@ export function AgentChatPage({
contentId,
theme: initialTheme,
lockTheme = false,
newChatAt,
onRecommendationClick: _onRecommendationClick,
onHasMessagesChange,
}: {
@@ -187,6 +189,7 @@ export function AgentChatPage({
contentId?: string;
theme?: string;
lockTheme?: boolean;
newChatAt?: number;
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
onHasMessagesChange?: (hasMessages: boolean) => void;
}) {
@@ -349,7 +352,6 @@ export function AgentChatPage({
// 使用 Agent Chat Hook(传递系统提示词)
const {
processStatus,
providerType,
setProviderType,
model,
@@ -374,6 +376,7 @@ export function AgentChatPage({
// 使用 ref 调用最新的 handleWriteFile
handleWriteFileRef.current?.(content, fileName);
},
workspaceId: projectId ?? "",
});
// 会话文件持久化 hook
@@ -422,7 +425,10 @@ export function AgentChatPage({
getContent(contentId)
.then((existingContent) => {
if (existingContent) {
syncContent(contentId, content);
const existingBody = existingContent.body || "";
if (existingBody !== content) {
syncContent(contentId, content);
}
} else {
console.warn(
"[AgentChatPage] contentId 对应的内容不存在,跳过同步:",
@@ -453,6 +459,9 @@ export function AgentChatPage({
// 追踪已恢复元数据和文件的会话 ID
const restoredMetaSessionId = useRef<string | null>(null);
const restoredFilesSessionId = useRef<string | null>(null);
const handledNewChatRequestRef = useRef<string | null>(null);
// 用于追踪是否已触发过 AI 引导
const hasTriggeredGuide = useRef(false);
// 当 sessionMeta 加载完成时,恢复主题和创建模式
useEffect(() => {
@@ -664,6 +673,12 @@ export function AgentChatPage({
) => {
const sourceText = textOverride ?? input;
if (!sourceText.trim() && (!images || images.length === 0)) return;
if (!projectId) {
toast.error("请先选择项目后再开始对话");
return;
}
let text = sourceText;
// 如果有引用的角色,注入角色信息
@@ -685,7 +700,7 @@ export function AgentChatPage({
setMentionedCharacters([]); // 清空引用的角色
await sendMessage(text, images || [], webSearch, thinking);
},
[input, mentionedCharacters, sendMessage],
[input, mentionedCharacters, projectId, sendMessage],
);
const handleClearMessages = useCallback(() => {
@@ -703,6 +718,35 @@ export function AgentChatPage({
processedMessageIds.current.clear();
}, [clearMessages]);
// 响应首页导航触发的新会话请求
useEffect(() => {
if (!newChatAt) {
return;
}
const requestKey = `${newChatAt}:${projectId ?? ""}`;
if (handledNewChatRequestRef.current === requestKey) {
return;
}
handledNewChatRequestRef.current = requestKey;
clearMessages({
showToast: false,
});
setInput("");
setLayoutMode("chat");
setShowSidebar(true);
setCanvasState(null);
setGeneralCanvasState(DEFAULT_CANVAS_STATE);
setTaskFiles([]);
setSelectedFileId(undefined);
setMentionedCharacters([]);
processedMessageIds.current.clear();
restoredMetaSessionId.current = null;
restoredFilesSessionId.current = null;
hasTriggeredGuide.current = false;
}, [newChatAt, projectId, clearMessages]);
const handleBackHome = useCallback(() => {
clearMessages({
showToast: false,
@@ -1228,8 +1272,6 @@ export function AgentChatPage({
[sendMessage],
);
// 用于追踪是否已触发过 AI 引导
const hasTriggeredGuide = useRef(false);
// 存储 triggerAIGuide 函数引用,避免在 useEffect 依赖中包含函数
const triggerAIGuideRef = useRef(triggerAIGuide);
triggerAIGuideRef.current = triggerAIGuide;
@@ -1349,7 +1391,7 @@ export function AgentChatPage({
model={model}
setModel={setModel}
onManageProviders={handleManageProviders}
disabled={!processStatus.running && false}
disabled={!projectId}
onClearMessages={handleClearMessages}
onToggleCanvas={handleToggleCanvas}
isCanvasOpen={layoutMode === "chat-canvas"}
@@ -1505,7 +1547,7 @@ export function AgentChatPage({
}}
>
{syncStatus === "syncing" && "正在同步..."}
{syncStatus === "success" && "✓ 已保存"}
{syncStatus === "success" && "✓ 已同步"}
{syncStatus === "error" && "⚠ 同步失败,将自动重试"}
</div>
)}
@@ -9,7 +9,7 @@ import styled from "styled-components";
import type { DocumentCanvasProps, ExportFormat, PlatformType } from "./types";
import { DocumentToolbar } from "./DocumentToolbar";
import { DocumentRenderer } from "./DocumentRenderer";
import { DocumentEditor } from "./DocumentEditor";
import { NotionEditor } from "./editor";
import { PlatformTabs } from "./PlatformTabs";
const Container = styled.div`
@@ -17,7 +17,7 @@ const Container = styled.div`
flex-direction: column;
height: 100%;
background: hsl(var(--background));
border-left: 1px solid hsl(var(--border));
border-right: 1px solid hsl(var(--border));
`;
const ContentArea = styled.div`
@@ -185,7 +185,7 @@ export const DocumentCanvas: React.FC<DocumentCanvasProps> = memo(
<ContentArea>
{state.isEditing ? (
<DocumentEditor
<NotionEditor
content={editingContent}
onChange={setEditingContent}
onSave={handleSave}
@@ -0,0 +1,168 @@
import React, { useState, useEffect, useRef } from "react";
import { type Editor } from "@tiptap/react";
import {
Bold,
Italic,
Strikethrough,
Code,
Highlighter,
Heading1,
Heading2,
Heading3,
} from "lucide-react";
interface BubbleToolbarProps {
editor: Editor;
}
interface ToolbarButtonProps {
onClick: () => void;
isActive?: boolean;
children: React.ReactNode;
title: string;
}
const ToolbarButton: React.FC<ToolbarButtonProps> = ({
onClick,
isActive,
children,
title,
}) => (
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
onClick();
}}
title={title}
className={`p-1.5 rounded transition-colors ${
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
{children}
</button>
);
export const BubbleToolbar: React.FC<BubbleToolbarProps> = ({ editor }) => {
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const toolbarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const updateToolbar = () => {
const { from, to, empty } = editor.state.selection;
if (empty || from === to) {
setVisible(false);
return;
}
setVisible(true);
const { view } = editor;
const start = view.coordsAtPos(from);
const end = view.coordsAtPos(to);
const wrapper = view.dom.closest(".notion-editor-wrapper");
if (!wrapper) return;
const wrapperRect = wrapper.getBoundingClientRect();
const toolbarWidth = toolbarRef.current?.offsetWidth ?? 300;
setPosition({
top: start.top - wrapperRect.top - 44,
left: (start.left + end.left) / 2 - wrapperRect.left - toolbarWidth / 2,
});
};
editor.on("selectionUpdate", updateToolbar);
editor.on("blur", () => {
// 延迟隐藏,允许点击工具栏按钮
setTimeout(() => setVisible(false), 200);
});
return () => {
editor.off("selectionUpdate", updateToolbar);
};
}, [editor]);
if (!visible) return null;
return (
<div
ref={toolbarRef}
className="absolute z-50 flex items-center gap-0.5 rounded-lg border border-border px-1 py-0.5 shadow-lg"
style={{
top: position.top,
left: position.left,
background: "hsl(var(--background))",
}}
>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive("bold")}
title="粗体"
>
<Bold className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive("italic")}
title="斜体"
>
<Italic className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive("strike")}
title="删除线"
>
<Strikethrough className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
isActive={editor.isActive("code")}
title="行内代码"
>
<Code className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHighlight().run()}
isActive={editor.isActive("highlight")}
title="高亮"
>
<Highlighter className="w-4 h-4" />
</ToolbarButton>
<div className="w-px h-5 bg-border mx-0.5" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive("heading", { level: 1 })}
title="标题 1"
>
<Heading1 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive("heading", { level: 2 })}
title="标题 2"
>
<Heading2 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive("heading", { level: 3 })}
title="标题 3"
>
<Heading3 className="w-4 h-4" />
</ToolbarButton>
</div>
);
};
@@ -0,0 +1,107 @@
import React, {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { createExtensions } from "./extensions";
import { BubbleToolbar } from "./BubbleToolbar";
import {
CommandList,
type SlashMenuState,
type SlashMenuKeyHandler,
} from "./SlashCommand";
import { markdownToHtml, htmlToMarkdown } from "./utils/markdown";
import "./editor-styles.css";
interface NotionEditorProps {
content: string;
onChange: (content: string) => void;
onSave: () => void;
onCancel: () => void;
}
const EMPTY_SLASH: SlashMenuState = {
isOpen: false,
items: [],
range: null,
clientRect: null,
};
export const NotionEditor: React.FC<NotionEditorProps> = memo(
({ content, onChange, onSave, onCancel }) => {
const [slashState, setSlashState] = useState<SlashMenuState>(EMPTY_SLASH);
const keyDownRef = useRef<SlashMenuKeyHandler | null>(null);
const extensions = useMemo(
() =>
createExtensions({
onStateChange: setSlashState,
onKeyDownRef: keyDownRef,
}),
[],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const initialContent = useMemo(() => markdownToHtml(content), []);
const editor = useEditor({
extensions,
content: initialContent,
onUpdate: ({ editor }) => {
onChange(htmlToMarkdown(editor.getHTML()));
},
});
const handleSlashClose = useCallback(() => {
setSlashState(EMPTY_SLASH);
}, []);
// 快捷键: Cmd+S 保存, Escape 取消
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
onSave();
}
if (e.key === "Escape" && !slashState.isOpen) {
e.preventDefault();
onCancel();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onSave, onCancel, slashState.isOpen]);
useEffect(() => {
if (editor) {
editor.commands.focus("end");
}
}, [editor]);
if (!editor) return null;
return (
<div className="notion-editor-wrapper flex-1">
<BubbleToolbar editor={editor} />
<EditorContent editor={editor} />
{slashState.isOpen && slashState.range && (
<CommandList
editor={editor}
items={slashState.items}
range={slashState.range}
clientRect={slashState.clientRect}
onKeyDownRef={keyDownRef}
onClose={handleSlashClose}
/>
)}
</div>
);
},
);
NotionEditor.displayName = "NotionEditor";
@@ -0,0 +1,393 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import React, { useEffect, useState } from "react";
import {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
CheckSquare,
Quote,
Code,
Minus,
ImageIcon,
Table,
} from "lucide-react";
import type { Editor, Range } from "@tiptap/core";
// --- 命令项定义 ---
export interface CommandItemDef {
title: string;
description: string;
searchTerms?: string[];
icon: React.ReactNode;
command: (p: { editor: Editor; range: Range }) => void;
}
const SLASH_ITEMS: CommandItemDef[] = [
{
title: "标题 1",
description: "大标题",
searchTerms: ["h1", "heading"],
icon: <Heading1 className="w-4 h-4" />,
command: ({ editor, range }) =>
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 1 })
.run(),
},
{
title: "标题 2",
description: "中标题",
searchTerms: ["h2", "heading"],
icon: <Heading2 className="w-4 h-4" />,
command: ({ editor, range }) =>
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 2 })
.run(),
},
{
title: "标题 3",
description: "小标题",
searchTerms: ["h3", "heading"],
icon: <Heading3 className="w-4 h-4" />,
command: ({ editor, range }) =>
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 3 })
.run(),
},
{
title: "待办列表",
description: "任务清单",
searchTerms: ["todo", "task", "checkbox"],
icon: <CheckSquare className="w-4 h-4" />,
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleTaskList().run(),
},
{
title: "无序列表",
description: "项目符号列表",
searchTerms: ["bullet", "unordered", "list"],
icon: <List className="w-4 h-4" />,
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleBulletList().run(),
},
{
title: "有序列表",
description: "编号列表",
searchTerms: ["ordered", "number", "list"],
icon: <ListOrdered className="w-4 h-4" />,
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleOrderedList().run(),
},
{
title: "引用",
description: "引用块",
searchTerms: ["blockquote", "quote"],
icon: <Quote className="w-4 h-4" />,
command: ({ editor, range }) =>
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
},
{
title: "代码块",
description: "代码片段",
searchTerms: ["code", "codeblock"],
icon: <Code className="w-4 h-4" />,
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "分割线",
description: "水平分隔线",
searchTerms: ["hr", "divider", "separator"],
icon: <Minus className="w-4 h-4" />,
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
},
{
title: "图片",
description: "插入图片链接",
searchTerms: ["image", "photo", "picture"],
icon: <ImageIcon className="w-4 h-4" />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
const url = window.prompt("输入图片 URL");
if (url) editor.chain().focus().setImage({ src: url }).run();
},
},
{
title: "表格",
description: "插入表格",
searchTerms: ["table", "grid"],
icon: <Table className="w-4 h-4" />,
command: ({ editor, range }) =>
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run(),
},
];
function filterItems(query: string): CommandItemDef[] {
const q = query.toLowerCase();
return SLASH_ITEMS.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q) ||
item.searchTerms?.some((t) => t.toLowerCase().includes(q)),
);
}
// --- 状态类型 ---
export interface SlashMenuState {
isOpen: boolean;
items: CommandItemDef[];
range: Range | null;
clientRect: DOMRect | null;
}
export type SlashMenuKeyHandler = (event: KeyboardEvent) => boolean;
// --- ProseMirror Plugin 实现 ---
const slashPluginKey = new PluginKey("slashCommand");
interface SlashCommandOptions {
onStateChange: (state: SlashMenuState) => void;
onKeyDownRef: React.MutableRefObject<SlashMenuKeyHandler | null>;
}
function createSlashPlugin(editor: Editor, options: SlashCommandOptions) {
let wasActive = false;
return new Plugin({
key: slashPluginKey,
state: {
init() {
return {
active: false as boolean,
slashPos: -1,
query: "",
items: [] as CommandItemDef[],
};
},
apply(tr, prev) {
if (!tr.docChanged) return prev;
const { $from } = tr.selection;
const textBefore = $from.parent.textBetween(
Math.max(0, $from.parentOffset - 20),
$from.parentOffset,
"\0",
);
const slashIdx = textBefore.lastIndexOf("/");
if (slashIdx === -1) {
return { active: false, slashPos: -1, query: "", items: [] };
}
const query = textBefore.slice(slashIdx + 1);
if (query.includes(" ") || query.includes("\0")) {
return { active: false, slashPos: -1, query: "", items: [] };
}
const items = filterItems(query);
const docSlashPos = $from.pos - (textBefore.length - slashIdx);
return { active: true, slashPos: docSlashPos, query, items };
},
},
props: {
handleKeyDown(view, event) {
const state = slashPluginKey.getState(view.state);
if (state?.active) {
return options.onKeyDownRef.current?.(event) ?? false;
}
return false;
},
},
view() {
return {
update: (view) => {
const state = slashPluginKey.getState(view.state);
const isActive = state?.active ?? false;
if (isActive) {
const { from } = view.state.selection;
const coords = view.coordsAtPos(from);
options.onStateChange({
isOpen: true,
items: state.items,
range: { from: state.slashPos, to: from },
clientRect: new DOMRect(
coords.left,
coords.top,
0,
coords.bottom - coords.top,
),
});
wasActive = true;
} else if (wasActive) {
options.onStateChange({
isOpen: false,
items: [],
range: null,
clientRect: null,
});
wasActive = false;
}
},
destroy: () => {
wasActive = false;
},
};
},
});
}
export const SlashCommand = Extension.create<SlashCommandOptions>({
name: "slashCommand",
addOptions() {
return {
onStateChange: () => {},
onKeyDownRef: { current: null },
};
},
addProseMirrorPlugins() {
return [createSlashPlugin(this.editor, this.options)];
},
});
// --- 命令列表 UI 组件 ---
interface CommandListProps {
editor: Editor;
items: CommandItemDef[];
range: Range;
clientRect: DOMRect | null;
onKeyDownRef: React.MutableRefObject<SlashMenuKeyHandler | null>;
onClose: () => void;
}
export const CommandList: React.FC<CommandListProps> = ({
editor,
items,
range,
clientRect,
onKeyDownRef,
onClose,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
const executeCommand = (item: CommandItemDef) => {
item.command({ editor, range });
onClose();
};
// 注册键盘处理
useEffect(() => {
onKeyDownRef.current = (event: KeyboardEvent) => {
if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((i) =>
items.length > 0 ? (i - 1 + items.length) % items.length : 0,
);
return true;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((i) =>
items.length > 0 ? (i + 1) % items.length : 0,
);
return true;
}
if (event.key === "Enter") {
event.preventDefault();
if (items[selectedIndex]) {
executeCommand(items[selectedIndex]);
}
return true;
}
if (event.key === "Escape") {
event.preventDefault();
onClose();
return true;
}
return false;
};
return () => {
onKeyDownRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, selectedIndex, onClose]);
if (items.length === 0) return null;
const top = clientRect ? clientRect.bottom + 4 : 0;
const left = clientRect ? clientRect.left : 0;
return (
<div
className="fixed z-[9999] w-64 max-h-72 overflow-y-auto rounded-lg border border-border shadow-lg"
style={{
top,
left,
background: "hsl(var(--background))",
}}
>
{items.map((item, index) => (
<button
key={item.title}
className={`flex items-center gap-3 w-full px-3 py-2 text-left text-sm transition-colors ${
index === selectedIndex
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-accent/50"
}`}
onMouseDown={(e) => {
e.preventDefault();
executeCommand(item);
}}
onMouseEnter={() => setSelectedIndex(index)}
>
<span
className="flex items-center justify-center w-8 h-8 rounded-md border border-border"
style={{ background: "hsl(var(--background))" }}
>
{item.icon}
</span>
<div>
<div className="font-medium">{item.title}</div>
<div className="text-xs text-muted-foreground">
{item.description}
</div>
</div>
</button>
))}
</div>
);
};
@@ -0,0 +1,155 @@
.notion-editor-wrapper {
position: relative;
height: 100%;
overflow-y: auto;
}
.notion-editor-wrapper .ProseMirror {
min-height: 100%;
padding: 24px 32px;
outline: none;
font-size: 15px;
line-height: 1.75;
color: hsl(var(--foreground));
}
/* Placeholder */
.notion-editor-wrapper .ProseMirror .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: hsl(var(--muted-foreground));
pointer-events: none;
height: 0;
}
/* Headings */
.notion-editor-wrapper .ProseMirror h1 {
font-size: 1.875rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
.notion-editor-wrapper .ProseMirror h2 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.notion-editor-wrapper .ProseMirror h3 {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
/* Paragraph */
.notion-editor-wrapper .ProseMirror p {
margin-bottom: 0.25rem;
}
/* Code block */
.notion-editor-wrapper .ProseMirror pre {
background: hsl(var(--muted));
border-radius: 0.5rem;
padding: 0.75rem 1rem;
font-family: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.875rem;
overflow-x: auto;
margin: 0.5rem 0;
}
.notion-editor-wrapper .ProseMirror pre code {
background: none;
padding: 0;
color: inherit;
}
/* Inline code */
.notion-editor-wrapper .ProseMirror code {
background: hsl(var(--muted));
padding: 0.15rem 0.35rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
/* Blockquote */
.notion-editor-wrapper .ProseMirror blockquote {
border-left: 3px solid hsl(var(--border));
padding-left: 1rem;
color: hsl(var(--muted-foreground));
margin: 0.5rem 0;
}
/* Lists */
.notion-editor-wrapper .ProseMirror ul,
.notion-editor-wrapper .ProseMirror ol {
padding-left: 1.5rem;
margin: 0.25rem 0;
}
/* Task list */
.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.notion-editor-wrapper .ProseMirror ul[data-type="taskList"] li label {
margin-top: 0.25rem;
}
.notion-editor-wrapper
.ProseMirror
ul[data-type="taskList"]
li
input[type="checkbox"] {
cursor: pointer;
accent-color: hsl(var(--primary));
}
/* Table */
.notion-editor-wrapper .ProseMirror table {
border-collapse: collapse;
width: 100%;
margin: 0.5rem 0;
}
.notion-editor-wrapper .ProseMirror th,
.notion-editor-wrapper .ProseMirror td {
border: 1px solid hsl(var(--border));
padding: 0.5rem 0.75rem;
text-align: left;
}
.notion-editor-wrapper .ProseMirror th {
background: hsl(var(--muted));
font-weight: 600;
}
/* Horizontal rule */
.notion-editor-wrapper .ProseMirror hr {
border: none;
border-top: 1px solid hsl(var(--border));
margin: 1rem 0;
}
/* Image */
.notion-editor-wrapper .ProseMirror img {
max-width: 100%;
border-radius: 0.5rem;
margin: 0.5rem 0;
}
/* Highlight */
.notion-editor-wrapper .ProseMirror mark {
background-color: hsl(50 100% 70% / 0.4);
border-radius: 0.15rem;
padding: 0.1rem 0;
}
@@ -0,0 +1,46 @@
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Image from "@tiptap/extension-image";
import Highlight from "@tiptap/extension-highlight";
import { Table } from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import { SlashCommand } from "./SlashCommand";
import type { SlashMenuState, SlashMenuKeyHandler } from "./SlashCommand";
import type { Extensions } from "@tiptap/react";
import type { MutableRefObject } from "react";
interface ExtensionConfig {
onStateChange: (state: SlashMenuState) => void;
onKeyDownRef: MutableRefObject<SlashMenuKeyHandler | null>;
}
export function createExtensions(config: ExtensionConfig): Extensions {
return [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
codeBlock: {
HTMLAttributes: { class: "notion-code-block" },
},
}),
Placeholder.configure({
placeholder: "输入内容,按 / 打开命令菜单…",
emptyEditorClass: "is-editor-empty",
}),
TaskList,
TaskItem.configure({ nested: true }),
Image.configure({ inline: false, allowBase64: true }),
Highlight.configure({ multicolor: false }),
Table.configure({ resizable: false }),
TableRow,
TableCell,
TableHeader,
SlashCommand.configure({
onStateChange: config.onStateChange,
onKeyDownRef: config.onKeyDownRef,
}),
];
}
@@ -0,0 +1 @@
export { NotionEditor } from "./NotionEditor";
@@ -0,0 +1,50 @@
import TurndownService from "turndown";
import { marked } from "marked";
// --- Markdown → HTML ---
export function markdownToHtml(md: string): string {
if (!md.trim()) return "";
return marked.parse(md, { async: false }) as string;
}
// --- HTML → Markdown ---
const turndown = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
emDelimiter: "*",
strongDelimiter: "**",
});
// 任务列表规则
turndown.addRule("taskListItem", {
filter: (node) =>
node.nodeName === "LI" &&
node.parentElement?.getAttribute("data-type") === "taskList",
replacement: (_content, node) => {
const el = node as HTMLElement;
const checkbox = el.querySelector('input[type="checkbox"]');
const checked = checkbox?.hasAttribute("checked") ?? false;
const text = _content.replace(/^\n+/, "").replace(/\n+$/, "");
return `- [${checked ? "x" : " "}] ${text}\n`;
},
});
// 高亮文本规则
turndown.addRule("highlight", {
filter: "mark",
replacement: (content) => `==${content}==`,
});
// 删除线规则
turndown.addRule("strikethrough", {
filter: "s",
replacement: (content) => `~~${content}~~`,
});
export function htmlToMarkdown(html: string): string {
if (!html.trim()) return "";
return turndown.turndown(html);
}
@@ -31,7 +31,7 @@ const Container = styled.div`
flex-direction: column;
height: 100%;
background: hsl(var(--background));
border-left: 1px solid hsl(var(--border));
border-right: 1px solid hsl(var(--border));
`;
const ContentArea = styled.div`
@@ -19,7 +19,7 @@ const Container = styled.div`
flex-direction: column;
height: 100%;
background: hsl(var(--background));
border-left: 1px solid hsl(var(--border));
border-right: 1px solid hsl(var(--border));
`;
const Header = styled.div`
@@ -32,6 +32,7 @@ const Container = styled.div`
flex-direction: column;
height: 100%;
background: hsl(var(--muted));
border-right: 1px solid hsl(var(--border));
`;
const MainArea = styled.div`
@@ -18,7 +18,7 @@ const Container = styled.div`
flex-direction: column;
height: 100%;
background: hsl(var(--background));
border-left: 1px solid hsl(var(--border));
border-right: 1px solid hsl(var(--border));
`;
const Header = styled.div`
@@ -73,15 +73,6 @@ export const LayoutTransition: React.FC<LayoutTransitionProps> = memo(
return (
<Container>
<ChatPanel
$width={chatStyles.width as string}
$duration={parseInt(
chatStyles.transition?.match(/\d+/)?.[0] || "300",
)}
>
{chatContent}
</ChatPanel>
<CanvasPanel
$visible={isCanvasVisible}
$transform={canvasStyles.transform as string}
@@ -92,6 +83,15 @@ export const LayoutTransition: React.FC<LayoutTransitionProps> = memo(
>
{canvasContent}
</CanvasPanel>
<ChatPanel
$width={chatStyles.width as string}
$duration={parseInt(
chatStyles.transition?.match(/\d+/)?.[0] || "300",
)}
>
{chatContent}
</ChatPanel>
</Container>
);
},
@@ -111,7 +111,7 @@ export function useLayoutTransition(
transform:
transitionState === "entering" || transitionState === "entered"
? "translateX(0)"
: "translateX(100%)",
: "translateX(-100%)",
opacity:
transitionState === "entering" || transitionState === "entered"
? 1
@@ -58,7 +58,7 @@ export const CanvasPanel: React.FC<CanvasPanelProps> = ({
}
return (
<div className="flex flex-col h-full bg-background border-l border-border">
<div className="flex flex-col h-full bg-background border-r border-border">
{/* 工具栏 */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-muted/50">
<div className="flex items-center gap-2">
+163 -2
View File
@@ -1,6 +1,6 @@
/**
* @file 图片生成页面
* @description 对齐 LobeHub 风格的绘画工作台布局与交互
* @description 对齐成熟产品风格的绘画工作台布局与交互
* @module components/image-gen/ImageGenPage
*/
@@ -19,6 +19,7 @@ import {
X,
} from "lucide-react";
import { useImageGen } from "./useImageGen";
import type { GeneratedImage } from "./types";
import type { Page } from "@/types/page";
interface ImageGenPageProps {
@@ -169,6 +170,39 @@ function fileToDataUrl(file: File): Promise<string> {
});
}
function resolveBatchImages(
images: GeneratedImage[],
selectedImageId: string | null,
): GeneratedImage[] {
if (!selectedImageId) {
return [];
}
const batchMatch = selectedImageId.match(/^img-(\d+)-\d+$/);
if (!batchMatch) {
const single = images.find((item) => item.id === selectedImageId);
return single ? [single] : [];
}
const batchPrefix = `img-${batchMatch[1]}-`;
return images
.filter((item) => item.id.startsWith(batchPrefix))
.sort((left, right) => left.createdAt - right.createdAt);
}
function getStatusText(status: GeneratedImage["status"]): string {
switch (status) {
case "complete":
return "已完成";
case "error":
return "失败";
case "generating":
return "生成中";
default:
return "待生成";
}
}
const Container = styled.div`
flex: 1;
min-height: 0;
@@ -449,6 +483,63 @@ const PreviewImage = styled.img`
object-fit: contain;
`;
const BatchGrid = styled.div`
width: 100%;
height: 100%;
padding: 12px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
align-content: start;
overflow: auto;
`;
const BatchItem = styled.button<{ $active: boolean }>`
border: 1px solid
${({ $active }) => ($active ? "hsl(var(--primary))" : "hsl(var(--border))")};
border-radius: 10px;
background: hsl(var(--background));
cursor: pointer;
display: flex;
flex-direction: column;
padding: 8px;
gap: 8px;
`;
const BatchPreviewWrap = styled.div`
border-radius: 8px;
background: hsl(var(--muted) / 0.25);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
`;
const BatchPlaceholder = styled.div`
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
color: hsl(var(--muted-foreground));
`;
const BatchMeta = styled.div`
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: hsl(var(--muted-foreground));
`;
const CanvasActions = styled.div`
position: absolute;
top: 12px;
@@ -551,7 +642,10 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
setSelectedModelId,
selectedSize,
setSelectedSize,
images,
selectedImage,
selectedImageId,
setSelectedImageId,
generating,
generateImage,
deleteImage,
@@ -588,6 +682,12 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
const canGenerate =
!!prompt.trim() && !!selectedProvider && !!selectedModelId && !generating;
const selectedBatchImages = useMemo(() => {
return resolveBatchImages(images, selectedImageId);
}, [images, selectedImageId]);
const shouldShowBatchGrid = selectedBatchImages.length > 1;
const handleCountSelect = (count: number) => {
setImageCount(count);
setIsEditingCustomCount(false);
@@ -843,7 +943,49 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
<Workspace>
<Canvas>
{selectedImage?.status === "complete" && selectedImage.url ? (
{shouldShowBatchGrid ? (
<BatchGrid>
{selectedBatchImages.map((item, index) => {
const parsedSize = parseSize(item.size);
const previewStyle = parsedSize
? {
aspectRatio: `${parsedSize.width}/${parsedSize.height}`,
}
: undefined;
return (
<BatchItem
key={item.id}
$active={item.id === selectedImageId}
onClick={() => setSelectedImageId(item.id)}
>
<BatchPreviewWrap style={previewStyle}>
{item.status === "complete" && item.url ? (
<img
src={item.url}
alt={item.prompt || `生成图片 ${index + 1}`}
/>
) : (
<BatchPlaceholder>
{item.status === "error" ? (
<ImageIcon size={28} />
) : (
<Loader2 size={28} className="animate-spin" />
)}
<span>{getStatusText(item.status)}</span>
</BatchPlaceholder>
)}
</BatchPreviewWrap>
<BatchMeta>
<span>第 {index + 1} 张</span>
<span>{getStatusText(item.status)}</span>
</BatchMeta>
</BatchItem>
);
})}
</BatchGrid>
) : selectedImage?.status === "complete" && selectedImage.url ? (
<>
<PreviewImage
src={selectedImage.url}
@@ -880,6 +1022,25 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
<h2>绘画</h2>
</Empty>
)}
{shouldShowBatchGrid &&
selectedImage?.status === "complete" &&
selectedImage.url && (
<CanvasActions>
<CanvasActionButton
title="在浏览器打开"
onClick={() => window.open(selectedImage.url, "_blank")}
>
<ExternalLink size={16} />
</CanvasActionButton>
<CanvasActionButton
title="删除"
onClick={() => deleteImage(selectedImage.id)}
>
<Trash2 size={16} />
</CanvasActionButton>
</CanvasActions>
)}
</Canvas>
<PromptDock>
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
export { MemoryPage } from "./MemoryPage";
export { default } from "./MemoryPage";
+158 -8
View File
@@ -20,9 +20,15 @@ import { cn } from "@/lib/utils";
import {
ProjectType,
USER_PROJECT_TYPES,
extractErrorMessage,
getCreateProjectErrorMessage,
getProjectTypeLabel,
getProjectTypeIcon,
getProjectByRootPath,
getWorkspaceProjectsRoot,
resolveProjectRootPath,
} from "@/lib/api/project";
import { toast } from "sonner";
interface CreateProjectDialogProps {
open: boolean;
@@ -42,6 +48,10 @@ export function CreateProjectDialog({
const [name, setName] = useState("");
const [type, setType] = useState<ProjectType>(defaultType || "general");
const [isSubmitting, setIsSubmitting] = useState(false);
const [workspaceRootPath, setWorkspaceRootPath] = useState("");
const [resolvedProjectPath, setResolvedProjectPath] = useState("");
const [pathChecking, setPathChecking] = useState(false);
const [pathConflictMessage, setPathConflictMessage] = useState("");
// 当对话框打开且 defaultType 变化时,更新类型选择
useEffect(() => {
@@ -57,6 +67,113 @@ export function CreateProjectDialog({
}
}, [open, defaultName]);
useEffect(() => {
if (!open) {
return;
}
let mounted = true;
const loadWorkspaceRoot = async () => {
try {
const root = await getWorkspaceProjectsRoot();
if (mounted) {
setWorkspaceRootPath(root);
}
} catch (error) {
console.error("加载 workspace 目录失败:", error);
if (mounted) {
setWorkspaceRootPath("");
}
}
};
void loadWorkspaceRoot();
return () => {
mounted = false;
};
}, [open]);
useEffect(() => {
if (!open) {
return;
}
const projectName = name.trim();
if (!projectName) {
setResolvedProjectPath("");
setPathChecking(false);
setPathConflictMessage("");
return;
}
let mounted = true;
const resolvePath = async () => {
try {
const path = await resolveProjectRootPath(projectName);
if (mounted) {
setResolvedProjectPath(path);
setPathConflictMessage("");
}
} catch (error) {
console.error("解析项目目录失败:", error);
if (mounted) {
setResolvedProjectPath("");
setPathConflictMessage("");
}
}
};
void resolvePath();
return () => {
mounted = false;
};
}, [open, name]);
useEffect(() => {
if (!open || !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;
};
}, [open, resolvedProjectPath]);
const handleSubmit = async () => {
if (!name.trim()) return;
@@ -67,12 +184,10 @@ export function CreateProjectDialog({
setType(defaultType || "general");
onOpenChange(false);
} catch (error) {
// 如果是用户取消选择目录,不显示错误
if (error instanceof Error && error.message === "用户取消选择目录") {
// 用户取消,不做任何处理
} else {
console.error("创建项目失败:", error);
}
console.error("创建项目失败:", error);
const message = extractErrorMessage(error);
const friendlyMessage = getCreateProjectErrorMessage(message);
toast.error(`创建项目失败: ${friendlyMessage}`);
} finally {
setIsSubmitting(false);
}
@@ -84,7 +199,7 @@ export function CreateProjectDialog({
<DialogHeader>
<DialogTitle>新建项目</DialogTitle>
<DialogDescription>
创建一个新的内容创作项目,选择项目类型以获得最佳体验。
创建一个新的内容创作项目,目录将固定在 workspace 目录下。
</DialogDescription>
</DialogHeader>
@@ -126,6 +241,36 @@ export function CreateProjectDialog({
))}
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="workspace-root">workspace 目录</Label>
<Input
id="workspace-root"
value={workspaceRootPath}
placeholder="加载中..."
readOnly
/>
</div>
<div className="grid gap-2">
<Label htmlFor="project-path-preview">项目路径预览</Label>
<Input
id="project-path-preview"
value={resolvedProjectPath}
placeholder="请输入项目名称"
readOnly
/>
<p className="text-xs text-muted-foreground break-all">
将创建到:
{resolvedProjectPath || "请输入项目名称"}
</p>
{pathChecking && (
<p className="text-xs text-muted-foreground">正在检查路径...</p>
)}
{!pathChecking && pathConflictMessage && (
<p className="text-xs text-destructive">{pathConflictMessage}</p>
)}
</div>
</div>
<DialogFooter>
@@ -134,7 +279,12 @@ export function CreateProjectDialog({
</Button>
<Button
onClick={handleSubmit}
disabled={!name.trim() || isSubmitting}
disabled={
!name.trim() ||
isSubmitting ||
pathChecking ||
!!pathConflictMessage
}
>
{isSubmitting ? "创建中..." : "创建"}
</Button>
+6 -77
View File
@@ -15,13 +15,13 @@ import {
ContentListItem,
listProjects,
createProject,
resolveProjectRootPath,
updateProject,
deleteProject,
isUserProjectType,
getContentStats,
getCreateProjectErrorMessage,
generateProjectName,
getDefaultProjectPath,
extractErrorMessage,
} from "@/lib/api/project";
import { ProjectCard } from "./ProjectCard";
import { NewProjectCard } from "./NewProjectCard";
@@ -30,7 +30,6 @@ import { CreateProjectDialog } from "./CreateProjectDialog";
import { DeleteProjectDialog } from "./DeleteProjectDialog";
import { ContentListPage } from "./ContentListPage";
import { toast } from "sonner";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { Page, PageParams } from "@/types/page";
interface ProjectsPageProps {
@@ -164,27 +163,8 @@ export function ProjectsPage({ onNavigate }: ProjectsPageProps) {
// 创建项目
const handleCreateProject = async (name: string, type: ProjectType) => {
// 选择项目目录
const selectedPath = await openDialog({
directory: true,
title: "选择项目目录",
});
if (!selectedPath) {
// 用户取消选择,抛出错误让对话框知道
throw new Error("用户取消选择目录");
}
try {
const projectPath = Array.isArray(selectedPath)
? selectedPath.length === 1
? selectedPath[0]
: null
: selectedPath;
if (!projectPath) {
throw new Error("请选择单个项目目录");
}
const projectPath = await resolveProjectRootPath(name);
await createProject({
name,
@@ -196,44 +176,13 @@ export function ProjectsPage({ onNavigate }: ProjectsPageProps) {
loadProjects();
} catch (error) {
console.error("创建项目失败:", error);
const errorMessage =
error instanceof Error
? error.message
: typeof error === "string"
? error
: error && typeof error === "object" && "message" in error
? String((error as { message?: unknown }).message)
: String(error);
const errorMessage = extractErrorMessage(error);
const friendlyMessage = getCreateProjectErrorMessage(errorMessage);
toast.error(`创建项目失败: ${friendlyMessage}`);
throw error;
}
};
// 快速创建项目(不弹窗,直接用规则创建)
const handleQuickCreateProject = async (type: ProjectType = "general") => {
// 生成规则名称
const name = generateProjectName(type);
try {
// 使用默认项目路径
const rootPath = getDefaultProjectPath();
await createProject({
name,
rootPath,
workspaceType: type,
});
// 静默成功,不显示 toast
console.log("[ProjectsPage] 快速创建项目成功:", name);
loadProjects();
} catch (error) {
console.error("快速创建项目失败:", error);
// 静默失败,不提示用户
}
};
// 切换收藏
const handleToggleFavorite = async (project: Project) => {
try {
@@ -382,17 +331,7 @@ export function ProjectsPage({ onNavigate }: ProjectsPageProps) {
<p className="mb-4">还没有项目</p>
<Button
onClick={() => {
// 将 ProjectFilter 转换为 ProjectType
let projectType: ProjectType = "general";
const filter = currentFilter as string;
if (
filter !== "all" &&
filter !== "favorites" &&
filter !== "archived"
) {
projectType = currentFilter as ProjectType;
}
handleQuickCreateProject(projectType);
setCreateDialogOpen(true);
}}
>
创建第一个项目
@@ -410,17 +349,7 @@ export function ProjectsPage({ onNavigate }: ProjectsPageProps) {
{(currentFilter as string) !== "archived" && (
<NewProjectCard
onClick={() => {
// 快速创建项目,不弹窗
let projectType: ProjectType = "general";
const filter = currentFilter as string;
if (
filter !== "all" &&
filter !== "favorites" &&
filter !== "archived"
) {
projectType = currentFilter as ProjectType;
}
handleQuickCreateProject(projectType);
setCreateDialogOpen(true);
}}
/>
)}
@@ -2,7 +2,7 @@
* 设置侧边栏组件
*
* 显示分组的设置导航菜单
* 参考 LobeHub 的 SettingsSidebar 设计
* 参考成熟产品的设置侧边栏设计
*/
import styled from "styled-components";
+3 -12
View File
@@ -2,7 +2,7 @@
* 设置页面主布局组件
*
* 采用左侧边栏 + 右侧内容的布局
* 参考 LobeHub 的设置布局设计
* 参考成熟产品的设置布局设计
*/
import { useState, ReactNode, useEffect } from "react";
@@ -37,13 +37,12 @@ import { HotkeysSettings } from "../general/hotkeys";
// 聊天外观设置
import { ChatAppearanceSettings } from "../general/chat-appearance";
// 记忆设置
import { MemorySettings } from "../agent/memory";
// 语音服务设置
import { VoiceSettings } from "../agent/voice";
// 图像生成设置
import { ImageGenSettings } from "../agent/image-gen";
// 助理服务设置
import { AssistantSettings } from "../agent/assistant";
// 图像生成设置
import { ImageGenSettings } from "../agent/image-gen";
// 数据统计
import { StatsSettings } from "../account/stats";
// 个人资料
@@ -206,14 +205,6 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
</>
);
case SettingsTabs.Memory:
return (
<>
<SettingHeader title="记忆设置" />
<MemorySettings />
</>
);
case SettingsTabs.ImageGen:
return (
<>
@@ -1,7 +1,7 @@
/**
* 个人资料设置页面组件
*
* 参考 LobeHub 的 profile 实现
* 参考成熟产品的资料实现
* 功能包括:用户头像、昵称、个人简介、偏好标签等
*/
@@ -1,7 +1,7 @@
/**
* 数据统计页面组件
*
* 参考 LobeHub 的 stats 实现
* 参考成熟产品的数据统计实现
* 功能包括:使用统计数据展示、Token 消耗统计等
*/
@@ -16,104 +16,43 @@ import {
RefreshCw,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface UsageStats {
/** 总对话数 */
total_conversations: number;
/** 总消息数 */
total_messages: number;
/** 总 Token 消耗 */
total_tokens: number;
/** 总使用时间(分钟) */
total_time_minutes: number;
/** 本月对话数 */
monthly_conversations: number;
/** 本月消息数 */
monthly_messages: number;
/** 本月 Token 消耗 */
monthly_tokens: number;
/** 今日对话数 */
today_conversations: number;
/** 今日消息数 */
today_messages: number;
/** 今日 Token 消耗 */
today_tokens: number;
}
interface ModelUsage {
model: string;
conversations: number;
tokens: number;
percentage: number;
}
interface DailyUsage {
date: string;
conversations: number;
tokens: number;
}
import {
getDailyUsageTrends,
getModelUsageRanking,
getUsageStats,
type DailyUsage,
type ModelUsage,
type UsageStatsResponse,
} from "@/hooks/useTauri";
export function StatsSettings() {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<UsageStats | null>(null);
const [stats, setStats] = useState<UsageStatsResponse | null>(null);
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
const [dailyUsage, setDailyUsage] = useState<DailyUsage[]>([]);
const [error, setError] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<"week" | "month" | "all">("month");
const loadStats = useCallback(async () => {
setLoading(true);
setError(null);
try {
// TODO: 实现获取统计数据 API
// const data = await getUsageStats(timeRange);
// setStats(data.stats);
// setModelUsage(data.modelUsage);
// setDailyUsage(data.dailyUsage);
// 模拟数据
setStats({
total_conversations: 328,
total_messages: 4521,
total_tokens: 1258000,
total_time_minutes: 1840,
monthly_conversations: 67,
monthly_messages: 892,
monthly_tokens: 245000,
today_conversations: 5,
today_messages: 42,
today_tokens: 12000,
});
setModelUsage([
{ model: "GPT-4", conversations: 145, tokens: 580000, percentage: 46 },
{
model: "GPT-3.5",
conversations: 128,
tokens: 420000,
percentage: 33,
},
{
model: "Claude 3",
conversations: 55,
tokens: 258000,
percentage: 21,
},
const [usageStats, ranking, trends] = await Promise.all([
getUsageStats(timeRange),
getModelUsageRanking(timeRange),
getDailyUsageTrends(timeRange),
]);
// 生成模拟的每日数据
const days = timeRange === "week" ? 7 : timeRange === "month" ? 30 : 90;
const mockDaily: DailyUsage[] = [];
for (let i = days - 1; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
mockDaily.push({
date: date.toISOString().split("T")[0],
conversations: Math.floor(Math.random() * 10) + 1,
tokens: Math.floor(Math.random() * 15000) + 2000,
});
}
setDailyUsage(mockDaily);
setStats(usageStats);
setModelUsage(ranking);
setDailyUsage(trends);
} catch (e) {
console.error("加载统计数据失败:", e);
setError(e instanceof Error ? e.message : "加载统计数据失败");
setStats(null);
setModelUsage([]);
setDailyUsage([]);
} finally {
setLoading(false);
}
@@ -169,6 +108,11 @@ export function StatsSettings() {
</div>
);
const maxDailyTokens =
dailyUsage.length > 0
? Math.max(...dailyUsage.map((day) => day.tokens))
: 0;
return (
<div className="space-y-4 max-w-4xl">
{/* 时间范围选择 */}
@@ -221,6 +165,12 @@ export function StatsSettings() {
</div>
</div>
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -241,7 +191,6 @@ export function StatsSettings() {
icon={Coins}
label="Token"
value={formatNumber(stats.today_tokens)}
trend={12}
/>
<StatCard
icon={Timer}
@@ -265,13 +214,11 @@ export function StatsSettings() {
label="对话"
value={stats.monthly_conversations.toString()}
subvalue={`${stats.monthly_messages} 条消息`}
trend={8}
/>
<StatCard
icon={Coins}
label="Token"
value={formatNumber(stats.monthly_tokens)}
trend={15}
/>
<StatCard
icon={Timer}
@@ -312,33 +259,39 @@ export function StatsSettings() {
{/* 模型使用排行 */}
<div className="rounded-lg border p-4">
<h3 className="text-sm font-medium mb-4">模型使用排行</h3>
<div className="space-y-3">
{modelUsage.map((model, index) => (
<div key={model.model} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">
#{index + 1}
</span>
<span className="font-medium">{model.model}</span>
{modelUsage.length > 0 ? (
<div className="space-y-3">
{modelUsage.map((model, index) => (
<div key={model.model} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">
#{index + 1}
</span>
<span className="font-medium">{model.model}</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>{model.conversations} 次对话</span>
<span>{formatNumber(model.tokens)} Token</span>
<span className="text-primary font-medium">
{model.percentage}%
</span>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>{model.conversations} 次对话</span>
<span>{formatNumber(model.tokens)} Token</span>
<span className="text-primary font-medium">
{model.percentage}%
</span>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${Math.min(model.percentage, 100)}%` }}
/>
</div>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${model.percentage}%` }}
/>
</div>
</div>
))}
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">
暂无模型使用数据
</div>
)}
</div>
{/* 每日使用趋势 */}
@@ -352,9 +305,8 @@ export function StatsSettings() {
</div>
<div className="h-40 flex items-end gap-1">
{dailyUsage.map((day, _index) => {
const maxTokens = Math.max(...dailyUsage.map((d) => d.tokens));
const height =
maxTokens > 0 ? (day.tokens / maxTokens) * 100 : 0;
maxDailyTokens > 0 ? (day.tokens / maxDailyTokens) * 100 : 0;
return (
<div
key={day.date}
@@ -409,7 +361,8 @@ export function StatsSettings() {
const dayData = dailyUsage[index];
const getIntensity = (tokens: number) => {
if (!dayData) return "bg-muted";
const max = Math.max(...dailyUsage.map((d) => d.tokens));
if (maxDailyTokens <= 0) return "bg-primary/10";
const max = maxDailyTokens;
const ratio = tokens / max;
if (ratio < 0.2) return "bg-primary/10";
if (ratio < 0.4) return "bg-primary/30";
@@ -1,7 +1,7 @@
/**
* 助理服务配置设置组件
*
* 参考 LobeHub 的 agent 实现
* 参考成熟产品的助理能力实现
* 功能包括:默认助理选择、助理参数配置等
*/
@@ -1,7 +1,7 @@
/**
* 绘画服务集成设置组件
*
* 参考 LobeHub 的 image 实现
* 参考成熟产品的图像能力实现
* 功能包括:图像生成服务商选择、默认参数配置等
*/
@@ -1,361 +0,0 @@
/**
* 记忆管理系统设置组件
*
* 参考 LobeHub 的 memory 实现,扩展更多功能
* 功能包括:记忆启用/禁用、容量管理、清理功能等
*/
import { useState, useEffect } from "react";
import {
BrainCircuit,
Trash2,
Database,
AlertCircle,
Info,
CheckCircle2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
interface MemoryConfig {
/** 是否启用记忆功能 */
enabled: boolean;
/** 最大记忆条数 */
max_entries?: number;
/** 记忆保留天数 */
retention_days?: number;
/** 自动清理过期记忆 */
auto_cleanup?: boolean;
}
interface MemoryStats {
/** 总记忆条数 */
total_entries: number;
/** 已使用的存储空间(字节) */
storage_used: number;
/** 记忆库数量 */
memory_count: number;
}
const DEFAULT_MEMORY_CONFIG: MemoryConfig = {
enabled: true,
max_entries: 1000,
retention_days: 30,
auto_cleanup: true,
};
/**
* 格式化存储大小
*/
function formatStorageSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
export function MemorySettings() {
const [config, setConfig] = useState<Config | null>(null);
const [memoryConfig, setMemoryConfig] = useState<MemoryConfig>(
DEFAULT_MEMORY_CONFIG,
);
const [memoryStats, setMemoryStats] = useState<MemoryStats | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [cleaning, setCleaning] = useState(false);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// 加载配置
useEffect(() => {
loadConfig();
loadMemoryStats();
}, []);
const loadConfig = async () => {
setLoading(true);
try {
const c = await getConfig();
setConfig(c);
setMemoryConfig(c.memory || DEFAULT_MEMORY_CONFIG);
} catch (e) {
console.error("加载记忆配置失败:", e);
} finally {
setLoading(false);
}
};
const loadMemoryStats = async () => {
try {
// TODO: 实现获取记忆统计的 API
// const stats = await getMemoryStats();
// setMemoryStats(stats);
// 模拟数据
setMemoryStats({
total_entries: 156,
storage_used: 256000, // 256 KB
memory_count: 12,
});
} catch (e) {
console.error("加载记忆统计失败:", e);
}
};
// 保存配置
const saveMemoryConfig = async (key: keyof MemoryConfig, value: any) => {
if (!config) return;
setSaving(true);
try {
const newConfig = {
...memoryConfig,
[key]: value,
};
const updatedFullConfig = {
...config,
memory: newConfig,
};
await saveConfig(updatedFullConfig);
setConfig(updatedFullConfig);
setMemoryConfig(newConfig);
showMessage("success", "设置已保存");
} catch (e) {
console.error("保存记忆配置失败:", e);
showMessage("error", "保存失败");
} finally {
setSaving(false);
}
};
// 清理记忆
const handleCleanup = async () => {
setCleaning(true);
try {
// TODO: 实现清理记忆的 API
// await cleanupMemory();
// 模拟清理
await new Promise((resolve) => setTimeout(resolve, 1000));
showMessage("success", "已清理过期记忆");
loadMemoryStats(); // 重新加载统计
} catch (e) {
console.error("清理记忆失败:", e);
showMessage("error", "清理失败");
} finally {
setCleaning(false);
}
};
const showMessage = (type: "success" | "error", text: string) => {
setMessage({ type, text });
setTimeout(() => setMessage(null), 3000);
};
const maxEntriesOptions = [100, 500, 1000, 2000, 5000];
const retentionDaysOptions = [7, 14, 30, 60, 90];
return (
<div className="space-y-4 max-w-2xl">
{/* 记忆统计卡片 */}
{memoryStats && (
<div className="rounded-lg border p-4 bg-gradient-to-br from-primary/5 to-primary/10">
<div className="flex items-center gap-2 mb-3">
<Database className="h-5 w-5 text-primary" />
<h3 className="text-sm font-medium">记忆统计</h3>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-primary">
{memoryStats.total_entries}
</div>
<div className="text-xs text-muted-foreground">记忆条数</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-primary">
{formatStorageSize(memoryStats.storage_used)}
</div>
<div className="text-xs text-muted-foreground">存储空间</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-primary">
{memoryStats.memory_count}
</div>
<div className="text-xs text-muted-foreground">记忆库数</div>
</div>
</div>
</div>
)}
{/* 启用记忆功能 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BrainCircuit className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">启用记忆功能</h3>
<p className="text-xs text-muted-foreground">
让 AI 记住之前的对话内容
</p>
</div>
</div>
<input
type="checkbox"
checked={memoryConfig.enabled}
onChange={(e) => saveMemoryConfig("enabled", e.target.checked)}
disabled={loading || saving}
className="w-4 h-4 rounded border-gray-300"
/>
</div>
</div>
{/* 最大记忆条数 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium">最大记忆条数</h3>
<p className="text-xs text-muted-foreground">
限制保存的记忆条目数量
</p>
</div>
<span className="text-sm font-medium text-primary">
{memoryConfig.max_entries || 1000}
</span>
</div>
<div className="grid grid-cols-5 gap-2">
{maxEntriesOptions.map((option) => (
<button
key={option}
onClick={() => saveMemoryConfig("max_entries", option)}
className={cn(
"px-2 py-1.5 rounded text-xs font-medium transition-colors border",
memoryConfig.max_entries === option
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
{option >= 1000 ? `${option / 1000}k` : option}
</button>
))}
</div>
</div>
{/* 记忆保留天数 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium">记忆保留天数</h3>
<p className="text-xs text-muted-foreground">
自动删除超过指定天数的记忆
</p>
</div>
<span className="text-sm font-medium text-primary">
{memoryConfig.retention_days || 30} 天
</span>
</div>
<div className="grid grid-cols-5 gap-2">
{retentionDaysOptions.map((option) => (
<button
key={option}
onClick={() => saveMemoryConfig("retention_days", option)}
className={cn(
"px-2 py-1.5 rounded text-xs font-medium transition-colors border",
memoryConfig.retention_days === option
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
{option} 天
</button>
))}
</div>
</div>
{/* 自动清理 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">自动清理过期记忆</h3>
<p className="text-xs text-muted-foreground">
定期自动删除过期的记忆条目
</p>
</div>
<input
type="checkbox"
checked={memoryConfig.auto_cleanup ?? true}
onChange={(e) => saveMemoryConfig("auto_cleanup", e.target.checked)}
disabled={loading || saving}
className="w-4 h-4 rounded border-gray-300"
/>
</div>
</div>
{/* 清理按钮 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Trash2 className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">清理记忆</h3>
<p className="text-xs text-muted-foreground">
手动清理所有过期和无效的记忆
</p>
</div>
</div>
<button
onClick={handleCleanup}
disabled={loading || cleaning}
className="flex items-center gap-1.5 px-3 py-1.5 rounded border text-sm hover:bg-muted transition-colors disabled:opacity-50"
>
{cleaning ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
清理中...
</>
) : (
<>
<Trash2 className="h-3.5 w-3.5" />
立即清理
</>
)}
</button>
</div>
</div>
{/* 提示信息 */}
<div className="flex items-start gap-2 text-xs text-muted-foreground p-3 bg-muted/30 rounded-lg">
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<p>
记忆功能会让 AI 在对话中记住之前的上下文信息。禁用后,AI
将无法跨对话记住信息。清理记忆是不可逆操作,请谨慎操作。
</p>
</div>
{/* 消息提示 */}
{message && (
<div
className={cn(
"flex items-center gap-2 p-3 rounded-lg",
message.type === "success"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
)}
>
{message.type === "success" ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<span className="text-sm">{message.text}</span>
</div>
)}
</div>
);
}
export default MemorySettings;
@@ -1,7 +1,7 @@
/**
* 语音服务配置设置组件
*
* 参考 LobeHub 的 TTS/STT 实现
* 参考成熟产品的 TTS/STT 实现
* 功能包括:TTS 服务商选择、STT 服务商选择、语音参数配置等
*/
@@ -1,7 +1,7 @@
/**
* 聊天外观设置组件
*
* 参考 LobeHub 的 chat-appearance 实现
* 参考成熟产品的聊天外观实现
* 功能包括:聊天气泡样式、字体大小、过渡模式等
*/
@@ -1,12 +1,17 @@
/**
* 快捷键设置页面
*
* 显示和配置应用快捷键
* 参考 LobeHub 的 Hotkey 设置设计
* 显示应用中已实现的快捷键
*/
// import { useState } from 'react';
import { useCallback, useEffect, useState } from "react";
import styled from "styled-components";
import { Loader2 } from "lucide-react";
import { getExperimentalConfig } from "@/hooks/useTauri";
import {
getVoiceInputConfig,
type VoiceInputConfig,
} from "@/lib/api/asrProvider";
const Container = styled.div`
display: flex;
@@ -14,6 +19,11 @@ const Container = styled.div`
gap: 24px;
`;
const HeaderHint = styled.div`
font-size: 12px;
color: hsl(var(--muted-foreground));
`;
const Section = styled.div`
display: flex;
flex-direction: column;
@@ -56,11 +66,32 @@ const HotkeyDescription = styled.div`
color: hsl(var(--muted-foreground));
`;
const HotkeyMeta = styled.div`
margin-top: 2px;
font-size: 11px;
color: hsl(var(--muted-foreground));
`;
const HotkeyValue = styled.div`
display: flex;
align-items: center;
gap: 4px;
`;
const StatusBadge = styled.span<{ $enabled: boolean }>`
margin-right: 8px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
border: 1px solid
${({ $enabled }) =>
$enabled ? "hsl(var(--primary) / 0.3)" : "hsl(var(--border))"};
color: ${({ $enabled }) =>
$enabled ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"};
background: ${({ $enabled }) =>
$enabled ? "hsl(var(--primary) / 0.08)" : "hsl(var(--muted) / 0.35)"};
`;
const KeyBadge = styled.span`
display: inline-flex;
align-items: center;
@@ -81,61 +112,105 @@ interface HotkeyConfig {
label: string;
description: string;
keys: string[];
enabled: boolean;
source: string;
}
const desktopHotkeys: HotkeyConfig[] = [
{
id: "toggle-main-window",
label: "显示/隐藏主窗口",
description: "全局快捷键显示或隐藏主窗口",
keys: ["Control", "E"],
},
{
id: "open-settings",
label: "应用设置",
description: "打开应用设置页面",
keys: ["Command Or Control", ","],
},
];
function formatShortcutKeys(shortcut: string): string[] {
const map: Record<string, string> = {
CommandOrControl: "⌘/Ctrl",
Command: "⌘",
Control: "Ctrl",
Ctrl: "Ctrl",
Alt: "Alt",
Option: "⌥",
Shift: "⇧",
Super: "Super",
};
const essentialHotkeys: HotkeyConfig[] = [
{
id: "command-panel",
label: "命令面板",
description: "打开全局命令面板快速访问功能",
keys: ["⌘", "K"],
},
{
id: "search",
label: "搜索",
description: "唤起当前页面主要搜索框",
keys: ["⌘", "J"],
},
{
id: "switch-assistant",
label: "快捷切换助理",
description: "通过按住 Ctrl 加数字 0-9 切换固定在侧边栏的助理",
keys: ["^", "1-9"],
},
{
id: "switch-default-chat",
label: "切换至默认会话",
description: "切换至会话标签并进入 Lobe AI",
keys: ["^", "·"],
},
{
id: "toggle-left-panel",
label: "显示/隐藏左侧面板",
description: "显示或隐藏左侧面板",
keys: ["⌘", "["],
},
{
id: "toggle-right-panel",
label: "显示/隐藏右侧面板",
description: "显示或隐藏右侧面板",
keys: ["⌘", "]"],
},
];
return shortcut
.split("+")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => map[part] ?? part);
}
interface HotkeyState {
globalHotkeys: HotkeyConfig[];
localHotkeys: HotkeyConfig[];
}
function buildHotkeys(
screenshotEnabled: boolean,
screenshotShortcut: string,
voiceConfig: VoiceInputConfig,
): HotkeyState {
const globalHotkeys: HotkeyConfig[] = [
{
id: "screenshot-chat",
label: "截图对话",
description: "触发全局截图并打开截图对话窗口",
keys: formatShortcutKeys(screenshotShortcut),
enabled: screenshotEnabled,
source: "实验功能 → 截图对话",
},
{
id: "voice-input",
label: "语音输入",
description: "按下开始录音,松开后识别并输出",
keys: formatShortcutKeys(voiceConfig.shortcut),
enabled: voiceConfig.enabled,
source: "语音服务",
},
{
id: "voice-translate",
label: "语音翻译模式",
description: "独立快捷键触发语音识别并执行翻译指令",
keys: voiceConfig.translate_shortcut
? formatShortcutKeys(voiceConfig.translate_shortcut)
: ["未设置"],
enabled: voiceConfig.enabled && !!voiceConfig.translate_shortcut,
source: `语音服务 → 指令 ${voiceConfig.translate_instruction_id}`,
},
];
const localHotkeys: HotkeyConfig[] = [
{
id: "terminal-search",
label: "终端搜索",
description: "在终端页面打开搜索框",
keys: ["⌘/Ctrl", "F"],
enabled: true,
source: "终端页面",
},
{
id: "terminal-font-plus",
label: "终端字体放大",
description: "在终端页面增大字体",
keys: ["⌘/Ctrl", "+"],
enabled: true,
source: "终端页面",
},
{
id: "terminal-font-minus",
label: "终端字体缩小",
description: "在终端页面减小字体",
keys: ["⌘/Ctrl", "-"],
enabled: true,
source: "终端页面",
},
{
id: "terminal-font-reset",
label: "终端字体重置",
description: "在终端页面重置字体大小",
keys: ["⌘/Ctrl", "0"],
enabled: true,
source: "终端页面",
},
];
return { globalHotkeys, localHotkeys };
}
function HotkeySection({
title,
@@ -152,8 +227,12 @@ function HotkeySection({
<HotkeyInfo>
<HotkeyLabel>{hotkey.label}</HotkeyLabel>
<HotkeyDescription>{hotkey.description}</HotkeyDescription>
<HotkeyMeta>{hotkey.source}</HotkeyMeta>
</HotkeyInfo>
<HotkeyValue>
<StatusBadge $enabled={hotkey.enabled}>
{hotkey.enabled ? "已启用" : "未启用"}
</StatusBadge>
{hotkey.keys.map((key, index) => (
<KeyBadge key={index}>{key}</KeyBadge>
))}
@@ -165,10 +244,77 @@ function HotkeySection({
}
export function HotkeysSettings() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [globalHotkeys, setGlobalHotkeys] = useState<HotkeyConfig[]>([]);
const [localHotkeys, setLocalHotkeys] = useState<HotkeyConfig[]>([]);
const loadHotkeys = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [experimentalConfig, voiceConfig] = await Promise.all([
getExperimentalConfig(),
getVoiceInputConfig(),
]);
const built = buildHotkeys(
experimentalConfig.screenshot_chat.enabled,
experimentalConfig.screenshot_chat.shortcut,
voiceConfig,
);
setGlobalHotkeys(built.globalHotkeys);
setLocalHotkeys(built.localHotkeys);
} catch (loadError) {
console.error("加载快捷键信息失败:", loadError);
setError(loadError instanceof Error ? loadError.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadHotkeys();
}, [loadHotkeys]);
return (
<Container>
<HotkeySection title="桌面端" hotkeys={desktopHotkeys} />
<HotkeySection title="基础" hotkeys={essentialHotkeys} />
<HeaderHint>
仅展示当前版本已实现的快捷键;全局快捷键会随配置实时更新。
</HeaderHint>
{loading ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
}}
>
<Loader2 size={16} className="animate-spin" />
正在加载快捷键信息...
</div>
) : error ? (
<div
style={{
padding: "10px 12px",
borderRadius: 8,
border: "1px solid hsl(var(--destructive) / 0.4)",
color: "hsl(var(--destructive))",
fontSize: 13,
}}
>
加载快捷键失败:{error}
</div>
) : (
<>
<HotkeySection title="全局快捷键" hotkeys={globalHotkeys} />
<HotkeySection title="页面内快捷键" hotkeys={localHotkeys} />
</>
)}
</Container>
);
}
@@ -2,7 +2,7 @@
* 设置分类 Hook
*
* 定义设置页面的分组和导航项
* 参考 LobeHub 的 useCategory 设计
* 参考成熟产品的分组导航设计
*/
import { useMemo } from "react";
@@ -16,7 +16,6 @@ import {
Brain,
Bot,
Blocks,
BrainCircuit,
Image,
Mic,
Server,
@@ -123,11 +122,6 @@ export function useSettingsCategory(): CategoryGroup[] {
label: t("settings.tab.skills", "技能管理"),
icon: Blocks,
},
{
key: SettingsTabs.Memory,
label: t("settings.tab.memory", "记忆设置"),
icon: BrainCircuit,
},
{
key: SettingsTabs.ImageGen,
label: t("settings.tab.imageGen", "绘画服务"),
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* 设置页面 V2 导出
*
* 新版设置页面,采用 LobeHub 风格的侧边栏布局
* 新版设置页面,采用统一侧边栏布局
*/
export { SettingsLayoutV2 as SettingsPageV2 } from "./_layout";
+38 -2
View File
@@ -8,6 +8,7 @@
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner";
import { safeListen } from "@/lib/dev-bridge";
import type { UnlistenFn } from "@tauri-apps/api/event";
@@ -48,6 +49,10 @@ const DEFAULT_CONFIG: TerminalAIConfig = {
autoExecute: false, // 默认需要手动批准
};
interface WorkspaceSummary {
id: string;
}
/**
* 加载持久化数据
*/
@@ -101,6 +106,7 @@ export function useTerminalAI(
// 会话 ID
const [sessionId, setSessionId] = useState<string | null>(null);
const workspaceIdRef = useRef<string | null>(null);
// 终端控制状态
const [terminalSessionId, setTerminalSessionId] = useState<string | null>(
@@ -152,6 +158,23 @@ export function useTerminalAI(
initAgent();
}, []);
const ensureWorkspaceId = useCallback(async (): Promise<string> => {
if (workspaceIdRef.current) {
return workspaceIdRef.current;
}
const workspace = await invoke<WorkspaceSummary | null>(
"workspace_get_default",
);
const resolvedWorkspaceId = workspace?.id?.trim();
if (!resolvedWorkspaceId) {
throw new Error("未找到默认工作区,请先创建或选择项目");
}
workspaceIdRef.current = resolvedWorkspaceId;
return resolvedWorkspaceId;
}, []);
/**
* 确保会话存在
*/
@@ -169,10 +192,13 @@ export function useTerminalAI(
请用简洁清晰的语言回答,必要时提供代码示例。`;
const resolvedWorkspaceId = await ensureWorkspaceId();
const response = await createAgentSession(
providerId,
resolvedWorkspaceId,
modelId,
systemPrompt,
undefined,
);
setSessionId(response.session_id);
@@ -182,7 +208,7 @@ export function useTerminalAI(
toast.error("创建 AI 会话失败");
return null;
}
}, [sessionId, providerId, modelId]);
}, [sessionId, providerId, modelId, ensureWorkspaceId]);
/**
* 获取终端上下文
@@ -421,9 +447,12 @@ export function useTerminalAI(
// 如果已连接终端,启用 terminal_mode(使用 TerminalTool 替代 BashTool)
const useTerminalMode = terminalSessionId !== null;
const resolvedWorkspaceId = await ensureWorkspaceId();
await sendAgentMessageStream(
messageContent,
eventName,
resolvedWorkspaceId,
activeSessionId,
modelId,
imagesToSend,
@@ -440,7 +469,14 @@ export function useTerminalAI(
}
}
},
[ensureSession, getTerminalContext, modelId, providerId, terminalSessionId],
[
ensureSession,
ensureWorkspaceId,
getTerminalContext,
modelId,
providerId,
terminalSessionId,
],
);
/**
+279 -50
View File
@@ -18,7 +18,16 @@ import {
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import {
@@ -28,11 +37,14 @@ import {
createContent,
createProject,
formatRelativeTime,
generateProjectName,
getContentTypeLabel,
getDefaultContentTypeForProject,
getDefaultProjectPath,
getProjectTypeLabel,
getWorkspaceProjectsRoot,
getProjectByRootPath,
getCreateProjectErrorMessage,
extractErrorMessage,
resolveProjectRootPath,
listContents,
listProjects,
updateContent,
@@ -65,7 +77,7 @@ export function WorkbenchPage({
viewMode: initialViewMode,
}: WorkbenchPageProps) {
const [showLeftSidebar, setShowLeftSidebar] = useState(true);
const [showRightSidebar, setShowRightSidebar] = useState(true);
const [showRightSidebar, setShowRightSidebar] = useState(false);
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>(
initialViewMode ?? (initialContentId ? "workspace" : "project-management"),
);
@@ -85,6 +97,14 @@ export function WorkbenchPage({
const [projectQuery, setProjectQuery] = useState("");
const [contentQuery, setContentQuery] = useState("");
const [createProjectDialogOpen, setCreateProjectDialogOpen] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
const [workspaceProjectsRoot, setWorkspaceProjectsRoot] = useState("");
const [creatingProject, setCreatingProject] = useState(false);
const [resolvedProjectPath, setResolvedProjectPath] = useState("");
const [pathChecking, setPathChecking] = useState(false);
const [pathConflictMessage, setPathConflictMessage] = useState("");
const selectedProject = useMemo(
() => projects.find((project) => project.id === selectedProjectId) ?? null,
[projects, selectedProjectId],
@@ -117,7 +137,6 @@ export function WorkbenchPage({
const handleEnterWorkspace = useCallback((contentId: string) => {
setSelectedContentId(contentId);
setWorkspaceMode("workspace");
setShowRightSidebar(true);
}, []);
const handleOpenProjectDetail = useCallback(() => {
@@ -200,22 +219,44 @@ export function WorkbenchPage({
[initialContentId],
);
const handleOpenCreateProjectDialog = useCallback(() => {
setNewProjectName(`${getProjectTypeLabel(theme as ProjectType)}项目`);
setResolvedProjectPath("");
setPathConflictMessage("");
setPathChecking(false);
setCreateProjectDialogOpen(true);
}, [theme]);
const handleCreateProject = useCallback(async () => {
const name = newProjectName.trim();
if (!name) {
toast.error("请输入项目名称");
return;
}
setCreatingProject(true);
try {
const projectName = generateProjectName(theme as ProjectType);
const rootPath = getDefaultProjectPath();
await createProject({
name: projectName,
const rootPath = await resolveProjectRootPath(name);
const createdProject = await createProject({
name,
rootPath,
workspaceType: theme as ProjectType,
});
setCreateProjectDialogOpen(false);
setSelectedProjectId(createdProject.id);
setProjectQuery("");
toast.success("已创建新项目");
await loadProjects();
} catch (error) {
console.error("创建项目失败:", error);
toast.error("创建项目失败");
const errorMessage = extractErrorMessage(error);
const friendlyMessage = getCreateProjectErrorMessage(errorMessage);
toast.error(`创建项目失败: ${friendlyMessage}`);
} finally {
setCreatingProject(false);
}
}, [loadProjects, theme]);
}, [loadProjects, newProjectName, theme]);
const handleCreateContent = useCallback(async () => {
if (!selectedProjectId) {
@@ -270,7 +311,7 @@ export function WorkbenchPage({
setSelectedContentId(initialContentId ?? null);
setWorkspaceMode(nextMode);
setShowLeftSidebar(true);
setShowRightSidebar(nextMode === "workspace");
setShowRightSidebar(false);
setContents([]);
void loadProjects();
}, [
@@ -281,6 +322,109 @@ export function WorkbenchPage({
theme,
]);
useEffect(() => {
let mounted = true;
const loadWorkspaceProjectsRoot = async () => {
try {
const root = await getWorkspaceProjectsRoot();
if (mounted) {
setWorkspaceProjectsRoot(root);
}
} catch (error) {
console.error("加载 workspace 目录失败:", error);
}
};
void loadWorkspaceProjectsRoot();
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 (!selectedProjectId) {
setContents([]);
@@ -321,35 +465,19 @@ export function WorkbenchPage({
</Button>
{workspaceMode === "workspace" && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowLeftSidebar((visible) => !visible)}
title={showLeftSidebar ? "隐藏左侧栏" : "显示左侧栏"}
>
{showLeftSidebar ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelLeftOpen className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowRightSidebar((visible) => !visible)}
title={showRightSidebar ? "隐藏右侧栏" : "显示右侧栏"}
>
{showRightSidebar ? (
<PanelRightClose className="h-4 w-4" />
) : (
<PanelRightOpen className="h-4 w-4" />
)}
</Button>
</>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowLeftSidebar((visible) => !visible)}
title={showLeftSidebar ? "隐藏左侧栏" : "显示左侧栏"}
>
{showLeftSidebar ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelLeftOpen className="h-4 w-4" />
)}
</Button>
)}
{workspaceMode !== "project-management" && (
@@ -371,6 +499,22 @@ export function WorkbenchPage({
{selectedProject.name}
</div>
)}
{workspaceMode === "workspace" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 ml-auto"
onClick={() => setShowRightSidebar((visible) => !visible)}
title={showRightSidebar ? "隐藏右侧栏" : "显示右侧栏"}
>
{showRightSidebar ? (
<PanelRightClose className="h-4 w-4" />
) : (
<PanelRightOpen className="h-4 w-4" />
)}
</Button>
)}
</header>
<div className="flex flex-1 min-h-0">
@@ -405,9 +549,7 @@ export function WorkbenchPage({
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
void handleCreateProject();
}}
onClick={handleOpenCreateProjectDialog}
title="新建项目"
>
<Plus className="h-4 w-4" />
@@ -546,9 +688,7 @@ export function WorkbenchPage({
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => {
void handleCreateProject();
}}
onClick={handleOpenCreateProjectDialog}
>
<FolderOpen className="h-4 w-4 mr-1" />
新建项目
@@ -585,7 +725,6 @@ export function WorkbenchPage({
onBack={handleBackToProjectManagement}
onNavigateToChat={() => {
setWorkspaceMode("workspace");
setShowRightSidebar(true);
}}
/>
)
@@ -596,9 +735,7 @@ export function WorkbenchPage({
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => {
void handleCreateProject();
}}
onClick={handleOpenCreateProjectDialog}
>
<FolderOpen className="h-4 w-4 mr-1" />
新建项目
@@ -618,6 +755,7 @@ export function WorkbenchPage({
) : (
<div className="flex-1 min-h-0">
<AgentChatPage
key={`${selectedProjectId || ""}:${selectedContentId || ""}:${theme || ""}:workspace`}
onNavigate={onNavigate}
projectId={selectedProjectId}
contentId={selectedContentId}
@@ -654,6 +792,97 @@ export function WorkbenchPage({
</aside>
)}
</div>
<Dialog
open={createProjectDialogOpen}
onOpenChange={(open) => {
if (!creatingProject) {
setCreateProjectDialogOpen(open);
}
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>新建项目</DialogTitle>
<DialogDescription>
请输入项目名称,项目将创建到固定 workspace 目录。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="workspace-project-name">项目名称</Label>
<Input
id="workspace-project-name"
value={newProjectName}
onChange={(event) => setNewProjectName(event.target.value)}
placeholder="请输入项目名称"
autoFocus
disabled={creatingProject}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="workspace-project-type">项目类型</Label>
<Input
id="workspace-project-type"
value={getProjectTypeLabel(theme as ProjectType)}
disabled
/>
</div>
<div className="grid gap-2">
<Label htmlFor="workspace-project-path">workspace 目录</Label>
<Input
id="workspace-project-path"
value={workspaceProjectsRoot}
placeholder="加载中..."
readOnly
/>
<p className="text-xs text-muted-foreground break-all">
将创建到:
{resolvedProjectPath
? resolvedProjectPath
: newProjectName.trim()
? `${workspaceProjectsRoot || "..."}/${newProjectName.trim()}`
: "请输入项目名称"}
</p>
{pathChecking && (
<p className="text-xs text-muted-foreground">正在检查路径...</p>
)}
{!pathChecking && pathConflictMessage && (
<p className="text-xs text-destructive">
{pathConflictMessage}
</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCreateProjectDialogOpen(false)}
disabled={creatingProject}
>
取消
</Button>
<Button
onClick={() => {
void handleCreateProject();
}}
disabled={
creatingProject ||
pathChecking ||
!!pathConflictMessage ||
!newProjectName.trim() ||
!workspaceProjectsRoot.trim()
}
>
{creatingProject ? "创建中..." : "创建项目"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+5 -1
View File
@@ -109,10 +109,14 @@ export function useProjects(): UseProjectsReturn {
/** 创建项目 */
const create = useCallback(
async (request: CreateProjectRequest): Promise<Project> => {
const rootPath = await invoke<string>("workspace_resolve_project_path", {
name: request.name,
});
const project = await invoke<Project>("workspace_create", {
request: {
name: request.name,
rootPath: request.name, // 使用名称作为路径
rootPath,
workspaceType: request.workspaceType,
},
});
+51
View File
@@ -792,11 +792,40 @@ export interface MemoryStatsResponse {
memory_count: number;
}
export interface MemoryCategoryStat {
category: "identity" | "context" | "preference" | "experience" | "activity";
count: number;
}
export interface MemoryEntryPreview {
id: string;
session_id: string;
file_type: string;
category: "identity" | "context" | "preference" | "experience" | "activity";
title: string;
summary: string;
updated_at: number;
tags: string[];
}
export interface MemoryOverviewResponse {
stats: MemoryStatsResponse;
categories: MemoryCategoryStat[];
entries: MemoryEntryPreview[];
}
export interface CleanupMemoryResult {
cleaned_entries: number;
freed_space: number;
}
export interface MemoryAnalysisResult {
analyzed_sessions: number;
analyzed_messages: number;
generated_entries: number;
deduplicated_entries: number;
}
/**
* 获取记忆统计信息
*/
@@ -804,6 +833,28 @@ export async function getMemoryStats(): Promise<MemoryStatsResponse> {
return safeInvoke("get_conversation_memory_stats");
}
/**
* 获取记忆总览(含分类与条目)
*/
export async function getMemoryOverview(
limit?: number,
): Promise<MemoryOverviewResponse> {
return safeInvoke("get_conversation_memory_overview", { limit });
}
/**
* 请求记忆分析(从历史会话提取记忆)
*/
export async function requestMemoryAnalysis(
fromTimestamp?: number,
toTimestamp?: number,
): Promise<MemoryAnalysisResult> {
return safeInvoke("request_conversation_memory_analysis", {
fromTimestamp,
toTimestamp,
});
}
/**
* 清理过期记忆
*/
+3 -1
View File
@@ -99,6 +99,8 @@ export const availableIcons = [
export type AvailableIcon = (typeof availableIcons)[number];
const LEGACY_PROXYCAST_HUB_ICON_ALIAS = `${"lobe"}${"hub"}`;
// ============================================================================
// Provider 类型到图标名称的映射
// ============================================================================
@@ -127,7 +129,7 @@ export const providerTypeToIcon: Record<string, string> = {
aws: "aws",
proxycast: "proxycast",
"proxycast-hub": "proxycast-hub",
lobehub: "proxycast",
[LEGACY_PROXYCAST_HUB_ICON_ALIAS]: "proxycast",
// ===== 主流 AI Provider =====
deepseek: "deepseek",
+34 -2
View File
@@ -351,20 +351,35 @@ export interface SkillInfo {
path?: string;
}
const requireWorkspaceId = (
workspaceId?: string,
fallbackWorkspaceId?: string,
): string => {
const resolvedWorkspaceId = (workspaceId ?? fallbackWorkspaceId)?.trim();
if (!resolvedWorkspaceId) {
throw new Error("workspaceId 不能为空,请先选择项目工作区");
}
return resolvedWorkspaceId;
};
/**
* 创建 Agent 会话
*/
export async function createAgentSession(
providerType: string,
workspaceId: string,
model?: string,
systemPrompt?: string,
skills?: SkillInfo[],
): Promise<CreateSessionResponse> {
const resolvedWorkspaceId = requireWorkspaceId(workspaceId);
return await safeInvoke("agent_create_session", {
providerType,
model,
systemPrompt,
skills,
workspaceId: resolvedWorkspaceId,
});
}
@@ -401,7 +416,7 @@ export async function sendAgentMessage(
* // 处理文本增量
* }
* });
* await sendAgentMessageStream(message, eventName, sessionId, model, undefined, provider);
* await sendAgentMessageStream(message, eventName, workspaceId, sessionId, model, undefined, provider);
* ```
*
* @deprecated 请使用 sendAsterMessageStream 代替
@@ -409,12 +424,16 @@ export async function sendAgentMessage(
export async function sendAgentMessageStream(
message: string,
eventName: string,
workspaceId: string,
sessionId?: string,
model?: string,
images?: ImageInput[],
provider?: string,
_terminalMode?: boolean,
projectId?: string,
): Promise<void> {
const resolvedWorkspaceId = requireWorkspaceId(workspaceId, projectId);
// 使用 Aster Agent 实现
return await safeInvoke("aster_agent_chat_stream", {
request: {
@@ -428,6 +447,8 @@ export async function sendAgentMessageStream(
model_name: model || "claude-sonnet-4-20250514",
}
: undefined,
project_id: projectId,
workspace_id: resolvedWorkspaceId,
},
});
}
@@ -725,9 +746,12 @@ export async function sendAsterMessageStream(
message: string,
sessionId: string,
eventName: string,
workspaceId: string,
images?: ImageInput[],
providerConfig?: AsterProviderConfig,
): Promise<void> {
const resolvedWorkspaceId = requireWorkspaceId(workspaceId);
return await safeInvoke("aster_agent_chat_stream", {
request: {
message,
@@ -735,6 +759,7 @@ export async function sendAsterMessageStream(
event_name: eventName,
images,
provider_config: providerConfig,
workspace_id: resolvedWorkspaceId,
},
});
}
@@ -750,10 +775,17 @@ export async function stopAsterSession(sessionId: string): Promise<boolean> {
* 创建 Aster 会话
*/
export async function createAsterSession(
workspaceId: string,
workingDir?: string,
name?: string,
): Promise<string> {
return await safeInvoke("aster_session_create", { workingDir, name });
const resolvedWorkspaceId = requireWorkspaceId(workspaceId);
return await safeInvoke("aster_session_create", {
workingDir,
workspaceId: resolvedWorkspaceId,
name,
});
}
/**
+100 -1
View File
@@ -5,7 +5,11 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import {
getWorkspaceProjectsRoot,
resolveProjectRootPath,
getProjectByRootPath,
isUserProjectType,
getProjectTypeLabel,
getProjectTypeIcon,
@@ -14,6 +18,7 @@ import {
getDefaultContentTypeForProject,
getCanvasTypeForProjectType,
getCreateProjectErrorMessage,
extractErrorMessage,
normalizeProject,
formatWordCount,
formatRelativeTime,
@@ -24,11 +29,87 @@ import {
type ContentStatus,
} from "./project";
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
// ============================================================================
// 辅助函数测试
// ============================================================================
describe("项目管理 API", () => {
describe("workspace 路径 API", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("应该调用命令获取 workspace 根目录", async () => {
vi.mocked(invoke).mockResolvedValueOnce(
"/Users/test/.proxycast/projects",
);
const root = await getWorkspaceProjectsRoot();
expect(root).toBe("/Users/test/.proxycast/projects");
expect(invoke).toHaveBeenCalledWith("workspace_get_projects_root");
});
it("应该调用命令解析项目目录", async () => {
vi.mocked(invoke).mockResolvedValueOnce(
"/Users/test/.proxycast/projects/MyProject",
);
const path = await resolveProjectRootPath("MyProject");
expect(path).toBe("/Users/test/.proxycast/projects/MyProject");
expect(invoke).toHaveBeenCalledWith("workspace_resolve_project_path", {
name: "MyProject",
});
});
it("应该将空名称传给后端统一处理", async () => {
vi.mocked(invoke).mockResolvedValueOnce(
"/Users/test/.proxycast/projects/未命名项目",
);
const path = await resolveProjectRootPath(" ");
expect(path).toBe("/Users/test/.proxycast/projects/未命名项目");
expect(invoke).toHaveBeenCalledWith("workspace_resolve_project_path", {
name: " ",
});
});
it("应该调用命令按路径获取项目", async () => {
vi.mocked(invoke).mockResolvedValueOnce({
id: "p1",
name: "测试项目",
workspace_type: "general",
root_path: "/Users/test/.proxycast/projects/demo",
});
const project = await getProjectByRootPath(
"/Users/test/.proxycast/projects/demo",
);
expect(project?.id).toBe("p1");
expect(project?.rootPath).toBe("/Users/test/.proxycast/projects/demo");
expect(invoke).toHaveBeenCalledWith("workspace_get_by_path", {
rootPath: "/Users/test/.proxycast/projects/demo",
});
});
it("按路径查询不存在项目时应该返回 null", async () => {
vi.mocked(invoke).mockResolvedValueOnce(null);
const project = await getProjectByRootPath(
"/Users/test/.proxycast/projects/missing",
);
expect(project).toBeNull();
});
});
describe("isUserProjectType", () => {
it("应该正确识别用户级项目类型", () => {
expect(isUserProjectType("general")).toBe(true);
@@ -151,7 +232,7 @@ describe("项目管理 API", () => {
it("应该透传路径已存在错误", () => {
expect(getCreateProjectErrorMessage("路径已存在: /tmp/project")).toBe(
"路径已存在: /tmp/project",
"项目目录已存在,请更换项目名称或清理同名目录",
);
});
@@ -177,6 +258,24 @@ describe("项目管理 API", () => {
});
});
describe("extractErrorMessage", () => {
it("应该提取 Error 实例 message", () => {
expect(extractErrorMessage(new Error("abc"))).toBe("abc");
});
it("应该处理字符串错误", () => {
expect(extractErrorMessage("hello")).toBe("hello");
});
it("应该处理对象 message 字段", () => {
expect(extractErrorMessage({ message: "bad" })).toBe("bad");
});
it("应该兜底处理未知类型", () => {
expect(extractErrorMessage(123)).toBe("123");
});
});
describe("normalizeProject", () => {
it("应该将 snake_case 字段转换为 camelCase", () => {
const raw = {
+38 -57
View File
@@ -245,12 +245,32 @@ export async function createProject(
return normalizeProject(project);
}
/** 获取统一 workspace 项目根目录 */
export async function getWorkspaceProjectsRoot(): Promise<string> {
return invoke<string>("workspace_get_projects_root");
}
/** 按项目名称解析固定项目目录 */
export async function resolveProjectRootPath(name: string): Promise<string> {
return invoke<string>("workspace_resolve_project_path", { name });
}
/** 获取项目列表 */
export async function listProjects(): Promise<Project[]> {
const projects = await invoke<RawProject[]>("workspace_list");
return projects.map((project) => normalizeProject(project));
}
/** 通过根路径获取项目 */
export async function getProjectByRootPath(
rootPath: string,
): Promise<Project | null> {
const project = await invoke<RawProject | null>("workspace_get_by_path", {
rootPath,
});
return project ? normalizeProject(project) : null;
}
/** 获取项目详情 */
export async function getProject(id: string): Promise<Project | null> {
const project = await invoke<RawProject | null>("workspace_get", { id });
@@ -406,7 +426,7 @@ export function getCreateProjectErrorMessage(message: string): string {
return "创建项目失败,请查看日志";
}
if (message.includes("路径已存在")) {
return message;
return "项目目录已存在,请更换项目名称或清理同名目录";
}
if (message.includes("no such column") || message.includes("has no column")) {
return "数据库结构过旧,请重启应用以执行迁移";
@@ -417,6 +437,23 @@ export function getCreateProjectErrorMessage(message: string): string {
return message;
}
/** 提取异常中的错误消息 */
export function extractErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
if (error && typeof error === "object" && "message" in error) {
return String((error as { message?: unknown }).message);
}
return String(error);
}
/** 格式化字数 */
export function formatWordCount(count: number): string {
if (count >= 10000) {
@@ -450,59 +487,3 @@ export function formatRelativeTime(timestamp: number): string {
return new Date(timestamp).toLocaleDateString();
}
}
/** 项目名称计数器(用于生成唯一的项目名称) */
let projectNameCounter = 0;
/** 重置项目名称计数器(用于测试或重新开始计数) */
export function resetProjectNameCounter() {
projectNameCounter = 0;
}
/**
* 生成项目名称(按规则)
* 格式: 项目-YYYY-MM-DD-NNN
*/
export function generateProjectName(type?: ProjectType): string {
const now = new Date();
const dateStr = now
.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.replace(/\//g, "-");
const typeLabel = type ? getProjectTypeLabel(type) : "项目";
// 每次调用时递增计数器
projectNameCounter++;
// 格式: 类型-YYYY-MM-DD-NNN (例如: 社媒内容-2026-02-01-001)
const seq = String(projectNameCounter).padStart(3, "0");
return `${typeLabel}-${dateStr}-${seq}`;
}
/**
* 获取默认项目根路径(用于自动创建项目)
* 如果用户未选择目录,则使用此默认路径
*/
export function getDefaultProjectPath(): string {
// 在用户主目录下创建 ProxyCast 项目文件夹
const homeDir =
(typeof window !== "undefined" &&
(window as any).__TAURI__?.path?.homeDir?.()) ||
"~/Documents/ProxyCast";
const now = new Date();
const dateStr = now
.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.replace(/\//g, "-");
const seq = String(projectNameCounter).padStart(3, "0");
return `${homeDir}/Project-${dateStr}-${seq}`;
}
+3
View File
@@ -76,6 +76,7 @@ export type Page =
| "tools"
| "plugins"
| "settings"
| "memory"
| "terminal"
| "sysinfo"
| "files"
@@ -121,6 +122,8 @@ export interface AgentPageParams {
theme?: string;
/** 是否锁定主题(锁定后不在首屏显示主题切换) */
lockTheme?: boolean;
/** 首页点击触发的新会话标记(时间戳) */
newChatAt?: number;
/** 工作台视图模式(仅主题工作台使用) */
workspaceViewMode?: WorkspaceViewMode;
}
-2
View File
@@ -31,7 +31,6 @@ export enum SettingsTabs {
Providers = "providers",
Assistant = "assistant",
Skills = "skills",
Memory = "memory",
ImageGen = "image-gen",
Voice = "voice",
@@ -79,7 +78,6 @@ export const SETTINGS_GROUPS: Record<SettingsGroupKey, SettingsTabs[]> = {
SettingsTabs.Providers,
SettingsTabs.Assistant,
SettingsTabs.Skills,
SettingsTabs.Memory,
SettingsTabs.ImageGen,
SettingsTabs.Voice,
],