From e0e9aba188821213d9c85b0ab1df3c86feee4840 Mon Sep 17 00:00:00 2001
From: coso
Date: Wed, 11 Feb 2026 14:37:44 +0800
Subject: [PATCH] chore: bump version to 0.63.0
Co-Authored-By: Claude Opus 4.6 (1M context)
---
package.json | 15 +-
src-tauri/Cargo.lock | 30 +-
src-tauri/Cargo.toml | 4 +-
src-tauri/crates/agent/src/session_store.rs | 40 +-
src-tauri/crates/core/src/agent/types.rs | 2 +
.../crates/core/src/database/dao/agent.rs | 11 +-
src-tauri/crates/core/src/database/schema.rs | 6 +-
.../services/src/aster_session_store.rs | 142 +-
src-tauri/src/agent/aster_agent.rs | 9 +-
src-tauri/src/app/runner.rs | 4 +
src-tauri/src/commands/agent_cmd.rs | 15 +
.../src/commands/api_key_provider_cmd.rs | 2 +-
src-tauri/src/commands/aster_agent_cmd.rs | 573 +++++++-
.../src/commands/memory_management_cmd.rs | 1028 +++++++++++++-
src-tauri/src/commands/workspace_cmd.rs | 49 +-
src-tauri/src/dev_bridge/dispatcher.rs | 1 +
.../conversation_statistics_service.rs | 663 +++++++--
src-tauri/src/voice/commands.rs | 69 +-
src-tauri/tauri.conf.headless.json | 2 +-
src-tauri/tauri.conf.json | 2 +-
src/App.tsx | 35 +-
src/components/AppSidebar.tsx | 22 +-
.../agent/chat/components/ProjectSelector.tsx | 53 +-
src/components/agent/chat/hooks/index.ts | 3 +-
.../agent/chat/hooks/useAgentChat.ts | 299 +++-
.../agent/chat/hooks/useAsterAgentChat.ts | 308 +++-
.../agent/chat/hooks/useContentSync.ts | 13 +
src/components/agent/chat/index.tsx | 56 +-
.../canvas/document/DocumentCanvas.tsx | 6 +-
.../canvas/document/editor/BubbleToolbar.tsx | 168 +++
.../canvas/document/editor/NotionEditor.tsx | 107 ++
.../canvas/document/editor/SlashCommand.tsx | 393 ++++++
.../canvas/document/editor/editor-styles.css | 155 +++
.../canvas/document/editor/extensions.ts | 46 +
.../canvas/document/editor/index.ts | 1 +
.../canvas/document/editor/utils/markdown.ts | 50 +
.../canvas/music/MusicCanvas.tsx | 2 +-
.../canvas/novel/NovelCanvas.tsx | 2 +-
.../canvas/poster/PosterCanvas.tsx | 1 +
.../canvas/script/ScriptCanvas.tsx | 2 +-
.../LayoutTransition/LayoutTransition.tsx | 18 +-
.../LayoutTransition/useLayoutTransition.ts | 2 +-
.../general-chat/canvas/CanvasPanel.tsx | 2 +-
src/components/image-gen/ImageGenPage.tsx | 165 ++-
src/components/memory/MemoryPage.tsx | 1237 +++++++++++++++++
src/components/memory/index.ts | 2 +
.../projects/CreateProjectDialog.tsx | 166 ++-
src/components/projects/ProjectsPage.tsx | 83 +-
.../settings-v2/_layout/SettingsSidebar.tsx | 2 +-
src/components/settings-v2/_layout/index.tsx | 15 +-
.../settings-v2/account/profile/index.tsx | 2 +-
.../settings-v2/account/stats/index.tsx | 183 +--
.../settings-v2/agent/assistant/index.tsx | 2 +-
.../settings-v2/agent/image-gen/index.tsx | 2 +-
.../settings-v2/agent/memory/index.tsx | 361 -----
.../settings-v2/agent/voice/index.tsx | 2 +-
.../general/chat-appearance/index.tsx | 2 +-
.../settings-v2/general/hotkeys/index.tsx | 260 +++-
.../settings-v2/hooks/useSettingsCategory.ts | 8 +-
src/components/settings-v2/index.ts | 2 +-
src/components/terminal/ai/useTerminalAI.ts | 40 +-
src/components/workspace/WorkbenchPage.tsx | 329 ++++-
src/hooks/useProjects.ts | 6 +-
src/hooks/useTauri.ts | 51 +
src/icons/providers/utils.ts | 4 +-
src/lib/api/agent.ts | 36 +-
src/lib/api/project.test.ts | 101 +-
src/lib/api/project.ts | 95 +-
src/types/page.ts | 3 +
src/types/settings.ts | 2 -
70 files changed, 6416 insertions(+), 1156 deletions(-)
create mode 100644 src/components/content-creator/canvas/document/editor/BubbleToolbar.tsx
create mode 100644 src/components/content-creator/canvas/document/editor/NotionEditor.tsx
create mode 100644 src/components/content-creator/canvas/document/editor/SlashCommand.tsx
create mode 100644 src/components/content-creator/canvas/document/editor/editor-styles.css
create mode 100644 src/components/content-creator/canvas/document/editor/extensions.ts
create mode 100644 src/components/content-creator/canvas/document/editor/index.ts
create mode 100644 src/components/content-creator/canvas/document/editor/utils/markdown.ts
create mode 100644 src/components/memory/MemoryPage.tsx
create mode 100644 src/components/memory/index.ts
delete mode 100644 src/components/settings-v2/agent/memory/index.tsx
diff --git a/package.json b/package.json
index 5ba351674..f97e70992 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 6a67eb092..d3140de7c 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -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",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 5a1348ca2..b781bd908 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -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"
diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs
index b1b7c5731..98a68e878 100644
--- a/src-tauri/crates/agent/src/session_store.rs
+++ b/src-tauri/crates/agent/src/session_store.rs
@@ -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,
}
+/// 解析会话 working_dir(优先入参,其次 workspace_id)
+fn resolve_session_working_dir(
+ db: &DbConnection,
+ working_dir: Option,
+ workspace_id: String,
+) -> Result
{!searchQuery && (
-
+ setCreateDialogOpen(true)}>
创建第一个项目
)}
@@ -218,6 +221,14 @@ export function ProjectSelector({
)}
+
+
);
}
diff --git a/src/components/agent/chat/hooks/index.ts b/src/components/agent/chat/hooks/index.ts
index 1b6e67246..ade9f0ea7 100644
--- a/src/components/agent/chat/hooks/index.ts
+++ b/src/components/agent/chat/hooks/index.ts
@@ -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
diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts
index 0a8d156fc..249a16b5f 100644
--- a/src/components/agent/chat/hooks/useAgentChat.ts
+++ b/src/components/agent/chat/hooks/useAgentChat.ts
@@ -116,7 +116,7 @@ const loadTransient = (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({
running: false,
});
@@ -180,12 +204,44 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
);
// Session State
- const [sessionId, setSessionId] = useState(() =>
- loadTransient("agent_curr_sessionId", null),
- );
- const [messages, setMessages] = useState(() =>
- loadTransient("agent_messages", []),
- );
+ const [sessionId, setSessionId] = useState(() => {
+ if (!workspaceId?.trim()) {
+ return null;
+ }
+
+ const scopedSessionId = loadTransient(
+ getScopedSessionKey(),
+ null,
+ );
+ if (scopedSessionId) {
+ return scopedSessionId;
+ }
+
+ const persistedSessionId = loadPersisted(
+ getScopedPersistedSessionKey(),
+ null,
+ );
+ if (persistedSessionId) {
+ return persistedSessionId;
+ }
+
+ // 兼容旧版本(未按 workspace 分片)
+ return loadTransient("agent_curr_sessionId", null);
+ });
+
+ const [messages, setMessages] = useState(() => {
+ if (!workspaceId?.trim()) {
+ return [];
+ }
+
+ const scopedMessages = loadTransient(getScopedMessagesKey(), []);
+ if (scopedMessages.length > 0) {
+ return scopedMessages;
+ }
+
+ // 兼容旧版本(未按 workspace 分片)
+ return loadTransient("agent_messages", []);
+ });
// 话题列表
const [topics, setTopics] = useState([]);
@@ -199,13 +255,20 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
// 当前会话的轮数计数器(用于判断何时生成标题)
const [_roundCount, _setRoundCount] = useState(() =>
- loadTransient("agent_curr_roundCount", 0),
+ workspaceId?.trim()
+ ? loadTransient(getScopedRoundCountKey(), 0)
+ : 0,
);
// 用于保存当前流式请求的取消函数
const unlistenRef = useRef(null);
// 用于保存当前正在处理的消息 ID
const currentAssistantMsgIdRef = useRef(null);
+ // 自动恢复/水合状态跟踪
+ const restoredWorkspaceRef = useRef(null);
+ const hydratedSessionRef = useRef(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(getScopedSessionKey(), null) ??
+ loadPersisted(getScopedPersistedSessionKey(), null);
+
+ const scopedMessages = loadTransient(getScopedMessagesKey(), []);
+ const scopedRoundCount = loadTransient(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(getScopedSessionKey(), null) ||
+ loadPersisted(getScopedPersistedSessionKey(), null);
+
+ const legacyCandidateRaw = loadTransient(
+ "agent_curr_sessionId",
+ null,
+ );
+ const legacyCandidateWorkspace = legacyCandidateRaw
+ ? loadPersisted(
+ `agent_session_workspace_${legacyCandidateRaw}`,
+ null,
+ )
+ : null;
+ const legacyCandidate =
+ legacyCandidateRaw &&
+ (!legacyCandidateWorkspace ||
+ legacyCandidateWorkspace === resolvedWorkspaceId)
+ ? legacyCandidateRaw
+ : null;
+
+ const mappedFallbackCandidate =
+ topics.find(
+ (topic) =>
+ loadPersisted(
+ `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,
diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts
index feef425fd..36262020a 100644
--- a/src/components/agent/chat/hooks/useAsterAgentChat.ts
+++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts
@@ -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 = (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 = (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(null);
- const [messages, setMessages] = useState([]);
+ const [sessionId, setSessionId] = useState(() => {
+ if (!workspaceId?.trim()) {
+ return null;
+ }
+
+ const scopedSessionId = loadTransient(
+ `aster_curr_sessionId_${workspaceId.trim()}`,
+ null,
+ );
+ if (scopedSessionId) {
+ return scopedSessionId;
+ }
+
+ return loadPersisted(
+ `aster_last_sessionId_${workspaceId.trim()}`,
+ null,
+ );
+ });
+ const [messages, setMessages] = useState(() =>
+ workspaceId?.trim()
+ ? loadTransient(`aster_messages_${workspaceId.trim()}`, [])
+ : [],
+ );
const [topics, setTopics] = useState([]);
const [isSending, setIsSending] = useState(false);
const [pendingActions, setPendingActions] = useState([]);
@@ -149,6 +250,9 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions = {}) {
// Refs
const unlistenRef = useRef(null);
const currentAssistantMsgIdRef = useRef(null);
+ const restoredWorkspaceRef = useRef(null);
+ const hydratedSessionRef = useRef(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(getScopedSessionKey(), null) ??
+ loadPersisted(getScopedPersistedSessionKey(), null);
+
+ const scopedMessages = loadTransient(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(getScopedSessionKey(), null) ||
+ loadPersisted(getScopedPersistedSessionKey(), null);
+ const mappedFallbackCandidate =
+ topics.find(
+ (topic) =>
+ loadPersisted(
+ `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 {
diff --git a/src/components/agent/chat/hooks/useContentSync.ts b/src/components/agent/chat/hooks/useContentSync.ts
index 9087363b8..a0a9c2d1d 100644
--- a/src/components/agent/chat/hooks/useContentSync.ts
+++ b/src/components/agent/chat/hooks/useContentSync.ts
@@ -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 秒后重置状态
diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx
index c51b486ec..3629b326a 100644
--- a/src/components/agent/chat/index.tsx
+++ b/src/components/agent/chat/index.tsx
@@ -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(null);
const restoredFilesSessionId = useRef(null);
+ const handledNewChatRequestRef = useRef(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" && "⚠ 同步失败,将自动重试"}
)}
diff --git a/src/components/content-creator/canvas/document/DocumentCanvas.tsx b/src/components/content-creator/canvas/document/DocumentCanvas.tsx
index ac5c4dfe2..ab00974d9 100644
--- a/src/components/content-creator/canvas/document/DocumentCanvas.tsx
+++ b/src/components/content-creator/canvas/document/DocumentCanvas.tsx
@@ -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 = memo(
{state.isEditing ? (
- void;
+ isActive?: boolean;
+ children: React.ReactNode;
+ title: string;
+}
+
+const ToolbarButton: React.FC = ({
+ onClick,
+ isActive,
+ children,
+ title,
+}) => (
+ {
+ 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}
+
+);
+
+export const BubbleToolbar: React.FC = ({ editor }) => {
+ const [visible, setVisible] = useState(false);
+ const [position, setPosition] = useState({ top: 0, left: 0 });
+ const toolbarRef = useRef(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 (
+
+
editor.chain().focus().toggleBold().run()}
+ isActive={editor.isActive("bold")}
+ title="粗体"
+ >
+
+
+
+
editor.chain().focus().toggleItalic().run()}
+ isActive={editor.isActive("italic")}
+ title="斜体"
+ >
+
+
+
+
editor.chain().focus().toggleStrike().run()}
+ isActive={editor.isActive("strike")}
+ title="删除线"
+ >
+
+
+
+
editor.chain().focus().toggleCode().run()}
+ isActive={editor.isActive("code")}
+ title="行内代码"
+ >
+
+
+
+
editor.chain().focus().toggleHighlight().run()}
+ isActive={editor.isActive("highlight")}
+ title="高亮"
+ >
+
+
+
+
+
+
editor.chain().focus().toggleHeading({ level: 1 }).run()}
+ isActive={editor.isActive("heading", { level: 1 })}
+ title="标题 1"
+ >
+
+
+
+
editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ isActive={editor.isActive("heading", { level: 2 })}
+ title="标题 2"
+ >
+
+
+
+
editor.chain().focus().toggleHeading({ level: 3 }).run()}
+ isActive={editor.isActive("heading", { level: 3 })}
+ title="标题 3"
+ >
+
+
+
+ );
+};
diff --git a/src/components/content-creator/canvas/document/editor/NotionEditor.tsx b/src/components/content-creator/canvas/document/editor/NotionEditor.tsx
new file mode 100644
index 000000000..0fc723c0d
--- /dev/null
+++ b/src/components/content-creator/canvas/document/editor/NotionEditor.tsx
@@ -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 = memo(
+ ({ content, onChange, onSave, onCancel }) => {
+ const [slashState, setSlashState] = useState(EMPTY_SLASH);
+ const keyDownRef = useRef(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 (
+
+
+
+ {slashState.isOpen && slashState.range && (
+
+ )}
+
+ );
+ },
+);
+
+NotionEditor.displayName = "NotionEditor";
diff --git a/src/components/content-creator/canvas/document/editor/SlashCommand.tsx b/src/components/content-creator/canvas/document/editor/SlashCommand.tsx
new file mode 100644
index 000000000..91b81a430
--- /dev/null
+++ b/src/components/content-creator/canvas/document/editor/SlashCommand.tsx
@@ -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: ,
+ command: ({ editor, range }) =>
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 1 })
+ .run(),
+ },
+ {
+ title: "标题 2",
+ description: "中标题",
+ searchTerms: ["h2", "heading"],
+ icon: ,
+ command: ({ editor, range }) =>
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 2 })
+ .run(),
+ },
+ {
+ title: "标题 3",
+ description: "小标题",
+ searchTerms: ["h3", "heading"],
+ icon: ,
+ command: ({ editor, range }) =>
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 3 })
+ .run(),
+ },
+ {
+ title: "待办列表",
+ description: "任务清单",
+ searchTerms: ["todo", "task", "checkbox"],
+ icon: ,
+ command: ({ editor, range }) =>
+ editor.chain().focus().deleteRange(range).toggleTaskList().run(),
+ },
+ {
+ title: "无序列表",
+ description: "项目符号列表",
+ searchTerms: ["bullet", "unordered", "list"],
+ icon:
,
+ command: ({ editor, range }) =>
+ editor.chain().focus().deleteRange(range).toggleBulletList().run(),
+ },
+ {
+ title: "有序列表",
+ description: "编号列表",
+ searchTerms: ["ordered", "number", "list"],
+ icon: ,
+ command: ({ editor, range }) =>
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run(),
+ },
+ {
+ title: "引用",
+ description: "引用块",
+ searchTerms: ["blockquote", "quote"],
+ icon:
,
+ command: ({ editor, range }) =>
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .toggleNode("paragraph", "paragraph")
+ .toggleBlockquote()
+ .run(),
+ },
+ {
+ title: "代码块",
+ description: "代码片段",
+ searchTerms: ["code", "codeblock"],
+ icon: ,
+ command: ({ editor, range }) =>
+ editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
+ },
+ {
+ title: "分割线",
+ description: "水平分隔线",
+ searchTerms: ["hr", "divider", "separator"],
+ icon: ,
+ command: ({ editor, range }) =>
+ editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
+ },
+ {
+ title: "图片",
+ description: "插入图片链接",
+ searchTerms: ["image", "photo", "picture"],
+ icon: ,
+ 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: ,
+ 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;
+}
+
+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({
+ 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;
+ onClose: () => void;
+}
+
+export const CommandList: React.FC = ({
+ 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 (
+
+ {items.map((item, index) => (
+
{
+ e.preventDefault();
+ executeCommand(item);
+ }}
+ onMouseEnter={() => setSelectedIndex(index)}
+ >
+
+ {item.icon}
+
+
+
{item.title}
+
+ {item.description}
+
+
+
+ ))}
+
+ );
+};
diff --git a/src/components/content-creator/canvas/document/editor/editor-styles.css b/src/components/content-creator/canvas/document/editor/editor-styles.css
new file mode 100644
index 000000000..ef3d9ff38
--- /dev/null
+++ b/src/components/content-creator/canvas/document/editor/editor-styles.css
@@ -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;
+}
diff --git a/src/components/content-creator/canvas/document/editor/extensions.ts b/src/components/content-creator/canvas/document/editor/extensions.ts
new file mode 100644
index 000000000..287fa072b
--- /dev/null
+++ b/src/components/content-creator/canvas/document/editor/extensions.ts
@@ -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;
+}
+
+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,
+ }),
+ ];
+}
diff --git a/src/components/content-creator/canvas/document/editor/index.ts b/src/components/content-creator/canvas/document/editor/index.ts
new file mode 100644
index 000000000..f25306110
--- /dev/null
+++ b/src/components/content-creator/canvas/document/editor/index.ts
@@ -0,0 +1 @@
+export { NotionEditor } from "./NotionEditor";
diff --git a/src/components/content-creator/canvas/document/editor/utils/markdown.ts b/src/components/content-creator/canvas/document/editor/utils/markdown.ts
new file mode 100644
index 000000000..0488a920e
--- /dev/null
+++ b/src/components/content-creator/canvas/document/editor/utils/markdown.ts
@@ -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);
+}
diff --git a/src/components/content-creator/canvas/music/MusicCanvas.tsx b/src/components/content-creator/canvas/music/MusicCanvas.tsx
index 9ea5e0f91..dc68edef3 100644
--- a/src/components/content-creator/canvas/music/MusicCanvas.tsx
+++ b/src/components/content-creator/canvas/music/MusicCanvas.tsx
@@ -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`
diff --git a/src/components/content-creator/canvas/novel/NovelCanvas.tsx b/src/components/content-creator/canvas/novel/NovelCanvas.tsx
index 9ddcdd010..3318af441 100644
--- a/src/components/content-creator/canvas/novel/NovelCanvas.tsx
+++ b/src/components/content-creator/canvas/novel/NovelCanvas.tsx
@@ -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`
diff --git a/src/components/content-creator/canvas/poster/PosterCanvas.tsx b/src/components/content-creator/canvas/poster/PosterCanvas.tsx
index 20f270f70..750021cc0 100644
--- a/src/components/content-creator/canvas/poster/PosterCanvas.tsx
+++ b/src/components/content-creator/canvas/poster/PosterCanvas.tsx
@@ -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`
diff --git a/src/components/content-creator/canvas/script/ScriptCanvas.tsx b/src/components/content-creator/canvas/script/ScriptCanvas.tsx
index d0963363b..cea831079 100644
--- a/src/components/content-creator/canvas/script/ScriptCanvas.tsx
+++ b/src/components/content-creator/canvas/script/ScriptCanvas.tsx
@@ -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`
diff --git a/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx b/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx
index 482268f8d..b373b3dfb 100644
--- a/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx
+++ b/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx
@@ -73,15 +73,6 @@ export const LayoutTransition: React.FC = memo(
return (
-
- {chatContent}
-
-
= memo(
>
{canvasContent}
+
+
+ {chatContent}
+
);
},
diff --git a/src/components/content-creator/core/LayoutTransition/useLayoutTransition.ts b/src/components/content-creator/core/LayoutTransition/useLayoutTransition.ts
index 8801876e8..eebec366d 100644
--- a/src/components/content-creator/core/LayoutTransition/useLayoutTransition.ts
+++ b/src/components/content-creator/core/LayoutTransition/useLayoutTransition.ts
@@ -111,7 +111,7 @@ export function useLayoutTransition(
transform:
transitionState === "entering" || transitionState === "entered"
? "translateX(0)"
- : "translateX(100%)",
+ : "translateX(-100%)",
opacity:
transitionState === "entering" || transitionState === "entered"
? 1
diff --git a/src/components/general-chat/canvas/CanvasPanel.tsx b/src/components/general-chat/canvas/CanvasPanel.tsx
index 67f31244f..9f5caff2d 100644
--- a/src/components/general-chat/canvas/CanvasPanel.tsx
+++ b/src/components/general-chat/canvas/CanvasPanel.tsx
@@ -58,7 +58,7 @@ export const CanvasPanel: React.FC = ({
}
return (
-
+
{/* 工具栏 */}
diff --git a/src/components/image-gen/ImageGenPage.tsx b/src/components/image-gen/ImageGenPage.tsx
index ab01575b3..b197e79d3 100644
--- a/src/components/image-gen/ImageGenPage.tsx
+++ b/src/components/image-gen/ImageGenPage.tsx
@@ -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
{
});
}
+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) {
diff --git a/src/components/memory/MemoryPage.tsx b/src/components/memory/MemoryPage.tsx
new file mode 100644
index 000000000..25ec9adb7
--- /dev/null
+++ b/src/components/memory/MemoryPage.tsx
@@ -0,0 +1,1237 @@
+/**
+ * 独立记忆页面
+ *
+ * 参考成熟产品的信息架构:
+ * - 左侧分类导航(搜索 / 首页 / 身份 / 情境 / 偏好 / 经验 / 活动)
+ * - 右侧主内容区(总览、分析、条目列表、详情)
+ *
+ * 所有数据均来自真实后端接口,不使用 Mock 数据。
+ */
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ AlertCircle,
+ BrainCircuit,
+ CalendarClock,
+ CheckCircle2,
+ Database,
+ HeartPulse,
+ Home,
+ Info,
+ LayoutGrid,
+ Lightbulb,
+ List,
+ Loader2,
+ MessagesSquare,
+ RefreshCw,
+ Search,
+ Signature,
+ Trash2,
+ type LucideIcon,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { Page, PageParams } from "@/types/page";
+import {
+ cleanupMemory,
+ getConfig,
+ getMemoryOverview,
+ requestMemoryAnalysis,
+ saveConfig,
+ type Config,
+ type MemoryAnalysisResult,
+ type MemoryCategoryStat,
+ type MemoryConfig as TauriMemoryConfig,
+ type MemoryEntryPreview,
+ type MemoryOverviewResponse,
+ type MemoryStatsResponse,
+} from "@/hooks/useTauri";
+
+type CategoryType = MemoryCategoryStat["category"];
+type CategoryFilter = "all" | CategoryType;
+type MemorySection = "home" | CategoryType;
+type ViewMode = "list" | "grid";
+
+const CATEGORY_META: Record<
+ CategoryType,
+ { label: string; description: string; icon: LucideIcon }
+> = {
+ identity: {
+ label: "身份",
+ description: "关于你是谁的稳定信息",
+ icon: Signature,
+ },
+ context: {
+ label: "情境",
+ description: "对话背景与当前约束",
+ icon: MessagesSquare,
+ },
+ preference: {
+ label: "偏好",
+ description: "你的习惯、口味与偏爱",
+ icon: HeartPulse,
+ },
+ experience: {
+ label: "经验",
+ description: "过往经历与可复用知识",
+ icon: Lightbulb,
+ },
+ activity: {
+ label: "活动",
+ description: "近期计划与进行中的事项",
+ icon: CalendarClock,
+ },
+};
+
+const CATEGORY_ORDER: CategoryType[] = [
+ "identity",
+ "context",
+ "preference",
+ "experience",
+ "activity",
+];
+
+const MEMORY_NAV_ITEMS: Array<{
+ key: MemorySection;
+ label: string;
+ icon: LucideIcon;
+ description: string;
+}> = [
+ {
+ key: "home",
+ label: "首页",
+ icon: Home,
+ description: "全部记忆",
+ },
+ {
+ key: "identity",
+ label: "身份",
+ icon: CATEGORY_META.identity.icon,
+ description: CATEGORY_META.identity.description,
+ },
+ {
+ key: "context",
+ label: "情境",
+ icon: CATEGORY_META.context.icon,
+ description: CATEGORY_META.context.description,
+ },
+ {
+ key: "preference",
+ label: "偏好",
+ icon: CATEGORY_META.preference.icon,
+ description: CATEGORY_META.preference.description,
+ },
+ {
+ key: "experience",
+ label: "经验",
+ icon: CATEGORY_META.experience.icon,
+ description: CATEGORY_META.experience.description,
+ },
+ {
+ key: "activity",
+ label: "活动",
+ icon: CATEGORY_META.activity.icon,
+ description: CATEGORY_META.activity.description,
+ },
+];
+
+const SECTION_SHORTCUTS: Record = {
+ "1": "home",
+ "2": "identity",
+ "3": "context",
+ "4": "preference",
+ "5": "experience",
+ "6": "activity",
+};
+
+const DEFAULT_MEMORY_CONFIG: TauriMemoryConfig = {
+ 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`;
+}
+
+function normalizeTimestampMs(timestampMs: number): number {
+ if (!timestampMs) return 0;
+ return timestampMs > 1_000_000_000_000 ? timestampMs : timestampMs * 1000;
+}
+
+function formatRelativeTimestamp(timestampMs: number): string {
+ const normalized = normalizeTimestampMs(timestampMs);
+ if (!normalized) return "未知时间";
+
+ const date = new Date(normalized);
+ if (Number.isNaN(date.getTime())) {
+ return "未知时间";
+ }
+
+ const now = Date.now();
+ const diffMs = now - date.getTime();
+ const diffMinutes = Math.floor(diffMs / 60000);
+ if (diffMinutes < 1) return "刚刚";
+ if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
+
+ const diffHours = Math.floor(diffMinutes / 60);
+ if (diffHours < 24) return `${diffHours} 小时前`;
+
+ const diffDays = Math.floor(diffHours / 24);
+ if (diffDays < 7) return `${diffDays} 天前`;
+
+ return `${date.getMonth() + 1}/${date.getDate()} ${date
+ .getHours()
+ .toString()
+ .padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
+}
+
+function formatAbsoluteTimestamp(timestampMs: number): string {
+ const normalized = normalizeTimestampMs(timestampMs);
+ if (!normalized) return "未知时间";
+
+ const date = new Date(normalized);
+ if (Number.isNaN(date.getTime())) {
+ return "未知时间";
+ }
+
+ return `${date.getFullYear()}-${(date.getMonth() + 1)
+ .toString()
+ .padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")} ${date
+ .getHours()
+ .toString()
+ .padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
+}
+
+function parseDateStartTimestamp(dateText: string): number | undefined {
+ if (!dateText) return undefined;
+ const date = new Date(`${dateText}T00:00:00`);
+ if (Number.isNaN(date.getTime())) return undefined;
+ return date.getTime();
+}
+
+function parseDateEndTimestamp(dateText: string): number | undefined {
+ if (!dateText) return undefined;
+ const date = new Date(`${dateText}T23:59:59.999`);
+ if (Number.isNaN(date.getTime())) return undefined;
+ return date.getTime();
+}
+
+function fileTypeLabel(fileType: string): string {
+ switch (fileType) {
+ case "task_plan":
+ return "任务计划";
+ case "findings":
+ return "研究发现";
+ case "progress":
+ return "会话进展";
+ case "error_log":
+ return "错误记录";
+ default:
+ return fileType || "未知类型";
+ }
+}
+
+function EmptyMemoryState({
+ onAnalyze,
+ loading,
+ disabled,
+}: {
+ onAnalyze: () => void;
+ loading: boolean;
+ disabled: boolean;
+}) {
+ return (
+
+
+
+
+
暂无记忆
+
+ 记忆提取是渐进式能力。积累更多真实对话后,系统会抽取并沉淀更稳定的可用信息。
+
+
+ {loading ? (
+ <>
+
+ 分析中...
+ >
+ ) : (
+ <>
+
+ {disabled ? "记忆功能已关闭" : "请求记忆分析"}
+ >
+ )}
+
+
+ );
+}
+
+function MemoryEntryCollection({
+ entries,
+ viewMode,
+ selectedEntryId,
+ onSelect,
+}: {
+ entries: MemoryEntryPreview[];
+ viewMode: ViewMode;
+ selectedEntryId: string | null;
+ onSelect: (entryId: string) => void;
+}) {
+ if (entries.length === 0) {
+ return (
+
+ 当前筛选条件下暂无记忆条目
+
+ );
+ }
+
+ if (viewMode === "grid") {
+ return (
+
+ {entries.map((entry) => {
+ const meta = CATEGORY_META[entry.category];
+ const selected = selectedEntryId === entry.id;
+
+ return (
+
onSelect(entry.id)}
+ className={cn(
+ "rounded-lg border p-3 text-left transition-colors",
+ selected
+ ? "border-primary bg-primary/5"
+ : "hover:bg-muted/50 hover:border-muted-foreground/30",
+ )}
+ >
+
+
+
+ {entry.title}
+
+
+ {meta.label} · {entry.session_id}
+
+
+
+ {formatRelativeTimestamp(entry.updated_at)}
+
+
+
+
+ {entry.summary || "暂无摘要"}
+
+
+ );
+ })}
+
+ );
+ }
+
+ return (
+
+ {entries.map((entry) => {
+ const meta = CATEGORY_META[entry.category];
+ const selected = selectedEntryId === entry.id;
+
+ return (
+
onSelect(entry.id)}
+ className={cn(
+ "w-full p-4 space-y-2 text-left transition-colors",
+ selected ? "bg-primary/5" : "hover:bg-muted/50",
+ )}
+ >
+
+
+
+ {entry.title}
+
+
+ {meta.label} · {entry.session_id}
+
+
+
+ {formatRelativeTimestamp(entry.updated_at)}
+
+
+
+
+ {entry.summary || "暂无摘要"}
+
+
+ {entry.tags.length > 0 && (
+
+ {entry.tags.slice(0, 4).map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+function MemoryDetailPanel({ entry }: { entry: MemoryEntryPreview | null }) {
+ if (!entry) {
+ return (
+
+ 请选择一条记忆查看详情
+
+ );
+ }
+
+ const meta = CATEGORY_META[entry.category];
+
+ return (
+
+
+
+
+
+
+
存储文件
+
{fileTypeLabel(entry.file_type)}
+
+
+
会话 ID
+
{entry.session_id}
+
+
+
更新时间
+
+ {formatAbsoluteTimestamp(entry.updated_at)}
+
+
+
+
+
+
摘要内容
+
+ {entry.summary || "暂无摘要"}
+
+
+
+
+
标签
+ {entry.tags.length === 0 ? (
+
暂无标签
+ ) : (
+
+ {entry.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+interface MemoryPageProps {
+ onNavigate?: (page: Page, params?: PageParams) => void;
+}
+
+export function MemoryPage({ onNavigate }: MemoryPageProps) {
+ const searchInputRef = useRef(null);
+
+ const [config, setConfig] = useState(null);
+ const [memoryConfig, setMemoryConfig] = useState(
+ DEFAULT_MEMORY_CONFIG,
+ );
+
+ const [overview, setOverview] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [cleaning, setCleaning] = useState(false);
+ const [analyzing, setAnalyzing] = useState(false);
+
+ const [activeSection, setActiveSection] = useState("home");
+ const [searchKeyword, setSearchKeyword] = useState("");
+ const [viewMode, setViewMode] = useState("list");
+ const [selectedEntryId, setSelectedEntryId] = useState(null);
+
+ const [analysisFromDate, setAnalysisFromDate] = useState("");
+ const [analysisToDate, setAnalysisToDate] = useState("");
+ const [analysisResult, setAnalysisResult] =
+ useState(null);
+
+ const maxEntriesOptions = [100, 500, 1000, 2000, 5000];
+ const retentionDaysOptions = [7, 14, 30, 60, 90];
+
+ const [message, setMessage] = useState<{
+ type: "success" | "error";
+ text: string;
+ } | null>(null);
+
+ const showMessage = useCallback((type: "success" | "error", text: string) => {
+ setMessage({ type, text });
+ setTimeout(() => setMessage(null), 3500);
+ }, []);
+
+ const stats: MemoryStatsResponse = useMemo(
+ () =>
+ overview?.stats ?? {
+ total_entries: 0,
+ storage_used: 0,
+ memory_count: 0,
+ },
+ [overview],
+ );
+
+ const categories = useMemo(() => {
+ if (!overview?.categories) {
+ return CATEGORY_ORDER.map((category) => ({ category, count: 0 }));
+ }
+
+ const categoryMap = new Map(
+ overview.categories.map((item) => [item.category, item.count]),
+ );
+
+ return CATEGORY_ORDER.map((category) => ({
+ category,
+ count: categoryMap.get(category) ?? 0,
+ }));
+ }, [overview]);
+
+ const categoryCountMap = useMemo(
+ () => new Map(categories.map((item) => [item.category, item.count])),
+ [categories],
+ );
+
+ const entries = useMemo(() => overview?.entries ?? [], [overview]);
+ const hasMemoryData = stats.total_entries > 0;
+
+ const activeCategoryFilter: CategoryFilter =
+ activeSection === "home" ? "all" : activeSection;
+
+ const filteredEntries = useMemo(() => {
+ const keyword = searchKeyword.trim().toLowerCase();
+
+ return entries.filter((entry) => {
+ if (
+ activeCategoryFilter !== "all" &&
+ entry.category !== activeCategoryFilter
+ ) {
+ return false;
+ }
+
+ if (!keyword) {
+ return true;
+ }
+
+ const content =
+ `${entry.title} ${entry.summary} ${entry.tags.join(" ")}`.toLowerCase();
+ return content.includes(keyword);
+ });
+ }, [activeCategoryFilter, entries, searchKeyword]);
+
+ useEffect(() => {
+ if (filteredEntries.length === 0) {
+ if (selectedEntryId !== null) {
+ setSelectedEntryId(null);
+ }
+ return;
+ }
+
+ if (
+ !selectedEntryId ||
+ !filteredEntries.some((entry) => entry.id === selectedEntryId)
+ ) {
+ setSelectedEntryId(filteredEntries[0].id);
+ }
+ }, [filteredEntries, selectedEntryId]);
+
+ const selectedEntry = useMemo(
+ () => filteredEntries.find((entry) => entry.id === selectedEntryId) ?? null,
+ [filteredEntries, selectedEntryId],
+ );
+
+ const loadConfig = useCallback(async () => {
+ const loadedConfig = await getConfig();
+ setConfig(loadedConfig);
+ setMemoryConfig(loadedConfig.memory || DEFAULT_MEMORY_CONFIG);
+ }, []);
+
+ const loadOverview = useCallback(async () => {
+ const data = await getMemoryOverview(120);
+ setOverview(data);
+ }, []);
+
+ const loadAll = useCallback(async () => {
+ setLoading(true);
+ try {
+ await Promise.all([loadConfig(), loadOverview()]);
+ } catch (error) {
+ console.error("加载记忆数据失败:", error);
+ showMessage("error", "加载记忆失败,请稍后重试");
+ } finally {
+ setLoading(false);
+ }
+ }, [loadConfig, loadOverview, showMessage]);
+
+ useEffect(() => {
+ loadAll();
+ }, [loadAll]);
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.metaKey || event.ctrlKey || event.altKey) {
+ return;
+ }
+
+ const target = event.target as HTMLElement | null;
+ if (target) {
+ const tagName = target.tagName.toLowerCase();
+ if (
+ tagName === "input" ||
+ tagName === "textarea" ||
+ target.isContentEditable
+ ) {
+ return;
+ }
+ }
+
+ if (event.key === "/") {
+ event.preventDefault();
+ searchInputRef.current?.focus();
+ return;
+ }
+
+ const shortcutSection = SECTION_SHORTCUTS[event.key];
+ if (shortcutSection) {
+ event.preventDefault();
+ setActiveSection(shortcutSection);
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, []);
+
+ const handleRefresh = useCallback(async () => {
+ setRefreshing(true);
+ try {
+ await loadOverview();
+ } catch (error) {
+ console.error("刷新记忆总览失败:", error);
+ showMessage("error", "刷新失败");
+ } finally {
+ setRefreshing(false);
+ }
+ }, [loadOverview, showMessage]);
+
+ const handleAnalyze = useCallback(async () => {
+ if (
+ analysisFromDate &&
+ analysisToDate &&
+ analysisFromDate > analysisToDate
+ ) {
+ showMessage("error", "开始日期不能晚于结束日期");
+ return;
+ }
+
+ if (!memoryConfig.enabled) {
+ showMessage("error", "记忆功能已关闭,请先开启");
+ return;
+ }
+
+ setAnalyzing(true);
+ try {
+ const fromTimestamp = parseDateStartTimestamp(analysisFromDate);
+ const toTimestamp = parseDateEndTimestamp(analysisToDate);
+
+ const result = await requestMemoryAnalysis(fromTimestamp, toTimestamp);
+ setAnalysisResult(result);
+ await loadOverview();
+
+ if (result.generated_entries > 0) {
+ showMessage(
+ "success",
+ `分析完成:新增 ${result.generated_entries} 条记忆(去重 ${result.deduplicated_entries} 条)`,
+ );
+ } else {
+ showMessage("success", "分析完成:暂无新的可提取记忆");
+ }
+ } catch (error) {
+ console.error("记忆分析失败:", error);
+ showMessage("error", "记忆分析失败,请稍后重试");
+ } finally {
+ setAnalyzing(false);
+ }
+ }, [
+ analysisFromDate,
+ analysisToDate,
+ loadOverview,
+ memoryConfig.enabled,
+ showMessage,
+ ]);
+
+ const handleCleanup = useCallback(async () => {
+ setCleaning(true);
+ try {
+ const result = await cleanupMemory();
+ await loadOverview();
+ showMessage(
+ "success",
+ `清理完成:清理 ${result.cleaned_entries} 条,释放 ${formatStorageSize(result.freed_space)}`,
+ );
+ } catch (error) {
+ console.error("清理记忆失败:", error);
+ showMessage("error", "清理失败");
+ } finally {
+ setCleaning(false);
+ }
+ }, [loadOverview, showMessage]);
+
+ const saveMemoryConfig = useCallback(
+ async (key: keyof TauriMemoryConfig, value: boolean | number) => {
+ if (!config) {
+ showMessage("error", "配置尚未加载完成");
+ return;
+ }
+
+ setSaving(true);
+ try {
+ const nextMemoryConfig: TauriMemoryConfig = {
+ ...memoryConfig,
+ [key]: value,
+ };
+
+ const nextConfig: Config = {
+ ...config,
+ memory: nextMemoryConfig,
+ };
+
+ await saveConfig(nextConfig);
+ setConfig(nextConfig);
+ setMemoryConfig(nextMemoryConfig);
+ showMessage("success", "记忆设置已保存");
+ } catch (error) {
+ console.error("保存记忆设置失败:", error);
+ showMessage("error", "记忆设置保存失败");
+ } finally {
+ setSaving(false);
+ }
+ },
+ [config, memoryConfig, showMessage],
+ );
+
+ const sectionTitle =
+ activeSection === "home"
+ ? "记忆首页"
+ : `${CATEGORY_META[activeSection].label}记忆`;
+
+ const sectionDescription =
+ activeSection === "home"
+ ? "查看全部记忆并触发分析任务"
+ : CATEGORY_META[activeSection].description;
+
+ const handleBackToHome = useCallback(() => {
+ if (onNavigate) {
+ onNavigate("agent", {
+ theme: "general",
+ lockTheme: false,
+ });
+ }
+ }, [onNavigate]);
+
+ return (
+
+
+
+
+ 返回首页
+
+
+
+
+
+
+
+
+
+
+
{sectionTitle}
+
+ {sectionDescription}
+
+
+
+
+
+
+ 刷新
+
+
+
+ {analyzing ? (
+ <>
+
+ 分析中...
+ >
+ ) : (
+ <>
+
+ 请求记忆分析
+ >
+ )}
+
+
+
+
+ {loading ? (
+
+
+ 正在加载记忆数据...
+
+ ) : (
+ <>
+
+
+
+
+ 记忆条数
+
+
+ {stats.total_entries}
+
+
+
+
+ 存储空间
+
+
+ {formatStorageSize(stats.storage_used)}
+
+
+
+
+ 记忆库数
+
+
+ {stats.memory_count}
+
+
+
+
+
+
+
+
+ 分析范围(可选)
+
+
+
+
+
+
+
+ {
+ setAnalysisFromDate("");
+ setAnalysisToDate("");
+ }}
+ disabled={
+ analyzing || (!analysisFromDate && !analysisToDate)
+ }
+ className="self-end inline-flex items-center justify-center rounded border px-3 py-2 text-sm hover:bg-muted disabled:opacity-60"
+ >
+ 清空范围
+
+
+
+
+ 未选择日期时,会分析全部可用历史对话。
+
+
+
+ {analysisResult && (
+
+
+ 最近一次分析结果
+
+
+ 分析会话 {analysisResult.analyzed_sessions} 个,扫描消息{" "}
+ {analysisResult.analyzed_messages} 条,新增记忆{" "}
+ {analysisResult.generated_entries} 条,去重{" "}
+ {analysisResult.deduplicated_entries} 条。
+
+
+ )}
+
+ {!hasMemoryData ? (
+
+ ) : (
+ <>
+ {activeSection === "home" && (
+
+ {categories.map((item) => {
+ const meta = CATEGORY_META[item.category];
+ const Icon = meta.icon;
+
+ return (
+
setActiveSection(item.category)}
+ className="rounded-lg border p-3 bg-card text-left transition-colors hover:bg-muted/30"
+ >
+
+
+ {meta.label}
+
+
+ {item.count}
+
+
+ {meta.description}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
记忆条目
+
+
+ setViewMode("list")}
+ className={cn(
+ "inline-flex items-center gap-1.5 px-3 py-1.5 text-xs",
+ viewMode === "list"
+ ? "bg-primary text-primary-foreground"
+ : "hover:bg-muted",
+ )}
+ >
+
+ 列表
+
+ setViewMode("grid")}
+ className={cn(
+ "inline-flex items-center gap-1.5 px-3 py-1.5 text-xs",
+ viewMode === "grid"
+ ? "bg-primary text-primary-foreground"
+ : "hover:bg-muted",
+ )}
+ >
+
+ 网格
+
+
+
+
+
+ 当前筛选:
+ {activeCategoryFilter === "all"
+ ? "全部分类"
+ : CATEGORY_META[activeCategoryFilter].label}
+ ,共 {filteredEntries.length} 条结果
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
启用记忆功能
+
+ 控制是否允许系统提取并使用记忆
+
+
+
+
+ saveMemoryConfig("enabled", event.target.checked)
+ }
+ disabled={saving}
+ className="w-4 h-4 rounded border-gray-300"
+ />
+
+
+
+
+
最大记忆条数
+
+ {memoryConfig.max_entries || 1000}
+
+
+
+ {maxEntriesOptions.map((option) => (
+
+ saveMemoryConfig("max_entries", option)
+ }
+ className={cn(
+ "rounded border px-2 py-1.5 text-xs",
+ memoryConfig.max_entries === option
+ ? "bg-primary text-primary-foreground border-primary"
+ : "hover:bg-muted",
+ )}
+ disabled={saving}
+ >
+ {option >= 1000 ? `${option / 1000}k` : option}
+
+ ))}
+
+
+
+
+
+
+
+
记忆保留天数
+
+ {memoryConfig.retention_days || 30} 天
+
+
+
+ {retentionDaysOptions.map((option) => (
+
+ saveMemoryConfig("retention_days", option)
+ }
+ className={cn(
+ "rounded border px-2 py-1.5 text-xs",
+ memoryConfig.retention_days === option
+ ? "bg-primary text-primary-foreground border-primary"
+ : "hover:bg-muted",
+ )}
+ disabled={saving}
+ >
+ {option} 天
+
+ ))}
+
+
+
+
+
+
+ 自动清理过期记忆
+
+
+ 定期归档超出保留时长的历史记忆
+
+
+
+ saveMemoryConfig("auto_cleanup", event.target.checked)
+ }
+ disabled={saving}
+ className="w-4 h-4 rounded border-gray-300"
+ />
+
+
+
+
+
+ 手动清理过期和失效记忆
+
+
+ {cleaning ? (
+ <>
+
+ 清理中...
+ >
+ ) : (
+ <>
+
+ 立即清理
+ >
+ )}
+
+
+
+
+
+
+
+
+ 记忆关闭后将停止新增条目;历史条目仍可浏览。清理操作不可逆,请在确认后执行。
+
+
+ >
+ )}
+
+ {message && (
+
+ {message.type === "success" ? (
+
+ ) : (
+
+ )}
+
{message.text}
+
+ )}
+
+
+
+
+ );
+}
+
+export default MemoryPage;
diff --git a/src/components/memory/index.ts b/src/components/memory/index.ts
new file mode 100644
index 000000000..0d412dcf5
--- /dev/null
+++ b/src/components/memory/index.ts
@@ -0,0 +1,2 @@
+export { MemoryPage } from "./MemoryPage";
+export { default } from "./MemoryPage";
diff --git a/src/components/projects/CreateProjectDialog.tsx b/src/components/projects/CreateProjectDialog.tsx
index 65e6e236f..24f2ac290 100644
--- a/src/components/projects/CreateProjectDialog.tsx
+++ b/src/components/projects/CreateProjectDialog.tsx
@@ -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(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({
新建项目
- 创建一个新的内容创作项目,选择项目类型以获得最佳体验。
+ 创建一个新的内容创作项目,目录将固定在 workspace 目录下。
@@ -126,6 +241,36 @@ export function CreateProjectDialog({
))}
+
+
+
+
+
+
+
+
+
+
+ 将创建到:
+ {resolvedProjectPath || "请输入项目名称"}
+
+ {pathChecking && (
+
正在检查路径...
+ )}
+ {!pathChecking && pathConflictMessage && (
+
{pathConflictMessage}
+ )}
+
@@ -134,7 +279,12 @@ export function CreateProjectDialog({
{isSubmitting ? "创建中..." : "创建"}
diff --git a/src/components/projects/ProjectsPage.tsx b/src/components/projects/ProjectsPage.tsx
index 15850c38f..a5a9edd9a 100644
--- a/src/components/projects/ProjectsPage.tsx
+++ b/src/components/projects/ProjectsPage.tsx
@@ -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) {
还没有项目
{
- // 将 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" && (
{
- // 快速创建项目,不弹窗
- let projectType: ProjectType = "general";
- const filter = currentFilter as string;
- if (
- filter !== "all" &&
- filter !== "favorites" &&
- filter !== "archived"
- ) {
- projectType = currentFilter as ProjectType;
- }
- handleQuickCreateProject(projectType);
+ setCreateDialogOpen(true);
}}
/>
)}
diff --git a/src/components/settings-v2/_layout/SettingsSidebar.tsx b/src/components/settings-v2/_layout/SettingsSidebar.tsx
index 507feda71..4f657c70e 100644
--- a/src/components/settings-v2/_layout/SettingsSidebar.tsx
+++ b/src/components/settings-v2/_layout/SettingsSidebar.tsx
@@ -2,7 +2,7 @@
* 设置侧边栏组件
*
* 显示分组的设置导航菜单
- * 参考 LobeHub 的 SettingsSidebar 设计
+ * 参考成熟产品的设置侧边栏设计
*/
import styled from "styled-components";
diff --git a/src/components/settings-v2/_layout/index.tsx b/src/components/settings-v2/_layout/index.tsx
index 8b1efabc7..0eb00c41d 100644
--- a/src/components/settings-v2/_layout/index.tsx
+++ b/src/components/settings-v2/_layout/index.tsx
@@ -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 (
- <>
-
-
- >
- );
-
case SettingsTabs.ImageGen:
return (
<>
diff --git a/src/components/settings-v2/account/profile/index.tsx b/src/components/settings-v2/account/profile/index.tsx
index 82ad6398b..ac9a68829 100644
--- a/src/components/settings-v2/account/profile/index.tsx
+++ b/src/components/settings-v2/account/profile/index.tsx
@@ -1,7 +1,7 @@
/**
* 个人资料设置页面组件
*
- * 参考 LobeHub 的 profile 实现
+ * 参考成熟产品的资料实现
* 功能包括:用户头像、昵称、个人简介、偏好标签等
*/
diff --git a/src/components/settings-v2/account/stats/index.tsx b/src/components/settings-v2/account/stats/index.tsx
index 73139c97b..7ba8a96dc 100644
--- a/src/components/settings-v2/account/stats/index.tsx
+++ b/src/components/settings-v2/account/stats/index.tsx
@@ -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(null);
+ const [stats, setStats] = useState(null);
const [modelUsage, setModelUsage] = useState([]);
const [dailyUsage, setDailyUsage] = useState([]);
+ const [error, setError] = useState(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() {
);
+ const maxDailyTokens =
+ dailyUsage.length > 0
+ ? Math.max(...dailyUsage.map((day) => day.tokens))
+ : 0;
+
return (
{/* 时间范围选择 */}
@@ -221,6 +165,12 @@ export function StatsSettings() {
+ {error && (
+
+ {error}
+
+ )}
+
{loading ? (
@@ -241,7 +191,6 @@ export function StatsSettings() {
icon={Coins}
label="Token"
value={formatNumber(stats.today_tokens)}
- trend={12}
/>
模型使用排行
-
- {modelUsage.map((model, index) => (
-
-
-
-
- #{index + 1}
-
-
{model.model}
+ {modelUsage.length > 0 ? (
+
+ {modelUsage.map((model, index) => (
+
+
+
+
+ #{index + 1}
+
+ {model.model}
+
+
+ {model.conversations} 次对话
+ {formatNumber(model.tokens)} Token
+
+ {model.percentage}%
+
+
-
-
{model.conversations} 次对话
-
{formatNumber(model.tokens)} Token
-
- {model.percentage}%
-
+
-
-
- ))}
-
+ ))}
+
+ ) : (
+
+ 暂无模型使用数据
+
+ )}
{/* 每日使用趋势 */}
@@ -352,9 +305,8 @@ export function StatsSettings() {
{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 (
{
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";
diff --git a/src/components/settings-v2/agent/assistant/index.tsx b/src/components/settings-v2/agent/assistant/index.tsx
index deeffc251..00d0c38c3 100644
--- a/src/components/settings-v2/agent/assistant/index.tsx
+++ b/src/components/settings-v2/agent/assistant/index.tsx
@@ -1,7 +1,7 @@
/**
* 助理服务配置设置组件
*
- * 参考 LobeHub 的 agent 实现
+ * 参考成熟产品的助理能力实现
* 功能包括:默认助理选择、助理参数配置等
*/
diff --git a/src/components/settings-v2/agent/image-gen/index.tsx b/src/components/settings-v2/agent/image-gen/index.tsx
index 918ba9752..7eac2df53 100644
--- a/src/components/settings-v2/agent/image-gen/index.tsx
+++ b/src/components/settings-v2/agent/image-gen/index.tsx
@@ -1,7 +1,7 @@
/**
* 绘画服务集成设置组件
*
- * 参考 LobeHub 的 image 实现
+ * 参考成熟产品的图像能力实现
* 功能包括:图像生成服务商选择、默认参数配置等
*/
diff --git a/src/components/settings-v2/agent/memory/index.tsx b/src/components/settings-v2/agent/memory/index.tsx
deleted file mode 100644
index 0d2df791c..000000000
--- a/src/components/settings-v2/agent/memory/index.tsx
+++ /dev/null
@@ -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
(null);
- const [memoryConfig, setMemoryConfig] = useState(
- DEFAULT_MEMORY_CONFIG,
- );
- const [memoryStats, setMemoryStats] = useState(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 (
-
- {/* 记忆统计卡片 */}
- {memoryStats && (
-
-
-
-
记忆统计
-
-
-
-
- {memoryStats.total_entries}
-
-
记忆条数
-
-
-
- {formatStorageSize(memoryStats.storage_used)}
-
-
存储空间
-
-
-
- {memoryStats.memory_count}
-
-
记忆库数
-
-
-
- )}
-
- {/* 启用记忆功能 */}
-
-
-
-
-
-
启用记忆功能
-
- 让 AI 记住之前的对话内容
-
-
-
-
saveMemoryConfig("enabled", e.target.checked)}
- disabled={loading || saving}
- className="w-4 h-4 rounded border-gray-300"
- />
-
-
-
- {/* 最大记忆条数 */}
-
-
-
-
最大记忆条数
-
- 限制保存的记忆条目数量
-
-
-
- {memoryConfig.max_entries || 1000}
-
-
-
-
- {maxEntriesOptions.map((option) => (
- 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}
-
- ))}
-
-
-
- {/* 记忆保留天数 */}
-
-
-
-
记忆保留天数
-
- 自动删除超过指定天数的记忆
-
-
-
- {memoryConfig.retention_days || 30} 天
-
-
-
-
- {retentionDaysOptions.map((option) => (
- 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} 天
-
- ))}
-
-
-
- {/* 自动清理 */}
-
-
-
-
自动清理过期记忆
-
- 定期自动删除过期的记忆条目
-
-
-
saveMemoryConfig("auto_cleanup", e.target.checked)}
- disabled={loading || saving}
- className="w-4 h-4 rounded border-gray-300"
- />
-
-
-
- {/* 清理按钮 */}
-
-
-
-
-
-
清理记忆
-
- 手动清理所有过期和无效的记忆
-
-
-
-
- {cleaning ? (
- <>
-
- 清理中...
- >
- ) : (
- <>
-
- 立即清理
- >
- )}
-
-
-
-
- {/* 提示信息 */}
-
-
-
- 记忆功能会让 AI 在对话中记住之前的上下文信息。禁用后,AI
- 将无法跨对话记住信息。清理记忆是不可逆操作,请谨慎操作。
-
-
-
- {/* 消息提示 */}
- {message && (
-
- {message.type === "success" ? (
-
- ) : (
-
- )}
-
{message.text}
-
- )}
-
- );
-}
-
-export default MemorySettings;
diff --git a/src/components/settings-v2/agent/voice/index.tsx b/src/components/settings-v2/agent/voice/index.tsx
index 30a7ae38a..d99b37807 100644
--- a/src/components/settings-v2/agent/voice/index.tsx
+++ b/src/components/settings-v2/agent/voice/index.tsx
@@ -1,7 +1,7 @@
/**
* 语音服务配置设置组件
*
- * 参考 LobeHub 的 TTS/STT 实现
+ * 参考成熟产品的 TTS/STT 实现
* 功能包括:TTS 服务商选择、STT 服务商选择、语音参数配置等
*/
diff --git a/src/components/settings-v2/general/chat-appearance/index.tsx b/src/components/settings-v2/general/chat-appearance/index.tsx
index a99f44b8c..34d8d4e7a 100644
--- a/src/components/settings-v2/general/chat-appearance/index.tsx
+++ b/src/components/settings-v2/general/chat-appearance/index.tsx
@@ -1,7 +1,7 @@
/**
* 聊天外观设置组件
*
- * 参考 LobeHub 的 chat-appearance 实现
+ * 参考成熟产品的聊天外观实现
* 功能包括:聊天气泡样式、字体大小、过渡模式等
*/
diff --git a/src/components/settings-v2/general/hotkeys/index.tsx b/src/components/settings-v2/general/hotkeys/index.tsx
index 1cf7de182..20235eacc 100644
--- a/src/components/settings-v2/general/hotkeys/index.tsx
+++ b/src/components/settings-v2/general/hotkeys/index.tsx
@@ -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 = {
+ 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({
{hotkey.label}
{hotkey.description}
+ {hotkey.source}
+
+ {hotkey.enabled ? "已启用" : "未启用"}
+
{hotkey.keys.map((key, index) => (
{key}
))}
@@ -165,10 +244,77 @@ function HotkeySection({
}
export function HotkeysSettings() {
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [globalHotkeys, setGlobalHotkeys] = useState([]);
+ const [localHotkeys, setLocalHotkeys] = useState([]);
+
+ 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 (
-
-
+
+ 仅展示当前版本已实现的快捷键;全局快捷键会随配置实时更新。
+
+
+ {loading ? (
+
+
+ 正在加载快捷键信息...
+
+ ) : error ? (
+
+ 加载快捷键失败:{error}
+
+ ) : (
+ <>
+
+
+ >
+ )}
);
}
diff --git a/src/components/settings-v2/hooks/useSettingsCategory.ts b/src/components/settings-v2/hooks/useSettingsCategory.ts
index b6934ed1c..dec987321 100644
--- a/src/components/settings-v2/hooks/useSettingsCategory.ts
+++ b/src/components/settings-v2/hooks/useSettingsCategory.ts
@@ -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", "绘画服务"),
diff --git a/src/components/settings-v2/index.ts b/src/components/settings-v2/index.ts
index fdd951683..c697f626d 100644
--- a/src/components/settings-v2/index.ts
+++ b/src/components/settings-v2/index.ts
@@ -1,7 +1,7 @@
/**
* 设置页面 V2 导出
*
- * 新版设置页面,采用 LobeHub 风格的侧边栏布局
+ * 新版设置页面,采用统一侧边栏布局
*/
export { SettingsLayoutV2 as SettingsPageV2 } from "./_layout";
diff --git a/src/components/terminal/ai/useTerminalAI.ts b/src/components/terminal/ai/useTerminalAI.ts
index 2b6f520b4..ddab057c5 100644
--- a/src/components/terminal/ai/useTerminalAI.ts
+++ b/src/components/terminal/ai/useTerminalAI.ts
@@ -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(null);
+ const workspaceIdRef = useRef(null);
// 终端控制状态
const [terminalSessionId, setTerminalSessionId] = useState(
@@ -152,6 +158,23 @@ export function useTerminalAI(
initAgent();
}, []);
+ const ensureWorkspaceId = useCallback(async (): Promise => {
+ if (workspaceIdRef.current) {
+ return workspaceIdRef.current;
+ }
+
+ const workspace = await invoke(
+ "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,
+ ],
);
/**
diff --git a/src/components/workspace/WorkbenchPage.tsx b/src/components/workspace/WorkbenchPage.tsx
index e8469abba..9bbfdd8f9 100644
--- a/src/components/workspace/WorkbenchPage.tsx
+++ b/src/components/workspace/WorkbenchPage.tsx
@@ -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(
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({
{workspaceMode === "workspace" && (
- <>
- setShowLeftSidebar((visible) => !visible)}
- title={showLeftSidebar ? "隐藏左侧栏" : "显示左侧栏"}
- >
- {showLeftSidebar ? (
-
- ) : (
-
- )}
-
-
- setShowRightSidebar((visible) => !visible)}
- title={showRightSidebar ? "隐藏右侧栏" : "显示右侧栏"}
- >
- {showRightSidebar ? (
-
- ) : (
-
- )}
-
- >
+ setShowLeftSidebar((visible) => !visible)}
+ title={showLeftSidebar ? "隐藏左侧栏" : "显示左侧栏"}
+ >
+ {showLeftSidebar ? (
+
+ ) : (
+
+ )}
+
)}
{workspaceMode !== "project-management" && (
@@ -371,6 +499,22 @@ export function WorkbenchPage({
{selectedProject.name}
)}
+
+ {workspaceMode === "workspace" && (
+
setShowRightSidebar((visible) => !visible)}
+ title={showRightSidebar ? "隐藏右侧栏" : "显示右侧栏"}
+ >
+ {showRightSidebar ? (
+
+ ) : (
+
+ )}
+
+ )}
@@ -405,9 +549,7 @@ export function WorkbenchPage({
variant="ghost"
size="icon"
className="h-8 w-8"
- onClick={() => {
- void handleCreateProject();
- }}
+ onClick={handleOpenCreateProjectDialog}
title="新建项目"
>
@@ -546,9 +688,7 @@ export function WorkbenchPage({
{
- void handleCreateProject();
- }}
+ onClick={handleOpenCreateProjectDialog}
>
新建项目
@@ -585,7 +725,6 @@ export function WorkbenchPage({
onBack={handleBackToProjectManagement}
onNavigateToChat={() => {
setWorkspaceMode("workspace");
- setShowRightSidebar(true);
}}
/>
)
@@ -596,9 +735,7 @@ export function WorkbenchPage({
{
- void handleCreateProject();
- }}
+ onClick={handleOpenCreateProjectDialog}
>
新建项目
@@ -618,6 +755,7 @@ export function WorkbenchPage({
) : (
+
+
);
}
diff --git a/src/hooks/useProjects.ts b/src/hooks/useProjects.ts
index ddc0d3daf..383ae742c 100644
--- a/src/hooks/useProjects.ts
+++ b/src/hooks/useProjects.ts
@@ -109,10 +109,14 @@ export function useProjects(): UseProjectsReturn {
/** 创建项目 */
const create = useCallback(
async (request: CreateProjectRequest): Promise => {
+ const rootPath = await invoke("workspace_resolve_project_path", {
+ name: request.name,
+ });
+
const project = await invoke("workspace_create", {
request: {
name: request.name,
- rootPath: request.name, // 使用名称作为路径
+ rootPath,
workspaceType: request.workspaceType,
},
});
diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts
index 13e4a1515..6b843e5bb 100644
--- a/src/hooks/useTauri.ts
+++ b/src/hooks/useTauri.ts
@@ -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 {
return safeInvoke("get_conversation_memory_stats");
}
+/**
+ * 获取记忆总览(含分类与条目)
+ */
+export async function getMemoryOverview(
+ limit?: number,
+): Promise {
+ return safeInvoke("get_conversation_memory_overview", { limit });
+}
+
+/**
+ * 请求记忆分析(从历史会话提取记忆)
+ */
+export async function requestMemoryAnalysis(
+ fromTimestamp?: number,
+ toTimestamp?: number,
+): Promise {
+ return safeInvoke("request_conversation_memory_analysis", {
+ fromTimestamp,
+ toTimestamp,
+ });
+}
+
/**
* 清理过期记忆
*/
diff --git a/src/icons/providers/utils.ts b/src/icons/providers/utils.ts
index f7f04d347..5659899d7 100644
--- a/src/icons/providers/utils.ts
+++ b/src/icons/providers/utils.ts
@@ -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 = {
aws: "aws",
proxycast: "proxycast",
"proxycast-hub": "proxycast-hub",
- lobehub: "proxycast",
+ [LEGACY_PROXYCAST_HUB_ICON_ALIAS]: "proxycast",
// ===== 主流 AI Provider =====
deepseek: "deepseek",
diff --git a/src/lib/api/agent.ts b/src/lib/api/agent.ts
index 4270050ee..73fcae01e 100644
--- a/src/lib/api/agent.ts
+++ b/src/lib/api/agent.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
* 创建 Aster 会话
*/
export async function createAsterSession(
+ workspaceId: string,
workingDir?: string,
name?: string,
): Promise {
- return await safeInvoke("aster_session_create", { workingDir, name });
+ const resolvedWorkspaceId = requireWorkspaceId(workspaceId);
+
+ return await safeInvoke("aster_session_create", {
+ workingDir,
+ workspaceId: resolvedWorkspaceId,
+ name,
+ });
}
/**
diff --git a/src/lib/api/project.test.ts b/src/lib/api/project.test.ts
index b1af90215..1f5fbb8cf 100644
--- a/src/lib/api/project.test.ts
+++ b/src/lib/api/project.test.ts
@@ -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 = {
diff --git a/src/lib/api/project.ts b/src/lib/api/project.ts
index e2ac06ffd..31695e67c 100644
--- a/src/lib/api/project.ts
+++ b/src/lib/api/project.ts
@@ -245,12 +245,32 @@ export async function createProject(
return normalizeProject(project);
}
+/** 获取统一 workspace 项目根目录 */
+export async function getWorkspaceProjectsRoot(): Promise {
+ return invoke("workspace_get_projects_root");
+}
+
+/** 按项目名称解析固定项目目录 */
+export async function resolveProjectRootPath(name: string): Promise {
+ return invoke("workspace_resolve_project_path", { name });
+}
+
/** 获取项目列表 */
export async function listProjects(): Promise {
const projects = await invoke("workspace_list");
return projects.map((project) => normalizeProject(project));
}
+/** 通过根路径获取项目 */
+export async function getProjectByRootPath(
+ rootPath: string,
+): Promise {
+ const project = await invoke("workspace_get_by_path", {
+ rootPath,
+ });
+ return project ? normalizeProject(project) : null;
+}
+
/** 获取项目详情 */
export async function getProject(id: string): Promise {
const project = await invoke("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}`;
-}
diff --git a/src/types/page.ts b/src/types/page.ts
index e81407b28..4d0983de2 100644
--- a/src/types/page.ts
+++ b/src/types/page.ts
@@ -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;
}
diff --git a/src/types/settings.ts b/src/types/settings.ts
index b60983bec..a8a4b1491 100644
--- a/src/types/settings.ts
+++ b/src/types/settings.ts
@@ -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 = {
SettingsTabs.Providers,
SettingsTabs.Assistant,
SettingsTabs.Skills,
- SettingsTabs.Memory,
SettingsTabs.ImageGen,
SettingsTabs.Voice,
],