Commit 7f41ea8c authored by AI-甘富林's avatar AI-甘富林

feat(desktop): add per-project session management and switcher UI

- Extend shared IPC/DesktopApi types with per-project session listing and creation
- Add project store support for per-project sessions, active project metadata,
  and builtin home project descriptors
- Update preload bridge and desktop UI to show project switcher, project-scoped
  session lists, and per-project session actions
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent ab35460f
import { randomUUID, createHash } from "node:crypto";
import { randomUUID, createHash } from "node:crypto";
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import type {
......@@ -68,11 +68,17 @@ const ACTIVE_PROJECT_FILE = "active-project.json";
const PROJECT_FILE = "project.json";
const SESSIONS_FILE = "sessions.json";
const SESSION_MESSAGES_DIR = "session-messages";
const BUILTIN_HOME_PROJECT_ID = "home-chat";
const BUILTIN_HOME_PROJECT_NAME = "home-chat";
function nowIso(): string {
return new Date().toISOString();
}
function isBuiltinHomeProjectId(projectId: string): boolean {
return projectId === BUILTIN_HOME_PROJECT_ID;
}
function slugify(value: string): string {
const ascii = value
.normalize("NFKD")
......@@ -93,8 +99,109 @@ function sanitizeSkillId(value: string): string {
return trimmed || `skill-${createHash("sha1").update(value).digest("hex").slice(0, 8)}`;
}
const PROJECT_NAME_NOISE_TOKENS = [
"openclaw",
"qjclaw",
"skills",
"skill",
"delivery",
"project",
"projects",
"workspace",
"workflow"
] as const;
const PROJECT_PLATFORM_DISPLAY_NAMES: Array<{ pattern: RegExp; name: string }> = [
{ pattern: /xiaohongshu|xhs|rednote|小红书/i, name: "小红书专家" },
{ pattern: /douyin|tiktok|抖音/i, name: "抖音专家" },
{ pattern: /kuaishou|快手/i, name: "快手专家" },
{ pattern: /wechat|weixin|微信|视频号/i, name: "微信专家" },
{ pattern: /taobao|淘宝/i, name: "淘宝专家" }
];
function isInvalidDisplayName(value: string | undefined): boolean {
if (!value) {
return true;
}
const normalized = value.normalize("NFKC").trim();
if (!normalized) {
return true;
}
if (/^[??\s._-]+$/u.test(normalized)) {
return true;
}
if (normalized.includes("\uFFFD")) {
return true;
}
return false;
}
function cleanupProjectName(value: string | undefined): string {
if (!value) {
return "";
}
let next = value.normalize("NFKC").trim();
if (!next) {
return "";
}
next = next.replace(/\.zip$/i, "");
next = next.replace(/[_./\\]+/g, " ");
next = next.replace(/-+/g, " ");
next = next.replace(/\((skills?|delivery|project)\)/gi, " ");
next = next.replace(/\b(?:open[\s-]?claw|qjclaw)\b/gi, " ");
for (const token of PROJECT_NAME_NOISE_TOKENS) {
next = next.replace(new RegExp(`\\b${token}\\b`, "gi"), " ");
}
next = next.replace(/\s+/g, " ").trim();
next = next.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "").trim();
return next;
}
function toExpertDisplayName(rawValue: string | undefined): string | undefined {
if (isInvalidDisplayName(rawValue)) {
return undefined;
}
const cleaned = cleanupProjectName(rawValue);
if (isInvalidDisplayName(cleaned)) {
return undefined;
}
for (const candidate of PROJECT_PLATFORM_DISPLAY_NAMES) {
if (candidate.pattern.test(cleaned)) {
return candidate.name;
}
}
if (/专家$/u.test(cleaned)) {
return cleaned;
}
return `${cleaned}专家`;
}
function deriveProjectDisplayName(record: Pick<StoredProjectRecord, "id" | "name" | "description"> & {
platform?: unknown;
projectType?: unknown;
}): string {
const directMatch = [record.name, record.platform, record.projectType, record.id]
.map((value) => typeof value === "string" ? toExpertDisplayName(value) : undefined)
.find((value): value is string => Boolean(value));
if (directMatch) {
return directMatch;
}
const cleanedDescription = cleanupProjectName(record.description);
if (!isInvalidDisplayName(cleanedDescription)) {
return `${cleanedDescription}专家`;
}
return "自动化专家";
}
function sortProjects(items: ProjectSummary[]): ProjectSummary[] {
return [...items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || left.name.localeCompare(right.name, "zh-CN"));
return [...items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || (left.displayName ?? left.name).localeCompare(right.displayName ?? right.name, "zh-CN"));
}
function sortSessionStates(items: ProjectSessionState[]): ProjectSessionState[] {
......@@ -289,6 +396,8 @@ export class ProjectStoreService {
mkdir(path.join(workspaceRoot, MANIFESTS_DIR), { recursive: true })
]);
await this.ensureBuiltinHomeProject();
const projects = await this.readProjects();
if (projects.length === 0) {
await writeJsonFile(await this.getActiveProjectFilePath(), {});
......@@ -679,6 +788,15 @@ export class ProjectStoreService {
}
private async getProjectById(projectId: string): Promise<ProjectSummary> {
await this.initialize();
if (isBuiltinHomeProjectId(projectId)) {
const record = await this.readProjectRecord(projectId);
if (!record) {
throw new Error(`Project ${projectId} was not found.`);
}
return this.toProjectSummary(record);
}
const projects = await this.listProjects();
const project = projects.find((item) => item.id === projectId);
if (!project) {
......@@ -789,7 +907,29 @@ export class ProjectStoreService {
};
}
private async readProjects(): Promise<ProjectSummary[]> {
private async ensureBuiltinHomeProject(): Promise<void> {
const projectDir = await this.getProjectDir(BUILTIN_HOME_PROJECT_ID);
await mkdir(projectDir, { recursive: true });
const existing = await readJsonFile<StoredProjectRecord>(path.join(projectDir, PROJECT_FILE));
if (!existing) {
const timestamp = nowIso();
const record: StoredProjectRecord = {
id: BUILTIN_HOME_PROJECT_ID,
name: BUILTIN_HOME_PROJECT_NAME,
description: "Builtin home chat workspace.",
ready: true,
boundSkillIds: [],
updatedAt: timestamp
};
await writeJsonFile(path.join(projectDir, PROJECT_FILE), {
...record
});
}
await mkdir(path.join(projectDir, "memory"), { recursive: true });
await this.ensureProjectSessionStates(BUILTIN_HOME_PROJECT_ID);
}
private async readProjects(options?: { includeBuiltinHome?: boolean }): Promise<ProjectSummary[]> {
const workspaceRoot = await this.getWorkspaceRoot();
const projectsRoot = path.join(workspaceRoot, PROJECTS_DIR);
const entries = await readdir(projectsRoot, { withFileTypes: true }).catch(() => []);
......@@ -802,6 +942,9 @@ export class ProjectStoreService {
if (!record) {
continue;
}
if (!options?.includeBuiltinHome && isBuiltinHomeProjectId(record.id)) {
continue;
}
projects.push(this.toProjectSummary(record));
}
return projects;
......@@ -816,6 +959,14 @@ export class ProjectStoreService {
return {
id: record.id,
name: record.name,
displayName: deriveProjectDisplayName({
id: record.id,
name: record.name,
description: record.description,
platform: typeof record.platform === "string" ? record.platform : undefined,
projectType: typeof record.projectType === "string" ? record.projectType : undefined
}),
isBuiltinHome: isBuiltinHomeProjectId(record.id),
description: record.description,
version: record.version,
updatedAt: record.updatedAt,
......
......@@ -66,7 +66,9 @@ const desktopApi: DesktopApi = {
},
chat: {
listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.chatListSessions),
listSessionsByProject: (projectId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatListSessionsByProject, projectId),
createSession: (title?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatCreateSession, title),
createSessionForProject: (projectId: string, title?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatCreateSessionForProject, projectId, title),
closeSession: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatCloseSession, sessionId),
listMessages: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatListMessages, sessionId),
sendPrompt: (sessionId: string, prompt: string, skillId?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatSendPrompt, sessionId, prompt, skillId),
......
......@@ -82,6 +82,7 @@ interface SmokeStreamSnapshot {
}
const DEFAULT_SESSION_ID = "desktop-main";
const HOME_CHAT_PROJECT_ID = "home-chat";
const SUCCESS_NOTICE_TIMEOUT_MS = 2400;
const TYPEWRITER_CHARS_PER_FRAME = 3;
const MAX_TRACE_ITEMS = 60;
......@@ -349,10 +350,22 @@ const mockProjects: WorkspaceSummary["projects"] = [
{ id: "browser-ops", name: "???", displayName: "\u6d4f\u89c8\u5668\u81ea\u52a8\u5316\u4e13\u5bb6", version: "demo-project", updatedAt: new Date().toISOString(), skillCount: 3, ready: true, platform: "browser" }
];
const mockSessions = [
{ id: "project:xiaohongshu:default", projectId: "xiaohongshu", title: "\u9009\u9898\u8ba8\u8bba", updatedAt: new Date().toISOString() },
{ id: "project:xiaohongshu:publish", projectId: "xiaohongshu", title: "\u53d1\u5e03\u65f6\u95f4\u5b89\u6392", updatedAt: new Date().toISOString() }
];
const mockSessionsByProject: Record<string, WorkspaceSummary["sessions"]> = {
[HOME_CHAT_PROJECT_ID]: [
{ id: "project:home-chat:default", projectId: HOME_CHAT_PROJECT_ID, title: "首页对话", updatedAt: new Date().toISOString() }
],
xiaohongshu: [
{ id: "project:xiaohongshu:default", projectId: "xiaohongshu", title: "\u9009\u9898\u8ba8\u8bba", updatedAt: new Date().toISOString() },
{ id: "project:xiaohongshu:publish", projectId: "xiaohongshu", title: "\u53d1\u5e03\u65f6\u95f4\u5b89\u6392", updatedAt: new Date().toISOString() }
],
douyin: [
{ id: "project:douyin:default", projectId: "douyin", title: "\u8fd0\u8425\u7b56\u5212", updatedAt: new Date().toISOString() }
]
};
function getMockSessions(projectId?: string): WorkspaceSummary["sessions"] {
return mockSessionsByProject[projectId ?? mockProjects[0].id] ?? [];
}
const mockDesktopApi = {
workspace: {
getSummary: async () => ({
......@@ -381,7 +394,7 @@ const mockDesktopApi = {
projectReady: mockProjects[0].ready,
projectCount: mockProjects.length,
projects: mockProjects,
sessions: mockSessions,
sessions: getMockSessions(mockProjects[0].id),
skillCount: 2,
skills: [
{ id: "sheet", name: "Spreadsheet Tools", description: "Process spreadsheets and data summaries.", category: "office", enabled: true, ready: true, downloadState: "ready", fileName: "sheet.md" },
......@@ -433,9 +446,11 @@ const mockDesktopApi = {
modelConfig: { getSummary: async () => ({ source: "cloud", updatedAt: new Date().toISOString(), fetchedAt: new Date().toISOString(), routingMode: "platform-managed", fallbackMode: "cloud-required", defaultChatModelId: "gpt-5.4-mini", defaultChatModelLabel: "GPT-5.4 Mini", items: [], skillBindings: [], message: "mock" }) },
system: { getSummary: async () => ({ appName: "QianjiangClaw", appVersion: "0.1.0", isPackaged: false, platform: "win32", arch: "x64", appPath: "D:/qjclaw/apps/desktop", resourcesPath: "D:/qjclaw/apps/desktop/dist", userDataPath: "D:/qjclaw/.tmp/user-data", logsPath: "D:/qjclaw/.tmp/logs" }) },
chat: {
listSessions: async () => mockSessions,
listSessions: async () => getMockSessions(),
listSessionsByProject: async (projectId: string) => getMockSessions(projectId),
createSession: async (title?: string) => ({ id: `project:xiaohongshu:${createClientMessageId("session")}`, projectId: "xiaohongshu", title: title || "\u65b0\u5bf9\u8bdd", updatedAt: new Date().toISOString() }),
closeSession: async () => mockSessions,
createSessionForProject: async (projectId: string, title?: string) => ({ id: `project:${projectId}:${createClientMessageId("session")}`, projectId, title: title || "\u65b0\u5bf9\u8bdd", updatedAt: new Date().toISOString() }),
closeSession: async (sessionId: string) => getMockSessions(sessionId.split(":")[1]),
listMessages: async () => [],
sendPrompt: async (sessionId: string, prompt: string, skillId?: string) => ({ sessionId: sessionId || "project:xiaohongshu:default", reply: { id: "reply-1", role: "assistant", content: "Mock: " + prompt, createdAt: new Date().toISOString() }, executionPolicy: { source: skillId ? "cloud-skill-binding" : "cloud-default", modelId: "gpt-5.4-mini", modelLabel: "GPT-5.4 Mini", routingMode: "platform-managed", skillId, skillName: skillId, message: "mock" } }),
streamPrompt: async (_sessionId: string, prompt: string, skillId?: string) => {
......@@ -691,6 +706,7 @@ export default function App() {
const [systemSummary, setSystemSummary] = useState<SystemSummary | null>(null);
const [gatewayStatus, setGatewayStatus] = useState<GatewayStatus | null>(null);
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealth | null>(null);
const [sessions, setSessions] = useState<WorkspaceSummary["sessions"]>([]);
const [messages, setMessages] = useState<UiChatMessage[]>([]);
const [activeSessionId, setActiveSessionId] = useState(DEFAULT_SESSION_ID);
const [projectActionPending, setProjectActionPending] = useState(false);
......@@ -730,23 +746,31 @@ export default function App() {
? startupCurtainCopy.retryHint
: startupCurtainCopy.loadingLabel;
const projects = workspace?.projects ?? [];
const sessions = !setupRequired ? (workspace?.sessions ?? [{ id: activeSessionId, projectId: workspace?.currentProjectId ?? "default", title: ui.defaultChat, updatedAt: new Date().toISOString() }]) : [];
const activeProject = useMemo(() => projects.find((project) => project.id === workspace?.currentProjectId) ?? projects[0], [projects, workspace?.currentProjectId]);
const visibleProjects = useMemo(() => projects.filter((project) => !project.isBuiltinHome), [projects]);
const activeProject = useMemo(() => visibleProjects.find((project) => project.id === workspace?.currentProjectId) ?? visibleProjects[0], [visibleProjects, workspace?.currentProjectId]);
const activeExpertName = useMemo(() => getProjectDisplayName(activeProject), [activeProject]);
const activeExpertGuide = useMemo(() => getExpertGuide(activeProject), [activeProject]);
const expertPageProjects = useMemo(() => projects.slice(0, 2), [projects]);
const expertPageProjects = useMemo(() => visibleProjects.slice(0, 2), [visibleProjects]);
const expertCards = useMemo(() => expertPageProjects.map((project) => ({
project,
displayName: getProjectDisplayName(project),
guide: getExpertGuide(project),
isActive: project.id === activeProject?.id
})), [activeProject?.id, expertPageProjects]);
const sessionScopeProjectId = useMemo(() => {
if (setupRequired) {
return undefined;
}
return viewMode === "chat" ? HOME_CHAT_PROJECT_ID : activeProject?.id;
}, [activeProject?.id, setupRequired, viewMode]);
const resolvedActiveSessionId = useMemo(() => resolvePreferredSessionId(sessions, activeSessionId), [activeSessionId, sessions]);
const isBound = !setupRequired;
const hasActiveProject = Boolean(workspace?.projectReady && workspace?.currentProjectId);
const hasConversationProject = viewMode === "chat"
? visibleProjects.length > 0
: Boolean(workspace?.projectReady && activeProject?.id);
const showStartupOverlay = viewMode !== "settings" && ((refreshing && !workspace) || setupRequired || (isBound && chatLaunchState !== "ready"));
const sending = sendPhase !== "idle";
const canSend = isBound && hasActiveProject && prompt.trim().length > 0 && !sending && !saving;
const canSend = isBound && hasConversationProject && prompt.trim().length > 0 && !sending && !saving;
const sendButtonLabel = sendPhase === "preparing"
? ui.preparing
: sendPhase === "streaming" || sendPhase === "finalizing"
......@@ -815,9 +839,6 @@ export default function App() {
setRuntimeCloudStatus(nextCloud);
setRuntimeTelemetry(nextTelemetry);
setSystemSummary(nextSystem);
const nextSessions = nextWorkspace.sessions ?? [];
const nextSessionId = nextSessions.find((session) => session.id === activeSessionId)?.id ?? nextSessions[0]?.id ?? DEFAULT_SESSION_ID;
setActiveSessionId(nextSessionId);
setWorkspacePathDraft((current) => current || nextConfig.workspacePath);
setGatewayStatus(statusResult);
......@@ -830,7 +851,6 @@ export default function App() {
const canReadMessages = nextWorkspace.chatReady && canExchangeMessages(nextRuntime, statusResult);
if (canReadMessages) {
setGatewayHealth(await desktopApi.gateway.health().catch(() => null));
await loadMessages(nextSessionId, true, false);
} else {
setGatewayHealth(null);
setMessages([]);
......@@ -857,6 +877,46 @@ export default function App() {
setActiveSessionId(resolvedActiveSessionId);
}, [activeSessionId, resolvedActiveSessionId]);
useEffect(() => {
let cancelled = false;
async function syncScopedSessions() {
if (!isBound || setupRequired || !sessionScopeProjectId) {
if (!cancelled) {
setSessions([]);
setMessages([]);
}
return;
}
try {
const nextSessions = await desktopApi.chat.listSessionsByProject(sessionScopeProjectId);
if (cancelled) {
return;
}
setSessions(nextSessions);
const nextSessionId = resolvePreferredSessionId(nextSessions, activeSessionId);
setActiveSessionId(nextSessionId ?? DEFAULT_SESSION_ID);
if (!nextSessionId) {
setMessages([]);
}
} catch (error) {
if (cancelled) {
return;
}
setSessions([]);
setMessages([]);
setErrorText(err(error));
}
}
void syncScopedSessions();
return () => {
cancelled = true;
};
}, [activeSessionId, desktopApi.chat, isBound, sessionScopeProjectId, setupRequired, workspace]);
useEffect(() => {
if (viewMode === "settings" || !showStartupOverlay || !isBound || chatLaunchState !== "starting") {
return;
......@@ -999,12 +1059,9 @@ export default function App() {
try {
const nextWorkspace = await desktopApi.projects.setActive(projectId);
setWorkspace(nextWorkspace);
const nextSessionId = nextWorkspace.sessions[0]?.id ?? DEFAULT_SESSION_ID;
setActiveSessionId(nextSessionId);
setSessions([]);
setActiveSessionId(DEFAULT_SESSION_ID);
setMessages([]);
if (nextWorkspace.chatReady && canExchangeMessages(runtimeStatus, gatewayStatus)) {
await loadMessages(nextSessionId, true, false);
}
} catch (error) {
setErrorText(err(error));
} finally {
......@@ -1017,13 +1074,20 @@ export default function App() {
return;
}
if (!sessionScopeProjectId) {
return;
}
setProjectActionPending(true);
setErrorText("");
try {
const session = await desktopApi.chat.createSession();
const nextWorkspace = await desktopApi.workspace.getSummary().catch(() => null);
if (nextWorkspace) {
setWorkspace(nextWorkspace);
const session = await desktopApi.chat.createSessionForProject(sessionScopeProjectId);
setSessions((current) => [session, ...current.filter((item) => item.id !== session.id)]);
if (viewMode === "experts") {
const nextWorkspace = await desktopApi.workspace.getSummary().catch(() => null);
if (nextWorkspace) {
setWorkspace(nextWorkspace);
}
}
setActiveSessionId(session.id);
setMessages([]);
......@@ -1043,9 +1107,12 @@ export default function App() {
setErrorText("");
try {
const nextSessions = await desktopApi.chat.closeSession(sessionId);
const nextWorkspace = await desktopApi.workspace.getSummary().catch(() => null);
if (nextWorkspace) {
setWorkspace(nextWorkspace);
setSessions(nextSessions);
if (viewMode === "experts") {
const nextWorkspace = await desktopApi.workspace.getSummary().catch(() => null);
if (nextWorkspace) {
setWorkspace(nextWorkspace);
}
}
const nextSessionId = nextSessions.find((session) => session.id !== sessionId)?.id ?? nextSessions[0]?.id ?? DEFAULT_SESSION_ID;
setActiveSessionId(nextSessionId);
......@@ -1626,13 +1693,13 @@ export default function App() {
}
if (!sessionId) {
const createdSession = await desktopApi.chat.createSession();
if (!sessionScopeProjectId) {
throw new Error(ui.chatNotReadyError);
}
const createdSession = await desktopApi.chat.createSessionForProject(sessionScopeProjectId);
sessionId = createdSession.id;
setActiveSessionId(createdSession.id);
setWorkspace((current) => current ? {
...current,
sessions: [createdSession, ...current.sessions.filter((session) => session.id !== createdSession.id)]
} : current);
setSessions((current) => [createdSession, ...current.filter((session) => session.id !== createdSession.id)]);
}
setActiveSessionId(sessionId);
......@@ -1777,7 +1844,7 @@ export default function App() {
新建对话
</button>
);
const conversationPanelTitle = viewMode === "experts" ? activeExpertName : homeChatCopy.title;
const conversationPanelTitle = viewMode === "experts" ? activeExpertName : "对话";
const conversationPanelLead = viewMode === "chat" ? (
<div className="home-microcopy" aria-label="start your idea">
<span className="home-microcopy-icon">
......@@ -1936,7 +2003,7 @@ export default function App() {
</h1>
</div>
<nav className="nav-list">
{[{ id: "chat" as const, label: homeChatCopy.title }, { id: "experts" as const, label: ui.experts }, { id: "plugins" as const, label: ui.plugins }, { id: "settings" as const, label: ui.settings }].map((item) => (
{[{ id: "chat" as const, label: "对话" }, { id: "experts" as const, label: ui.experts }, { id: "plugins" as const, label: ui.plugins }, { id: "settings" as const, label: ui.settings }].map((item) => (
<button key={item.id} type="button" className={"nav-item" + (viewMode === item.id ? " active" : "")} onClick={() => setViewMode(item.id)}>{item.label}</button>
))}
</nav>
......
......@@ -21,7 +21,9 @@ export const IPC_CHANNELS = {
projectsList: "projects:list",
projectsSetActive: "projects:set-active",
chatListSessions: "chat:list-sessions",
chatListSessionsByProject: "chat:list-sessions-by-project",
chatCreateSession: "chat:create-session",
chatCreateSessionForProject: "chat:create-session-for-project",
chatCloseSession: "chat:close-session",
chatListMessages: "chat:list-messages",
chatSendPrompt: "chat:send-prompt",
......@@ -327,6 +329,8 @@ export interface ProjectPackageConfig {
export interface ProjectSummary {
id: string;
name: string;
displayName?: string;
isBuiltinHome?: boolean;
description?: string;
version?: string;
updatedAt: string;
......@@ -673,7 +677,9 @@ export interface DesktopApi {
};
chat: {
listSessions(): Promise<ProjectSessionSummary[]>;
listSessionsByProject(projectId: string): Promise<ProjectSessionSummary[]>;
createSession(title?: string): Promise<ProjectSessionSummary>;
createSessionForProject(projectId: string, title?: string): Promise<ProjectSessionSummary>;
closeSession(sessionId: string): Promise<ProjectSessionSummary[]>;
listMessages(sessionId: string): Promise<ChatMessage[]>;
sendPrompt(sessionId: string, prompt: string, skillId?: string): Promise<PromptResult>;
......@@ -685,13 +691,3 @@ export interface DesktopApi {
exportSnapshot(): Promise<DiagnosticsExportResult>;
};
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment