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.
268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
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',
|
||
};
|
||
}
|