diff --git a/sdk/config/v0_vercel_binding.json b/sdk/config/v0_vercel_binding.json new file mode 100644 index 0000000000..28921dc06d --- /dev/null +++ b/sdk/config/v0_vercel_binding.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "updatedAt": "2026-07-24T22:40:00+08:00", + "github": { + "owner": "fnvtk", + "repo": "workphone-sdk", + "defaultBranch": "main" + }, + "v0": { + "projectId": "3vTFXIHvZVu", + "chatId": "llvv3dzaeNB", + "projectUrl": "https://v0.app/chat/projects/3vTFXIHvZVu", + "chatUrl": "https://v0.app/fnvtk/chat/llvv3dzaeNB", + "effectiveVercelProjectId": "prj_3u2Lbg0ZbTM9XSv3CoABT9fL1FGi", + "apiBindingStatus": "mismatch_with_source_project" + }, + "vercel": { + "projectId": "prj_whd5VGB8Ghc7LoDvD5O96ii5TDtA", + "projectName": "workphone-console", + "productionUrl": "https://workphone-console.vercel.app", + "gitRepository": "fnvtk/workphone-sdk", + "deploymentStatus": "ready" + }, + "policy": { + "controlChannel": "api_cli_only", + "githubIsSourceOfTruth": true, + "allowDuplicateProjectCreation": false + } +} diff --git a/sdk/scripts/bind_v0_vercel.mjs b/sdk/scripts/bind_v0_vercel.mjs index 8f6ab8cb8f..770a77f8c4 100644 --- a/sdk/scripts/bind_v0_vercel.mjs +++ b/sdk/scripts/bind_v0_vercel.mjs @@ -29,10 +29,13 @@ function readEnvFile(file) { function loadCredentials() { const sources = [ - readEnvFile(path.join(ROOT, ".env")), readEnvFile( "/Users/karuo/Documents/开发/3、自营项目/上帝之眼/.env", ), + readEnvFile( + "/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木果_项目模板/v0前端生成与预览交付/.env.private", + ), + readEnvFile(path.join(ROOT, ".env")), process.env, ]; return Object.assign({}, ...sources); @@ -85,7 +88,6 @@ async function getOrCreateVercelProject(token) { type: "github", repo: GITHUB_REPO, }, - rootDirectory: ".", installCommand: "npm ci --prefix sdk/android-ui", buildCommand: "npm run build --prefix sdk/android-ui", outputDirectory: "sdk/android-ui/dist", @@ -102,7 +104,6 @@ async function configureVercelProject(token) { method: "PATCH", body: JSON.stringify({ framework: "vite", - rootDirectory: ".", installCommand: "npm ci --prefix sdk/android-ui", buildCommand: "npm run build --prefix sdk/android-ui", outputDirectory: "sdk/android-ui/dist", @@ -130,7 +131,6 @@ async function deploy(token) { }, projectSettings: { framework: "vite", - rootDirectory: ".", installCommand: "npm ci --prefix sdk/android-ui", buildCommand: "npm run build --prefix sdk/android-ui", outputDirectory: "sdk/android-ui/dist", @@ -164,27 +164,32 @@ async function bindV0(v0Token, vercelProjectId) { ); if (project.vercelProjectId === vercelProjectId) return project; - try { - return await request( - V0_API, - `/v1/projects/${V0_PROJECT_ID}`, - v0Token, - { - method: "PATCH", - body: JSON.stringify({ vercelProjectId }), - }, - ); - } catch (error) { - if (![404, 405, 422].includes(error.status)) throw error; - return request(V0_API, "/v1/projects", v0Token, { - method: "POST", - body: JSON.stringify({ - name: "工作手机控制台", - description: "工作手机前端,GitHub main 为唯一源码真源", - vercelProjectId, - }), - }); + await request( + V0_API, + `/v1/projects/${V0_PROJECT_ID}`, + v0Token, + { + method: "PATCH", + body: JSON.stringify({ vercelProjectId }), + }, + ); + const verified = await request( + V0_API, + `/v1/projects/${V0_PROJECT_ID}`, + v0Token, + ); + if (verified.vercelProjectId !== vercelProjectId) { + const error = new Error("v0_vercel_binding_not_persisted"); + error.data = { + projectId: V0_PROJECT_ID, + expectedVercelProjectId: vercelProjectId, + actualVercelProjectId: verified.vercelProjectId || null, + action: + "保留现有项目与对话,禁止自动新建重复项目;等待 v0 官方绑定 API 可回读后续跑。", + }; + throw error; } + return verified; } async function triggerV0Deployment(v0Token) { diff --git a/sdk/scripts/v0_control.mjs b/sdk/scripts/v0_control.mjs new file mode 100644 index 0000000000..dca7fbc69f --- /dev/null +++ b/sdk/scripts/v0_control.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { execFileSync } from "node:child_process"; + +const ROOT = path.resolve(import.meta.dirname, "..", ".."); +const binding = JSON.parse( + fs.readFileSync(path.join(ROOT, "sdk/config/v0_vercel_binding.json"), "utf8"), +); + +function envFile(file) { + if (!fs.existsSync(file)) return {}; + return Object.fromEntries( + fs + .readFileSync(file, "utf8") + .split(/\r?\n/) + .map((line) => line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/)) + .filter(Boolean) + .map((match) => [match[1], match[2].replace(/^["']|["']$/g, "")]), + ); +} + +const credentials = Object.assign( + {}, + envFile("/Users/karuo/Documents/开发/3、自营项目/上帝之眼/.env"), + envFile( + "/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木果_项目模板/v0前端生成与预览交付/.env.private", + ), + envFile(path.join(ROOT, ".env")), + process.env, +); + +async function jsonRequest(url, token, options = {}) { + let response; + try { + response = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...options.headers, + }, + }); + } catch (cause) { + throw new Error(`network_error:${new URL(url).hostname}:${cause.message}`); + } + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + if (!response.ok) { + const error = new Error(`${options.method || "GET"} ${url}: ${response.status}`); + error.status = response.status; + error.data = data; + throw error; + } + return data; +} + +function requireTokens() { + for (const key of ["V0_API_KEY", "VERCEL_TOKEN"]) { + if (!credentials[key]) throw new Error(`missing_${key.toLowerCase()}`); + } +} + +async function getRepository() { + const url = `https://api.github.com/repos/${binding.github.owner}/${binding.github.repo}`; + const options = { headers: { Accept: "application/vnd.github+json" } }; + if (credentials.GITHUB_TOKEN) { + try { + return await jsonRequest(url, credentials.GITHUB_TOKEN, options); + } catch (error) { + if (error.status !== 401) throw error; + } + } + const ghToken = execFileSync("gh", ["auth", "token"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + if (!ghToken) throw new Error("missing_github_token"); + return jsonRequest(url, ghToken, options); +} + +async function getStatus() { + requireTokens(); + const [v0Project, chat, vercelProject, repository] = await Promise.all([ + jsonRequest( + `https://api.v0.dev/v1/projects/${binding.v0.projectId}`, + credentials.V0_API_KEY, + ), + jsonRequest( + `https://api.v0.dev/v1/chats/${binding.v0.chatId}`, + credentials.V0_API_KEY, + ), + jsonRequest( + `https://api.vercel.com/v9/projects/${binding.vercel.projectId}`, + credentials.VERCEL_TOKEN, + ), + getRepository(), + ]); + + const production = vercelProject.targets?.production; + const demoUrl = chat.latestVersion?.demoUrl || chat.demoUrl || null; + let effectiveV0VercelProjectId = v0Project.vercelProjectId || null; + if (!effectiveV0VercelProjectId && demoUrl) { + effectiveV0VercelProjectId = + new URL(demoUrl).searchParams.get("__v0_vercel_project_id") || null; + } + const productionUrl = + binding.vercel.productionUrl || + (production?.alias?.[0] ? `https://${production.alias[0]}` : null); + let productionHttpStatus = null; + if (productionUrl) { + try { + const response = await fetch(productionUrl, { redirect: "follow" }); + productionHttpStatus = response.status; + } catch (cause) { + productionHttpStatus = Number( + execFileSync( + "curl", + ["-L", "-sS", "-o", "/dev/null", "-w", "%{http_code}", productionUrl], + { encoding: "utf8" }, + ).trim(), + ); + } + } + + return { + github: { + repository: repository.full_name, + defaultBranch: repository.default_branch, + private: repository.private, + }, + vercel: { + projectId: vercelProject.id, + projectName: vercelProject.name, + gitRepository: vercelProject.link + ? `${vercelProject.link.org}/${vercelProject.link.repo}` + : null, + deploymentId: production?.id || null, + deploymentStatus: production?.readyState || null, + productionUrl, + productionHttpStatus, + }, + v0: { + projectId: v0Project.id, + projectName: v0Project.name, + vercelProjectId: v0Project.vercelProjectId || null, + effectiveVercelProjectId: effectiveV0VercelProjectId, + matchesSourceProject: + effectiveV0VercelProjectId === binding.vercel.projectId, + chatId: chat.id, + chatName: chat.name || chat.title, + latestVersionId: chat.latestVersion?.id || null, + latestVersionStatus: chat.latestVersion?.status || null, + demoUrl, + screenshotUrl: chat.latestVersion?.screenshotUrl || null, + }, + }; +} + +async function waitForChat(previousVersionId) { + for (let attempt = 0; attempt < 90; attempt += 1) { + const chat = await jsonRequest( + `https://api.v0.dev/v1/chats/${binding.v0.chatId}`, + credentials.V0_API_KEY, + ); + const version = chat.latestVersion; + if ( + version?.id !== previousVersionId && + ["completed", "failed", "error"].includes(version?.status) + ) { + return chat; + } + await new Promise((resolve) => setTimeout(resolve, 4000)); + } + throw new Error("v0_message_timeout"); +} + +async function sendMessage(message) { + requireTokens(); + const before = await jsonRequest( + `https://api.v0.dev/v1/chats/${binding.v0.chatId}`, + credentials.V0_API_KEY, + ); + await jsonRequest( + `https://api.v0.dev/v1/chats/${binding.v0.chatId}/messages`, + credentials.V0_API_KEY, + { + method: "POST", + body: JSON.stringify({ message }), + }, + ); + const chat = await waitForChat(before.latestVersion?.id); + return { + chatId: chat.id, + versionId: chat.latestVersion?.id || null, + status: chat.latestVersion?.status || null, + demoUrl: chat.latestVersion?.demoUrl || chat.demoUrl || null, + screenshotUrl: chat.latestVersion?.screenshotUrl || null, + }; +} + +async function main() { + const [command = "status", ...args] = process.argv.slice(2); + if (command === "status") { + console.log(JSON.stringify(await getStatus(), null, 2)); + return; + } + if (command === "send") { + const message = args.join(" ").trim(); + if (!message) throw new Error("missing_message"); + console.log(JSON.stringify(await sendMessage(message), null, 2)); + return; + } + throw new Error(`unsupported_command:${command}`); +} + +main().catch((error) => { + console.error( + JSON.stringify( + { + status: "failed", + error: error.message, + detail: error.data?.error || error.data || null, + }, + null, + 2, + ), + ); + process.exit(1); +}); diff --git a/开发文档/10、项目管理/工作日志.md b/开发文档/10、项目管理/工作日志.md index cce6234ca0..880ef7f28f 100644 --- a/开发文档/10、项目管理/工作日志.md +++ b/开发文档/10、项目管理/工作日志.md @@ -5489,3 +5489,13 @@ profile/contacts/groups/group_members(28)/labels(323)/messages(发后读回闭 - 能力不足返回结构化503,并透传 `raw_rpc_receipt/readback`。 - 接口回归 `104 passed`;OpenAPI保留200/422/503响应声明。 - 当前没有WSS在线、Hook附着和真机四证,矩阵第53项与Swagger继续保持灰色。 + +## 2026-07-24|V0 / GitHub / Vercel API 控制闭环 + +- 新增 `sdk/scripts/v0_control.mjs`,支持四平台状态回读与向唯一 v0 对话发送增量需求。 +- 新增非敏感绑定真源 `sdk/config/v0_vercel_binding.json`,禁止自动创建重复项目。 +- GitHub 真源 `fnvtk/workphone-sdk/main`、Vercel 生产部署 `READY`、生产地址 HTTP 200。 +- v0 对话 `llvv3dzaeNB` 最新版本 `completed`;从 demoUrl 回读其实际绑定项目为 + `workphone-console-ru`,与 GitHub 真源 Vercel 项目不一致。 +- Vercel 官方 PATCH 不接受已有项目的 `gitRepository` 字段;本轮保留证据,不删除项目。 +- 卡若AI `v0前端生成与预览交付` Skill 已补 API 优先、自动发现、自动纠错、防重复及完成门禁。 diff --git a/开发文档/8、部署/同步与反馈记录.md b/开发文档/8、部署/同步与反馈记录.md new file mode 100644 index 0000000000..bde61d4578 --- /dev/null +++ b/开发文档/8、部署/同步与反馈记录.md @@ -0,0 +1,47 @@ +--- +tags: + - 工作手机 + - V0 + - Vercel + - GitHub +parent: "[[开发文档/8、部署/README|部署]]" +related: + - "[[开发文档/README|开发文档]]" + - "[[开发文档/10、项目管理/工作日志|工作日志]]" +--- + +# 同步与反馈记录 + +## 2026-07-24 V0 / GitHub / Vercel API 控制闭环 + +### 已确认 + +- GitHub 真源:`fnvtk/workphone-sdk`,默认分支 `main`,私有仓库。 +- Vercel 源码项目:`prj_whd5VGB8Ghc7LoDvD5O96ii5TDtA`(`workphone-console`)。 +- Vercel Git 绑定:`fnvtk/workphone-sdk`。 +- 最新生产部署:`dpl_GfW8hmkKThnC4bgwjCBYpYEZQMwv`,状态 `READY`。 +- 生产地址:,HTTP 200,标题“工作手机”。 +- v0 项目:`3vTFXIHvZVu`。 +- v0 对话:`llvv3dzaeNB`,名称“工作手机”。 +- v0 最新版本:`b_ywtNWC2ycWf`,状态 `completed`。 +- API 控制命令:`node sdk/scripts/v0_control.mjs status`。 +- 同一对话续办命令:`node sdk/scripts/v0_control.mjs send "增量需求"`。 + +### 自动纠错结果 + +- 已停止浏览器坐标点击,后续固定使用官方 API/CLI。 +- 已支持 Vercel、v0、GitHub 凭证状态校验;GitHub 环境令牌失效时自动回退本机 `gh auth token`。 +- Node 访问生产域异常时自动回退 `curl`,仍回传真实 HTTP 状态。 +- 已通过 v0 `demoUrl` 的 `__v0_vercel_project_id` 识别页面未直接返回的实际 Vercel 项目 ID。 +- 已禁止 v0 绑定失败时自动新建重复项目。 + +### 当前唯一缺口 + +v0 当前对话实际关联 Vercel 项目 +`prj_3u2Lbg0ZbTM9XSv3CoABT9fL1FGi`(`workphone-console-ru`),与 GitHub +真源项目 `prj_whd5VGB8Ghc7LoDvD5O96ii5TDtA` 不一致。Vercel 官方 +`PATCH /v9/projects/{id}` 对已有项目传入 `gitRepository` 返回 HTTP 400: +`Invalid request: should NOT have additional property gitRepository`。v0 项目 GET +也未直接返回 `vercelProjectId`。因此本轮不删除、不新建项目,保留完整证据, +等待官方提供“已有项目连接 Git 仓库”或“已有 v0 项目改绑 Vercel 项目”的 API +后再合并为单项目。