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 { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { import type {
...@@ -68,11 +68,17 @@ const ACTIVE_PROJECT_FILE = "active-project.json"; ...@@ -68,11 +68,17 @@ const ACTIVE_PROJECT_FILE = "active-project.json";
const PROJECT_FILE = "project.json"; const PROJECT_FILE = "project.json";
const SESSIONS_FILE = "sessions.json"; const SESSIONS_FILE = "sessions.json";
const SESSION_MESSAGES_DIR = "session-messages"; const SESSION_MESSAGES_DIR = "session-messages";
const BUILTIN_HOME_PROJECT_ID = "home-chat";
const BUILTIN_HOME_PROJECT_NAME = "home-chat";
function nowIso(): string { function nowIso(): string {
return new Date().toISOString(); return new Date().toISOString();
} }
function isBuiltinHomeProjectId(projectId: string): boolean {
return projectId === BUILTIN_HOME_PROJECT_ID;
}
function slugify(value: string): string { function slugify(value: string): string {
const ascii = value const ascii = value
.normalize("NFKD") .normalize("NFKD")
...@@ -93,8 +99,109 @@ function sanitizeSkillId(value: string): string { ...@@ -93,8 +99,109 @@ function sanitizeSkillId(value: string): string {
return trimmed || `skill-${createHash("sha1").update(value).digest("hex").slice(0, 8)}`; 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[] { 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[] { function sortSessionStates(items: ProjectSessionState[]): ProjectSessionState[] {
...@@ -289,6 +396,8 @@ export class ProjectStoreService { ...@@ -289,6 +396,8 @@ export class ProjectStoreService {
mkdir(path.join(workspaceRoot, MANIFESTS_DIR), { recursive: true }) mkdir(path.join(workspaceRoot, MANIFESTS_DIR), { recursive: true })
]); ]);
await this.ensureBuiltinHomeProject();
const projects = await this.readProjects(); const projects = await this.readProjects();
if (projects.length === 0) { if (projects.length === 0) {
await writeJsonFile(await this.getActiveProjectFilePath(), {}); await writeJsonFile(await this.getActiveProjectFilePath(), {});
...@@ -679,6 +788,15 @@ export class ProjectStoreService { ...@@ -679,6 +788,15 @@ export class ProjectStoreService {
} }
private async getProjectById(projectId: string): Promise<ProjectSummary> { 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 projects = await this.listProjects();
const project = projects.find((item) => item.id === projectId); const project = projects.find((item) => item.id === projectId);
if (!project) { if (!project) {
...@@ -789,7 +907,29 @@ export class ProjectStoreService { ...@@ -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 workspaceRoot = await this.getWorkspaceRoot();
const projectsRoot = path.join(workspaceRoot, PROJECTS_DIR); const projectsRoot = path.join(workspaceRoot, PROJECTS_DIR);
const entries = await readdir(projectsRoot, { withFileTypes: true }).catch(() => []); const entries = await readdir(projectsRoot, { withFileTypes: true }).catch(() => []);
...@@ -802,6 +942,9 @@ export class ProjectStoreService { ...@@ -802,6 +942,9 @@ export class ProjectStoreService {
if (!record) { if (!record) {
continue; continue;
} }
if (!options?.includeBuiltinHome && isBuiltinHomeProjectId(record.id)) {
continue;
}
projects.push(this.toProjectSummary(record)); projects.push(this.toProjectSummary(record));
} }
return projects; return projects;
...@@ -816,6 +959,14 @@ export class ProjectStoreService { ...@@ -816,6 +959,14 @@ export class ProjectStoreService {
return { return {
id: record.id, id: record.id,
name: record.name, 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, description: record.description,
version: record.version, version: record.version,
updatedAt: record.updatedAt, updatedAt: record.updatedAt,
......
...@@ -66,7 +66,9 @@ const desktopApi: DesktopApi = { ...@@ -66,7 +66,9 @@ const desktopApi: DesktopApi = {
}, },
chat: { chat: {
listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.chatListSessions), listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.chatListSessions),
listSessionsByProject: (projectId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatListSessionsByProject, projectId),
createSession: (title?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatCreateSession, title), 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), closeSession: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatCloseSession, sessionId),
listMessages: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatListMessages, 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), sendPrompt: (sessionId: string, prompt: string, skillId?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatSendPrompt, sessionId, prompt, skillId),
......
This diff is collapsed.
...@@ -21,7 +21,9 @@ export const IPC_CHANNELS = { ...@@ -21,7 +21,9 @@ export const IPC_CHANNELS = {
projectsList: "projects:list", projectsList: "projects:list",
projectsSetActive: "projects:set-active", projectsSetActive: "projects:set-active",
chatListSessions: "chat:list-sessions", chatListSessions: "chat:list-sessions",
chatListSessionsByProject: "chat:list-sessions-by-project",
chatCreateSession: "chat:create-session", chatCreateSession: "chat:create-session",
chatCreateSessionForProject: "chat:create-session-for-project",
chatCloseSession: "chat:close-session", chatCloseSession: "chat:close-session",
chatListMessages: "chat:list-messages", chatListMessages: "chat:list-messages",
chatSendPrompt: "chat:send-prompt", chatSendPrompt: "chat:send-prompt",
...@@ -327,6 +329,8 @@ export interface ProjectPackageConfig { ...@@ -327,6 +329,8 @@ export interface ProjectPackageConfig {
export interface ProjectSummary { export interface ProjectSummary {
id: string; id: string;
name: string; name: string;
displayName?: string;
isBuiltinHome?: boolean;
description?: string; description?: string;
version?: string; version?: string;
updatedAt: string; updatedAt: string;
...@@ -673,7 +677,9 @@ export interface DesktopApi { ...@@ -673,7 +677,9 @@ export interface DesktopApi {
}; };
chat: { chat: {
listSessions(): Promise<ProjectSessionSummary[]>; listSessions(): Promise<ProjectSessionSummary[]>;
listSessionsByProject(projectId: string): Promise<ProjectSessionSummary[]>;
createSession(title?: string): Promise<ProjectSessionSummary>; createSession(title?: string): Promise<ProjectSessionSummary>;
createSessionForProject(projectId: string, title?: string): Promise<ProjectSessionSummary>;
closeSession(sessionId: string): Promise<ProjectSessionSummary[]>; closeSession(sessionId: string): Promise<ProjectSessionSummary[]>;
listMessages(sessionId: string): Promise<ChatMessage[]>; listMessages(sessionId: string): Promise<ChatMessage[]>;
sendPrompt(sessionId: string, prompt: string, skillId?: string): Promise<PromptResult>; sendPrompt(sessionId: string, prompt: string, skillId?: string): Promise<PromptResult>;
...@@ -685,13 +691,3 @@ export interface DesktopApi { ...@@ -685,13 +691,3 @@ export interface DesktopApi {
exportSnapshot(): Promise<DiagnosticsExportResult>; 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