feat(projects): add interactive AI app scaffold skill

This commit is contained in:
2026-09-04 20:36:28 +08:00
parent 336e0bb0ca
commit 959b6333b5
40 changed files with 2647 additions and 65 deletions

View File

@@ -24,6 +24,7 @@ export interface InspectedDevicePackage {
resolvedVersion: string | null;
skillEntries: DevicePackageSkillEntry[];
extensionEntries: string[];
hasSkillScripts: boolean;
ignoredLifecycleScripts: string[];
}
@@ -143,6 +144,25 @@ async function collectSkillFiles(root: string): Promise<string[]> {
return result;
}
async function directoryContainsFile(directory: string): Promise<boolean> {
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (entry.isFile() || entry.isSymbolicLink()) return true;
if (entry.isDirectory() && await directoryContainsFile(path.join(directory, entry.name))) {
return true;
}
}
return false;
}
async function skillHasScripts(skillPath: string): Promise<boolean> {
const scriptsPath = path.join(path.dirname(skillPath), 'scripts');
const metadata = await stat(scriptsPath).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return null;
throw error;
});
return metadata?.isDirectory() === true && await directoryContainsFile(scriptsPath);
}
function manifestEntries(value: unknown, field: string): string[] {
if (value === undefined) return [];
const values = typeof value === 'string' ? [value] : value;
@@ -195,7 +215,8 @@ export async function inspectDevicePackage(
if (await existsFile(path.join(root, 'SKILL.md'))) skillFiles.push(path.join(root, 'SKILL.md'));
const uniqueSkills = new Map<string, DevicePackageSkillEntry>();
for (const skillPath of [...new Set(skillFiles.map((entry) => path.resolve(entry)))]) {
const uniqueSkillPaths = [...new Set(skillFiles.map((entry) => path.resolve(entry)))];
for (const skillPath of uniqueSkillPaths) {
const id = await skillId(skillPath);
if (uniqueSkills.has(id)) throw new Error(`Duplicate Skill id: ${id}`);
uniqueSkills.set(id, { id, entryPath: relativeContainedPath(root, skillPath) });
@@ -214,6 +235,7 @@ export async function inspectDevicePackage(
resolvedVersion: nonempty(packageJson?.version, 128) ?? nonempty(codexManifest?.version, 128),
skillEntries: [...uniqueSkills.values()].sort((left, right) => left.id.localeCompare(right.id)),
extensionEntries: extensionEntries.sort(),
hasSkillScripts: (await Promise.all(uniqueSkillPaths.map(skillHasScripts))).some(Boolean),
ignoredLifecycleScripts: LIFECYCLE_SCRIPT_NAMES.filter((name) => typeof scripts[name] === 'string'),
};
}

View File

@@ -21,7 +21,13 @@ const PLAN_TTL_MS = 10 * 60 * 1000;
const INSTALL_OUTPUT_BYTES = 64 * 1024;
const INSTALL_TIMEOUT_MS = 2 * 60 * 1000;
const INDEX_SCHEMA_VERSION = 1;
const EXECUTABLE_WARNING = '此包包含可执行 Pi extension,将获得完整桌面权限,包括当前进程可用的文件、网络和进程权限。';
const EXECUTABLE_WARNING = '此包包含可执行代码,将获得完整桌面权限,包括当前进程可用的文件、网络和进程权限。';
const SKILL_SCRIPT_WARNING = '此包包含可执行 Skill 脚本Skill 被调用时,脚本可能使用当前桌面用户可用的文件、网络和进程权限。';
function executableWarning(kind: InstallPreviewV1['kind'], includesExecutableCode: boolean): string | null {
if (!includesExecutableCode) return null;
return kind === 'skill-only' ? SKILL_SCRIPT_WARNING : EXECUTABLE_WARNING;
}
export class DevicePackageError extends Error {
constructor(
@@ -375,7 +381,10 @@ export class DevicePackageManager {
resolvedSource: string;
inspected: Awaited<ReturnType<typeof inspectDevicePackage>>;
}): Promise<InstallPreviewV1> {
const includesExecutableCode = input.inspected.extensionEntries.length > 0;
const kind = devicePackageKind(input.inspected);
const includesExecutableCode = input.inspected.extensionEntries.length > 0
|| input.inspected.hasSkillScripts;
const warning = executableWarning(kind, includesExecutableCode);
const resolvedVersion = input.inspected.resolvedVersion
?? `local-${new Date(this.now()).toISOString().replace(/[-:.]/gu, '')}`;
safeStorageSegment(resolvedVersion);
@@ -388,12 +397,12 @@ export class DevicePackageManager {
packageId: input.inspected.packageId,
displayName: input.inspected.displayName,
resolvedVersion,
kind: devicePackageKind(input.inspected),
kind,
skillEntries: input.inspected.skillEntries,
extensionEntries: input.inspected.extensionEntries,
includesExecutableCode,
ignoredLifecycleScripts: input.inspected.ignoredLifecycleScripts,
warnings: includesExecutableCode ? [EXECUTABLE_WARNING] : [],
warnings: warning ? [warning] : [],
scope: 'device-parent-workers',
};
const record: DevicePackageRecordV1 = {
@@ -435,7 +444,8 @@ export class DevicePackageManager {
if (Date.parse(plan.preview.expiresAt) <= this.now()) {
throw new DevicePackageError('local_package_plan_expired', 'Install preview has expired');
}
if (plan.preview.includesExecutableCode && !plan.preview.warnings.includes(EXECUTABLE_WARNING)) {
const warning = executableWarning(plan.preview.kind, plan.preview.includesExecutableCode);
if (warning && !plan.preview.warnings.includes(warning)) {
throw new DevicePackageError('local_package_confirmation_required', 'Executable permission warning is missing');
}
const stagedPackage = path.join(this.stagingDir, planId, 'package');

View File

@@ -2,7 +2,7 @@ import { mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import {
isProjectAgentAvatarDataUrl,
isProjectType,
normalizeProjectType,
type ProjectAgentResponsibility,
type ProjectType,
} from '../../shared/project-config';
@@ -199,7 +199,8 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon
}
const record = value as Partial<CodingProjectConfigV2>;
if (record.schemaVersion !== 2) throw new Error('Unsupported coding project config schema');
if (!isProjectType(record.projectType)) throw new Error('Invalid project type');
const projectType = normalizeProjectType(record.projectType);
if (!projectType) throw new Error('Invalid project type');
if (record.projectId !== undefined && !isCanonicalCodingProjectId(record.projectId)) {
throw new Error('Project identity is invalid');
}
@@ -216,7 +217,7 @@ export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectCon
validateAgentNames(agents);
return {
schemaVersion: 2,
projectType: record.projectType,
projectType,
...(record.projectId !== undefined ? { projectId: record.projectId } : {}),
initialized: record.initialized === true,
agents,

View File

@@ -524,7 +524,7 @@ export class PiAgentServerProcess {
],
{
cwd: this.options.runtimeRoot,
env: buildPiWorkerEnvironment(this.options.configDir),
env: buildPiWorkerEnvironment(this.options.configDir, this.options.executablePath),
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
detached: platform() !== 'win32',

View File

@@ -180,6 +180,7 @@ function diagnosticPathAliases(value: string | undefined): string[] {
export function buildPiWorkerEnvironment(
configDir: string,
executablePath: string,
overlay: NodeJS.ProcessEnv = {},
inherited: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
@@ -196,6 +197,7 @@ export function buildPiWorkerEnvironment(
return {
...env,
...overlay,
MAKELORE_NODE_EXECUTABLE: executablePath,
ELECTRON_RUN_AS_NODE: '1',
PI_CODING_AGENT_DIR: configDir,
PI_OFFLINE: '1',
@@ -335,7 +337,11 @@ export class PiWorkerProcess {
[this.options.cliPath, ...args],
{
cwd: this.options.cwd,
env: buildPiWorkerEnvironment(this.options.configDir, this.options.env),
env: buildPiWorkerEnvironment(
this.options.configDir,
this.options.executablePath,
this.options.env,
),
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
detached: platform() !== 'win32',

View File

@@ -279,7 +279,7 @@ async function readPublishableProjectType(projectPath: string): Promise<Publisha
if (result.status === 'missing' || result.config.projectType === 'custom') {
throw new ProjectPackageError(
'PROJECT_TYPE_UNPUBLISHABLE',
'自定义项目暂未配置发布方式,请新建小游戏或小程序项目',
'自定义项目暂未配置发布方式,请新建交互式 AI 应用项目',
);
}
return result.config.projectType;