feat(coding): add conversation archive and first-message titles

This commit is contained in:
2026-09-20 12:43:51 +08:00
parent d61226532a
commit fd0293fe3d
22 changed files with 698 additions and 69 deletions

View File

@@ -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);

View 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);
}