feat: organize project conversations and add coding teacher side chat

This commit is contained in:
2026-09-22 12:30:26 +08:00
parent 2f82b9f79c
commit 9242a40d87
45 changed files with 2987 additions and 1137 deletions

View File

@@ -0,0 +1,52 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type { CodingConversationV2 } from './conversation-store';
import { validateSessionKey } from './conversation-store';
import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts';
import { getPiManagedPaths } from '../coding-runtime/pi/resource-loader';
import { projectPiSessionSnapshot } from '../coding-runtime/pi/session-projector';
/** Read durable active-branch history without starting or reconfiguring Pi. */
export async function readCodingConversationHistory(
userDataDir: string,
projectId: string,
conversation: CodingConversationV2
): Promise<ConversationSnapshot> {
const base: ConversationSnapshot = {
schemaVersion: 1,
conversation: {
id: conversation.id,
projectId,
agentId: conversation.agentId,
title: conversation.title,
model: { model: conversation.model, modelResolution: conversation.modelResolution },
},
nodes: [],
run: { status: 'idle' },
queue: { items: [] },
context: { usedTokens: 0, contextWindow: 0, compaction: 'idle' },
pendingInteractions: [],
worker: { status: 'stopped', generation: 0 },
cursor: { workerGeneration: 0, seq: 0 },
};
if (!conversation.sessionKey) return base;
const filename = path.join(
getPiManagedPaths(userDataDir).sessionsDir,
projectId,
validateSessionKey(conversation.sessionKey) + '.jsonl'
);
const text = await readFile(filename, 'utf8');
// Pi 0.84.2 rebuilds its current leaf from the last non-header entry.
const entries = text
.split('\n')
.filter((line) => line.trim())
.map((line) => JSON.parse(line) as { id: string; type: string })
.filter((entry) => entry.type !== 'session');
const snapshot = await projectPiSessionSnapshot({
snapshot: base,
workerGeneration: 1,
state: { isStreaming: false },
entries: { entries, leafId: entries.at(-1)?.id ?? null },
});
return { ...snapshot, worker: { status: 'stopped', generation: 1 } };
}

View File

