Merge permanent conversation deletion and simplified project configuration

This commit is contained in:
鲨鱼辣椒 committed 2026-10-14 12:41:37 +08:00
commit c77d8cf3a2
21 files changed
+1104 -326

No files matched your search

+4
View File
@@ -41,6 +41,7 @@ import { createDataServiceOperations } from '../services/data-service-client';
import { createPreviewDataSessionManager, type PreviewDataSessionManager } from '../services/preview-data-session';
import {
archivePiConversationSession,
deletePiConversationSession,
getPiManagedPaths,
} from '../coding-runtime/pi/resource-loader';
import { createProjectPluginService } from '../coding-plugins/project-service';
@@ -515,6 +516,9 @@ export function createCodingComposition(
const conversations = new CodingConversationService(projects, runtime, {
readHistory: (projectId, conversation) => readCodingConversationHistory(options.paths.userDataDir, projectId, conversation),
onDelete: (projectId, conversationId) => teacher.removeSource(projectId, conversationId),
deleteSession: async ({ projectId, sessionKey }) => {
await deletePiConversationSession({ userDataDir: options.paths.userDataDir, projectId, sessionKey });
},
archiveSession: async ({ projectId, sessionKey }) => {
await archivePiConversationSession({
userDataDir: options.paths.userDataDir,
@@ -50,6 +50,7 @@ interface AcceptanceRecord {
export interface CodingConversationServiceOptions {
readHistory?(projectId: string, conversation: CodingConversationV2): Promise<ConversationSnapshot>;
onDelete?(projectId: string, conversationId: string): Promise<void>;
deleteSession?(input: { projectId: string; sessionKey: string }): Promise<void>;
archiveSession?(input: {
projectId: string;
sessionKey: string;
@@ -267,6 +268,7 @@ class PatchBatchScheduler {
export class CodingConversationService {
private readonly prepareFlights = new Map<string, Promise<PrepareConversationInput>>();
private readonly acceptances = new Map<string, AcceptanceRecord>();
private readonly deleteFlights = new Map<string, Promise<void>>();
constructor(
readonly projects: CodingProjectService,
@@ -307,7 +309,9 @@ export class CodingConversationService {
}
private async assertUnarchived(conversationId: string): Promise<void> {
this.assertNotDeleting(conversationId);
const { conversation } = await this.projects.findActiveConversation(conversationId);
this.assertNotDeleting(conversationId);
if (conversation.archivedAt) {
throw new CodingConversationServiceError(409, 'CODING_CONVERSATION_ARCHIVED', '请先恢复已归档的对话,再继续操作。');
}
@@ -380,13 +384,26 @@ export class CodingConversationService {
}
async deleteConversation(conversationId: string): Promise<void> {
const { project, conversation } = await this.projects.findActiveConversation(conversationId);
const prior = this.deleteFlights.get(conversationId);
if (prior) return await prior;
const flight = this.performDelete(conversationId).finally(() => {
if (this.deleteFlights.get(conversationId) === flight) this.deleteFlights.delete(conversationId);
});
this.deleteFlights.set(conversationId, flight);
return await flight;
}
private async performDelete(conversationId: string): Promise<void> {
const { project } = await this.projects.findActiveConversation(conversationId);
await this.prepareFlights.get(conversationId)?.catch(() => undefined);
try {
await this.runtime.dispose(conversationId, 'conversation_deleted');
} catch (error) {
runtimeError(error);
}
await this.archiveSession(project.id, conversation.sessionKey);
// A prepare already in flight may have created the durable session binding.
const conversation = await this.projects.conversationStore(project.path).get(conversationId);
await this.deleteSession(project.id, conversation?.sessionKey);
await this.options.onDelete?.(project.id, conversationId);
await persist(() => this.projects.conversationStore(project.path).delete(conversationId));
this.titleContexts.delete(conversationId);
@@ -394,6 +411,7 @@ export class CodingConversationService {
for (const key of [...this.acceptances.keys()]) {
if (key.startsWith(`${conversationId}\u0000`)) this.acceptances.delete(key);
}
this.metadataChanged(project.id, conversationId);
}
async getSnapshot(conversationId: string): Promise<ConversationSnapshot> {
@@ -426,6 +444,7 @@ export class CodingConversationService {
attachments?: unknown;
}): Promise<CodingPromptAcceptance> {
const conversationId = requiredString(input.conversationId, 'Conversation id', 128);
this.assertNotDeleting(conversationId);
const clientRequestId = typeof input.clientRequestId === 'string' ? input.clientRequestId : '';
if (!REQUEST_ID_PATTERN.test(clientRequestId)) {
throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', 'Client request id is invalid');
@@ -469,6 +488,7 @@ export class CodingConversationService {
try {
await this.assertUnarchived(conversationId);
await this.ensurePrepared(conversationId);
this.assertNotDeleting(conversationId);
const acceptance = await this.runtime.prompt({
conversationId,
clientRequestId,
@@ -732,6 +752,7 @@ export class CodingConversationService {
private ensurePrepared(conversationId: string): Promise<PrepareConversationInput> {
const id = conversationId.trim();
this.assertNotDeleting(id);
const prior = this.prepareFlights.get(id);
if (prior) return prior;
const flight = (async () => {
@@ -756,6 +777,7 @@ export class CodingConversationService {
modelResolution: conversation.modelResolution,
},
};
this.assertNotDeleting(id);
try { await this.runtime.prepare(prepared); } catch (error) { runtimeError(error); }
if (this.titleContexts.has(id)) {
const snapshot = await this.runtime.getSnapshot(id);
@@ -806,4 +828,18 @@ export class CodingConversationService {
}
await persist(() => this.options.archiveSession!({ projectId, sessionKey }));
}
private assertNotDeleting(conversationId: string): void {
if (this.deleteFlights.has(conversationId)) {
throw new CodingConversationServiceError(409, 'CODING_CONVERSATION_DELETING', '对话正在删除,请稍候。');
}
}
private async deleteSession(projectId: string, sessionKey: string | undefined): Promise<void> {
if (!sessionKey) return;
if (!this.options.deleteSession) {
throw new CodingConversationServiceError(500, 'CODING_STORAGE_WRITE_FAILED', 'Coding session deletion is unavailable');
}
await persist(() => this.options.deleteSession!({ projectId, sessionKey }));
}
}
+28 -1
View File
@@ -1,4 +1,4 @@
import { mkdir, rename, stat } from 'node:fs/promises';
import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { atomicWriteJson, atomicWriteText } from '../../coding-projects/atomic-json';
import { validateSessionKey } from '../../coding-projects/conversation-store';
@@ -166,6 +166,33 @@ export async function archivePiConversationSession(input: {
}
}
export async function deletePiConversationSession(input: {
userDataDir: string;
projectId: string;
sessionKey: string;
}): Promise<void> {
const projectId = managedSegment(input.projectId, 'Project id');
const sessionKey = validateSessionKey(input.sessionKey);
const paths = getPiManagedPaths(input.userDataDir);
const basename = `${sessionKey}.jsonl`;
for (const root of [paths.sessionsDir, paths.trashDir]) {
const directory = path.join(root, projectId);
const entries = await readdir(directory, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return [];
throw error;
});
for (const entry of entries) {
// Pi 0.84 stores sessions as <ISO timestamp>_<session id>.jsonl;
// earlier managed sessions used the session id alone.
const timestamp = entry.name.endsWith(`_${basename}`)
? entry.name.slice(0, -(`_${basename}`.length))
: '';
if (entry.name !== basename && !/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/u.test(timestamp)) continue;
await rm(path.join(directory, entry.name), { force: true });
}
}
}
function normalizeSkillEntries(skillEntries: readonly PiSkillEntry[]): PiSkillEntry[] {
const result: PiSkillEntry[] = [];
const seen = new Set<string>();