Commit 5abfbb86 authored by edy's avatar edy

feat(desktop): archive workspace task panel artifacts

parent 4415dece
Pipeline #18464 failed
...@@ -35,6 +35,7 @@ import { ProjectExecutionRouter } from "./services/project-execution-router.js"; ...@@ -35,6 +35,7 @@ import { ProjectExecutionRouter } from "./services/project-execution-router.js";
import { ProjectIntentRouterService } from "./services/project-intent-router.js"; import { ProjectIntentRouterService } from "./services/project-intent-router.js";
import { ProjectSkillRouterService } from "./services/project-skill-router.js"; import { ProjectSkillRouterService } from "./services/project-skill-router.js";
import { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js"; import { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
import { TaskPanelService } from "./services/task-panel-service.js";
import { StartupLogger } from "./services/startup-logger.js"; import { StartupLogger } from "./services/startup-logger.js";
interface RendererSmokeState { interface RendererSmokeState {
...@@ -2301,6 +2302,7 @@ async function bootstrap(): Promise<void> { ...@@ -2301,6 +2302,7 @@ async function bootstrap(): Promise<void> {
await runtimeManager.configure(); await runtimeManager.configure();
await traceBootstrap("runtime-configure-done"); await traceBootstrap("runtime-configure-done");
const projectWorkspaceExecutor = new ProjectWorkspaceExecutorService(runtimeManager); const projectWorkspaceExecutor = new ProjectWorkspaceExecutorService(runtimeManager);
const taskPanelService = new TaskPanelService(systemSummary.userDataPath);
const runtimeStatus = await runtimeManager.status(); const runtimeStatus = await runtimeManager.status();
const runtimeGatewayConnection = await runtimeManager.getGatewayConnection(); const runtimeGatewayConnection = await runtimeManager.getGatewayConnection();
if (systemSummary.isPackaged && runtimeStatus.payloadState !== "ready") { if (systemSummary.isPackaged && runtimeStatus.payloadState !== "ready") {
...@@ -2388,6 +2390,7 @@ async function bootstrap(): Promise<void> { ...@@ -2388,6 +2390,7 @@ async function bootstrap(): Promise<void> {
projectSkillRouter, projectSkillRouter,
projectExecutionRouter, projectExecutionRouter,
projectWorkspaceExecutor, projectWorkspaceExecutor,
taskPanelService,
startupLogger: startupLogger!, startupLogger: startupLogger!,
systemSummary, systemSummary,
localOpenClawConfig localOpenClawConfig
...@@ -2507,6 +2510,3 @@ if (!hasSingleInstanceLock) { ...@@ -2507,6 +2510,3 @@ if (!hasSingleInstanceLock) {
} }
...@@ -14,6 +14,7 @@ import { ...@@ -14,6 +14,7 @@ import {
type PluginSummary, type PluginSummary,
type ProjectIntentSuggestion, type ProjectIntentSuggestion,
type ProjectResolvedAttachment, type ProjectResolvedAttachment,
type TaskPanelArtifact,
type RuntimeCloudFetchAction, type RuntimeCloudFetchAction,
type RuntimeCloudStatus, type RuntimeCloudStatus,
type RuntimeStatus, type RuntimeStatus,
...@@ -57,6 +58,7 @@ import type { ProjectContextService } from "./services/project-context.js"; ...@@ -57,6 +58,7 @@ import type { ProjectContextService } from "./services/project-context.js";
import type { ProjectExecutionRouter } from "./services/project-execution-router.js"; import type { ProjectExecutionRouter } from "./services/project-execution-router.js";
import type { ProjectSkillRouterService } from "./services/project-skill-router.js"; import type { ProjectSkillRouterService } from "./services/project-skill-router.js";
import type { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js"; import type { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
import type { TaskPanelService } from "./services/task-panel-service.js";
import { import {
buildProjectModelRuntime, buildProjectModelRuntime,
materializeProjectModelRuntime materializeProjectModelRuntime
...@@ -101,6 +103,7 @@ interface MainServices { ...@@ -101,6 +103,7 @@ interface MainServices {
projectSkillRouter: ProjectSkillRouterService; projectSkillRouter: ProjectSkillRouterService;
projectExecutionRouter: ProjectExecutionRouter; projectExecutionRouter: ProjectExecutionRouter;
projectWorkspaceExecutor: ProjectWorkspaceExecutorService; projectWorkspaceExecutor: ProjectWorkspaceExecutorService;
taskPanelService: TaskPanelService;
startupLogger: StartupLogger; startupLogger: StartupLogger;
appVersion: string; appVersion: string;
systemSummary: SystemSummary; systemSummary: SystemSummary;
...@@ -521,6 +524,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -521,6 +524,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
projectSkillRouter, projectSkillRouter,
projectExecutionRouter, projectExecutionRouter,
projectWorkspaceExecutor, projectWorkspaceExecutor,
taskPanelService,
startupLogger, startupLogger,
systemSummary, systemSummary,
localOpenClawConfig localOpenClawConfig
...@@ -1646,6 +1650,65 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1646,6 +1650,65 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}; };
}; };
const isXhsProject = (value: string): boolean => /xiaohongshu|xhs|小红书/.test(value);
const isDouyinProject = (value: string): boolean => /douyin|抖音|tiktok/.test(value);
const resolveTaskPanelExpertName = (projectId: string, projectRoot: string): string | null => {
const normalizedProjectId = projectId.trim().toLowerCase();
const projectBaseName = path.basename(projectRoot).toLowerCase();
if (isXhsProject(normalizedProjectId)) {
return "小红书专家";
}
if (isDouyinProject(normalizedProjectId)) {
return "抖音专家";
}
if (isXhsProject(projectBaseName)) {
return "小红书专家";
}
if (isDouyinProject(projectBaseName)) {
return "抖音专家";
}
return null;
};
const toTaskPanelTitle = (prompt: string): string => {
const firstLine = prompt.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "未命名任务";
return firstLine.length > 40 ? firstLine.slice(0, 40) + "..." : firstLine;
};
const recordWorkspaceTaskPanelExecution = async (input: {
sessionId: string;
projectId: string;
projectRoot: string;
prompt: string;
runId: string;
artifacts?: TaskPanelArtifact[];
}): Promise<void> => {
const expertName = resolveTaskPanelExpertName(input.projectId, input.projectRoot);
if (!expertName) {
return;
}
try {
await taskPanelService.recordWorkspaceExecution({
sessionId: input.sessionId,
runId: input.runId,
expertName,
taskTitle: toTaskPanelTitle(input.prompt),
completedAt: new Date().toISOString(),
messageCount: 2,
artifacts: input.artifacts ?? []
});
} catch (error) {
await startupLogger.warn("diagnostics", "task-panel.archive-failed", "Failed to archive workspace-entry task panel data.", {
sessionId: input.sessionId,
projectId: input.projectId,
runId: input.runId,
error: error instanceof Error ? error.message : String(error)
});
}
};
const sendHomeImagePrompt = async ( const sendHomeImagePrompt = async (
sessionId: string, sessionId: string,
prompt: string, prompt: string,
...@@ -1865,11 +1928,27 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1865,11 +1928,27 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
fallbackExecutionPolicy.modelId, fallbackExecutionPolicy.modelId,
executionSkillId executionSkillId
); );
await recordWorkspaceTaskPanelExecution({
sessionId: executionSessionId,
projectId: preparedExecution.sessionState.projectId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt,
runId: result.runId,
artifacts: result.artifacts
});
return { ...fallbackResult, executionPolicy: fallbackExecutionPolicy }; return { ...fallbackResult, executionPolicy: fallbackExecutionPolicy };
} }
await projectStore.appendSessionMessage(executionSessionId, result.reply); await projectStore.appendSessionMessage(executionSessionId, result.reply);
await projectStore.updateSessionLastActive(executionSessionId).catch(() => undefined); await projectStore.updateSessionLastActive(executionSessionId).catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(executionSessionId, result.reply.content, preparedExecution.executionPolicy.modelId, executionSkillId); runtimeCloudSupervisor.noteMessageSent(executionSessionId, result.reply.content, preparedExecution.executionPolicy.modelId, executionSkillId);
await recordWorkspaceTaskPanelExecution({
sessionId: executionSessionId,
projectId: preparedExecution.sessionState.projectId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt,
runId: result.runId,
artifacts: result.artifacts
});
return { return {
sessionId: executionSessionId, sessionId: executionSessionId,
reply: result.reply, reply: result.reply,
...@@ -2277,9 +2356,17 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -2277,9 +2356,17 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
statusLabel: undefined, statusLabel: undefined,
statusDetail: undefined statusDetail: undefined
})); }));
await projectStore.updateSessionLastActive(nextSessionId).catch(() => undefined); await projectStore.updateSessionLastActive(nextSessionId).catch(() => undefined);
})().catch(() => undefined); })().catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(nextSessionId, reply.content, executionPolicy?.modelId, executionSkillId); runtimeCloudSupervisor.noteMessageSent(nextSessionId, reply.content, executionPolicy?.modelId, executionSkillId);
void recordWorkspaceTaskPanelExecution({
sessionId: executionSessionId,
projectId: preparedExecution.sessionState.projectId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt,
runId: result.runId,
artifacts: result.artifacts
});
queueOrSend({ queueOrSend({
type: "completed", type: "completed",
requestId, requestId,
...@@ -2332,6 +2419,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -2332,6 +2419,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
})); }));
await projectStore.updateSessionLastActive(executionSessionId).catch(() => undefined); await projectStore.updateSessionLastActive(executionSessionId).catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(executionSessionId, result.reply.content, executionPolicy?.modelId, executionSkillId); runtimeCloudSupervisor.noteMessageSent(executionSessionId, result.reply.content, executionPolicy?.modelId, executionSkillId);
await recordWorkspaceTaskPanelExecution({
sessionId: executionSessionId,
projectId: preparedExecution.sessionState.projectId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt,
runId: result.runId,
artifacts: result.artifacts
});
queueOrSend({ queueOrSend({
type: "completed", type: "completed",
requestId, requestId,
...@@ -2602,7 +2697,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -2602,7 +2697,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
ipcMain.handle(IPC_CHANNELS.expertsList, async () => expertCatalogService.list()); ipcMain.handle(IPC_CHANNELS.expertsList, async () => expertCatalogService.list());
ipcMain.handle(IPC_CHANNELS.modelConfigGetSummary, async () => modelConfigClient.getSummary()); ipcMain.handle(IPC_CHANNELS.modelConfigGetSummary, async () => modelConfigClient.getSummary());
ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary); ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary);
ipcMain.handle(IPC_CHANNELS.tasksListByDate, async () => []); ipcMain.handle(IPC_CHANNELS.tasksListByDate, async (_event, date: string) => taskPanelService.listByDate(date));
ipcMain.handle(IPC_CHANNELS.skillCatalogList, async () => skillCatalogService.listForActiveProject()); ipcMain.handle(IPC_CHANNELS.skillCatalogList, async () => skillCatalogService.listForActiveProject());
ipcMain.handle(IPC_CHANNELS.projectsList, async () => projectStore.listProjects()); ipcMain.handle(IPC_CHANNELS.projectsList, async () => projectStore.listProjects());
...@@ -2734,7 +2829,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -2734,7 +2829,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
getSummary: () => Promise.resolve(systemSummary) getSummary: () => Promise.resolve(systemSummary)
}, },
tasks: { tasks: {
listByDate: async () => [] listByDate: (date: string) => taskPanelService.listByDate(date)
}, },
chat: { chat: {
listSessions: async () => { listSessions: async () => {
......
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { readFile, stat } from "node:fs/promises"; import type { Dirent } from "node:fs";
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { RuntimeManager } from "@qjclaw/runtime-manager"; import type { RuntimeManager } from "@qjclaw/runtime-manager";
import type { ChatMessage, ProjectResolvedAttachment } from "@qjclaw/shared-types"; import type { ChatMessage, ProjectResolvedAttachment, TaskPanelArtifact } from "@qjclaw/shared-types";
interface ProjectWorkspaceExecutionInput { interface ProjectWorkspaceExecutionInput {
sessionId: string; sessionId: string;
...@@ -39,6 +40,7 @@ interface RunnerEvent { ...@@ -39,6 +40,7 @@ interface RunnerEvent {
export interface ProjectWorkspaceHandoffResult { export interface ProjectWorkspaceHandoffResult {
runId: string; runId: string;
artifacts?: TaskPanelArtifact[];
handoff: { handoff: {
target: "chat-fallback"; target: "chat-fallback";
content: string; content: string;
...@@ -47,6 +49,7 @@ export interface ProjectWorkspaceHandoffResult { ...@@ -47,6 +49,7 @@ export interface ProjectWorkspaceHandoffResult {
export interface ProjectWorkspaceReplyResult { export interface ProjectWorkspaceReplyResult {
runId: string; runId: string;
artifacts?: TaskPanelArtifact[];
reply: ChatMessage; reply: ChatMessage;
} }
...@@ -68,6 +71,37 @@ interface ResolvedProjectAutomationCommand { ...@@ -68,6 +71,37 @@ interface ResolvedProjectAutomationCommand {
} }
const EVENT_PREFIX = "QJC_WORKSPACE_EVENT\t"; const EVENT_PREFIX = "QJC_WORKSPACE_EVENT\t";
const WORKSPACE_ARTIFACT_EXTENSIONS = new Set([
".md",
".txt",
".pdf",
".docx",
".xlsx",
".csv",
".json",
".mp4",
".mov",
".png",
".jpg",
".jpeg",
".webp"
]);
const TEXT_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]);
export interface WorkspaceArtifactSnapshotEntry {
path: string;
relativePath: string;
size: number;
mtimeMs: number;
}
export type WorkspaceArtifactSnapshot = Map<string, WorkspaceArtifactSnapshotEntry>;
export interface CollectWorkspaceExecutionArtifactsInput {
projectRoot: string;
beforeSnapshot: WorkspaceArtifactSnapshot;
assistantSummary?: string;
}
function toErrorMessage(error: unknown): string { function toErrorMessage(error: unknown): string {
if (error instanceof Error) { if (error instanceof Error) {
...@@ -135,6 +169,135 @@ async function pathExists(targetPath: string): Promise<boolean> { ...@@ -135,6 +169,135 @@ async function pathExists(targetPath: string): Promise<boolean> {
} }
} }
function toLocalDateTimeValue(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
const second = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
function normalizeSnapshotPath(value: string): string {
return value.replace(/\\/g, "/");
}
function shouldSkipWorkspaceArtifactPath(relativePath: string): boolean {
const segments = normalizeSnapshotPath(relativePath).split("/").filter(Boolean);
if (segments.some((segment) => segment === ".git" || segment === "node_modules" || segment.startsWith(".qjc-"))) {
return true;
}
if (segments[0] === "memory" && segments[1] === "workspace-runner") {
return true;
}
if (segments[0] === "inputs") {
return true;
}
return false;
}
function inferWorkspaceArtifactKind(filePath: string): string {
const extension = path.extname(filePath).toLowerCase();
if (extension === ".mp4" || extension === ".mov") {
return "视频";
}
if (extension === ".png" || extension === ".jpg" || extension === ".jpeg" || extension === ".webp") {
return "图片";
}
if (extension === ".xlsx" || extension === ".csv") {
return "表格";
}
return "文档";
}
async function readWorkspaceArtifactSummary(filePath: string, fallbackSummary?: string): Promise<string | undefined> {
if (!TEXT_SUMMARY_EXTENSIONS.has(path.extname(filePath).toLowerCase())) {
return fallbackSummary?.trim().slice(0, 120) || undefined;
}
try {
const content = await readFile(filePath, "utf8");
return content.replace(/\s+/g, " ").trim().slice(0, 120) || fallbackSummary?.trim().slice(0, 120) || undefined;
} catch {
return fallbackSummary?.trim().slice(0, 120) || undefined;
}
}
async function scanWorkspaceArtifacts(
projectRoot: string,
currentDirectory: string,
snapshot: WorkspaceArtifactSnapshot
): Promise<void> {
let entries: Dirent[];
try {
entries = await readdir(currentDirectory, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const absolutePath = path.join(currentDirectory, entry.name);
const relativePath = normalizeSnapshotPath(path.relative(projectRoot, absolutePath));
if (!relativePath || shouldSkipWorkspaceArtifactPath(relativePath)) {
continue;
}
if (entry.isDirectory()) {
await scanWorkspaceArtifacts(projectRoot, absolutePath, snapshot);
continue;
}
if (!entry.isFile() || !WORKSPACE_ARTIFACT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
continue;
}
try {
const stats = await stat(absolutePath);
snapshot.set(relativePath, {
path: absolutePath,
relativePath,
size: stats.size,
mtimeMs: stats.mtimeMs
});
} catch {
// Files may be moved by the runtime while we are scanning.
}
}
}
export async function createWorkspaceArtifactSnapshot(projectRoot: string): Promise<WorkspaceArtifactSnapshot> {
const snapshot: WorkspaceArtifactSnapshot = new Map();
await scanWorkspaceArtifacts(projectRoot, projectRoot, snapshot);
return snapshot;
}
export async function collectWorkspaceExecutionArtifacts(
input: CollectWorkspaceExecutionArtifactsInput
): Promise<TaskPanelArtifact[]> {
const afterSnapshot = await createWorkspaceArtifactSnapshot(input.projectRoot);
const changedEntries = [...afterSnapshot.values()]
.filter((entry) => {
const before = input.beforeSnapshot.get(entry.relativePath);
return !before || before.size !== entry.size || before.mtimeMs !== entry.mtimeMs;
})
.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
const artifacts: TaskPanelArtifact[] = [];
for (const entry of changedEntries) {
artifacts.push({
id: "artifact-" + Buffer.from(entry.relativePath).toString("base64url").slice(0, 32),
name: path.basename(entry.path),
kind: inferWorkspaceArtifactKind(entry.path),
summary: await readWorkspaceArtifactSummary(entry.path, input.assistantSummary),
url: entry.path,
path: entry.path,
producedAt: toLocalDateTimeValue(new Date(entry.mtimeMs))
});
}
return artifacts;
}
function parseRunnerEvent(line: string): RunnerEvent | null { function parseRunnerEvent(line: string): RunnerEvent | null {
if (!line.startsWith(EVENT_PREFIX)) { if (!line.startsWith(EVENT_PREFIX)) {
return null; return null;
...@@ -299,6 +462,7 @@ export class ProjectWorkspaceExecutorService { ...@@ -299,6 +462,7 @@ export class ProjectWorkspaceExecutorService {
const paths = this.runtimeManager.resolveBundledPaths(); const paths = this.runtimeManager.resolveBundledPaths();
const automationCommand = await resolveProjectAutomationCommand(input.projectRoot, input, paths.pythonExecutable); const automationCommand = await resolveProjectAutomationCommand(input.projectRoot, input, paths.pythonExecutable);
const runnerScriptPath = automationCommand ? null : await resolveRunnerScriptPath(); const runnerScriptPath = automationCommand ? null : await resolveRunnerScriptPath();
const beforeArtifactSnapshot = await createWorkspaceArtifactSnapshot(input.projectRoot);
const vendorPackageDir = path.join(paths.runtimeDir, "openclaw", "package"); const vendorPackageDir = path.join(paths.runtimeDir, "openclaw", "package");
const instrumentationDir = path.join(paths.runtimeDataDir, "workspace-runner", "instrumented-modules"); const instrumentationDir = path.join(paths.runtimeDataDir, "workspace-runner", "instrumented-modules");
...@@ -360,6 +524,11 @@ export class ProjectWorkspaceExecutorService { ...@@ -360,6 +524,11 @@ export class ProjectWorkspaceExecutorService {
settled = true; settled = true;
reject(buildWorkspaceExecutionError(message, errorCategory)); reject(buildWorkspaceExecutionError(message, errorCategory));
}; };
const collectArtifacts = (assistantSummary?: string) => collectWorkspaceExecutionArtifacts({
projectRoot: input.projectRoot,
beforeSnapshot: beforeArtifactSnapshot,
assistantSummary
}).catch(() => []);
child.stdout.setEncoding("utf8"); child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => { child.stdout.on("data", (chunk: string) => {
...@@ -401,14 +570,17 @@ export class ProjectWorkspaceExecutorService { ...@@ -401,14 +570,17 @@ export class ProjectWorkspaceExecutorService {
const content = typeof event.content === "string" && event.content.trim() const content = typeof event.content === "string" && event.content.trim()
? event.content ? event.content
: extractReplyText(event.result); : extractReplyText(event.result);
resolve({ void collectArtifacts(content).then((artifacts) => {
runId: activeRunId, resolve({
reply: { runId: activeRunId,
id: randomUUID(), artifacts,
role: "assistant", reply: {
content, id: randomUUID(),
createdAt: new Date().toISOString() role: "assistant",
} content,
createdAt: new Date().toISOString()
}
});
}); });
return; return;
} }
...@@ -418,14 +590,18 @@ export class ProjectWorkspaceExecutorService { ...@@ -418,14 +590,18 @@ export class ProjectWorkspaceExecutorService {
return; return;
} }
settled = true; settled = true;
resolve({ const content = typeof event.content === "string" && event.content.trim()
runId: activeRunId, ? event.content
handoff: { : input.userPrompt?.trim() || input.prompt;
target: "chat-fallback", void collectArtifacts(content).then((artifacts) => {
content: typeof event.content === "string" && event.content.trim() resolve({
? event.content runId: activeRunId,
: input.userPrompt?.trim() || input.prompt artifacts,
} handoff: {
target: "chat-fallback",
content
}
});
}); });
return; return;
} }
...@@ -473,4 +649,3 @@ export class ProjectWorkspaceExecutorService { ...@@ -473,4 +649,3 @@ export class ProjectWorkspaceExecutorService {
}); });
} }
} }
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import type { TaskPanelArtifact, TaskPanelItem } from "@qjclaw/shared-types";
interface TaskPanelState {
itemsByDate: Record<string, TaskPanelItem[]>;
}
export interface RecordWorkspaceExecutionInput {
sessionId: string;
runId: string;
date?: string;
expertName: string;
taskTitle: string;
completedAt?: string;
messageCount?: number;
artifacts: TaskPanelArtifact[];
}
const VALID_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function createEmptyState(): TaskPanelState {
return {
itemsByDate: {}
};
}
function hashValue(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
function normalizeArtifactKey(artifact: TaskPanelArtifact): string {
const key = artifact.path?.trim() || artifact.url?.trim() || artifact.name.trim() || artifact.id;
return key.replace(/[\\/]+/g, "/").toLowerCase();
}
function dedupeArtifacts(artifacts: TaskPanelArtifact[]): TaskPanelArtifact[] {
const seen = new Set<string>();
const deduped: TaskPanelArtifact[] = [];
for (const artifact of artifacts) {
const key = normalizeArtifactKey(artifact);
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
deduped.push({
...artifact,
id: artifact.id || "artifact-" + hashValue(key),
url: artifact.url ?? artifact.path
});
}
return deduped;
}
function toLocalDateInputValue(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function toLocalTimeValue(date: Date): string {
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
const second = String(date.getSeconds()).padStart(2, "0");
return `${hour}:${minute}:${second}`;
}
function parseCompletedAt(value?: string): Date {
const date = value ? new Date(value) : new Date();
return Number.isNaN(date.getTime()) ? new Date() : date;
}
function isTaskPanelState(value: unknown): value is TaskPanelState {
return Boolean(
value
&& typeof value === "object"
&& !Array.isArray(value)
&& typeof (value as { itemsByDate?: unknown }).itemsByDate === "object"
&& !Array.isArray((value as { itemsByDate?: unknown }).itemsByDate)
);
}
export class TaskPanelService {
private readonly statePath: string;
private writeChain: Promise<unknown> = Promise.resolve();
constructor(userDataPath: string) {
this.statePath = path.join(userDataPath, "task-panel", "state.json");
}
async listByDate(date: string): Promise<TaskPanelItem[]> {
if (!VALID_DATE_PATTERN.test(date)) {
return [];
}
const state = await this.loadState();
return [...(state.itemsByDate[date] ?? [])];
}
async recordWorkspaceExecution(input: RecordWorkspaceExecutionInput): Promise<TaskPanelItem | null> {
const operation = this.writeChain
.catch(() => undefined)
.then(() => this.recordWorkspaceExecutionUnlocked(input));
this.writeChain = operation;
return operation;
}
private async recordWorkspaceExecutionUnlocked(input: RecordWorkspaceExecutionInput): Promise<TaskPanelItem | null> {
const completedAt = parseCompletedAt(input.completedAt);
const date = input.date && VALID_DATE_PATTERN.test(input.date)
? input.date
: toLocalDateInputValue(completedAt);
const runKey = `${input.sessionId.trim()}::${input.runId.trim() || randomUUID()}`;
const itemId = "task-" + hashValue(runKey);
const artifacts = dedupeArtifacts(input.artifacts);
const nextItem: TaskPanelItem = {
id: itemId,
date,
expertName: input.expertName,
taskTitle: input.taskTitle,
status: "completed",
statusDetail: artifacts.length ? `已完成,识别到 ${artifacts.length} 个产物` : "已完成",
creditsUsed: 0,
messageCount: input.messageCount ?? 2,
updatedAt: toLocalTimeValue(completedAt),
artifacts
};
const state = await this.loadState();
const items = [...(state.itemsByDate[date] ?? [])];
const existingIndex = items.findIndex((item) => item.id === itemId);
if (existingIndex >= 0) {
const existing = items[existingIndex]!;
nextItem.artifacts = dedupeArtifacts([...existing.artifacts, ...nextItem.artifacts]);
nextItem.statusDetail = nextItem.artifacts.length
? `已完成,识别到 ${nextItem.artifacts.length} 个产物`
: "已完成";
items[existingIndex] = nextItem;
} else {
items.push(nextItem);
}
state.itemsByDate[date] = items.sort((left, right) => {
const leftKey = `${left.date} ${left.updatedAt ?? ""}`;
const rightKey = `${right.date} ${right.updatedAt ?? ""}`;
return rightKey.localeCompare(leftKey);
});
await this.saveState(state);
return nextItem;
}
private async loadState(): Promise<TaskPanelState> {
try {
const raw = await readFile(this.statePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
return isTaskPanelState(parsed) ? parsed : createEmptyState();
} catch {
return createEmptyState();
}
}
private async saveState(state: TaskPanelState): Promise<void> {
await mkdir(path.dirname(this.statePath), { recursive: true });
const tempPath = this.statePath + ".tmp";
await writeFile(tempPath, JSON.stringify(state, null, 2), "utf8");
await rename(tempPath, this.statePath);
}
}
import test from "node:test"
import assert from "node:assert/strict"
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import {
collectWorkspaceExecutionArtifacts,
createWorkspaceArtifactSnapshot
} from "../src/main/services/project-workspace-executor.ts"
async function withProjectRoot<T>(run: (projectRoot: string) => Promise<T>) {
const projectRoot = await mkdtemp(path.join(tmpdir(), "qjc-workspace-artifacts-"))
try {
return await run(projectRoot)
} finally {
await rm(projectRoot, { recursive: true, force: true })
}
}
test("detects new supported documents and summarizes markdown content", async () => {
await withProjectRoot(async (projectRoot) => {
const before = await createWorkspaceArtifactSnapshot(projectRoot)
await writeFile(path.join(projectRoot, "result.md"), "这是本次产物摘要内容,用于工作台展示。".repeat(6), "utf8")
const artifacts = await collectWorkspaceExecutionArtifacts({
projectRoot,
beforeSnapshot: before,
assistantSummary: "assistant fallback"
})
assert.equal(artifacts.length, 1)
assert.equal(artifacts[0]?.name, "result.md")
assert.equal(artifacts[0]?.kind, "文档")
assert.equal(artifacts[0]?.path, path.join(projectRoot, "result.md"))
assert.equal(artifacts[0]?.url, path.join(projectRoot, "result.md"))
assert.match(artifacts[0]?.summary ?? "", /^这是本次产物摘要内容/)
assert.ok(artifacts[0]?.producedAt)
})
})
test("detects modified files and ignores unsupported or excluded paths", async () => {
await withProjectRoot(async (projectRoot) => {
await writeFile(path.join(projectRoot, "existing.txt"), "before", "utf8")
const before = await createWorkspaceArtifactSnapshot(projectRoot)
await new Promise((resolve) => setTimeout(resolve, 20))
await writeFile(path.join(projectRoot, "existing.txt"), "after", "utf8")
await writeFile(path.join(projectRoot, "scratch.tmp"), "ignore", "utf8")
await mkdir(path.join(projectRoot, "node_modules"), { recursive: true })
await writeFile(path.join(projectRoot, "node_modules", "package.md"), "ignore", "utf8")
await mkdir(path.join(projectRoot, "memory", "workspace-runner"), { recursive: true })
await writeFile(path.join(projectRoot, "memory", "workspace-runner", "trace.md"), "ignore", "utf8")
const artifacts = await collectWorkspaceExecutionArtifacts({
projectRoot,
beforeSnapshot: before,
assistantSummary: "assistant fallback"
})
assert.deepEqual(artifacts.map((artifact) => artifact.name), ["existing.txt"])
assert.equal(artifacts[0]?.kind, "文档")
})
})
test("infers image video and spreadsheet artifact kinds", async () => {
await withProjectRoot(async (projectRoot) => {
const before = await createWorkspaceArtifactSnapshot(projectRoot)
await writeFile(path.join(projectRoot, "cover.png"), "image", "utf8")
await writeFile(path.join(projectRoot, "clip.mp4"), "video", "utf8")
await writeFile(path.join(projectRoot, "data.xlsx"), "sheet", "utf8")
const artifacts = await collectWorkspaceExecutionArtifacts({
projectRoot,
beforeSnapshot: before,
assistantSummary: "assistant fallback"
})
assert.deepEqual(
Object.fromEntries(artifacts.map((artifact) => [artifact.name, artifact.kind])),
{
"clip.mp4": "视频",
"cover.png": "图片",
"data.xlsx": "表格"
}
)
})
})
import test from "node:test"
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
const ipcSource = readFileSync(new URL("../src/main/ipc.ts", import.meta.url), "utf8")
const indexSource = readFileSync(new URL("../src/main/index.ts", import.meta.url), "utf8")
test("desktop IPC wires tasks:list-by-date to TaskPanelService", () => {
assert.match(ipcSource, /taskPanelService\.listByDate\(date\)/)
assert.doesNotMatch(ipcSource, /tasksListByDate,\s*async \(\) => \[\]/)
assert.match(indexSource, /new TaskPanelService\(systemSummary\.userDataPath\)/)
})
test("workspace-entry success paths archive xhs and douyin executions without throwing", () => {
assert.match(ipcSource, /recordWorkspaceTaskPanelExecution/)
assert.match(ipcSource, /xiaohongshu\|xhs\|小红书/)
assert.match(ipcSource, /douyin\|抖音\|tiktok/)
})
test("task panel expert matching prioritizes project id before filesystem path", () => {
assert.match(ipcSource, /const normalizedProjectId = projectId\.trim\(\)\.toLowerCase\(\)/)
assert.match(ipcSource, /const projectBaseName = path\.basename\(projectRoot\)\.toLowerCase\(\)/)
assert.match(ipcSource, /isXhsProject\(normalizedProjectId\)/)
assert.match(ipcSource, /isDouyinProject\(normalizedProjectId\)/)
assert.doesNotMatch(ipcSource, /`\$\{projectId\} \$\{path\.basename\(projectRoot\)\} \$\{projectRoot\}`/)
})
import test from "node:test"
import assert from "node:assert/strict"
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { TaskPanelService } from "../src/main/services/task-panel-service.ts"
async function withService<T>(run: (service: TaskPanelService, userDataPath: string) => Promise<T>) {
const userDataPath = await mkdtemp(path.join(tmpdir(), "qjc-task-panel-"))
try {
const service = new TaskPanelService(userDataPath)
return await run(service, userDataPath)
} finally {
await rm(userDataPath, { recursive: true, force: true })
}
}
test("returns an empty list for empty state and invalid dates", async () => {
await withService(async (service) => {
assert.deepEqual(await service.listByDate("2026-05-15"), [])
assert.deepEqual(await service.listByDate("2026/05/15"), [])
})
})
test("records expert workspace executions and sorts newest first", async () => {
await withService(async (service) => {
await service.recordWorkspaceExecution({
sessionId: "session-xhs",
runId: "run-xhs",
date: "2026-05-15",
expertName: "小红书专家",
taskTitle: "整理小红书选题",
completedAt: "2026-05-15T02:00:00.000Z",
artifacts: [
{ id: "a", name: "xhs.md", kind: "文档", path: "/tmp/xhs.md", url: "/tmp/xhs.md", producedAt: "2026-05-15 10:00:00" }
]
})
await service.recordWorkspaceExecution({
sessionId: "session-douyin",
runId: "run-douyin",
date: "2026-05-15",
expertName: "抖音专家",
taskTitle: "整理抖音脚本",
completedAt: "2026-05-15T03:00:00.000Z",
artifacts: [
{ id: "b", name: "douyin.csv", kind: "表格", path: "/tmp/douyin.csv", url: "/tmp/douyin.csv", producedAt: "2026-05-15 11:00:00" }
]
})
const items = await service.listByDate("2026-05-15")
assert.equal(items.length, 2)
assert.equal(items[0]?.expertName, "抖音专家")
assert.equal(items[1]?.expertName, "小红书专家")
assert.equal(items[0]?.messageCount, 2)
})
})
test("dedupes repeated runs and repeated artifact paths", async () => {
await withService(async (service) => {
const input = {
sessionId: "session-xhs",
runId: "run-xhs",
date: "2026-05-15",
expertName: "小红书专家",
taskTitle: "整理小红书选题",
completedAt: "2026-05-15T02:00:00.000Z",
artifacts: [
{ id: "one", name: "xhs.md", kind: "文档", path: "/tmp/xhs.md", url: "/tmp/xhs.md", producedAt: "2026-05-15 10:00:00" },
{ id: "two", name: "xhs-copy.md", kind: "文档", path: "/tmp/xhs.md", url: "/tmp/xhs.md", producedAt: "2026-05-15 10:00:00" }
]
}
await service.recordWorkspaceExecution(input)
await service.recordWorkspaceExecution(input)
const items = await service.listByDate("2026-05-15")
assert.equal(items.length, 1)
assert.equal(items[0]?.artifacts.length, 1)
})
})
test("preserves all tasks when multiple executions are recorded concurrently", async () => {
await withService(async (service) => {
await Promise.all(Array.from({ length: 12 }, (_, index) => service.recordWorkspaceExecution({
sessionId: `session-${index}`,
runId: `run-${index}`,
date: "2026-05-15",
expertName: index % 2 === 0 ? "小红书专家" : "抖音专家",
taskTitle: `并发任务 ${index}`,
completedAt: `2026-05-15T02:00:${String(index).padStart(2, "0")}.000Z`,
artifacts: [
{ id: `artifact-${index}`, name: `result-${index}.md`, kind: "文档", path: `/tmp/result-${index}.md`, url: `/tmp/result-${index}.md` }
]
})))
const items = await service.listByDate("2026-05-15")
assert.equal(items.length, 12)
assert.deepEqual(new Set(items.map((item) => item.id)).size, 12)
})
})
test("falls back to empty state when the state file is corrupt", async () => {
await withService(async (service, userDataPath) => {
await mkdir(path.join(userDataPath, "task-panel"), { recursive: true })
await writeFile(path.join(userDataPath, "task-panel", "state.json"), "{bad json", "utf8")
assert.deepEqual(await service.listByDate("2026-05-15"), [])
})
})
...@@ -85,7 +85,7 @@ function TaskPanelMetricIcon({ kind }: { kind: "credits" | "messages" | "artifac ...@@ -85,7 +85,7 @@ function TaskPanelMetricIcon({ kind }: { kind: "credits" | "messages" | "artifac
} }
function TaskPanelOutputIcon({ artifact }: { artifact: TaskPanelArtifact }) { function TaskPanelOutputIcon({ artifact }: { artifact: TaskPanelArtifact }) {
const normalizedKind = [artifact.kind, artifact.name, artifact.url].filter(Boolean).join(" ").toLowerCase() const normalizedKind = [artifact.kind, artifact.name, artifact.path, artifact.url].filter(Boolean).join(" ").toLowerCase()
if (/视频|video|mp4|mov|m4v|avi|webm/.test(normalizedKind)) { if (/视频|video|mp4|mov|m4v|avi|webm/.test(normalizedKind)) {
return ( return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
...@@ -134,7 +134,12 @@ function TaskPanelStatCards({ items }: { items: TaskPanelItem[] }) { ...@@ -134,7 +134,12 @@ function TaskPanelStatCards({ items }: { items: TaskPanelItem[] }) {
) )
} }
function formatTaskPanelOutputTime(task: TaskPanelItem) { function formatTaskPanelOutputTime(task: TaskPanelItem, artifact?: TaskPanelArtifact) {
const producedAtText = artifact ? artifact.producedAt ?? "" : ""
if (producedAtText.trim()) {
return producedAtText.trim()
}
const dateText = task.date.replaceAll("-", "/") const dateText = task.date.replaceAll("-", "/")
const timeText = task.updatedAt?.trim() ?? "00:00:00" const timeText = task.updatedAt?.trim() ?? "00:00:00"
const timeMatch = timeText.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/) const timeMatch = timeText.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
...@@ -191,7 +196,9 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) { ...@@ -191,7 +196,9 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) {
</div> </div>
</div> </div>
<div className="task-panel-output-list"> <div className="task-panel-output-list">
{outputs.length ? outputs.map(({ artifact, task }) => ( {outputs.length ? outputs.map(({ artifact, task }) => {
const artifactPath = artifact.path ?? artifact.url
return (
<article key={task.id + "-" + artifact.id} className="task-panel-output-item"> <article key={task.id + "-" + artifact.id} className="task-panel-output-item">
<span className="task-panel-output-icon" aria-hidden="true"> <span className="task-panel-output-icon" aria-hidden="true">
<TaskPanelOutputIcon artifact={artifact} /> <TaskPanelOutputIcon artifact={artifact} />
...@@ -202,15 +209,15 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) { ...@@ -202,15 +209,15 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) {
<span className="task-panel-output-kind">{artifact.kind ?? "产物"}</span> <span className="task-panel-output-kind">{artifact.kind ?? "产物"}</span>
</div> </div>
<p>{artifact.summary ?? task.taskTitle}</p> <p>{artifact.summary ?? task.taskTitle}</p>
{artifact.url ? ( {artifactPath ? (
<div className="task-panel-output-url-row"> <div className="task-panel-output-url-row">
<button <button
type="button" type="button"
className="task-panel-output-url" className="task-panel-output-url"
title={artifact.url} title={artifactPath}
onClick={() => void copyArtifactUrl(artifact.id, artifact.url ?? "")} onClick={() => void copyArtifactUrl(artifact.id, artifactPath)}
> >
{artifact.url} {artifactPath}
</button> </button>
{copiedArtifactId === artifact.id ? ( {copiedArtifactId === artifact.id ? (
<span className="task-panel-artifact-copied" aria-live="polite">✅已复制</span> <span className="task-panel-artifact-copied" aria-live="polite">✅已复制</span>
...@@ -224,11 +231,12 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) { ...@@ -224,11 +231,12 @@ function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) {
</span> </span>
<div> <div>
<strong title={task.expertName}>{task.expertName}</strong> <strong title={task.expertName}>{task.expertName}</strong>
<span title={task.taskTitle}>{formatTaskPanelOutputTime(task)} · {task.taskTitle}</span> <span title={task.taskTitle}>{formatTaskPanelOutputTime(task, artifact)} · {task.taskTitle}</span>
</div> </div>
</div> </div>
</article> </article>
)) : ( )
}) : (
<div className="empty-state task-panel-state task-panel-output-empty"> <div className="empty-state task-panel-state task-panel-output-empty">
当前日期暂无内容产出 当前日期暂无内容产出
</div> </div>
......
...@@ -146,5 +146,8 @@ export function summarizeTaskPanelItems(items: TaskPanelItem[]): TaskPanelSummar ...@@ -146,5 +146,8 @@ export function summarizeTaskPanelItems(items: TaskPanelItem[]): TaskPanelSummar
} }
export async function loadTaskPanelItems(date: string): Promise<TaskPanelItem[]> { export async function loadTaskPanelItems(date: string): Promise<TaskPanelItem[]> {
if (typeof window !== "undefined" && window.qjcDesktop) {
return window.qjcDesktop.tasks.listByDate(date)
}
return mockTaskPanelItems.filter((item) => item.date === date) return mockTaskPanelItems.filter((item) => item.date === date)
} }
import test from "node:test"
import assert from "node:assert/strict"
test("loads real task panel data from the desktop bridge when available", async () => {
const calls: string[] = []
globalThis.window = {
qjcDesktop: {
tasks: {
listByDate: async (date: string) => {
calls.push(date)
return [
{
id: "real-task",
date,
expertName: "小红书专家",
taskTitle: "真实任务",
status: "completed",
statusDetail: "已完成",
messageCount: 2,
updatedAt: "10:00:00",
artifacts: []
}
]
}
}
}
} as unknown as Window & typeof globalThis
const { loadTaskPanelItems } = await import("../src/features/tasks/taskPanelData.ts")
const items = await loadTaskPanelItems("2026-05-15")
assert.deepEqual(calls, ["2026-05-15"])
assert.equal(items[0]?.expertName, "小红书专家")
})
test("summarizes task panel items from real artifacts and messages", async () => {
const { summarizeTaskPanelItems } = await import("../src/features/tasks/taskPanelData.ts")
assert.deepEqual(summarizeTaskPanelItems([
{
id: "one",
date: "2026-05-15",
expertName: "小红书专家",
taskTitle: "one",
status: "completed",
statusDetail: "已完成",
messageCount: 2,
updatedAt: "10:00:00",
artifacts: [
{ id: "a", name: "a.md", path: "/tmp/a.md" },
{ id: "b", name: "b.png", path: "/tmp/b.png" }
]
},
{
id: "two",
date: "2026-05-15",
expertName: "抖音专家",
taskTitle: "two",
status: "completed",
statusDetail: "已完成",
messageCount: 2,
updatedAt: "11:00:00",
artifacts: [
{ id: "c", name: "c.mp4", path: "/tmp/c.mp4" }
]
}
]), {
creditsUsed: 0,
messageCount: 4,
artifactCount: 3,
employeeCount: 2
})
})
import test from "node:test"
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
const source = readFileSync(new URL("../src/features/tasks/TaskPanelView.tsx", import.meta.url), "utf8")
test("task panel output uses real artifact paths and produced timestamps", () => {
assert.match(source, /artifact\.path\s*\?\?\s*artifact\.url/)
assert.match(source, /artifact\.producedAt\s*\?\?/)
})
...@@ -863,6 +863,8 @@ export interface TaskPanelArtifact { ...@@ -863,6 +863,8 @@ export interface TaskPanelArtifact {
kind?: string; kind?: string;
summary?: string; summary?: string;
url?: string; url?: string;
path?: string;
producedAt?: string;
} }
export interface TaskPanelItem { export interface TaskPanelItem {
......
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