收敛客户端静态发布链路

This commit is contained in:
2026-08-10 17:04:29 +08:00
parent 2e737dee31
commit 4df047708b
37 changed files with 604 additions and 3071 deletions

View File

@@ -28,10 +28,6 @@ const SKILL_DISPLAY_BY_ID: Record<string, SkillDisplayInfo> = {
name: '宣传与作品展示',
description: '编写真实易懂的项目介绍、演讲稿、宣传页和发布故事。',
},
'deploy-publish-check': {
name: '部署发布检查',
description: '检查项目构建、打包、部署、回滚和作品提交准备。',
},
'nianxxgame-skill': {
name: '网页游戏开发',
description: '完成游戏策划、开发、试玩、体验打磨和发布验证。',

View File

@@ -1,10 +0,0 @@
import { hostApiFetch } from '@/lib/host-api';
import type { WorksDeployCheckResult } from '../../shared/works-square-deploy-check';
export type { WorksDeployCheckItem, WorksDeployCheckReport, WorksDeployCheckResult } from '../../shared/works-square-deploy-check';
export async function fetchWorksDeployCheck(projectId: string): Promise<WorksDeployCheckResult> {
return await hostApiFetch<WorksDeployCheckResult>(
`/api/opencode/projects/${encodeURIComponent(projectId)}/works-deploy-check`,
);
}

View File

@@ -1,24 +0,0 @@
import { hostApiFetch } from '@/lib/host-api';
export type WorksPublishData = {
app_id: string;
title: string;
summary: string;
category: string;
age_band: string;
difficulty: string;
version_name: string;
change_log: string;
zip_file_path: string;
};
export type WorksPublishReadResult =
| { status: 'ready'; filePath: string; publish: WorksPublishData }
| { status: 'missing'; filePath: string }
| { status: 'invalid'; filePath: string; error: string };
export async function fetchWorksPublishFile(projectId: string): Promise<WorksPublishReadResult> {
return await hostApiFetch<WorksPublishReadResult>(
`/api/opencode/projects/${encodeURIComponent(projectId)}/works-publish`,
);
}

View File

@@ -1,76 +0,0 @@
type PublishSourceDeliverable = {
stageId: string;
content: string;
};
export function getWorksPublishDefaultSummary(
projectName: string,
deliverables: PublishSourceDeliverable[],
): string {
const preferredDeliverable = deliverables.find((deliverable) => deliverable.stageId === 'marketing')
?? deliverables.find((deliverable) => deliverable.stageId === 'pm')
?? deliverables[0];
const sourceText = preferredDeliverable?.content
.replace(/<[^>]*>/g, ' ')
.replace(/[#*_`>|[\]()-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (sourceText) {
return sourceText.slice(0, 180);
}
return `${projectName} 的 Makelore 课程作品。`;
}
export function getDeployPackageZipPath(
deliverables: PublishSourceDeliverable[],
stageId = 'deploy',
): string | null {
const deployDeliverables = deliverables.filter((deliverable) => deliverable.stageId === stageId);
for (let index = deployDeliverables.length - 1; index >= 0; index -= 1) {
const match = deployDeliverables[index].content.match(/(?:[A-Za-z]:[\\/][^\s`'",。;;<>]+|\/[^\s`'",。;;<>]+)\.zip\b/i);
if (match?.[0]) return match[0];
}
return null;
}
export function buildDeployProductBuildPrompt(input: {
projectName: string;
projectPath: string;
zipPath: string | null;
agentName?: string;
}): string {
const agentName = input.agentName?.trim() || '部署工程师';
return [
`请作为${agentName},直接开始构建当前项目的可发布产品文件。`,
`项目名称:${input.projectName}`,
`项目根目录:${input.projectPath}`,
'处理过程交互规则:',
'1. 先自己检查项目类型、启动方式和已有文件,能根据项目事实判断的就直接做。',
'2. 只有遇到会改变产品公开信息、需要用户选择、或需要权限确认的动作时,才在聊天页提问。',
'3. 需要运行命令或修改文件时,按工具权限流程处理;每完成一个关键步骤,用 1-2 句大白话同步进度。',
'4. 不要让用户填写作品 ID、标题、简介、分类、年龄段、难度、版本名、更新说明或 zip 路径,这些都由你根据项目内容写入文件。',
'构建要求:',
'1. 确认或创建 `Dockerfile`。',
'2. 确认或创建 `docker-compose.yml`。公开服务进程必须监听容器内 `0.0.0.0:8080`,端口映射使用 `127.0.0.1::8080` 动态宿主机端口。',
'3. 确认或创建 `niancode.yml`,使用 `runtime: compose`、`compose.file: docker-compose.yml`、`compose.public_service` 和 `compose.public_port: 8080`。',
'4. 把当前项目打成 `.zip` 包。zip 根目录必须直接包含 `niancode.yml` 和 `docker-compose.yml`,不要再套一层项目目录。',
'5. 打包时排除 `.git/`、`node_modules/`、`.venv/`、`venv/`、`.next/cache/`、`.pytest_cache/`、`.env`、证书、私钥和本地缓存。',
'6. 先执行完整发布验收:结构化解析 `niancode.yml` 和 `docker-compose.yml`,检查 build context、Dockerfile、端口、容器内 `0.0.0.0:8080` 监听、volume、运行命令、锁文件、Vite 相对资源、沙箱存储、密钥、环境变量、ZIP 路径/大小/数量和缓存排除项。只允许安全默认值 `${NAME:-safe-default}`;真实 `.env*` 只能排除,`.env.example` 只能放无敏感示例。',
'7. 如果本机有 Docker 和浏览器,执行完整 smoke`docker compose config`、`docker compose build --pull=false`、`docker compose up -d`、容器状态、`docker port 8080/tcp`、HTTP smoke、Vite 入口 HTML 资源路径、日志和清理;然后在全新空目录解压 ZIP 重复整套 smoke。如果本机没有 Docker 或浏览器,不要假装 PASS也不要停在“等用户去服务器启动”把对应动态检查写成 `status: BLOCKED`、`execution: unavailable`,在 detail/部署报告中说明“本机环境不可用,交由 Works Square 云端构建验证”,继续生成 ZIP、works-publish.json 和交接报告。',
'8. 在不带 `allow-same-origin` 的 sandbox iframe 中做浏览器 smoke任何白屏、未捕获 `SecurityError` 或启动失败都必须判定为 BLOCKED。若项目是游戏或画布项目额外覆盖桌面视口、移动视口和一次 resize/全屏变化,确认根节点占满 iframe、主画布按逻辑分辨率保持最大等比尺寸任一维度比预期小超过 5% 就是 BLOCKED。若浏览器不可用同样用 `execution: unavailable` 记录事实,不要伪造画布尺寸证据。',
'9. 创建或更新项目根目录 `works-publish.json`,让 Main 的云端部署协调器读取它。',
' 必填字段app_id、title、summary、category、age_band、difficulty、version_name、change_log、zip_file_path。',
' zip_file_path 必须指向已经生成的 `.zip` 包。',
'10. 在项目根目录创建 `works-deploy-check.json`。它必须是 UTF-8 JSONschema_version 为 1包含 status、checked_at、zip_file_path、zip_sha256以及 compose_config、compose_build、compose_up、container_port、http_smoke、fresh_directory_smoke、sandbox_browser_smoke 这些 checks每项都要有 PASS、SKIPPED、BLOCKED 之一以及 detail、command动态检查还要标记 `execution: executed` 或 `execution: unavailable`。实际成功才写 PASSDocker 或浏览器自动化不可用时写 SKIPPED 或 BLOCKED + execution: unavailable命令执行失败或项目有问题写 BLOCKED。游戏或画布项目还必须增加 `game_canvas_smoke` 和尺寸证据。总状态优先级为 BLOCKED > SKIPPED > PASS报告路径不一致或哈希不一致仍然 BLOCKED云端只允许把明确标记 unavailable 的本地动态检查交给 Works Square 复核;哈希不一致仍然 BLOCKED。',
'11. 输出 `部署报告.md`,逐项列出 ZIP 路径、大小、解压大小、文件数、根目录前 40 项、公开服务/8080、容器内 `0.0.0.0:8080`、每个 build context/Dockerfile、无 bind mount、禁止项、Compose build/up、容器状态、docker port、HTTP 状态码、Vite base、入口 HTML、Web Storage/Cookie/IndexedDB/Service Worker、sandbox smoke游戏或画布项目再列出逻辑分辨率、桌面/移动视口、主画布实际尺寸、最大等比预期尺寸和 resize 复测;最后写 PASS、SKIPPED 或 BLOCKED。',
'交接规则:',
'1. 当前任务如果由“部署与上传”页面的“构建产品”触发Main 已经登记一次云端部署意图你只需要完成文件、ZIP 和报告Main 会在交接文件就绪且当前登录态有效时自动把 ZIP 提交到 Works Square 的远端构建接口。',
'2. 不要让用户去服务器手动启动 Docker也不要询问 SSH 主机、用户名、端口或目标路径;本机 Docker/浏览器不可用不是让用户接管部署的理由。',
'3. 不能把“已生成文件”“已提交远端”“远端构建完成”“已公开”混为一谈。只有看到 `works-cloud-deploy.json` 的 `status: submitted` 和真实 `version_id` 才能说已提交远端queued/building/reviewing 仍然不是已公开。',
'4. 如果当前会话没有云端部署意图文件,完成交接文件后说明需要从“部署与上传”页面点击“构建产品”来授权一次云端提交;不要自行调用未知服务器或暴露令牌。',
'5. 如果当前会话不能读取登录态或访问令牌,不要卡住构建;保留 `works-publish.json`、`works-deploy-check.json` 和 zip 包,让 Main 在登录态恢复后自动继续,并在报告里写明等待登录。',
input.zipPath
? `已检测到旧部署报告里的 zip 包路径:${input.zipPath},请核对后决定是否复用或重新生成。`
: '如果还没有 zip 包,请先生成;完成静态安全检查和真实可执行的动态 smoke 后,把最终路径写入 works-publish.json。若动态环境不可用必须明确标记 SKIPPED 或 BLOCKED + execution: unavailable 并让 Main 的云端复核接管,不得伪造 PASS仅环境原因的 SKIPPED 必须提示未验证风险。',
].join('\n');
}

View File

@@ -1,5 +1,4 @@
import { hostApiFetch } from '@/lib/host-api';
import type { WorksCloudDeploymentRecord } from '../../shared/works-cloud-deployment';
export type ProjectPublic = {
app_id: string;
@@ -12,6 +11,8 @@ export type ProjectPublic = {
status?: string | null;
updated_at?: string | null;
playable?: boolean;
play_url?: string | null;
/** @deprecated Use play_url. Kept for one client compatibility release. */
runtime_url?: string | null;
creator_name?: string | null;
buddy_name?: string | null;
@@ -109,15 +110,6 @@ export type WorksProjectStatusDetail = {
versions: WorksProjectVersion[];
};
export type WorksProjectUploadInput = {
accessToken: string;
appId: string;
projectId: string;
versionName: string;
changeLog: string;
zipFilePath: string;
};
export type WorksStaticPackageSummary = {
archiveName: string;
sha256: string;
@@ -293,7 +285,7 @@ export function toPlazaCard(project: ProjectPublic): PlazaCard {
difficulty: project.difficulty ?? null,
updatedAt: project.updated_at ?? null,
playable: Boolean(project.playable),
runtimeUrl: project.runtime_url ?? null,
runtimeUrl: project.play_url ?? project.runtime_url ?? null,
};
}
@@ -414,25 +406,6 @@ export async function fetchCurrentWorksProjectStatus(
return assertSuccess(response, 'status', 'Failed to load Works Square project status');
}
export async function uploadWorksProjectZip(
input: WorksProjectUploadInput,
): Promise<WorksProjectVersionUpload> {
const response = await hostApiFetch<WorksActionResponse<'upload', WorksProjectVersionUpload>>(
`/api/works/projects/${encodeURIComponent(input.appId)}/versions/upload`,
{
method: 'POST',
body: JSON.stringify({
accessToken: input.accessToken,
projectId: input.projectId,
versionName: input.versionName,
changeLog: input.changeLog,
zipFilePath: input.zipFilePath,
}),
},
);
return assertSuccess(response, 'upload', 'Failed to upload Works Square project zip');
}
export async function publishWorksProjectSource(
input: WorksProjectSourcePublishInput,
): Promise<WorksProjectSourcePublishResult> {
@@ -460,28 +433,6 @@ export async function publishWorksProjectSource(
return { package: response.package, upload: response.upload };
}
export async function armWorksCloudDeployment(projectId: string): Promise<WorksCloudDeploymentRecord> {
const response = await hostApiFetch<WorksActionResponse<'deployment', WorksCloudDeploymentRecord>>(
`/api/opencode/projects/${encodeURIComponent(projectId)}/works-cloud-deploy`,
{ method: 'POST', body: JSON.stringify({}) },
);
return assertSuccess(response, 'deployment', 'Failed to arm cloud deployment');
}
export async function fetchWorksCloudDeployment(
projectId: string,
): Promise<WorksCloudDeploymentRecord | null> {
const response = await hostApiFetch<{
success: boolean;
deployment?: WorksCloudDeploymentRecord | null;
error?: string;
}>(`/api/opencode/projects/${encodeURIComponent(projectId)}/works-cloud-deploy`);
if (!response.success) {
throw new WorksSquareApiError(response.error || 'Failed to read cloud deployment status');
}
return response.deployment ?? null;
}
export async function fetchWorksProjectVersions(
accessToken: string,
appId: string,

View File

@@ -277,17 +277,6 @@ const courseSkills: Skill[] = [
appliesTo: ['市场运营'],
statusLabel: '平台内置 · 只读',
},
{
id: 'deploy-publish-check',
name: '部署发布检查',
description: '检查项目构建、打包、部署、回滚和作品提交准备。',
prompt: '帮助发布部署执行完整的 Works Square Compose/ZIP/Docker/容器内 0.0.0.0:8080/HTTP/sandbox smoke 检查,使用安全环境变量默认值;游戏或画布项目额外覆盖桌面、移动和 resize 画布尺寸,写入 works-publish.json、works-deploy-check.json 和部署报告;真实执行失败或静态安全问题必须 BLOCKED不得云端提交本机 Docker/浏览器不可用时写 SKIPPED 或 BLOCKED + execution: unavailable不伪造 PASS由 Main 云端部署协调器提交远端构建,不要要求用户 SSH 到服务器。',
inputs: ['发布目标', 'Docker 构建命令', 'Demo 或素材', '隐私与安全情况', '登录态或访问令牌'],
outputs: ['部署报告', '发布步骤', '回滚步骤', '作品广场提交清单', 'works-publish.json', 'works-deploy-check.json', 'zip 包路径', '作品广场上传接口结果'],
guardrails: ['目标和权限不明确不发布', '检查密钥、私有路径、真实 .env*、未提供默认值的环境变量、bind mount、缓存和不安全命令', '上传包必须包含 niancode.yml、Dockerfile 和 docker-compose.yml', '游戏或画布项目只有真实浏览器成功时才提供 game_canvas_smoke 尺寸证据', '缺少 works-publish.json、works-deploy-check.json 或 zip_sha256 不匹配时阻止上传', 'Docker/浏览器环境不可用时输出 SKIPPED 或 BLOCKED + execution: unavailable不伪造 PASS有云端意图时交给 Main 远端复核'],
appliesTo: ['发布部署'],
statusLabel: '平台内置 · 只读',
},
{
id: 'nianxxgame-skill',
name: '网页游戏开发',