feat: 增加v0与Vercel接口控制闭环
This commit is contained in:
29
sdk/config/v0_vercel_binding.json
Normal file
29
sdk/config/v0_vercel_binding.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,8 +164,7 @@ async function bindV0(v0Token, vercelProjectId) {
|
||||
);
|
||||
if (project.vercelProjectId === vercelProjectId) return project;
|
||||
|
||||
try {
|
||||
return await request(
|
||||
await request(
|
||||
V0_API,
|
||||
`/v1/projects/${V0_PROJECT_ID}`,
|
||||
v0Token,
|
||||
@@ -174,17 +173,23 @@ async function bindV0(v0Token, vercelProjectId) {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
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
232
sdk/scripts/v0_control.mjs
Normal 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);
|
||||
});
|
||||
@@ -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 优先、自动发现、自动纠错、防重复及完成门禁。
|
||||
|
||||
47
开发文档/8、部署/同步与反馈记录.md
Normal file
47
开发文档/8、部署/同步与反馈记录.md
Normal 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
|
||||
后再合并为单项目。
|
||||
Reference in New Issue
Block a user