Files
makelore/electron/coding-projects/legacy-v1.ts
brother7 5a275b93a7 feat: remove legacy OpenCode runtime
Cut product flows over to Coding/Pi and retain only the migration-owned v1 boundary. Promote supported native optional packages because electron-builder omitted pnpm transitive optional closure from the packaged ASAR.
2026-08-24 12:17:43 +08:00

268 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import path from 'node:path';
import {
isProjectAgentAvatarDataUrl,
isProjectType,
validateAgentNames,
type ProjectAgentConfig,
type ProjectConfig,
type ProjectType,
} from '../../shared/project-config';
import type { ProductModelRef } from '../coding-runtime/contracts';
import type { ProviderAccount } from '../shared/providers/types';
const RETIRED_PROJECT_SKILL_IDS = new Set(['game-engine']);
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean))];
}
function normalizeProjectSkillIds(value: unknown): string[] {
return normalizeStringList(value).filter((skillId) => !RETIRED_PROJECT_SKILL_IDS.has(skillId));
}
function normalizeProjectType(value: unknown): ProjectType {
if (value === undefined) return 'custom';
if (isProjectType(value)) return value;
throw new Error('Invalid project type');
}
function normalizeAgent(value: unknown): ProjectAgentConfig | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const raw = value as Partial<ProjectAgentConfig>;
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) return null;
const rawResponsibility = raw.responsibility;
const responsibility = rawResponsibility && typeof rawResponsibility === 'object'
? rawResponsibility as Partial<ProjectAgentConfig['responsibility']>
: {};
return {
id,
avatarId: typeof raw.avatarId === 'string' && /^avatar-(0[1-9]|1[0-6])$/.test(raw.avatarId)
? raw.avatarId
: 'avatar-01',
avatarDataUrl: isProjectAgentAvatarDataUrl(raw.avatarDataUrl) ? raw.avatarDataUrl : undefined,
roleName: typeof raw.roleName === 'string' && raw.roleName.trim()
? raw.roleName.trim()
: '项目伙伴',
name,
builtIn: raw.builtIn === true,
enabled: raw.enabled !== false,
model: typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : null,
skillIds: normalizeProjectSkillIds(raw.skillIds),
responsibility: {
mission: typeof responsibility.mission === 'string' ? responsibility.mission.trim() : '',
owns: normalizeStringList(responsibility.owns),
boundaries: normalizeStringList(responsibility.boundaries),
collaborators: normalizeStringList(responsibility.collaborators),
principles: normalizeStringList(responsibility.principles),
},
prompt: typeof raw.prompt === 'string' ? raw.prompt.trim() : '',
archivedAt: typeof raw.archivedAt === 'string' && raw.archivedAt.trim()
? raw.archivedAt.trim()
: null,
pinned: raw.pinned === true,
createdAt: typeof raw.createdAt === 'string' && raw.createdAt.trim()
? raw.createdAt.trim()
: undefined,
updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt.trim()
? raw.updatedAt.trim()
: undefined,
};
}
export function normalizeLegacyProjectConfigV1(value: unknown): ProjectConfig {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Project config must be an object');
}
const raw = value as Partial<ProjectConfig>;
if (raw.schemaVersion !== 1) throw new Error('Unsupported project config schema');
const defaultModel = typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
? raw.defaultModel.trim()
: null;
const agents = Array.isArray(raw.agents) ? raw.agents.map(normalizeAgent) : [];
if (agents.some((agent) => !agent)) throw new Error('Invalid project Agent configuration');
const normalizedAgents = agents
.filter((agent): agent is ProjectAgentConfig => Boolean(agent))
.map((agent) => agent.model || !defaultModel ? agent : { ...agent, model: defaultModel });
if (new Set(normalizedAgents.map((agent) => agent.id)).size !== normalizedAgents.length) {
throw new Error('Duplicate project Agent id');
}
const createdAt = typeof raw.createdAt === 'string' && raw.createdAt
? raw.createdAt
: new Date().toISOString();
const initialized = raw.initialized === true;
if (initialized && validateAgentNames(normalizedAgents).length > 0) {
throw new Error('Initialized project has invalid Agent names');
}
return {
schemaVersion: 1,
projectType: normalizeProjectType(raw.projectType),
initialized,
defaultModel,
agents: normalizedAgents,
knowledgeDirectory: 'knowledge',
createdAt,
updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt ? raw.updatedAt : createdAt,
};
}
function yamlString(value: string): string {
return JSON.stringify(value);
}
function buildProjectAgentPrompt(config: ProjectConfig, current: ProjectAgentConfig): string {
const peers = config.agents.filter((agent) => agent.id !== current.id);
const responsibility = current.responsibility.mission.trim()
? [
'## 你的职责',
current.responsibility.mission,
'',
'## 负责内容',
...current.responsibility.owns.map((item) => `- ${item}`),
'',
'## 工作边界',
...current.responsibility.boundaries.map((item) => `- ${item}`),
'',
'## 工作原则',
...current.responsibility.principles.map((item) => `- ${item}`),
'',
]
: [];
return [
`# ${current.name} · ${current.roleName}`,
'',
`你的名字是「${current.name}」,在与用户对话和介绍自己时使用这个名字。`,
`你在当前项目中的职能是「${current.roleName}」。`,
`你的稳定角色 ID 是 \`${current.id}\`。你只属于当前项目。`,
'',
current.prompt.trim(),
...responsibility,
'请尊重用户已有文件和未提交改动;遇到不确定的高影响决策时先说明影响。',
'',
'## 项目伙伴',
...(peers.length > 0
? peers.map((peer) => `- ${peer.name}${peer.roleName}${peer.id}`)
: ['- 暂无其他项目伙伴。']),
'',
'项目目录是当前项目的长期上下文。开始工作前读取与任务相关的文件,结束时把实际进展、假设、证据、风险和下一步写入项目文件。',
].join('\n');
}
function buildSelectedSkillGuidance(skillIds: string[]): string {
const guidance: string[] = [];
if (skillIds.includes('frontend-slides')) {
guidance.push([
'## 自动调用:项目演示',
'当用户要求项目汇报、结题展示、答辩、项目介绍或其他演示文稿时,主动调用 `frontend-slides`,把当前项目的真实内容整理成可播放的本地 HTML 幻灯片。项目接近完成且交付清单需要项目演示时,主动提醒用户并准备生成;不要生成 PPTX、不要部署到云端。',
].join('\n'));
}
if (skillIds.includes('grilling')) {
guidance.push([
'## 自动调用:方案质询',
'当任务涉及重要实现、设计、架构或其他未确认的高影响决策时,主动调用 `grilling`。在用户确认共享理解前,不要修改项目状态。',
].join('\n'));
}
if (skillIds.includes('planning-with-files')) {
guidance.push([
'## 自动调用:项目规划',
'当任务包含多个阶段、需要研究或可能跨会话继续时,主动调用 `planning-with-files`,并按该技能在当前项目根目录直接维护 `task_plan.md`、`findings.md` 和 `progress.md`;使用当前会话项目目录的绝对路径,不要写入 Skill 安装目录、进程工作目录、用户目录或 `.niancode/agent-planning/`。',
].join('\n'));
}
return guidance.join('\n\n');
}
function buildLegacyAgentMarkdown(config: ProjectConfig, agent: ProjectAgentConfig): string {
const skills = [
' "*": deny',
...normalizeProjectSkillIds(agent.skillIds)
.filter((skill) => skill !== '*')
.map((skill) => ` ${skill}: allow`),
].join('\n');
const shellPermission = agent.skillIds.includes('game-assets') ? ' bash: allow\n' : '';
const prompt = [
agent.prompt.trim() || buildProjectAgentPrompt(config, agent),
buildSelectedSkillGuidance(agent.skillIds),
].filter(Boolean).join('\n\n');
return `---
description: ${yamlString(agent.name)}
mode: all
${agent.model ? `model: ${yamlString(agent.model)}\n` : ''}permission:
${shellPermission} # Skills that produce local assets need the bundled CLI, never a remote bootstrap.
skill:
${skills}
---
${prompt}`;
}
export interface LegacyProjectAgentManifestEntry {
relativePath: string;
content: string;
}
export function buildLegacyProjectAgentManifest(
config: ProjectConfig,
): LegacyProjectAgentManifestEntry[] {
return config.agents
.map((agent) => ({
relativePath: path.posix.join('agent', `${agent.id}.md`),
content: buildLegacyAgentMarkdown(config, agent),
}))
.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
}
function sanitizeLegacyProviderKey(value: string): string {
return value
.trim()
.replace(/[^A-Za-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
|| 'provider';
}
function legacyProviderKey(account: Pick<ProviderAccount, 'id' | 'vendorId'>): string {
return account.vendorId === 'custom' || account.vendorId === 'ollama'
? sanitizeLegacyProviderKey(account.id)
: sanitizeLegacyProviderKey(account.vendorId);
}
function configuredLegacyModelIds(account: ProviderAccount, providerKey: string): Set<string> {
const ids = new Set<string>();
for (const value of [
account.model,
...(account.fallbackModels ?? []),
...(account.metadata?.customModels ?? []),
]) {
const normalized = value?.trim();
if (!normalized) continue;
ids.add(normalized.startsWith(`${providerKey}/`)
? normalized.slice(providerKey.length + 1)
: normalized);
}
return ids;
}
export function resolveLegacyProjectModel(
legacyModel: string,
accounts: ProviderAccount[],
): ProductModelRef | null {
const separator = legacyModel.indexOf('/');
if (separator <= 0 || separator >= legacyModel.length - 1) return null;
const providerKey = legacyModel.slice(0, separator);
const modelId = legacyModel.slice(separator + 1);
const matches = accounts.filter((account) => (
legacyProviderKey(account) === providerKey
&& configuredLegacyModelIds(account, providerKey).has(modelId)
));
if (matches.length !== 1) return null;
return {
accountId: matches[0].id,
modelId,
thinkingLevel: 'off',
};
}