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

@@ -555,6 +555,7 @@ export function createCodingComposition(
await agentServer.stop();
},
async shutdown() {
conversations.dispose();
gameResourceDelivery.dispose();
previewDataSession?.dispose();
if (typeof options.browser.configurePreviewDataSession === 'function') {

View File

@@ -89,7 +89,8 @@ export async function handleCodingConversationRoutes(
res,
event.type,
event,
`${event.conversationId}:${event.workerGeneration}:${event.toSeq}`,
event.type === 'conversation.metadata-changed' ? undefined
: `${event.conversationId}:${event.workerGeneration}:${event.type === 'snapshot' ? event.seq : event.toSeq}`,
)) break;
}
} finally {

View File

@@ -10,6 +10,7 @@ const FIXED_CODING_ERROR_MESSAGES: Readonly<Record<string, string>> = {
CODING_AGENT_NOT_FOUND: '当前伙伴不可用,请重新选择。',
CODING_AGENT_ID_IMMUTABLE: '已有伙伴标识不能修改。',
CODING_CONVERSATION_NOT_FOUND: '指定的对话不存在。',
CODING_CONVERSATION_ARCHIVED: '请先恢复已归档的对话,再继续操作。',
CODING_CONVERSATION_REQUEST_INVALID: '对话请求无效,请检查输入。',
CODING_FILE_NOT_FOUND: '指定的项目文件不存在。',
CODING_FILE_REQUEST_INVALID: '文件请求无效,请检查输入。',

View File

@@ -10,6 +10,8 @@ export const CODING_CONVERSATIONS_PATH = '.makelore/conversations.json';
export interface CodingConversationV2 extends CodingConversationMetadata, ConversationModelState {
piSessionId?: string;
sessionKey?: string;
titleMode?: 'automatic' | 'manual';
autoTitleSet?: boolean;
}
export interface CodingConversationFileV2 {
@@ -22,6 +24,7 @@ export interface CreateCodingConversationInput {
title: string;
model: ProductModelRef | null;
modelResolution: 'resolved' | 'required';
titleMode?: 'automatic' | 'manual';
}
export interface PiSessionBinding {
@@ -83,6 +86,8 @@ function normalizeConversation(value: unknown): CodingConversationV2 {
id,
agentId,
title,
titleMode: record.titleMode === 'automatic' ? 'automatic' : 'manual',
autoTitleSet: record.titleMode === 'automatic' ? record.autoTitleSet === true : true,
...normalizeModelState(record),
...(piSessionId ? { piSessionId } : {}),
...(sessionKey ? { sessionKey } : {}),
@@ -139,7 +144,9 @@ export function createCodingConversationStore(
file: CodingConversationFileV2;
}>): Promise<T> {
const execute = async () => {
const change = await operation(await readNow());
const current = await readNow();
const change = await operation(current);
if (change.file === current) return change.result;
const normalized = normalizeCodingConversationFileV2(change.file);
await writer(conversationFilePath(projectPath), normalized);
return change.result;
@@ -167,6 +174,8 @@ export function createCodingConversationStore(
id: createId(),
agentId: input.agentId,
title: input.title,
titleMode: input.titleMode ?? 'manual',
autoTitleSet: input.titleMode !== 'automatic',
model: input.model,
modelResolution: input.modelResolution,
archivedAt: null,
@@ -192,10 +201,10 @@ export function createCodingConversationStore(
if (!current) throw new Error('Conversation does not exist');
const updated = normalizeConversation({
...current,
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.title !== undefined ? { title: patch.title, titleMode: 'manual', autoTitleSet: true } : {}),
...(patch.archivedAt !== undefined ? { archivedAt: patch.archivedAt } : {}),
...(patch.unread !== undefined ? { unread: patch.unread } : {}),
updatedAt: now(),
updatedAt: patch.archivedAt !== undefined || patch.unread !== undefined ? now() : current.updatedAt,
});
return {
result: updated,
@@ -207,6 +216,16 @@ export function createCodingConversationStore(
});
},
async setFirstMessageTitle(conversationId: string, title: string) {
return await mutate(async (file) => {
const current = file.conversations.find((item) => item.id === conversationId);
if (!current || current.titleMode !== 'automatic' || current.autoTitleSet) return { result: null, file };
const updated = { ...current, title, autoTitleSet: true };
return { result: updated, file: { ...file,
conversations: file.conversations.map((item) => item.id === conversationId ? updated : item) } };
});
},
async setModelState(
conversationId: string,
modelState: ConversationModelState,

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