feat: 增加v0与Vercel接口控制闭环

This commit is contained in:
Manus AI
2026-07-24 22:29:18 +08:00
parent fb1b5c4b6c
commit 0b66eda254
5 changed files with 347 additions and 24 deletions

View File

@@ -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
}
}

View File

@@ -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) {

232
sdk/scripts/v0_control.mjs Normal file
View File

@@ -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);
});

View File

@@ -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-24V0 / 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 优先自动发现自动纠错防重复及完成门禁

View File

@@ -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`
- 生产地址:<https://workphone-console.vercel.app>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
后再合并为单项目。