gaoding-cli 1.0.0-alpha.19 → 1.0.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/src/bootstrap/create-runtime.js +1 -1
- package/dist/src/cli/errors.js +14 -0
- package/dist/src/features/editor/bridge-client.js +25 -12
- package/dist/src/features/editor/bridge-server.js +36 -15
- package/dist/src/features/editor/public-url.js +36 -0
- package/dist/src/features/editor/session.js +20 -6
- package/dist/src/features/editor/use-cases.js +28 -1
- package/package.json +1 -1
- package/skills/gd-cli/references/editor.md +248 -8
- package/skills/gd-cli/references/errors.md +1 -0
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ gd-cli model list --tool <tool>
|
|
|
31
31
|
gd-cli model get <model>
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
选定 Model 后,以 `model get` 返回的 `parameters` 与 `usageDescription`
|
|
34
|
+
选定 Model 后,以 `model get` 返回的 `parameters` 与 `usageDescription` 构造实际输入;其中 `cost` 是当前上架 SKU 的稿豆价格区间,不表示某次调用的实际扣费。`tool call --schema` 只给出整个 Tool 的参数并集。运行 `gd-cli --help` 查看当前命令;运行任一命令的 `--help` 查看参数。`gd-cli update` 固定检查 npm `latest`,支持更新 npm、pnpm 全局安装并同步 Agent Skill。
|
|
35
35
|
|
|
36
36
|
发布切换必须先完成精确版本的安装验收,再执行仓库中的 `scripts/promote-latest.mjs`;不要直接用未校验的 `npm dist-tag add` 移动 `latest`。
|
|
37
37
|
|
|
@@ -40,7 +40,7 @@ const productionEndpoints = {
|
|
|
40
40
|
toolApi: new URL("https://gdcli.gaoding.com/api"),
|
|
41
41
|
damApi: new URL("https://gdcli.gaoding.com/api"),
|
|
42
42
|
ssoApi: new URL("https://www.gaoding.com/api/sso"),
|
|
43
|
-
authPageOrigin: new URL("https://www.gaoding.
|
|
43
|
+
authPageOrigin: new URL("https://www.gaoding.art")
|
|
44
44
|
};
|
|
45
45
|
export function createProductionRuntime(options = {}) {
|
|
46
46
|
const bundledSkills = loadBundledSkills(fileURLToPath(import.meta.url));
|
package/dist/src/cli/errors.js
CHANGED
|
@@ -25,6 +25,12 @@ export class CliUsageError extends Error {
|
|
|
25
25
|
this.name = "CliUsageError";
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
const INSUFFICIENT_CREDITS_REMOTE_CODE = "12020770";
|
|
29
|
+
const INSUFFICIENT_CREDITS_NEXT_STEPS = [
|
|
30
|
+
"前往 https://www.gaoding.art/pricing 购买稿豆",
|
|
31
|
+
"也可以执行 gd-cli org switch 切换到有可用稿豆的组织",
|
|
32
|
+
"处理后重新发起本次创作"
|
|
33
|
+
];
|
|
28
34
|
export function isHelpOutcome(error) {
|
|
29
35
|
return error instanceof CommanderError
|
|
30
36
|
&& error.exitCode === 0
|
|
@@ -62,6 +68,9 @@ export function mapCliError(error, options) {
|
|
|
62
68
|
|| error instanceof CommanderError) {
|
|
63
69
|
return failure("USAGE_ERROR", "命令或参数无效,请查看 --help。", 2);
|
|
64
70
|
}
|
|
71
|
+
if (isInsufficientCreditsError(error)) {
|
|
72
|
+
return failure("INSUFFICIENT_CREDITS", "当前组织稿豆不足,无法完成本次创作。", 1, INSUFFICIENT_CREDITS_NEXT_STEPS);
|
|
73
|
+
}
|
|
65
74
|
if (error instanceof RemoteProtocolError) {
|
|
66
75
|
return failure("REMOTE_PROTOCOL_INCOMPATIBLE", "稿定服务返回了当前 CLI 无法安全处理的结果;为避免重复创作,已停止处理。", 1);
|
|
67
76
|
}
|
|
@@ -89,6 +98,11 @@ export function mapCliError(error, options) {
|
|
|
89
98
|
}
|
|
90
99
|
return failure("INTERNAL_ERROR", "CLI 发生内部错误。", 1);
|
|
91
100
|
}
|
|
101
|
+
function isInsufficientCreditsError(error) {
|
|
102
|
+
return (error instanceof RemoteRequestError
|
|
103
|
+
|| error instanceof RemoteProtocolError
|
|
104
|
+
|| error instanceof ToolTaskFailedError) && error.remoteCode === INSUFFICIENT_CREDITS_REMOTE_CODE;
|
|
105
|
+
}
|
|
92
106
|
export function classifyTelemetryError(error) {
|
|
93
107
|
const outcome = mapCliError(error, { aborted: false });
|
|
94
108
|
const remoteError = error instanceof RemoteRequestError
|
|
@@ -6,6 +6,12 @@ export class EditorBridgeError extends Error {
|
|
|
6
6
|
this.code = code;
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
+
export class EditorBridgeUnavailableError extends Error {
|
|
10
|
+
constructor() {
|
|
11
|
+
super("Editor Bridge is unavailable.");
|
|
12
|
+
this.name = "EditorBridgeUnavailableError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
9
15
|
export function createEditorBridgeClient(options) {
|
|
10
16
|
const fetch = options.fetch ?? globalThis.fetch;
|
|
11
17
|
const baseUrl = `http://127.0.0.1:${options.state.port}`;
|
|
@@ -28,8 +34,7 @@ export function createEditorBridgeClient(options) {
|
|
|
28
34
|
async rpc(method, payload, signal) {
|
|
29
35
|
const value = await request("/rpc", "POST", { method, ...(payload === undefined ? {} : { payload }) }, signal);
|
|
30
36
|
if (!isRecord(value) ||
|
|
31
|
-
!hasExactKeys(value, ["result"])
|
|
32
|
-
!("result" in value)) {
|
|
37
|
+
(!hasExactKeys(value, []) && !hasExactKeys(value, ["result"]))) {
|
|
33
38
|
throw invalidResponse();
|
|
34
39
|
}
|
|
35
40
|
return value.result;
|
|
@@ -44,16 +49,24 @@ export function createEditorBridgeClient(options) {
|
|
|
44
49
|
}
|
|
45
50
|
};
|
|
46
51
|
async function request(pathname, method, body, signal) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
signal.throwIfAborted();
|
|
53
|
+
let response;
|
|
54
|
+
try {
|
|
55
|
+
response = await fetch(`${baseUrl}${pathname}`, {
|
|
56
|
+
method,
|
|
57
|
+
headers: {
|
|
58
|
+
Authorization: `Bearer ${options.state.token}`,
|
|
59
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" })
|
|
60
|
+
},
|
|
61
|
+
redirect: "error",
|
|
62
|
+
signal,
|
|
63
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) })
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
signal.throwIfAborted();
|
|
68
|
+
throw new EditorBridgeUnavailableError();
|
|
69
|
+
}
|
|
57
70
|
let value;
|
|
58
71
|
try {
|
|
59
72
|
value = await response.json();
|
|
@@ -2,10 +2,12 @@ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { WebSocket, WebSocketServer } from "ws";
|
|
4
4
|
import { EDITOR_PROTOCOL_VERSION } from "./protocol.js";
|
|
5
|
+
const DEFAULT_RPC_TIMEOUT_MS = 60_000;
|
|
5
6
|
export async function startEditorBridge(options) {
|
|
6
7
|
const token = options.token ?? randomBytes(32).toString("hex");
|
|
7
8
|
const now = options.now ?? (() => new Date());
|
|
8
9
|
const nextRequestId = options.requestId ?? randomUUID;
|
|
10
|
+
const rpcTimeoutMs = Math.max(0, options.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS);
|
|
9
11
|
const webSockets = new WebSocketServer({ noServer: true });
|
|
10
12
|
let pageSocket = null;
|
|
11
13
|
let pending;
|
|
@@ -69,13 +71,14 @@ export async function startEditorBridge(options) {
|
|
|
69
71
|
const pageResponse = parseResponseMessage(message);
|
|
70
72
|
if (!pageResponse || pageResponse.requestId !== pending?.requestId)
|
|
71
73
|
return;
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
+
const current = takePending(pageResponse.requestId);
|
|
75
|
+
if (!current)
|
|
76
|
+
return;
|
|
74
77
|
if (pageResponse.error) {
|
|
75
|
-
sendJson(response, 422, { error: pageResponse.error });
|
|
78
|
+
sendJson(current.response, 422, { error: pageResponse.error });
|
|
76
79
|
}
|
|
77
80
|
else {
|
|
78
|
-
sendJson(response, 200, { result: pageResponse.result });
|
|
81
|
+
sendJson(current.response, 200, { result: pageResponse.result });
|
|
79
82
|
}
|
|
80
83
|
});
|
|
81
84
|
socket.on("error", () => {
|
|
@@ -143,7 +146,14 @@ export async function startEditorBridge(options) {
|
|
|
143
146
|
}
|
|
144
147
|
const requestId = nextRequestId();
|
|
145
148
|
const socket = pageSocket;
|
|
146
|
-
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
const current = takePending(requestId);
|
|
151
|
+
if (!current)
|
|
152
|
+
return;
|
|
153
|
+
sendError(current.response, 504, "EDITOR_RPC_TIMEOUT", "The Editor page did not respond in time.");
|
|
154
|
+
setImmediate(() => void close());
|
|
155
|
+
}, rpcTimeoutMs);
|
|
156
|
+
pending = { requestId, response, timer };
|
|
147
157
|
const message = {
|
|
148
158
|
type: "request",
|
|
149
159
|
requestId,
|
|
@@ -152,16 +162,19 @@ export async function startEditorBridge(options) {
|
|
|
152
162
|
};
|
|
153
163
|
try {
|
|
154
164
|
socket.send(JSON.stringify(message), (error) => {
|
|
155
|
-
if (!error
|
|
165
|
+
if (!error)
|
|
156
166
|
return;
|
|
157
|
-
|
|
158
|
-
|
|
167
|
+
const current = takePending(requestId);
|
|
168
|
+
if (!current)
|
|
169
|
+
return;
|
|
170
|
+
sendError(current.response, 503, "EDITOR_DISCONNECTED", "The Editor page disconnected.");
|
|
159
171
|
});
|
|
160
172
|
}
|
|
161
173
|
catch {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
174
|
+
const current = takePending(requestId);
|
|
175
|
+
if (current) {
|
|
176
|
+
sendError(current.response, 503, "EDITOR_DISCONNECTED", "The Editor page disconnected.");
|
|
177
|
+
}
|
|
165
178
|
}
|
|
166
179
|
return;
|
|
167
180
|
}
|
|
@@ -172,12 +185,20 @@ export async function startEditorBridge(options) {
|
|
|
172
185
|
}
|
|
173
186
|
sendError(response, 404, "NOT_FOUND", "Editor Bridge route not found.");
|
|
174
187
|
}
|
|
188
|
+
function takePending(requestId) {
|
|
189
|
+
if (!pending || (requestId !== undefined && pending.requestId !== requestId)) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
const current = pending;
|
|
193
|
+
pending = undefined;
|
|
194
|
+
clearTimeout(current.timer);
|
|
195
|
+
return current;
|
|
196
|
+
}
|
|
175
197
|
function failPending(status, code, message) {
|
|
176
|
-
|
|
198
|
+
const current = takePending();
|
|
199
|
+
if (!current)
|
|
177
200
|
return;
|
|
178
|
-
|
|
179
|
-
pending = undefined;
|
|
180
|
-
sendError(response, status, code, message);
|
|
201
|
+
sendError(current.response, status, code, message);
|
|
181
202
|
}
|
|
182
203
|
function close() {
|
|
183
204
|
if (closing)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isSensitiveKey } from "../../platform/redact.js";
|
|
2
|
+
const PRIVATE_SEGMENTS = new Set([
|
|
3
|
+
"token",
|
|
4
|
+
"signature",
|
|
5
|
+
"secret",
|
|
6
|
+
"password",
|
|
7
|
+
"credential",
|
|
8
|
+
"credentials"
|
|
9
|
+
]);
|
|
10
|
+
const PRIVATE_FOCUSED_KEYS = new Set([
|
|
11
|
+
"focused-editor-token",
|
|
12
|
+
"focused-editor-port"
|
|
13
|
+
]);
|
|
14
|
+
export function toPublicEditorUrl(input) {
|
|
15
|
+
const output = new URL(input.href);
|
|
16
|
+
removePrivateParameters(output.searchParams);
|
|
17
|
+
const fragment = output.hash.slice(1);
|
|
18
|
+
if (fragment.includes("=") || fragment.includes("&")) {
|
|
19
|
+
const parameters = new URLSearchParams(fragment);
|
|
20
|
+
removePrivateParameters(parameters);
|
|
21
|
+
output.hash = parameters.toString();
|
|
22
|
+
}
|
|
23
|
+
return output;
|
|
24
|
+
}
|
|
25
|
+
function removePrivateParameters(parameters) {
|
|
26
|
+
for (const key of new Set(parameters.keys())) {
|
|
27
|
+
if (isPrivateKey(key))
|
|
28
|
+
parameters.delete(key);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function isPrivateKey(key) {
|
|
32
|
+
const lower = key.toLowerCase();
|
|
33
|
+
if (isSensitiveKey(key) || PRIVATE_FOCUSED_KEYS.has(lower))
|
|
34
|
+
return true;
|
|
35
|
+
return lower.split(/[-_.]+/u).some((part) => PRIVATE_SEGMENTS.has(part));
|
|
36
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { createEditorBridgeClient } from "./bridge-client.js";
|
|
1
|
+
import { createEditorBridgeClient, EditorBridgeUnavailableError } from "./bridge-client.js";
|
|
2
2
|
import { startEditorBridgeProcess } from "./bridge-process.js";
|
|
3
|
+
import { toPublicEditorUrl } from "./public-url.js";
|
|
3
4
|
const DEFAULT_CREATE_URL = new URL("https://www.gaoding.art/editor/canvas?mode=create&type=board");
|
|
4
5
|
const DEFAULT_WORK_URL = new URL("https://www.gaoding.art/editor/canvas");
|
|
5
6
|
const DEFAULT_CONNECTION_TIMEOUT_MS = 60_000;
|
|
@@ -14,7 +15,7 @@ export class EditorTargetError extends Error {
|
|
|
14
15
|
export class EditorSessionNotFoundError extends Error {
|
|
15
16
|
code = "EDITOR_SESSION_NOT_FOUND";
|
|
16
17
|
constructor() {
|
|
17
|
-
super("
|
|
18
|
+
super("当前没有可用的 Editor 会话,请重新执行 gd-cli editor connect [target]。");
|
|
18
19
|
this.name = "EditorSessionNotFoundError";
|
|
19
20
|
}
|
|
20
21
|
}
|
|
@@ -58,7 +59,7 @@ export function createEditorSession(dependencies) {
|
|
|
58
59
|
await cleanupFailedSession(state);
|
|
59
60
|
throw cause;
|
|
60
61
|
}
|
|
61
|
-
return { target: targetUrl.href };
|
|
62
|
+
return { target: toPublicEditorUrl(targetUrl).href };
|
|
62
63
|
},
|
|
63
64
|
async disconnect(signal) {
|
|
64
65
|
signal.throwIfAborted();
|
|
@@ -71,8 +72,7 @@ export function createEditorSession(dependencies) {
|
|
|
71
72
|
}
|
|
72
73
|
catch (cause) {
|
|
73
74
|
signal.throwIfAborted();
|
|
74
|
-
await
|
|
75
|
-
throw cause;
|
|
75
|
+
await throwUnavailableSession(cause, state);
|
|
76
76
|
}
|
|
77
77
|
await client.disconnect(signal);
|
|
78
78
|
await dependencies.store.removeIfOwned(state.token);
|
|
@@ -83,9 +83,21 @@ export function createEditorSession(dependencies) {
|
|
|
83
83
|
const state = await dependencies.store.read();
|
|
84
84
|
if (state === null)
|
|
85
85
|
throw new EditorSessionNotFoundError();
|
|
86
|
-
|
|
86
|
+
try {
|
|
87
|
+
return await createClient(state).rpc(method, payload, signal);
|
|
88
|
+
}
|
|
89
|
+
catch (cause) {
|
|
90
|
+
signal.throwIfAborted();
|
|
91
|
+
await throwUnavailableSession(cause, state);
|
|
92
|
+
}
|
|
87
93
|
}
|
|
88
94
|
};
|
|
95
|
+
async function throwUnavailableSession(cause, state) {
|
|
96
|
+
if (!(cause instanceof EditorBridgeUnavailableError))
|
|
97
|
+
throw cause;
|
|
98
|
+
await dependencies.store.removeIfOwned(state.token);
|
|
99
|
+
throw new EditorSessionNotFoundError();
|
|
100
|
+
}
|
|
89
101
|
async function replaceCurrentSession(signal) {
|
|
90
102
|
const current = await dependencies.store.read();
|
|
91
103
|
if (current === null)
|
|
@@ -96,6 +108,8 @@ export function createEditorSession(dependencies) {
|
|
|
96
108
|
}
|
|
97
109
|
catch (cause) {
|
|
98
110
|
signal.throwIfAborted();
|
|
111
|
+
if (!(cause instanceof EditorBridgeUnavailableError))
|
|
112
|
+
throw cause;
|
|
99
113
|
await dependencies.store.removeIfOwned(current.token);
|
|
100
114
|
return;
|
|
101
115
|
}
|
|
@@ -2,7 +2,9 @@ import { mkdtemp, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { OutputContractError } from "../../bootstrap/validators.js";
|
|
5
|
+
import { toPublicEditorUrl } from "./public-url.js";
|
|
5
6
|
const PNG_DATA_URL_PREFIX = "data:image/png;base64,";
|
|
7
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
6
8
|
const STRICT_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
7
9
|
export function createEditorUseCases(dependencies) {
|
|
8
10
|
const validate = dependencies.validate;
|
|
@@ -40,7 +42,13 @@ export function createEditorUseCases(dependencies) {
|
|
|
40
42
|
return result;
|
|
41
43
|
},
|
|
42
44
|
async save({ signal }) {
|
|
43
|
-
const
|
|
45
|
+
const raw = await dependencies.session.rpc("save", undefined, signal);
|
|
46
|
+
validate.save(raw);
|
|
47
|
+
const url = safeEditorResultUrl(raw.url);
|
|
48
|
+
const result = {
|
|
49
|
+
...raw,
|
|
50
|
+
url: toPublicEditorUrl(url).href
|
|
51
|
+
};
|
|
44
52
|
validate.save(result);
|
|
45
53
|
return result;
|
|
46
54
|
}
|
|
@@ -57,5 +65,24 @@ function decodePngDataUrl(value) {
|
|
|
57
65
|
const bytes = Buffer.from(encoded, "base64");
|
|
58
66
|
if (bytes.toString("base64") !== encoded)
|
|
59
67
|
throw new OutputContractError();
|
|
68
|
+
if (bytes.length < PNG_SIGNATURE.length ||
|
|
69
|
+
!bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
|
|
70
|
+
throw new OutputContractError();
|
|
71
|
+
}
|
|
60
72
|
return bytes;
|
|
61
73
|
}
|
|
74
|
+
function safeEditorResultUrl(value) {
|
|
75
|
+
let url;
|
|
76
|
+
try {
|
|
77
|
+
url = new URL(value);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new OutputContractError();
|
|
81
|
+
}
|
|
82
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") ||
|
|
83
|
+
url.username !== "" ||
|
|
84
|
+
url.password !== "") {
|
|
85
|
+
throw new OutputContractError();
|
|
86
|
+
}
|
|
87
|
+
return url;
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,252 @@
|
|
|
1
1
|
# AI+ Editor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## 使用边界
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
2. 用 `gd-cli editor snapshot` 读取元素与选择状态。
|
|
7
|
-
3. 把有序元素操作写入 JSON,再用 `gd-cli editor apply --input actions.json` 应用;也可用 `--input -`。
|
|
8
|
-
4. 再次执行 `gd-cli editor snapshot`,必要时执行 `gd-cli editor screenshot` 做视觉复核。
|
|
9
|
-
5. 用 `gd-cli editor save` 保存;需要机器可读结果时仅对支持的命令使用 `--json`。
|
|
10
|
-
6. 完成后用 `gd-cli editor disconnect` 断开连接。
|
|
5
|
+
使用 `gd-cli editor` 读取和操作浏览器中当前一个 AI+ Editor 作品。当前版本只支持一个 Editor Session、当前 Page 的直属 Shape 和 Connector,以及单操作者串行操作。
|
|
11
6
|
|
|
12
|
-
|
|
7
|
+
支持的 Shape:
|
|
8
|
+
|
|
9
|
+
- `rectangle`
|
|
10
|
+
- `rounded-rectangle`
|
|
11
|
+
- `triangle-up`
|
|
12
|
+
- `ellipse`
|
|
13
|
+
- `diamond`
|
|
14
|
+
|
|
15
|
+
Connector 必须从一个 Shape 绑定到另一个 Shape,`lineType` 只支持 `straight` 和 `elbowed`。当前不支持 Group、图片、视频、独立文本、嵌套元素、自由 Connector、半绑定 Connector、Connector label、端点坐标、选择修改或页面管理。
|
|
16
|
+
|
|
17
|
+
当前 Page 只要包含不支持的直属元素,`snapshot`、`apply` 和 `screenshot` 都会返回 `UNSUPPORTED_PAGE_CONTENT`。此时停止操作,让用户换用空白或专用 Page;保留原作品内容。
|
|
18
|
+
|
|
19
|
+
## 标准流程
|
|
20
|
+
|
|
21
|
+
按以下闭环执行,完成标准是保存后得到 `workId` 和公开作品 URL:
|
|
22
|
+
|
|
23
|
+
1. 建立 Session。target 省略时在线上新建 Board;纯数字按线上作品 ID 打开;完整 HTTP/HTTPS URL 按该 URL 的环境打开:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
gd-cli editor connect
|
|
27
|
+
gd-cli editor connect 37428738696269847
|
|
28
|
+
gd-cli editor connect "http://my.gaoding.art:3000/?mode=create&type=board"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
三种形式只选与目标匹配的一种。`connect` 只有在页面已连接,且浏览器 AI+ 登录、作品加载、Board 类型、编辑/保存权限和内容检查全部通过后才成功。连接失败时停止作图。
|
|
32
|
+
|
|
33
|
+
2. 读取当前 Page:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
gd-cli editor snapshot
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
3. 根据 Snapshot 生成完整、有序的 Action JSON 数组,通过文件或 stdin 应用:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
gd-cli editor apply --input actions.json
|
|
43
|
+
# 或
|
|
44
|
+
gd-cli editor apply --input -
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
4. 每次 Apply 后都重新读取结构。只要涉及布局、文字或 Connector,再获取截图并打开实际 PNG 复查:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
gd-cli editor snapshot
|
|
51
|
+
gd-cli editor screenshot --json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`screenshot --json` 返回 `{ "path": "<绝对 PNG 路径>" }`。使用本地图片查看能力检查图片;文件存在或 Action 已提交都不代表渲染正确。
|
|
55
|
+
|
|
56
|
+
5. 结构和视觉复查均通过后显式保存:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
gd-cli editor save --json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
成功 stdout 形如:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"workId": "37428738696269847",
|
|
67
|
+
"url": "https://www.gaoding.art/editor/canvas?mode=user&type=board&id=37428738696269847"
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
只有该命令成功才可以声明作品已保存。新 Board 在第一次显式保存时创建作品 ID;把返回的公开 `url` 交给用户,并保留其中的环境参数。
|
|
72
|
+
|
|
73
|
+
6. 保存成功后断开 Session:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
gd-cli editor disconnect
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`connect` 会替换旧 Session 并打开作品页面。连接前置检查失败会清理本次 Session,但保留浏览器页面;用户在页面中完成登录或处理访问权限后,再重新执行 `connect`。GD CLI 的 AK/SK 登录不能替代浏览器 AI+ 登录,账号密码应由用户在浏览器登录界面输入。
|
|
80
|
+
|
|
81
|
+
如果 AI+ 在同一个 SPA 页面再次加载或切换作品,当前 Session 会主动断开以避免误写;此时重新执行 `gd-cli editor connect [target]`。
|
|
82
|
+
|
|
83
|
+
## 复查作品
|
|
84
|
+
|
|
85
|
+
复查依据必须是 Apply 后的最新 Snapshot 和实际截图。Snapshot 用于确认元素、字段和连接关系;截图用于判断路径、文字和布局。
|
|
86
|
+
|
|
87
|
+
逐项检查,全部通过才算完成:
|
|
88
|
+
|
|
89
|
+
- 每个 Connector 的 `fromId`、`toId` 都指向预期 Shape,方向符合语义;
|
|
90
|
+
- 每条可见路径从 `fromId` Shape 边界开始、在 `toId` Shape 边界结束,不穿过任一端点 Shape 的填充或文字;
|
|
91
|
+
- 同轴相邻 Connector 也逐条检查,不能把端点穿线解释成连续线效果;
|
|
92
|
+
- Connector 不穿过无关 Shape,没有明显绕行、不自然折返或错误连接;
|
|
93
|
+
- Shape 中的文字完整可读,没有截断、溢出或意外换行;
|
|
94
|
+
- Shape、文字和 Connector 没有非预期重叠,必要间距真实存在;
|
|
95
|
+
- 整体阅读方向清楚,构图平衡,关系容易沿 Connector 追踪。
|
|
96
|
+
|
|
97
|
+
发现可执行问题时,先读取最新 Snapshot,再提交修正。Update 必须包含完整元素,不能提交 patch。若 `fromId`、`toId` 正确,但截图中的 Connector 仍穿过端点 Shape,原样提交该 Connector 的完整 Update 以触发原生路径重算;业务关系正确时不为绕开渲染问题改动关系。
|
|
98
|
+
|
|
99
|
+
修正后再次执行 `snapshot`;涉及视觉变化时再次执行 `screenshot --json` 并打开图片。存在问题就继续修正;没有可执行问题时结束复查并保存。
|
|
100
|
+
|
|
101
|
+
## Snapshot 契约
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
type FocusedPageSnapshot = {
|
|
105
|
+
elements: Array<FocusedShape | FocusedConnector>;
|
|
106
|
+
selectedIds: string[];
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type FocusedShape = {
|
|
110
|
+
_type: "shape";
|
|
111
|
+
id: string;
|
|
112
|
+
shapeType:
|
|
113
|
+
| "rectangle"
|
|
114
|
+
| "rounded-rectangle"
|
|
115
|
+
| "triangle-up"
|
|
116
|
+
| "ellipse"
|
|
117
|
+
| "diamond";
|
|
118
|
+
x: number;
|
|
119
|
+
y: number;
|
|
120
|
+
w: number;
|
|
121
|
+
h: number;
|
|
122
|
+
text?: string;
|
|
123
|
+
fill: string;
|
|
124
|
+
stroke: string | null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
type FocusedConnector = {
|
|
128
|
+
_type: "connector";
|
|
129
|
+
id: string;
|
|
130
|
+
fromId: string;
|
|
131
|
+
toId: string;
|
|
132
|
+
lineType: "straight" | "elbowed";
|
|
133
|
+
color: string;
|
|
134
|
+
};
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`x`、`y` 是 Shape 左上角的 Page 坐标,`w`、`h` 是尺寸。`selectedIds` 只读。
|
|
138
|
+
|
|
139
|
+
只使用简短、有语义的 `id`,例如 `start`、`review`、`start-to-review`。Bridge 会把这些 ID 映射到 Editor 内部标识。Connector 用 `fromId`、`toId` 表达有方向的 Shape 关系。
|
|
140
|
+
|
|
141
|
+
## Action 契约
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
type FocusedEditorAction =
|
|
145
|
+
| { _type: "create"; element: FocusedShape | FocusedConnector }
|
|
146
|
+
| { _type: "update"; element: FocusedShape | FocusedConnector }
|
|
147
|
+
| { _type: "delete"; id: string };
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- 输入必须是 JSON 数组,Action 按数组顺序执行。
|
|
151
|
+
- Create 的 `id` 必须唯一;Connector 可以引用本数组中更早创建的 Shape。
|
|
152
|
+
- Update 必须提交完整元素,不能提交 patch,也不能改变 `id` 或 `_type`。
|
|
153
|
+
- Delete 的 `id` 必须存在;删除 Shape 会同时删除绑定到它的 Connector。
|
|
154
|
+
- 对象不接受额外字段。所有数值必须有限,`w`、`h` 必须大于 0。
|
|
155
|
+
- `fill`、`stroke` 和 `color` 使用 `#RRGGBB` 或 `#RRGGBBAA`;只有 Shape 的 `stroke` 可以为 `null`。
|
|
156
|
+
- 整批 Action 会先预检,再在一个可回滚事务中应用。任何一项无效时整批不写入;修正输入后再提交。
|
|
157
|
+
|
|
158
|
+
## 完整示例
|
|
159
|
+
|
|
160
|
+
以下数组创建从左到右的 `Start -> Review? -> Done` 流程图:
|
|
161
|
+
|
|
162
|
+
```json
|
|
163
|
+
[
|
|
164
|
+
{
|
|
165
|
+
"_type": "create",
|
|
166
|
+
"element": {
|
|
167
|
+
"_type": "shape",
|
|
168
|
+
"id": "start",
|
|
169
|
+
"shapeType": "rounded-rectangle",
|
|
170
|
+
"x": 80,
|
|
171
|
+
"y": 160,
|
|
172
|
+
"w": 180,
|
|
173
|
+
"h": 80,
|
|
174
|
+
"text": "Start",
|
|
175
|
+
"fill": "#ffffff",
|
|
176
|
+
"stroke": "#222222"
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"_type": "create",
|
|
181
|
+
"element": {
|
|
182
|
+
"_type": "shape",
|
|
183
|
+
"id": "review",
|
|
184
|
+
"shapeType": "diamond",
|
|
185
|
+
"x": 360,
|
|
186
|
+
"y": 140,
|
|
187
|
+
"w": 140,
|
|
188
|
+
"h": 120,
|
|
189
|
+
"text": "Review?",
|
|
190
|
+
"fill": "#f2f6ff",
|
|
191
|
+
"stroke": "#222222"
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"_type": "create",
|
|
196
|
+
"element": {
|
|
197
|
+
"_type": "shape",
|
|
198
|
+
"id": "done",
|
|
199
|
+
"shapeType": "rounded-rectangle",
|
|
200
|
+
"x": 600,
|
|
201
|
+
"y": 160,
|
|
202
|
+
"w": 180,
|
|
203
|
+
"h": 80,
|
|
204
|
+
"text": "Done",
|
|
205
|
+
"fill": "#e8f7ed",
|
|
206
|
+
"stroke": "#222222"
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"_type": "create",
|
|
211
|
+
"element": {
|
|
212
|
+
"_type": "connector",
|
|
213
|
+
"id": "start-to-review",
|
|
214
|
+
"fromId": "start",
|
|
215
|
+
"toId": "review",
|
|
216
|
+
"lineType": "straight",
|
|
217
|
+
"color": "#222222"
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"_type": "create",
|
|
222
|
+
"element": {
|
|
223
|
+
"_type": "connector",
|
|
224
|
+
"id": "review-to-done",
|
|
225
|
+
"fromId": "review",
|
|
226
|
+
"toId": "done",
|
|
227
|
+
"lineType": "straight",
|
|
228
|
+
"color": "#222222"
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
]
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
修改现有元素时,以最新 Snapshot 中的完整对象为基础,只改变目标字段后提交完整 Update。
|
|
235
|
+
|
|
236
|
+
## 失败处理
|
|
237
|
+
|
|
238
|
+
- `LOGIN_REQUIRED`:停止作图,让用户在已打开的 AI+ 页面登录,然后重新执行 `connect`;浏览器登录不能用 `gd-cli auth` 代替。
|
|
239
|
+
- `EDITOR_NOT_READY`:停止并检查页面是否完成加载;重新执行 `connect`,成功前不调用写操作。
|
|
240
|
+
- `WORK_NOT_EDITABLE`:停止;当前用户没有该作品的编辑或保存能力。
|
|
241
|
+
- `EDITOR_SESSION_NOT_FOUND`:重新执行 `gd-cli editor connect [target]`,然后读取 Snapshot。
|
|
242
|
+
- `EDITOR_NOT_CONNECTED`:页面连接已丢失;重新连接目标作品,不轮询旧 Session。
|
|
243
|
+
- `UNSUPPORTED_PAGE_CONTENT`:停止,让用户改用空白或专用 Page。
|
|
244
|
+
- `INVALID_ACTIONS`:根据本契约和最新 Snapshot 修正 Action,不原样重试。
|
|
245
|
+
- `APPLY_FAILED`:运行时异常已触发批次回滚;重新读取 Snapshot,确认作品处于 Apply 前状态后再决定下一步。
|
|
246
|
+
- `EDITOR_ROLLBACK_FAILED`:无法确认恢复到 Apply 前状态。停止所有 `apply` 和 `save`,只读取 Snapshot 或截图并报告。
|
|
247
|
+
- `SAVE_FAILED`:保留当前 Session,处理明确失败原因后可再次执行 `save --json`;成功前不报告已保存。
|
|
248
|
+
- `EDITOR_BUSY`:等待当前命令结束后再发下一条,保持串行。
|
|
249
|
+
- `EDITOR_RPC_TIMEOUT`:页面是否执行完成未知,Bridge 会关闭。读取类操作可重新连接同一作品后重试;`apply` 或 `save` 属于不确定写入,先重新连接同一作品并检查真实状态,再决定后续动作。无法检查原作品时停止并报告,不自动重试该写入。
|
|
250
|
+
- `PROTOCOL_VERSION_MISMATCH`:停止并升级 GD CLI,或使用协议匹配的 AI+ Editor。
|
|
251
|
+
|
|
252
|
+
任何写操作在超时、取消或连接中断后都可能已经提交。恢复时先观察真实状态,避免重复创建、重复 Apply 或重复 Save。
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
- 退出码 `1` 表示运行、服务或状态失败;只执行错误中 `error.details.next_steps` 明确给出的恢复步骤,缺失时停止并报告。
|
|
7
7
|
- 退出码 `2` 表示命令或参数错误;执行对应命令的 `--help` 后修正调用。
|
|
8
8
|
- 退出码 `130` 表示用户中断;立即停止,不自动重试。
|
|
9
|
+
- `INSUFFICIENT_CREDITS` 表示当前组织稿豆不足;按 `error.details.next_steps` 引导用户前往 https://www.gaoding.art/pricing 购买稿豆,或执行 `gd-cli org switch` 切换到有可用稿豆的组织,处理后再重新发起创作。
|
|
9
10
|
|
|
10
11
|
Agent 或 Tool 等可能消耗稿豆的请求如果可能已经提交、但完成状态未知,停止并报告,不自动重试原调用。
|
|
11
12
|
|