@mccaskie-labs/workbench-ai 0.2.1 → 0.3.0
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/CHANGELOG.md +11 -0
- package/README.md +13 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +245 -0
- package/dist/content.d.ts +128 -0
- package/dist/content.js +80 -0
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @mccaskie-labs/workbench-ai
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Client questions for AI, pictures and picture search (feature or content): a routing question, then who may use a visitor feature and its monthly cap, or drafts of content made once. `npx workbench-ai drafts text|image|search` and `pick` make and keep that content through the Gateway with the development key. A drafts skill with no development key now offers the client's own wording or picture as well as the standard version.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- @mccaskie-labs/workbench-config@0.2.4
|
|
13
|
+
|
|
3
14
|
## 0.2.1
|
|
4
15
|
|
|
5
16
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -40,3 +40,16 @@ Every result carries the **resolved** model id (never the alias), the Gateway's
|
|
|
40
40
|
The package doesn't retry: the Gateway already does (3 attempts on transient failures). Error codes: `refused`, `truncated`, `invalid_output`, `unexpected_stop`, and the Gateway's `unauthorised`, `forbidden_scope`, `invalid_request`, `model_not_allowed`, `budget_exceeded`, `rate_limited`, `not_found`, `not_available`, `upstream_error`, `upstream_timeout`, `internal_error`, plus `network` and `invalid_config`.
|
|
41
41
|
|
|
42
42
|
Runs on Node 24 and the Cloudflare Workers runtime. `zod` 4 is a peer dependency.
|
|
43
|
+
|
|
44
|
+
## Content made once: `npx workbench-ai`
|
|
45
|
+
|
|
46
|
+
For wording or a picture made once for a site, rather than a feature visitors use (docs/capabilities-manifest.md §9.8). Run in the project's folder; it uses the development key (`WORKBENCH_GATEWAY_KEY` starting `wbk_dev_`) in `.dev.vars` and the Gateway address from `.dev.vars` or `wrangler.jsonc`.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
npx workbench-ai drafts text <name> --about "<what it is for>" [--count 3] [--spelling en-AU]
|
|
50
|
+
npx workbench-ai drafts image <name> --about "<what it shows>" [--aspect 3:2] [--count 3]
|
|
51
|
+
npx workbench-ai drafts search <name> --about "<what to search for>" [--count 4]
|
|
52
|
+
npx workbench-ai pick <name> <round>-<draft> [--licence "<the owner's words>"] [--replace]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`drafts` writes the drafts and a preview page to `workbench/<ai|images|image-search>/drafts/<name>/` (keep it out of git), prints what the round cost, and stops after three rounds. `pick` keeps one in `workbench/<tool>/<name>.json`, and for a picture writes `public/images/<name>.<ext>`. A found picture needs `--licence`. Exit codes: 2 for a bad command, 3 for no development key or Gateway address, 4 after the last round.
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { type ContentAi } from "./content.ts";
|
|
3
|
+
/** Rounds of drafts per piece (the offers' drafts.max_rounds), counted in the drafts folder's rounds.json. */
|
|
4
|
+
export declare const MAX_ROUNDS = 3;
|
|
5
|
+
interface Args {
|
|
6
|
+
positional: string[];
|
|
7
|
+
flags: Record<string, string | true>;
|
|
8
|
+
}
|
|
9
|
+
/** Simple KEY=value lines; quotes around a value are dropped. Values are never printed. */
|
|
10
|
+
export declare function readDevVars(text: string): Record<string, string>;
|
|
11
|
+
export declare function drafts(dir: string, args: Args, ai?: ContentAi): Promise<string[]>;
|
|
12
|
+
type Fetch = (url: string) => Promise<{
|
|
13
|
+
ok: boolean;
|
|
14
|
+
status: number;
|
|
15
|
+
headers: {
|
|
16
|
+
get(name: string): string | null;
|
|
17
|
+
};
|
|
18
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
19
|
+
}>;
|
|
20
|
+
export declare function pick(dir: string, args: Args, fetcher?: Fetch): Promise<string[]>;
|
|
21
|
+
export declare function main(argv: string[]): Promise<number>;
|
|
22
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Content made once for a site (docs/capabilities-manifest.md §9.8), run in a
|
|
3
|
+
// project's folder:
|
|
4
|
+
//
|
|
5
|
+
// workbench-ai drafts text|image|search <name> --about "<what it is for>" [--count 3] [--aspect 3:2] [--spelling en-AU] [dir]
|
|
6
|
+
// Drafts through the Workbench Gateway with the project's development key
|
|
7
|
+
// from .dev.vars. Writes each draft and a preview page to
|
|
8
|
+
// workbench/<tool>/drafts/<name>/ (not committed) and prints what the
|
|
9
|
+
// round cost.
|
|
10
|
+
// workbench-ai pick <name> <round>-<draft> [--licence "<the owner's words>"] [--replace] [dir]
|
|
11
|
+
// Keeps that draft: workbench/<tool>/<name>.json, and for a picture the
|
|
12
|
+
// file in public/images/. A found picture needs --licence: what the owner
|
|
13
|
+
// said gives the site the right to use it.
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { ASPECT_RATIOS, CONTENT_KINDS, draftContent } from "./content.js";
|
|
18
|
+
/** Rounds of drafts per piece (the offers' drafts.max_rounds), counted in the drafts folder's rounds.json. */
|
|
19
|
+
export const MAX_ROUNDS = 3;
|
|
20
|
+
const USAGE = [
|
|
21
|
+
'usage: workbench-ai drafts text|image|search <name> --about "<what it is for>" [--count 3] [--aspect 3:2] [--spelling en-AU] [dir]',
|
|
22
|
+
' workbench-ai pick <name> <round>-<draft> [--licence "<the owner\'s words>"] [--replace] [dir]',
|
|
23
|
+
].join("\n");
|
|
24
|
+
const NAME = /^[a-z][a-z0-9_]{0,39}$/;
|
|
25
|
+
/** The largest found picture `pick` downloads. */
|
|
26
|
+
const MAX_DOWNLOAD = 10 * 1024 * 1024;
|
|
27
|
+
const read = (path) => readFileSync(path, "utf8").replace(/\r\n/g, "\n");
|
|
28
|
+
const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
29
|
+
const dollars = (n) => `US$${n.toFixed(2)}`;
|
|
30
|
+
const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
31
|
+
const extFor = (mime) => (mime === "image/jpeg" ? "jpg" : mime === "image/webp" ? "webp" : "png");
|
|
32
|
+
class Stop extends Error {
|
|
33
|
+
exitCode;
|
|
34
|
+
constructor(message, exitCode = 1) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.exitCode = exitCode;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function parse(argv) {
|
|
40
|
+
const out = { positional: [], flags: {} };
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const a = argv[i];
|
|
43
|
+
if (!a.startsWith("--"))
|
|
44
|
+
out.positional.push(a);
|
|
45
|
+
else if (a === "--replace")
|
|
46
|
+
out.flags.replace = true;
|
|
47
|
+
else if (!["--about", "--count", "--aspect", "--spelling", "--licence"].includes(a))
|
|
48
|
+
throw new Stop(`${a} isn't an option\n${USAGE}`, 2);
|
|
49
|
+
else {
|
|
50
|
+
const v = argv[++i];
|
|
51
|
+
if (v === undefined)
|
|
52
|
+
throw new Stop(`${a} needs a value\n${USAGE}`, 2);
|
|
53
|
+
out.flags[a.slice(2)] = v;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** Simple KEY=value lines; quotes around a value are dropped. Values are never printed. */
|
|
59
|
+
export function readDevVars(text) {
|
|
60
|
+
const vars = {};
|
|
61
|
+
for (const line of text.split("\n")) {
|
|
62
|
+
const m = /^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
|
|
63
|
+
if (m)
|
|
64
|
+
vars[m[1]] = m[2].replace(/^(["'])(.*)\1$/, "$2");
|
|
65
|
+
}
|
|
66
|
+
return vars;
|
|
67
|
+
}
|
|
68
|
+
/** The development key and the Gateway's address: .dev.vars first, then wrangler.jsonc's vars for the address. */
|
|
69
|
+
function gatewayFor(dir) {
|
|
70
|
+
const vars = existsSync(join(dir, ".dev.vars")) ? readDevVars(read(join(dir, ".dev.vars"))) : {};
|
|
71
|
+
const key = vars.WORKBENCH_GATEWAY_KEY;
|
|
72
|
+
if (!key || !key.startsWith("wbk_dev_")) {
|
|
73
|
+
throw new Stop("No development key: .dev.vars has no WORKBENCH_GATEWAY_KEY starting wbk_dev_. Drafts need one, so ask the owner for the wording or picture instead. Never ask for a key in chat: the owner adds it to .dev.vars.", 3);
|
|
74
|
+
}
|
|
75
|
+
let url = vars.WORKBENCH_GATEWAY_URL;
|
|
76
|
+
if (!url && existsSync(join(dir, "wrangler.jsonc")))
|
|
77
|
+
url = /"WORKBENCH_GATEWAY_URL"\s*:\s*"([^"]+)"/.exec(read(join(dir, "wrangler.jsonc")))?.[1];
|
|
78
|
+
if (!url || !/^https:\/\//.test(url))
|
|
79
|
+
throw new Stop("No Gateway address: set WORKBENCH_GATEWAY_URL in wrangler.jsonc's vars or .dev.vars.", 3);
|
|
80
|
+
return { key, url };
|
|
81
|
+
}
|
|
82
|
+
/** Where a piece's drafts are, whichever kind it is: a name is used by one kind only. */
|
|
83
|
+
function findDrafts(dir, name) {
|
|
84
|
+
for (const kind of Object.keys(CONTENT_KINDS)) {
|
|
85
|
+
const folder = join(dir, CONTENT_KINDS[kind].folder, "drafts", name);
|
|
86
|
+
if (existsSync(join(folder, "rounds.json")))
|
|
87
|
+
return { kind, folder };
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const readRounds = (folder) => (existsSync(join(folder, "rounds.json")) ? JSON.parse(read(join(folder, "rounds.json"))) : { rounds: [] });
|
|
92
|
+
export async function drafts(dir, args, ai) {
|
|
93
|
+
const [, kind, name] = args.positional;
|
|
94
|
+
const about = args.flags.about;
|
|
95
|
+
if (!kind || !(kind in CONTENT_KINDS) || !name || !NAME.test(name) || typeof about !== "string" || about.trim() === "")
|
|
96
|
+
throw new Stop(USAGE, 2);
|
|
97
|
+
const k = kind;
|
|
98
|
+
const count = Number(args.flags.count ?? (k === "search" ? 4 : 3));
|
|
99
|
+
if (!Number.isInteger(count) || count < 2 || count > 4)
|
|
100
|
+
throw new Stop("--count must be 2, 3 or 4", 2);
|
|
101
|
+
const aspect = args.flags.aspect;
|
|
102
|
+
if (aspect !== undefined && (k !== "image" || !ASPECT_RATIOS.includes(aspect)))
|
|
103
|
+
throw new Stop(`--aspect is for pictures, and must be one of ${ASPECT_RATIOS.join(" ")}`, 2);
|
|
104
|
+
const spelling = args.flags.spelling;
|
|
105
|
+
if (spelling !== undefined && !["en-AU", "en-PH", "en-GB", "en-US"].includes(spelling))
|
|
106
|
+
throw new Stop("--spelling must be en-AU, en-PH, en-GB or en-US", 2);
|
|
107
|
+
if (!existsSync(join(dir, "package.json")))
|
|
108
|
+
throw new Stop(`${dir} has no package.json: run this in the project's folder`, 2);
|
|
109
|
+
const other = findDrafts(dir, name);
|
|
110
|
+
if (other && other.kind !== k)
|
|
111
|
+
throw new Stop(`${name} already has ${other.kind} drafts: give this piece another name.`, 2);
|
|
112
|
+
const folder = join(dir, CONTENT_KINDS[k].folder, "drafts", name);
|
|
113
|
+
const history = readRounds(folder);
|
|
114
|
+
const round = history.rounds.length + 1;
|
|
115
|
+
if (round > MAX_ROUNDS)
|
|
116
|
+
throw new Stop(`${name} has had ${MAX_ROUNDS} rounds of drafts. Ask the owner to pick one of them, or to give the wording or picture themselves.`, 4);
|
|
117
|
+
const client = ai ?? (await gatewayAi(dir));
|
|
118
|
+
const r = await draftContent(client, { kind: k, name, about, count, ...(aspect ? { aspect: aspect } : {}), ...(spelling ? { spelling: spelling } : {}), seen: history.rounds.flatMap((x) => x.ideas) });
|
|
119
|
+
mkdirSync(folder, { recursive: true });
|
|
120
|
+
// The round is recorded before anything else can fail, so a paid round is always counted.
|
|
121
|
+
history.rounds.push({ round, kind: k, about, cost_usd: r.costUsd, calls: r.calls.map((c) => ({ request_id: c.requestId, model: c.model, cost_usd: c.costUsd })), ideas: r.drafts.map((d) => d.idea) });
|
|
122
|
+
writeFileSync(join(folder, "rounds.json"), json(history));
|
|
123
|
+
const rel = `${CONTENT_KINDS[k].folder}/drafts/${name}`;
|
|
124
|
+
const lines = [`Round ${round} of ${MAX_ROUNDS} for ${name}: ${r.drafts.length} draft${r.drafts.length === 1 ? "" : "s"}. This round cost ${dollars(r.costUsd)}.`];
|
|
125
|
+
const cards = [];
|
|
126
|
+
for (const [i, d] of r.drafts.entries()) {
|
|
127
|
+
const id = `${round}-${i + 1}`;
|
|
128
|
+
const saved = { idea: d.idea };
|
|
129
|
+
let shown = "";
|
|
130
|
+
if (d.text !== undefined) {
|
|
131
|
+
saved.text = d.text;
|
|
132
|
+
shown = d.text.split(/\n{2,}/).map((p) => `<p>${esc(p)}</p>`).join("");
|
|
133
|
+
}
|
|
134
|
+
else if (d.image) {
|
|
135
|
+
const file = `${id}.${extFor(d.image.mimeType)}`;
|
|
136
|
+
writeFileSync(join(folder, file), d.image.bytes);
|
|
137
|
+
Object.assign(saved, { prompt: d.prompt, file, mime_type: d.image.mimeType, model: d.image.model, request_id: d.image.requestId });
|
|
138
|
+
shown = `<img src="${file}" alt="">`;
|
|
139
|
+
}
|
|
140
|
+
else if (d.found) {
|
|
141
|
+
saved.found = d.found;
|
|
142
|
+
shown = `<img src="${esc(d.found.thumbnailUrl)}" alt=""><p><a href="${esc(d.found.pageUrl)}">${esc(d.found.site)}</a>: check its licence before using it.</p>`;
|
|
143
|
+
}
|
|
144
|
+
writeFileSync(join(folder, `${id}.json`), json(saved));
|
|
145
|
+
cards.push(`<section><h2>${id}</h2><p><strong>${esc(d.idea)}</strong></p>${shown}</section>`);
|
|
146
|
+
lines.push(` ${id}: ${d.idea}`);
|
|
147
|
+
}
|
|
148
|
+
const page = `<!doctype html><meta charset="utf-8"><title>${esc(name)}: round ${round}</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem}section{border-top:1px solid #ccc;padding:1rem 0}img{max-width:100%;height:auto}</style><h1>${esc(name)}: round ${round}</h1>${cards.join("")}`;
|
|
149
|
+
writeFileSync(join(folder, `round-${round}.html`), page);
|
|
150
|
+
lines.push(`Preview: ${rel}/round-${round}.html`);
|
|
151
|
+
for (const why of r.failed)
|
|
152
|
+
lines.push(` (${why})`);
|
|
153
|
+
if (r.drafts.length === 0)
|
|
154
|
+
throw new Stop(`${lines.join("\n")}\nNo drafts this round. Run it again with other words, or ask the owner for the wording or picture.`, 1);
|
|
155
|
+
lines.push(`Show the owner the preview, then keep their choice with: npx workbench-ai pick ${name} <round>-<draft>${k === "search" ? ' --licence "<what the owner said gives the site the right to use it>"' : ""}`);
|
|
156
|
+
return lines;
|
|
157
|
+
}
|
|
158
|
+
async function gatewayAi(dir) {
|
|
159
|
+
const { key, url } = gatewayFor(dir);
|
|
160
|
+
const { createAi } = await import("./ai.js");
|
|
161
|
+
return createAi({ key, env: "development", url });
|
|
162
|
+
}
|
|
163
|
+
export async function pick(dir, args, fetcher = fetch) {
|
|
164
|
+
const [, name, which] = args.positional;
|
|
165
|
+
if (!name || !NAME.test(name) || !which || !/^\d+-\d+$/.test(which))
|
|
166
|
+
throw new Stop(USAGE, 2);
|
|
167
|
+
const found = findDrafts(dir, name);
|
|
168
|
+
if (!found)
|
|
169
|
+
throw new Stop(`There are no drafts for ${name}.`, 2);
|
|
170
|
+
const { kind, folder } = found;
|
|
171
|
+
const src = join(folder, `${which}.json`);
|
|
172
|
+
if (!existsSync(src))
|
|
173
|
+
throw new Stop(`There is no draft ${which} for ${name}.`, 2);
|
|
174
|
+
const [round, draft] = which.split("-").map(Number);
|
|
175
|
+
const meta = readRounds(folder).rounds.find((r) => r.round === round);
|
|
176
|
+
if (!meta)
|
|
177
|
+
throw new Stop(`${name}'s rounds.json has no round ${round}.`, 2);
|
|
178
|
+
const target = join(dir, CONTENT_KINDS[kind].folder, `${name}.json`);
|
|
179
|
+
if (existsSync(target) && args.flags.replace !== true)
|
|
180
|
+
throw new Stop(`${CONTENT_KINDS[kind].folder}/${name}.json is already saved. Pass --replace once the owner has agreed to replace it.`, 2);
|
|
181
|
+
const licence = args.flags.licence;
|
|
182
|
+
if (kind === "search" && (typeof licence !== "string" || licence.trim().length < 3)) {
|
|
183
|
+
throw new Stop("A found picture needs --licence: ask the owner what gives the site the right to use it (their own photo, a free licence and its name, permission from the owner), in their words.", 2);
|
|
184
|
+
}
|
|
185
|
+
const d = JSON.parse(read(src));
|
|
186
|
+
const picked = { round, draft, cost_usd: meta.cost_usd, calls: meta.calls };
|
|
187
|
+
const saved = { format: 1, route: "content", kind, name, about: meta.about, idea: d.idea };
|
|
188
|
+
const lines = [];
|
|
189
|
+
if (kind === "text") {
|
|
190
|
+
saved.text = d.text;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
const images = join(dir, "public/images");
|
|
194
|
+
mkdirSync(images, { recursive: true });
|
|
195
|
+
let file;
|
|
196
|
+
if (kind === "image") {
|
|
197
|
+
file = `public/images/${name}.${extFor(d.mime_type)}`;
|
|
198
|
+
writeFileSync(join(dir, file), readFileSync(join(folder, d.file)));
|
|
199
|
+
Object.assign(saved, { prompt: d.prompt, model: d.model, request_id: d.request_id });
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
const f = d.found;
|
|
203
|
+
if (!f.imageUrl.startsWith("https://"))
|
|
204
|
+
throw new Stop(`${which}'s picture isn't on https, so it isn't downloaded.`, 1);
|
|
205
|
+
const res = await fetcher(f.imageUrl);
|
|
206
|
+
const type = (res.headers.get("content-type") ?? "").split(";")[0].trim();
|
|
207
|
+
if (!res.ok || !["image/png", "image/jpeg", "image/webp"].includes(type))
|
|
208
|
+
throw new Stop(`${which}'s picture couldn't be downloaded (HTTP ${res.status}, ${type || "no type"}). Pick another.`, 1);
|
|
209
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
210
|
+
if (bytes.byteLength > MAX_DOWNLOAD)
|
|
211
|
+
throw new Stop(`${which}'s picture is over 10 MB. Pick another.`, 1);
|
|
212
|
+
file = `public/images/${name}.${extFor(type)}`;
|
|
213
|
+
writeFileSync(join(dir, file), bytes);
|
|
214
|
+
Object.assign(saved, { source: { title: f.title, site: f.site, page_url: f.pageUrl, image_url: f.imageUrl, width: f.width, height: f.height }, licence: licence.trim() });
|
|
215
|
+
}
|
|
216
|
+
saved.file = file;
|
|
217
|
+
lines.push(`Saved ${file}.`);
|
|
218
|
+
}
|
|
219
|
+
saved.picked = picked;
|
|
220
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
221
|
+
writeFileSync(target, json(saved));
|
|
222
|
+
lines.unshift(`Saved ${CONTENT_KINDS[kind].folder}/${name}.json from draft ${which}.`);
|
|
223
|
+
if (kind === "text")
|
|
224
|
+
lines.push("Put the wording into the strings catalogue (see the workbench-strings skill), keyed for where it is shown.");
|
|
225
|
+
return lines;
|
|
226
|
+
}
|
|
227
|
+
export async function main(argv) {
|
|
228
|
+
try {
|
|
229
|
+
const args = parse(argv);
|
|
230
|
+
const command = args.positional[0];
|
|
231
|
+
const rest = args.positional.slice(3);
|
|
232
|
+
const dir = resolve(rest[0] ?? ".");
|
|
233
|
+
const lines = command === "drafts" ? await drafts(dir, args) : command === "pick" ? await pick(dir, args) : null;
|
|
234
|
+
if (!lines)
|
|
235
|
+
throw new Stop(USAGE, 2);
|
|
236
|
+
console.log(lines.join("\n"));
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
console.error(e.message);
|
|
241
|
+
return e instanceof Stop ? e.exitCode : 1;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url))
|
|
245
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { AspectRatio } from "./types.ts";
|
|
2
|
+
/** The part of the Ai client the drafts need. */
|
|
3
|
+
export interface ContentAi {
|
|
4
|
+
structured<T>(req: {
|
|
5
|
+
model: string;
|
|
6
|
+
job: string;
|
|
7
|
+
system: string;
|
|
8
|
+
prompt: string;
|
|
9
|
+
schema: {
|
|
10
|
+
safeParse(v: unknown): {
|
|
11
|
+
success: true;
|
|
12
|
+
data: T;
|
|
13
|
+
} | {
|
|
14
|
+
success: false;
|
|
15
|
+
error: {
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
spelling?: "en-AU" | "en-PH" | "en-GB" | "en-US";
|
|
21
|
+
maxTokens?: number;
|
|
22
|
+
promptVersion?: string;
|
|
23
|
+
}): Promise<{
|
|
24
|
+
data: T;
|
|
25
|
+
model: string;
|
|
26
|
+
requestId: string;
|
|
27
|
+
costUsd: number;
|
|
28
|
+
}>;
|
|
29
|
+
images: {
|
|
30
|
+
generate(req: {
|
|
31
|
+
prompt: string;
|
|
32
|
+
aspectRatio: AspectRatio;
|
|
33
|
+
job: string;
|
|
34
|
+
model?: string;
|
|
35
|
+
promptVersion?: string;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
bytes: Uint8Array;
|
|
38
|
+
mimeType: string;
|
|
39
|
+
model: string;
|
|
40
|
+
requestId: string;
|
|
41
|
+
costUsd: number;
|
|
42
|
+
}>;
|
|
43
|
+
search(req: {
|
|
44
|
+
q: string;
|
|
45
|
+
job: string;
|
|
46
|
+
num?: number;
|
|
47
|
+
}): Promise<{
|
|
48
|
+
title: string;
|
|
49
|
+
imageUrl: string;
|
|
50
|
+
thumbnailUrl: string;
|
|
51
|
+
width: number;
|
|
52
|
+
height: number;
|
|
53
|
+
source: string;
|
|
54
|
+
pageUrl: string;
|
|
55
|
+
}[] & {
|
|
56
|
+
requestId: string;
|
|
57
|
+
costUsd: number;
|
|
58
|
+
}>;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** The three kinds, each the content route of one tool, with the folder its answers go in. */
|
|
62
|
+
export declare const CONTENT_KINDS: {
|
|
63
|
+
readonly text: {
|
|
64
|
+
readonly tool: "ai";
|
|
65
|
+
readonly folder: "workbench/ai";
|
|
66
|
+
readonly job: "ai.drafts";
|
|
67
|
+
};
|
|
68
|
+
readonly image: {
|
|
69
|
+
readonly tool: "images";
|
|
70
|
+
readonly folder: "workbench/images";
|
|
71
|
+
readonly job: "images.drafts";
|
|
72
|
+
};
|
|
73
|
+
readonly search: {
|
|
74
|
+
readonly tool: "image-search";
|
|
75
|
+
readonly folder: "workbench/image-search";
|
|
76
|
+
readonly job: "image-search.drafts";
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
export type ContentKind = keyof typeof CONTENT_KINDS;
|
|
80
|
+
export declare const ASPECT_RATIOS: readonly AspectRatio[];
|
|
81
|
+
export interface ContentRequest {
|
|
82
|
+
kind: ContentKind;
|
|
83
|
+
/** The piece's name: about_us, home_hero. */
|
|
84
|
+
name: string;
|
|
85
|
+
/** What it is for, in the owner's words. */
|
|
86
|
+
about: string;
|
|
87
|
+
count: number;
|
|
88
|
+
aspect?: AspectRatio;
|
|
89
|
+
spelling?: "en-AU" | "en-PH" | "en-GB" | "en-US";
|
|
90
|
+
/** Ideas from earlier rounds, so a new round tries something else. */
|
|
91
|
+
seen?: string[];
|
|
92
|
+
}
|
|
93
|
+
/** One draft: its idea, and the wording, the picture or the search result. */
|
|
94
|
+
interface ContentDraft {
|
|
95
|
+
idea: string;
|
|
96
|
+
text?: string;
|
|
97
|
+
prompt?: string;
|
|
98
|
+
image?: {
|
|
99
|
+
bytes: Uint8Array;
|
|
100
|
+
mimeType: string;
|
|
101
|
+
model: string;
|
|
102
|
+
requestId: string;
|
|
103
|
+
};
|
|
104
|
+
found?: {
|
|
105
|
+
title: string;
|
|
106
|
+
imageUrl: string;
|
|
107
|
+
thumbnailUrl: string;
|
|
108
|
+
width: number;
|
|
109
|
+
height: number;
|
|
110
|
+
site: string;
|
|
111
|
+
pageUrl: string;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export interface ContentRound {
|
|
115
|
+
drafts: ContentDraft[];
|
|
116
|
+
/** Each paid call in the round. */
|
|
117
|
+
calls: {
|
|
118
|
+
requestId: string;
|
|
119
|
+
model: string;
|
|
120
|
+
costUsd: number;
|
|
121
|
+
}[];
|
|
122
|
+
costUsd: number;
|
|
123
|
+
/** Drafts that failed, with the reason (a picture the provider didn't make). */
|
|
124
|
+
failed: string[];
|
|
125
|
+
}
|
|
126
|
+
/** One round of drafts. Every paid call is in `calls`, even when a draft fails. */
|
|
127
|
+
export declare function draftContent(ai: ContentAi, req: ContentRequest): Promise<ContentRound>;
|
|
128
|
+
export {};
|
package/dist/content.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Content made once for a site (docs/capabilities-manifest.md §9.8): wording,
|
|
2
|
+
// a picture made by AI, or a picture found on the web. `npx workbench-ai
|
|
3
|
+
// drafts` offers a few to pick from, through the Workbench Gateway with the
|
|
4
|
+
// project's development key, and `pick` keeps the one the owner chose. This
|
|
5
|
+
// module holds the calls and their prompts; it takes the AI client as a
|
|
6
|
+
// parameter so tests can script it.
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
/** The three kinds, each the content route of one tool, with the folder its answers go in. */
|
|
9
|
+
export const CONTENT_KINDS = {
|
|
10
|
+
text: { tool: "ai", folder: "workbench/ai", job: "ai.drafts" },
|
|
11
|
+
image: { tool: "images", folder: "workbench/images", job: "images.drafts" },
|
|
12
|
+
search: { tool: "image-search", folder: "workbench/image-search", job: "image-search.drafts" },
|
|
13
|
+
};
|
|
14
|
+
export const ASPECT_RATIOS = ["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"];
|
|
15
|
+
const TEXT_MODEL = "claude-default";
|
|
16
|
+
const IMAGE_MODEL = "image-default";
|
|
17
|
+
const PROMPT_VERSION = "content-drafts-1";
|
|
18
|
+
const TEXT_SYSTEM = `You write wording for a small business's website: a page's main text, a short blurb, a product or service description. Write it for the people who will read it on the site, in plain words, short sentences and short paragraphs.
|
|
19
|
+
|
|
20
|
+
Never invent facts about the business: no prices, dates, numbers, awards, names, places or promises the owner didn't give you. Where the wording needs a fact you don't have, write it in square brackets for the owner to fill in, like [year founded].
|
|
21
|
+
|
|
22
|
+
Make each draft clearly different from the others in what it says first, its length and its tone, not just in wording. Start each with one plain sentence (idea) that tells the owner what is different about it.`;
|
|
23
|
+
const IMAGE_SYSTEM = `You write prompts for an AI image model, for pictures on a small business's website. Each prompt describes one picture in concrete visual terms: the subject, the setting, the light, the style and the framing. No text, letters, logos or brand names in the picture, and no real, identifiable people.
|
|
24
|
+
|
|
25
|
+
Make each prompt clearly different from the others in subject, style or composition, not just in wording. Start each with one plain sentence (idea) that tells the owner what the picture shows and how it differs.`;
|
|
26
|
+
const textReply = z.object({ drafts: z.array(z.object({ idea: z.string(), text: z.string() })) });
|
|
27
|
+
const promptReply = z.object({ drafts: z.array(z.object({ idea: z.string(), prompt: z.string() })) });
|
|
28
|
+
function prompt(req, what) {
|
|
29
|
+
return [
|
|
30
|
+
`Name: ${req.name.replace(/_/g, " ")}.`,
|
|
31
|
+
`What the owner said it is for: ${req.about}`,
|
|
32
|
+
...(req.aspect ? [`Shape: ${req.aspect} (width:height).`] : []),
|
|
33
|
+
`Make ${req.count} ${what}.`,
|
|
34
|
+
...(req.seen?.length ? [`The owner has already seen these and wants something different:\n${req.seen.map((s) => `- ${s}`).join("\n")}`] : []),
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
|
37
|
+
/** One round of drafts. Every paid call is in `calls`, even when a draft fails. */
|
|
38
|
+
export async function draftContent(ai, req) {
|
|
39
|
+
const job = CONTENT_KINDS[req.kind].job;
|
|
40
|
+
const out = { drafts: [], calls: [], costUsd: 0, failed: [] };
|
|
41
|
+
const paid = (c) => {
|
|
42
|
+
out.calls.push({ requestId: c.requestId, model: c.model, costUsd: c.costUsd });
|
|
43
|
+
out.costUsd += c.costUsd;
|
|
44
|
+
};
|
|
45
|
+
const spelling = req.spelling ? { spelling: req.spelling } : {};
|
|
46
|
+
if (req.kind === "text") {
|
|
47
|
+
const r = await ai.structured({ model: TEXT_MODEL, job, system: TEXT_SYSTEM, prompt: prompt(req, "drafts"), schema: textReply, maxTokens: 8000, promptVersion: PROMPT_VERSION, ...spelling });
|
|
48
|
+
paid(r);
|
|
49
|
+
for (const d of r.data.drafts.slice(0, req.count))
|
|
50
|
+
if (d.text.trim())
|
|
51
|
+
out.drafts.push({ idea: d.idea, text: d.text.trim() });
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
if (req.kind === "image") {
|
|
55
|
+
const aspectRatio = req.aspect ?? "3:2";
|
|
56
|
+
const r = await ai.structured({ model: TEXT_MODEL, job, system: IMAGE_SYSTEM, prompt: prompt(req, "picture prompts"), schema: promptReply, maxTokens: 4000, promptVersion: PROMPT_VERSION, ...spelling });
|
|
57
|
+
paid(r);
|
|
58
|
+
for (const [i, d] of r.data.drafts.slice(0, req.count).entries()) {
|
|
59
|
+
try {
|
|
60
|
+
const img = await ai.images.generate({ prompt: d.prompt, aspectRatio, job, model: IMAGE_MODEL, promptVersion: PROMPT_VERSION });
|
|
61
|
+
paid(img);
|
|
62
|
+
out.drafts.push({ idea: d.idea, prompt: d.prompt, image: { bytes: img.bytes, mimeType: img.mimeType, model: img.model, requestId: img.requestId } });
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
out.failed.push(`picture ${i + 1}: ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
// A search: the owner's words are the query, and the first results wide enough for a web page are offered.
|
|
71
|
+
const results = await ai.images.search({ q: req.about, job, num: 10 });
|
|
72
|
+
paid({ requestId: results.requestId, model: "serper", costUsd: results.costUsd });
|
|
73
|
+
for (const f of results.filter((x) => x.imageUrl.startsWith("https://") && x.width >= 600).slice(0, req.count)) {
|
|
74
|
+
out.drafts.push({
|
|
75
|
+
idea: `${f.title} (${f.source}, ${f.width}×${f.height})`,
|
|
76
|
+
found: { title: f.title, imageUrl: f.imageUrl, thumbnailUrl: f.thumbnailUrl, width: f.width, height: f.height, site: f.source, pageUrl: f.pageUrl },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mccaskie-labs/workbench-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Claude through the Workbench Gateway: text, structured output, streaming and budget status, with no Anthropic key in the app. Refusals and truncations throw; createFakeAi for tests.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
},
|
|
21
21
|
"./package.json": "./package.json"
|
|
22
22
|
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"workbench-ai": "./dist/cli.js"
|
|
25
|
+
},
|
|
23
26
|
"files": [
|
|
24
27
|
"dist",
|
|
25
28
|
"README.md",
|
|
@@ -36,7 +39,7 @@
|
|
|
36
39
|
},
|
|
37
40
|
"dependencies": {
|
|
38
41
|
"@anthropic-ai/sdk": "^1.5.0",
|
|
39
|
-
"@mccaskie-labs/workbench-config": "^0.2.
|
|
42
|
+
"@mccaskie-labs/workbench-config": "^0.2.4"
|
|
40
43
|
},
|
|
41
44
|
"peerDependencies": {
|
|
42
45
|
"zod": "^4.1.0"
|