feat(coding): add conversation archive and first-message titles
This commit is contained in:
@@ -555,6 +555,7 @@ export function createCodingComposition(
|
||||
await agentServer.stop();
|
||||
},
|
||||
async shutdown() {
|
||||
conversations.dispose();
|
||||
gameResourceDelivery.dispose();
|
||||
previewDataSession?.dispose();
|
||||
if (typeof options.browser.configurePreviewDataSession === 'function') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '文件请求无效,请检查输入。',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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