返回 html-video
amr.ts
根目录 / packages / runtime / src / defs / amr.ts
1 import { execFile } from 'node:child_process';
2 import { homedir } from 'node:os';
3 import { join } from 'node:path';
4 import { readFileSync } from 'node:fs';
5 import { promisify } from 'node:util';
6 import type { AgentDef } from '../types.js';
7
8 const exec = promisify(execFile);
9
10 /**
11 * Open Design AMR (Vela) agent.
12 *
13 * AMR is the `vela` CLI's ACP stdio mode: `vela agent run --runtime opencode`
14 * starts a private OpenCode server and speaks ACP JSON-RPC over stdio. The user
15 * authenticates once in the Open Design app (`vela login`, browser OAuth) — we
16 * reuse that login state, never ask for an API key.
17 *
18 * Binary resolution (so every user can use AMR, not just OD installers):
19 * 1. PATH (`vela`)
20 * 2. Open Design.app bundle (reuse an existing OD install)
21 * 3. @powerformer/vela-cli npm package — ships per-platform `vela` binaries,
22 * so html-video bundles AMR support without any external install.
23 * ($OPEN_DESIGN_VELA_CLI_BIN overrides everything, matching OD's env knob.)
24 *
25 * Login state lives in ~/.vela/config.json (profile → runtimeKey/apiUrl/user);
26 * each user signs in with their OWN OD/AMR account (browser OAuth via
27 * `vela login`) — html-video never holds a key. We treat a profile with a
28 * runtimeKey + `vela whoami` success as "logged in".
29 *
30 * Stage 2 (this revision): bundled-binary distribution via vela-cli.
31 * The ACP JSON-RPC client (initialize → session/new → session/prompt → streamed
32 * session/update) lands in stage 4.
33 */
34
35 const VELA_CLI_BIN_ENV = 'OPEN_DESIGN_VELA_CLI_BIN';
36
37 const VELA_BUNDLE_FALLBACKS = [
38 '/Applications/Open Design.app/Contents/Resources/open-design/bin/vela',
39 join(homedir(), 'Applications/Open Design.app/Contents/Resources/open-design/bin/vela'),
40 ];
41
42 /** Resolve `vela` from the bundled @powerformer/vela-cli npm package (per-platform
43 * binaries). Returns an absolute path, or null if the package isn't installed /
44 * has no binary for this platform. */
45 async function resolveBundledVela(): Promise<string | null> {
46 const envOverride = process.env[VELA_CLI_BIN_ENV]?.trim();
47 if (envOverride) return envOverride;
48 try {
49 const mod = (await import('@powerformer/vela-cli')) as unknown as {
50 resolveVelaCliBin?: (opts?: { strict?: boolean }) => unknown;
51 };
52 if (typeof mod.resolveVelaCliBin !== 'function') return null;
53 const resolved = await Promise.resolve(mod.resolveVelaCliBin({ strict: false }));
54 if (typeof resolved === 'string') return resolved.trim() || null;
55 if (resolved && typeof resolved === 'object') {
56 const p = (resolved as { path?: unknown }).path;
57 if (typeof p === 'string') return p.trim() || null;
58 }
59 return null;
60 } catch {
61 return null;
62 }
63 }
64
65 interface VelaProfile {
66 runtimeKey?: string;
67 apiUrl?: string;
68 user?: { email?: string; plan?: string } | null;
69 }
70
71 /** Read ~/.vela/config.json and return the active profile (prod by default). */
72 export function readVelaProfile(): { name: string; profile: VelaProfile } | null {
73 try {
74 const raw = readFileSync(join(homedir(), '.vela', 'config.json'), 'utf8');
75 const cfg = JSON.parse(raw) as { profiles?: Record<string, VelaProfile> };
76 const profiles = cfg.profiles ?? {};
77 const name = process.env.VELA_PROFILE?.trim() || (profiles.prod ? 'prod' : Object.keys(profiles)[0] ?? '');
78 const profile = profiles[name];
79 return profile ? { name, profile } : null;
80 } catch {
81 return null;
82 }
83 }
84
85 export const amr: AgentDef = {
86 id: 'amr',
87 name: 'Open Design AMR',
88 bin: 'vela',
89 binFallbacks: VELA_BUNDLE_FALLBACKS,
90 resolveBinFallback: resolveBundledVela,
91 versionArgs: ['--version'],
92 // ACP stdio runtime: starts a private OpenCode server, talks JSON-RPC.
93 buildArgs: () => ['agent', 'run', '--runtime', 'opencode'],
94 streamFormat: 'acp-json-rpc',
95 // Identify html-video as the host so the vela CLI tags its command +
96 // model_request analytics with source=html_video (revenue attribution).
97 env: { AMR_CLIENT_SOURCE: 'html_video' },
98 // AMR home; ?source=html_video lets vela's home page_view attribute the visit
99 // to html-video (same host dimension as the model-spend attribution above).
100 installUrl: 'https://open-design.ai/amr?source=html_video',
101 // AMR rejects session/prompt until a model is set. Default to deepseek-v4-flash
102 // (the "Lower cost / Many models" official pick); overridable per-call later.
103 defaultModel: 'deepseek-v4-flash',
104
105 // Found on disk → confirm the user is actually logged in. AMR needs no API
106 // key, but it does need a live `vela login` session.
107 async extraDetect(resolvedBin: string) {
108 const prof = readVelaProfile();
109 if (!prof || !prof.profile.runtimeKey) {
110 return { available: false, hint: 'Sign in to AMR in the Open Design app first (vela login).' };
111 }
112 // whoami is the authoritative liveness check — the stored runtimeKey can
113 // expire even when config.json still looks populated.
114 try {
115 const { stdout } = await exec(resolvedBin, ['whoami'], { timeout: 6000 });
116 const out = stdout.trim();
117 if (/not logged in|run `?vela login`?/i.test(out)) {
118 return { available: false, hint: 'AMR session expired — re-run vela login in Open Design.' };
119 }
120 const email = prof.profile.user?.email;
121 const plan = prof.profile.user?.plan;
122 return { available: true, version: `AMR${email ? ` · ${email}` : ''}${plan ? ` (${plan})` : ''}` };
123 } catch (err) {
124 const msg = err instanceof Error ? err.message : String(err);
125 if (/not logged in/i.test(msg)) {
126 return { available: false, hint: 'AMR session expired — re-run vela login in Open Design.' };
127 }
128 return { available: false, hint: `vela whoami failed: ${msg.slice(0, 120)}` };
129 }
130 },
131 };
132
133 /** Preferred ordering — surface the cheap/fast default + flagships first. */
134 const AMR_MODEL_RANK: ReadonlyMap<string, number> = new Map(
135 ['deepseek-v4-flash', 'deepseek-v4-pro', 'claude-opus-4.8', 'claude-sonnet-4.6', 'gpt-5.5', 'gemini-3.1-pro-preview']
136 .map((id, i) => [id, i]),
137 );
138
139 export interface AmrModel { id: string; label: string }
140
141 /**
142 * List the live AMR catalog via `vela model list`. Each line is
143 * `<model-id>\t<provider>`; the id is already the link-facing slug AMR accepts
144 * in session/set_model, so no normalization is needed. Ordered preferred-first.
145 */
146 export async function listAmrModels(resolvedBin: string): Promise<AmrModel[]> {
147 const { stdout } = await exec(resolvedBin, ['model', 'list'], { timeout: 10_000, maxBuffer: 1024 * 1024 });
148 const seen = new Set<string>();
149 const models: AmrModel[] = [];
150 for (const line of String(stdout).split('\n')) {
151 const id = line.split('\t')[0]?.trim();
152 if (!id || id.startsWith('#') || seen.has(id)) continue;
153 seen.add(id);
154 models.push({ id, label: id });
155 }
156 return models.sort((a, b) => {
157 const ra = AMR_MODEL_RANK.get(a.id) ?? Number.MAX_SAFE_INTEGER;
158 const rb = AMR_MODEL_RANK.get(b.id) ?? Number.MAX_SAFE_INTEGER;
159 return ra - rb || a.id.localeCompare(b.id);
160 });
161 }
162
162 lines TYPESCRIPT