@@ -225,6 +225,8 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon
projectType,
...(record.projectId !== undefined ? { projectId: record.projectId } : {}),
initialized: record.initialized === true,
...(typeof record.defaultAgentId === 'string' && AGENT_ID_PATTERN.test(record.defaultAgentId)
? { defaultAgentId: record.defaultAgentId } : {}),
agents,
knowledgeDirectory: 'knowledge',
createdAt,
@@ -232,6 +234,27 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon
};
}
export function ensureDefaultCodingAgent(config: CodingProjectConfigV2, now = new Date().toISOString()): CodingProjectConfigV2 {
const usable = config.agents.filter((agent) => agent.enabled && !agent.archivedAt);
const current = usable.find((agent) => agent.id === config.defaultAgentId);
if (current && config.initialized) return config;
const existing = current ?? usable.find((agent) => agent.pinned) ?? usable[0];
if (existing) return { ...config, defaultAgentId: existing.id, initialized: true, updatedAt: now };
let id = 'coding-default';
let suffix = 1;
while (config.agents.some((agent) => agent.id === id)) id = `coding-default-${suffix++}`;
let name = '编程助手';
suffix = 1;
while (config.agents.some((agent) => agent.name === name)) name = `编程助手 ${suffix++}`;
const agent: CodingProjectAgentV2 = {
id, name, avatarId: 'avatar-01', roleName: '编程助手', builtIn: true, enabled: true,
model: null, modelResolution: 'required', skillIds: [], prompt: '',
responsibility: { mission: '协助完成当前项目的编程任务。', owns: [], boundaries: [], collaborators: [], principles: [] },
archivedAt: null, pinned: true, createdAt: now, updatedAt: now,
};
return { ...config, defaultAgentId: id, initialized: true, agents: [...config.agents, agent], updatedAt: now };
}
export function createCodingProjectConfigV2(
now = new Date().toISOString(),
projectType: ProjectType = 'custom',
@@ -240,7 +263,7 @@ export function createCodingProjectConfigV2(
if (projectId !== undefined && !isCanonicalCodingProjectId(projectId)) {
throw new Error('Project identity is invalid');
}
return {
return ensureDefaultCodingAgent({
schemaVersion: 2,
projectType,
...(projectId !== undefined ? { projectId } : {}),
@@ -249,7 +272,7 @@ export function createCodingProjectConfigV2(
knowledgeDirectory: 'knowledge',
createdAt: now,
updatedAt: now,
};
}, now);
}
export async function readCodingProjectConfigV2(
@@ -281,6 +304,7 @@ export async function createCodingProjectMetadata(
options: {
projectType?: ProjectType;
projectId?: string;
defaultModel?: ProductModelRef | null;
now?: string;
writer?: JsonFileWriter;
} = {},
@@ -292,6 +316,10 @@ export async function createCodingProjectMetadata(
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const config = createCodingProjectConfigV2(options.now, options.projectType, options.projectId);
if (options.defaultModel) {
config.agents[0].model = normalizeProductModelRef(options.defaultModel);
config.agents[0].modelResolution = 'resolved';
}
await Promise.all([
mkdir(path.join(projectPath, '.makelore'), { recursive: true }),
mkdir(path.join(projectPath, 'knowledge'), { recursive: true }),

View File

@@ -8,6 +8,7 @@ import {
type CodingConversationV2,
} from './conversation-store';
import {
ensureDefaultCodingAgent,
isCanonicalCodingProjectId,
normalizeCodingProjectConfigV2,
normalizeProjectIdentityChoice,
@@ -60,6 +61,7 @@ export interface CodingProjectConfigSnapshot {
}
export interface CodingProjectServiceOptions {
getDefaultModel?(): Promise<import('../../shared/coding-conversation-contracts').ProductModelRef | null>;
createProjectId?: () => string;
now?: () => string;
onResourcesChanged?(project: CodingProject): Promise<void> | void;
@@ -287,6 +289,7 @@ export class CodingProjectService {
const { project, config } = await createLocalCodingProject({
projectPath,
projectId,
defaultModel: await this.options.getDefaultModel?.().catch(() => null),
...(input.projectType ? { projectType: input.projectType } : {}),
}, this.store);
const snapshot = { project, config, knowledgeFiles: [] };
@@ -338,7 +341,8 @@ export class CodingProjectService {
async getConfig(projectId?: string): Promise<CodingProjectConfigSnapshot> {
const project = projectId ? await this.getProject(projectId) : await this.requireActiveProject();
const config = await this.requireCurrentConfig(project);
return config.projectId
return config.projectId && config.initialized && config.agents.some((agent) =>
agent.id === config.defaultAgentId && agent.enabled && !agent.archivedAt)
? await this.projectConfigSnapshot(project, config)
: await this.ensureProjectIdentity(project);
}
@@ -530,20 +534,21 @@ export class CodingProjectService {
): Promise<CodingProjectConfigSnapshot> {
return await this.serializeIdentityTransition(project.path, async () => {
const current = await this.requireCurrentConfig(project);
if (current.projectId) {
const normalized = ensureDefaultCodingAgent(current, this.options.now?.());
if (current.projectId && normalized === current) {
return await this.projectConfigSnapshot(project, current);
}
const nextProjectId = resolveProjectIdentity(
const nextProjectId = current.projectId ?? resolveProjectIdentity(
{ kind: 'create' },
this.options.createProjectId ?? randomUUID,
);
await this.options.onProjectIdentityChanging?.(
if (!current.projectId) await this.options.onProjectIdentityChanging?.(
project,
undefined,
nextProjectId,
);
const next = {
...current,
...normalized,
projectId: nextProjectId,
updatedAt: this.options.now?.() ?? new Date().toISOString(),
};

View File

@@ -196,6 +196,7 @@ export async function createLocalCodingProject(
projectPath: string;
projectType?: ProjectType;
projectId?: string;
defaultModel?: import('../../shared/coding-conversation-contracts').ProductModelRef | null;
now?: string;
},
store: CodingProjectStore,
@@ -203,6 +204,7 @@ export async function createLocalCodingProject(
const config = await createCodingProjectMetadata(input.projectPath, {
projectType: input.projectType,
projectId: input.projectId,
defaultModel: input.defaultModel,
now: input.now,
});
const project = await store.openFolder(input.projectPath);