mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: bump version to 0.63.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+14
-1
@@ -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": {
|
||||
|
||||
Generated
+15
-15
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/// 列出所有会话
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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(¶ms, 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
@@ -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,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,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,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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 秒后重置状态
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -0,0 +1,2 @@
|
||||
export { MemoryPage } from "./MemoryPage";
|
||||
export { default } from "./MemoryPage";
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
/**
|
||||
* 设置页面 V2 导出
|
||||
*
|
||||
* 新版设置页面,采用 LobeHub 风格的侧边栏布局
|
||||
* 新版设置页面,采用统一侧边栏布局
|
||||
*/
|
||||
|
||||
export { SettingsLayoutV2 as SettingsPageV2 } from "./_layout";
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期记忆
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user