feat(coding): add conversation archive and first-message titles
This commit is contained in:
@@ -14,6 +14,7 @@ import type {
|
||||
PromptMode,
|
||||
} from './contracts';
|
||||
import type { CodingConversationV2 } from '../coding-projects/conversation-store';
|
||||
import { firstMessageTitle } from './conversation-title';
|
||||
import {
|
||||
CodingProjectService,
|
||||
CodingProjectServiceError,
|
||||
@@ -57,6 +58,7 @@ export interface CodingConversationServiceOptions {
|
||||
}
|
||||
|
||||
export type CodingConversationStreamEvent =
|
||||
| { type: 'conversation.metadata-changed'; projectId: string; conversationId: string }
|
||||
| {
|
||||
type: 'snapshot';
|
||||
conversationId: string;
|
||||
@@ -125,7 +127,8 @@ async function persist<T>(operation: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
|
||||
function publicConversation(conversation: CodingConversationV2): CodingConversationV2 {
|
||||
const { piSessionId: _piSessionId, sessionKey: _sessionKey, ...safe } = conversation;
|
||||
const { piSessionId: _piSessionId, sessionKey: _sessionKey,
|
||||
titleMode: _titleMode, autoTitleSet: _autoTitleSet, ...safe } = conversation;
|
||||
return safe;
|
||||
}
|
||||
|
||||
@@ -267,7 +270,46 @@ export class CodingConversationService {
|
||||
readonly projects: CodingProjectService,
|
||||
readonly runtime: CodingConversationRuntime,
|
||||
private readonly options: CodingConversationServiceOptions = {},
|
||||
) {}
|
||||
) {
|
||||
this.unsubscribeTitles = runtime.subscribe((event) => {
|
||||
if (event.patch.op !== 'message.upsert') return;
|
||||
const title = firstMessageTitle(event.patch.node);
|
||||
if (!title) return;
|
||||
const context = this.titleContexts.get(event.conversationId);
|
||||
if (!context) return;
|
||||
void this.setFirstMessageTitle(context, event.conversationId, title).catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
private readonly titleContexts = new Map<string, { id: string; path: string }>();
|
||||
private readonly metadataListeners = new Set<(event: CodingConversationStreamEvent) => void>();
|
||||
private readonly unsubscribeTitles: () => void;
|
||||
|
||||
private async setFirstMessageTitle(project: { id: string; path: string }, id: string, title: string): Promise<void> {
|
||||
if (await this.projects.conversationStore(project.path).setFirstMessageTitle(id, title)) {
|
||||
this.metadataChanged(project.id, id);
|
||||
}
|
||||
this.titleContexts.delete(id);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.unsubscribeTitles();
|
||||
this.titleContexts.clear();
|
||||
this.metadataListeners.clear();
|
||||
}
|
||||
|
||||
private metadataChanged(projectId: string, conversationId: string): void {
|
||||
for (const listener of this.metadataListeners) {
|
||||
listener({ type: 'conversation.metadata-changed', projectId, conversationId });
|
||||
}
|
||||
}
|
||||
|
||||
private async assertUnarchived(conversationId: string): Promise<void> {
|
||||
const { conversation } = await this.projects.findActiveConversation(conversationId);
|
||||
if (conversation.archivedAt) {
|
||||
throw new CodingConversationServiceError(409, 'CODING_CONVERSATION_ARCHIVED', '请先恢复已归档的对话,再继续操作。');
|
||||
}
|
||||
}
|
||||
|
||||
async listConversations(projectId?: string): Promise<CodingConversationV2[]> {
|
||||
const project = projectId
|
||||
@@ -295,6 +337,7 @@ export class CodingConversationService {
|
||||
const created = await persist(() => this.projects.conversationStore(project.path).create({
|
||||
agentId,
|
||||
title: requiredString(input.title, 'Conversation title', MAX_TITLE_LENGTH),
|
||||
titleMode: input.title === '新对话' ? 'automatic' : 'manual',
|
||||
model: agent.model,
|
||||
modelResolution: agent.modelResolution,
|
||||
}));
|
||||
@@ -330,6 +373,7 @@ export class CodingConversationService {
|
||||
: {}),
|
||||
...(typeof patch.unread === 'boolean' ? { unread: patch.unread } : {}),
|
||||
}));
|
||||
this.metadataChanged(project.id, conversationId);
|
||||
return publicConversation(updated);
|
||||
}
|
||||
|
||||
@@ -342,6 +386,7 @@ export class CodingConversationService {
|
||||
}
|
||||
await this.archiveSession(project.id, conversation.sessionKey);
|
||||
await persist(() => this.projects.conversationStore(project.path).delete(conversationId));
|
||||
this.titleContexts.delete(conversationId);
|
||||
this.prepareFlights.delete(conversationId);
|
||||
for (const key of [...this.acceptances.keys()]) {
|
||||
if (key.startsWith(`${conversationId}\u0000`)) this.acceptances.delete(key);
|
||||
@@ -406,6 +451,7 @@ export class CodingConversationService {
|
||||
const acceptanceState: AcceptanceRecord['state'] = { value: 'protected' };
|
||||
const flight = (async (): Promise<CodingPromptAcceptance> => {
|
||||
try {
|
||||
await this.assertUnarchived(conversationId);
|
||||
await this.ensurePrepared(conversationId);
|
||||
const acceptance = await this.runtime.prompt({
|
||||
conversationId,
|
||||
@@ -497,6 +543,7 @@ export class CodingConversationService {
|
||||
}
|
||||
|
||||
async fork(sourceConversationId: string, sourceEntryId?: string): Promise<CodingConversationV2> {
|
||||
await this.assertUnarchived(sourceConversationId);
|
||||
const entryId = requiredString(sourceEntryId, 'Fork source entry id', 256);
|
||||
const prepared = await this.ensurePrepared(sourceConversationId);
|
||||
let sourceSnapshot: ConversationSnapshot;
|
||||
@@ -524,6 +571,7 @@ export class CodingConversationService {
|
||||
const created = await persist(() => store.create({
|
||||
agentId: source.conversation.agentId,
|
||||
title: `${source.conversation.title} (fork)`,
|
||||
titleMode: 'manual',
|
||||
model: source.conversation.model,
|
||||
modelResolution: source.conversation.modelResolution,
|
||||
}));
|
||||
@@ -591,6 +639,10 @@ export class CodingConversationService {
|
||||
: requiredString(conversationId, 'Conversation id', 128);
|
||||
if (id) await this.projects.findActiveConversation(id);
|
||||
const queue = new PatchQueue();
|
||||
const metadataListener = (event: CodingConversationStreamEvent) => {
|
||||
if (!id || event.conversationId === id) queue.push(event);
|
||||
};
|
||||
this.metadataListeners.add(metadataListener);
|
||||
const batches = new PatchBatchScheduler((event) => queue.push(event), this.options);
|
||||
const cursors = new Map<string, ConversationSnapshot['cursor']>();
|
||||
let initialize!: () => void;
|
||||
@@ -642,6 +694,7 @@ export class CodingConversationService {
|
||||
snapshots,
|
||||
events: queue,
|
||||
close: () => {
|
||||
this.metadataListeners.delete(metadataListener);
|
||||
unsubscribe();
|
||||
batches.close();
|
||||
queue.close();
|
||||
@@ -649,6 +702,7 @@ export class CodingConversationService {
|
||||
};
|
||||
} catch (error) {
|
||||
initialize();
|
||||
this.metadataListeners.delete(metadataListener);
|
||||
unsubscribe();
|
||||
batches.close();
|
||||
queue.close();
|
||||
@@ -662,6 +716,9 @@ export class CodingConversationService {
|
||||
if (prior) return prior;
|
||||
const flight = (async () => {
|
||||
const { project, conversation } = await this.projects.findActiveConversation(id);
|
||||
if (conversation.titleMode === 'automatic' && !conversation.autoTitleSet) {
|
||||
this.titleContexts.set(id, { id: project.id, path: project.path });
|
||||
}
|
||||
const config = await this.projects.getConfig(project.id);
|
||||
const agent = config.config.agents.find((candidate) => (
|
||||
candidate.id === conversation.agentId && candidate.enabled && !candidate.archivedAt
|
||||
@@ -680,6 +737,11 @@ export class CodingConversationService {
|
||||
},
|
||||
};
|
||||
try { await this.runtime.prepare(prepared); } catch (error) { runtimeError(error); }
|
||||
if (this.titleContexts.has(id)) {
|
||||
const snapshot = await this.runtime.getSnapshot(id);
|
||||
const title = snapshot.nodes.map(firstMessageTitle).find((candidate) => candidate !== null);
|
||||
if (title) await this.setFirstMessageTitle(project, id, title).catch(() => undefined);
|
||||
}
|
||||
return prepared;
|
||||
})().finally(() => {
|
||||
if (this.prepareFlights.get(id) === flight) this.prepareFlights.delete(id);
|
||||
|
||||
11
electron/coding-runtime/conversation-title.ts
Normal file
11
electron/coding-runtime/conversation-title.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ConversationNode } from './contracts';
|
||||
|
||||
/** Only real user messages name a conversation; no model call or tool output. */
|
||||
export function firstMessageTitle(node: ConversationNode): string | null {
|
||||
if (node.kind !== 'message' || node.role !== 'user' || node.status !== 'complete') return null;
|
||||
const text = node.blocks.flatMap((block) => block.kind === 'text' ? [block.text] : []).join('\n').trim();
|
||||
if (text.startsWith('/')) return null;
|
||||
const firstLine = text.split(/\r?\n/)[0].replace(/\s+/g, ' ').trim();
|
||||
return [...firstLine].slice(0, 32).join('')
|
||||
|| (node.blocks.some((block) => block.kind === 'image') ? '附件对话' : null);
|
||||
}
|
||||
Reference in New Issue
Block a user