scaffold.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. #!/usr/bin/env node
  2. /**
  3. * scaffold.mjs —— 生成一个**最小可跑的智能体骨架**(无构建)。
  4. *
  5. * 用法:node scaffold.mjs <targetDir> --project <name> [--force]
  6. *
  7. * 产出结构(源码即产物,浏览器直跑):
  8. * <target>/
  9. * CLAUDE.md
  10. * src/ index.html <base> + importmap(最小) + 入口
  11. * app.js 装配 + 路由
  12. * lib/ 智能体核心(纯 JS,双兼容)
  13. * graph.js 一个最小 LangGraph 图(route → plan → respond)
  14. * fmode-chat.js 最小 OpenAI 兼容客户端
  15. * telemetry.js token/耗时埋点
  16. * mcp/ MCP 层
  17. * protocol.js JSON-RPC 2.0 骨架
  18. * tool-specs.js 工具规格(JSON Schema)
  19. * worker.js Worker 宿主
  20. * scripts/ deploy.sh 无构建部署 + 自动部署 + 验真
  21. * .claude/rules/ 智能体规则(从规则仓同步)
  22. *
  23. * 诚实失败:目标已存在文件默认**不覆盖**(--force 才覆盖)。
  24. */
  25. import { mkdirSync, writeFileSync, existsSync, readFileSync, chmodSync } from 'node:fs';
  26. import { join, resolve, dirname } from 'node:path';
  27. import { fileURLToPath } from 'node:url';
  28. const __dirname = dirname(fileURLToPath(import.meta.url));
  29. const args = process.argv.slice(2);
  30. const target = args.find((a) => a && !a.startsWith('-'));
  31. const projIdx = args.indexOf('--project');
  32. const project = projIdx > -1 ? args[projIdx + 1] : 'my-agent';
  33. const force = args.includes('--force');
  34. if (!target) { console.error('用法:node scaffold.mjs <targetDir> --project <name> [--force]'); process.exit(2); }
  35. const root = resolve(target);
  36. const files = {};
  37. const add = (p, c) => { files[p] = c; };
  38. add('src/index.html', `<!doctype html>
  39. <html lang="zh-CN">
  40. <head>
  41. <meta charset="utf-8" />
  42. <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
  43. <base href="/${project}/" />
  44. <title>${project} · 智能体</title>
  45. <!-- 依赖走 importmap(浏览器无构建)。生产环境可改为 Repose 公共 importmap。 -->
  46. <script type="importmap">
  47. {
  48. "imports": {
  49. "lit": "/dev/npm/lit/v3.3.3/index.js",
  50. "lit/": "/dev/npm/lit/v3.3.3/",
  51. "@lit-labs/signals": "/dev/npm/@lit-labs/signals/v0.3.0/index.js",
  52. "@langchain/langgraph": "./vendor/langgraph/@langchain/langgraph/dist/web.js",
  53. "@langchain/langgraph/": "./vendor/langgraph/@langchain/langgraph/",
  54. "@langchain/core": "./vendor/langgraph/@langchain/core/dist/index.js",
  55. "@langchain/core/": "./vendor/langgraph/@langchain/core/",
  56. "langsmith": "./vendor/shims/langsmith.js",
  57. "langsmith/run_trees": "./vendor/shims/langsmith.js",
  58. "node:async_hooks": "./vendor/shims/async-hooks.js",
  59. "base64-js": "./vendor/shims/base64-js.js"
  60. }
  61. }
  62. </script>
  63. </head>
  64. <body>
  65. <app-root></app-root>
  66. <script type="module" src="./app.js"></script>
  67. </body>
  68. </html>
  69. `);
  70. add('src/app.js', `/**
  71. * app.js —— 最小智能体入口(无构建)。
  72. * 依赖:lit(importmap)+ lib/graph.js。此处只要跑通「一句话 → 图 → 回复」。
  73. */
  74. import { html, LitElement } from 'lit';
  75. import { signal } from '@lit-labs/signals';
  76. import { runTurn } from './lib/graph.js';
  77. class AppRoot extends LitElement {
  78. static properties = { _reply: { type: String, state: true }, _busy: { type: Boolean, state: true } };
  79. createRenderRoot() { return this; } // 无构建演示:不用 Shadow DOM 也行
  80. constructor() { super(); this._reply = ''; this._busy = false; }
  81. async _submit(e) {
  82. e.preventDefault();
  83. const text = this.renderRoot.querySelector('input').value.trim();
  84. if (!text) return;
  85. this._busy = true; this._reply = '';
  86. try {
  87. const out = await runTurn({ utterance: text }, { token: window.__FMODE_TOKEN__ || '' });
  88. this._reply = out.state?.reply || '(无回复)';
  89. } catch (err) {
  90. this._reply = '出错:' + (err?.message || err);
  91. } finally { this._busy = false; }
  92. }
  93. render() {
  94. return html\`
  95. <h1>${project} · 智能体</h1>
  96. <form @submit=\${this._submit}>
  97. <input placeholder="说点什么…" ?disabled=\${this._busy} />
  98. <button ?disabled=\${this._busy}>\${this._busy ? '…' : '发送'}</button>
  99. </form>
  100. \${this._reply ? html\`<pre>\${this._reply}</pre>\` : ''}
  101. \`;
  102. }
  103. }
  104. customElements.define('app-root', AppRoot);
  105. void signal;
  106. `);
  107. add('src/lib/graph.js', `/**
  108. * lib/graph.js —— 最小 LangGraph 图:route → plan → respond。
  109. * ⚠️ 浏览器无构建要点见规则 .claude/rules/no-build-langgraph.md:
  110. * - 3 个替身(langsmith / node:async_hooks / base64-js)缺一白屏;
  111. * - ctx 挂 config.configurable.__dc(别用闭包);
  112. * - 凭据不进 State。
  113. */
  114. import { StateGraph, START, END, Annotation } from '@langchain/langgraph';
  115. import { chatComplete } from './fmode-chat.js';
  116. const S = Annotation.Root({
  117. utterance: Annotation({ reducer: (_, b) => b, default: () => '' }),
  118. reply: Annotation({ reducer: (_, b) => b, default: () => '' }),
  119. });
  120. function ctxOf(config) {
  121. const c = config?.configurable?.__dc;
  122. if (!c) throw new Error('运行时上下文缺失(config.configurable.__dc)');
  123. return c;
  124. }
  125. async function planNode(state, config) {
  126. const { token, model } = ctxOf(config);
  127. const res = await chatComplete({
  128. token, model,
  129. messages: [
  130. { role: 'system', content: '你是一个简洁的助手,用中文回答。' },
  131. { role: 'user', content: state.utterance },
  132. ],
  133. });
  134. return { reply: res.content || '' };
  135. }
  136. // respond 占位(真实项目在此收尾/落库)
  137. async function respondNode(state) { return {}; }
  138. let compiled = null;
  139. function build() {
  140. return new StateGraph(S)
  141. .addNode('plan', planNode)
  142. .addNode('respond', respondNode)
  143. .addEdge(START, 'plan')
  144. .addEdge('plan', 'respond')
  145. .addEdge('respond', END)
  146. .compile();
  147. }
  148. export async function runTurn({ utterance }, { token, model = 'deepseek-flash' } = {}) {
  149. if (!compiled) compiled = build();
  150. const state = await compiled.invoke(
  151. { utterance: String(utterance || '') },
  152. { configurable: { __dc: { token, model } } },
  153. );
  154. return { state };
  155. }
  156. `);
  157. add('src/lib/fmode-chat.js', `/**
  158. * lib/fmode-chat.js —— 最小 OpenAI 兼容客户端(自写,不引 @langchain/openai)。
  159. * 凭据显式传入;错误剔除疑似 key。
  160. */
  161. const BASE = 'https://api.fmode.cn/v1';
  162. function scrub(t) { return String(t ?? '').replace(/(sk-[A-Za-z0-9_-]{6,}|Bearer\\s+\\S+)/g, '[redacted]'); }
  163. export async function chatComplete({ token, model, messages, tools, signal, timeoutMs = 45000 }) {
  164. const ctrl = new AbortController();
  165. const timer = setTimeout(() => ctrl.abort(), timeoutMs);
  166. if (signal) signal.addEventListener('abort', () => ctrl.abort(), { once: true });
  167. try {
  168. const res = await fetch(\`\${BASE}/chat/completions\`, {
  169. method: 'POST',
  170. headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: \`Bearer \${token}\` } : {}) },
  171. body: JSON.stringify({ model, messages, ...(tools ? { tools } : {}) }),
  172. signal: ctrl.signal,
  173. });
  174. const raw = await res.text();
  175. let data; try { data = JSON.parse(raw); } catch { data = raw; }
  176. if (!res.ok) throw new Error(scrub(\`HTTP \${res.status} \${typeof data === 'string' ? data : JSON.stringify(data)}\`));
  177. const msg = data?.choices?.[0]?.message || {};
  178. return { content: msg.content || '', toolCalls: msg.tool_calls || [], usage: data?.usage || null };
  179. } finally { clearTimeout(timer); }
  180. }
  181. `);
  182. add('src/lib/telemetry.js', `/**
  183. * lib/telemetry.js —— token 归因 + 耗时分离(口径见规则 measurement.md)。
  184. * 关键:框架调度与 IO 分开计时;await 期间暂停框架计时。
  185. */
  186. const timers = new Map();
  187. export const t0 = (k) => { timers.set(k, performance.now()); return k; };
  188. export const t1 = (k) => { const s = timers.get(k); if (s == null) return 0; timers.delete(k); return performance.now() - s; };
  189. export function estimateTokens(text) {
  190. const s = String(text ?? ''); let cjk = 0;
  191. for (const ch of s) { const c = ch.codePointAt(0); if (c >= 0x4e00 && c <= 0x9fff) cjk += 1; }
  192. return Math.round(cjk + (s.length - cjk) / 4);
  193. }
  194. /** 记一次 LLM 调用的 token(分项 + 输入输出) */
  195. export function newTokenLedger() { return { calls: 0, buckets: {}, io: { in: 0, out: 0, hasReal: false } }; }
  196. export function recordCall(ledger, { system = '', tools = [], history = [], retrieved = '', actual } = {}) {
  197. const parts = {
  198. system: estimateTokens(system),
  199. tools: estimateTokens(JSON.stringify(tools || [])),
  200. history: estimateTokens(JSON.stringify(history || [])),
  201. retrieved: estimateTokens(retrieved),
  202. };
  203. ledger.calls += 1;
  204. for (const [k, v] of Object.entries(parts)) ledger.buckets[k] = (ledger.buckets[k] || 0) + v;
  205. const est = Object.values(parts).reduce((a, b) => a + b, 0);
  206. ledger.io.in += actual?.prompt_tokens ?? est;
  207. ledger.io.out += actual?.completion_tokens ?? 0;
  208. if (actual?.prompt_tokens != null) ledger.io.hasReal = true;
  209. }
  210. /** 输入:输出比例(无输出真值时 ratio=null,不硬凑) */
  211. export function ioRatio(io) {
  212. const { in: i, out: o } = io || { in: 0, out: 0 };
  213. return { in: i, out: o, ratio: o > 0 ? +(i / o).toFixed(2) : null, total: i + o };
  214. }
  215. `);
  216. add('src/mcp/protocol.js', `/**
  217. * mcp/protocol.js —— 最小 MCP(JSON-RPC 2.0)服务端骨架。
  218. * ⚠️ Worker 内**只能** import 相对路径纯 JS(module worker 不支持 importmap)。
  219. */
  220. export const MCP_PROTOCOL_VERSION = '2025-06-18';
  221. export class McpServer {
  222. constructor(info = {}) { this.info = { name: 'mcp', version: '0.1.0', ...info }; this.tools = new Map(); }
  223. register(tool) {
  224. if (!tool?.name || typeof tool.handler !== 'function' || !tool.inputSchema) throw new Error('非法工具定义');
  225. this.tools.set(tool.name, tool);
  226. }
  227. async handle(req, ctx = {}) {
  228. const { id, method, params } = req || {};
  229. const ok = (result) => ({ jsonrpc: '2.0', id, result });
  230. const err = (code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
  231. if (method === 'initialize') return ok({ protocolVersion: MCP_PROTOCOL_VERSION, serverInfo: this.info, capabilities: { tools: {} } });
  232. if (method === 'tools/list') return ok({ tools: [...this.tools.values()].map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })) });
  233. if (method === 'tools/call') {
  234. const t = this.tools.get(params?.name);
  235. if (!t) return err(-32602, \`未知工具:\${params?.name}\`);
  236. try { return ok(await t.handler(params?.arguments || {}, ctx)); }
  237. catch (e) { return err(-32000, String(e?.message || e)); }
  238. }
  239. if (method === 'ping') return ok({});
  240. return err(-32601, \`未实现的方法:\${method}\`);
  241. }
  242. }
  243. `);
  244. add('src/mcp/tool-specs.js', `/**
  245. * mcp/tool-specs.js —— 工具规格(**纯 JS + JSON Schema**,Worker 内可用)。
  246. * 主线程侧的 zod 版应与本文件的参数键保持一致(assertSpecParity)。
  247. */
  248. export const TOOL_SPECS = [
  249. {
  250. name: 'echo',
  251. title: '回声',
  252. description: '把输入原样返回。最小示例工具。',
  253. inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'], additionalProperties: false },
  254. buildBrief: (a) => String(a?.text ?? '').trim(),
  255. },
  256. ];
  257. `);
  258. add('src/mcp/worker.js', `/**
  259. * mcp/worker.js —— Worker 宿主(浏览器端 MCP)。
  260. * 纪律:只 import 相对路径纯 JS;**凭据不进来**(要凭据的动作由主线程做)。
  261. */
  262. import { McpServer } from './protocol.js';
  263. import { TOOL_SPECS } from './tool-specs.js';
  264. const server = new McpServer({ name: '${project}-mcp', version: '0.1.0' });
  265. for (const spec of TOOL_SPECS) {
  266. server.register({
  267. name: spec.name, description: spec.description, inputSchema: spec.inputSchema,
  268. handler: async (args) => ({ content: [{ type: 'text', text: 'ok: ' + spec.buildBrief(args) }] }),
  269. });
  270. }
  271. self.onmessage = async (e) => {
  272. const m = e.data;
  273. if (!m || typeof m !== 'object') return;
  274. const res = await server.handle(m, {});
  275. if (res) self.postMessage(res);
  276. };
  277. `);
  278. add('package.json', `{
  279. "name": "${project}",
  280. "version": "0.1.0",
  281. "private": true,
  282. "type": "module",
  283. "description": "无构建智能体(源码即产物,浏览器 ESM 直跑)",
  284. "scripts": {
  285. "check": "node --check src/app.js && node --check src/lib/graph.js && node --check src/mcp/protocol.js"
  286. }
  287. }
  288. `);
  289. add('scripts/deploy.sh', `#!/usr/bin/env bash
  290. # 无构建部署 + 自动部署(参数化,别硬编码站点)。
  291. set -euo pipefail
  292. REPO_ROOT="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"; cd "$REPO_ROOT"
  293. PROJECT="\${PROJECT:-${project}}"
  294. OBSUTIL="\${OBSUTIL:-obsutil}"
  295. EP="\${OBS_ENDPOINT:-obs.<region>.myhuaweicloud.com}"
  296. BUCKET="\${OBS_BUCKET:?请设置 OBS_BUCKET(如 obs://my-bucket)}"
  297. # 凭据从环境注入;**AK/SK 不写进本文件**
  298. OBS_AK="\${OBS_AK:?set OBS_AK}"; OBS_SK="\${OBS_SK:?set OBS_SK}"
  299. PREFIX="dev/$PROJECT"
  300. echo "==> 体检"; node --check src/app.js
  301. echo "==> index.html 先删后传"
  302. "$OBSUTIL" rm "$BUCKET/$PREFIX/index.html" -i="$OBS_AK" -k="$OBS_SK" -e="$EP" -f >/dev/null 2>&1 || true
  303. "$OBSUTIL" cp ./src/index.html "$BUCKET/$PREFIX/index.html" -i="$OBS_AK" -k="$OBS_SK" -e="$EP" -acl=public-read -f \\
  304. -meta="Cache-Control:no-cache, no-store, must-revalidate#Content-Type:text/html; charset=utf-8" >/dev/null
  305. echo "==> 同步源码 + vendor"
  306. "$OBSUTIL" sync ./src/ "$BUCKET/$PREFIX/" -i="$OBS_AK" -k="$OBS_SK" -e="$EP" -acl=public-read -exclude="index.html"
  307. [ -d ./vendor ] && "$OBSUTIL" sync ./vendor/ "$BUCKET/$PREFIX/vendor/" -i="$OBS_AK" -k="$OBS_SK" -e="$EP" -acl=public-read
  308. echo "==> 刷 CDN(自行接刷新脚本;失败要退出非 0)"
  309. echo "完成:https://<domain>/$PREFIX/"
  310. `);
  311. add('CLAUDE.md', `# ${project} — 项目规范
  312. 本项目遵守 **fmode-code-agent** 规则(智能体开发)。
  313. - 规则索引:\`.claude/rules/agent-architecture.md\`(三层架构)、\`agent-mcp-skill.md\`、
  314. \`no-build-langgraph.md\`、\`measurement.md\`、\`deployment.md\`、\`pitfalls.md\`。
  315. - 前端规范(如用 Lit)见 **fmode-code-lit** 规则仓。
  316. ## 自动部署
  317. \`\`\`bash
  318. bash scripts/install-hooks.sh # 装上 pre-push:体检 → 发布 → 验真
  319. bash scripts/auto-deploy.sh --watch
  320. \`\`\`
  321. `);
  322. // ---- 落盘(不覆盖已存在,除非 --force)----
  323. let created = 0, skipped = 0;
  324. for (const [rel, content] of Object.entries(files)) {
  325. const p = join(root, rel);
  326. if (existsSync(p) && !force) { skipped++; console.log(' · 跳过(已存在)', rel); continue; }
  327. mkdirSync(dirname(p), { recursive: true });
  328. writeFileSync(p, content);
  329. if (rel.endsWith('.sh')) { try { chmodSync(p, 0o755); } catch {} }
  330. created++;
  331. }
  332. // 同步规则(若有)
  333. console.log(`骨架生成:新增 ${created} 个,跳过 ${skipped} 个 → ${root}`);
  334. console.log('下一步:');
  335. console.log(' 1) 同步 LangGraph 依赖到 vendor/(见规则 no-build-langgraph.md)');
  336. console.log(' 2) 装规则:node <skill_dir>/scripts/install-rules.mjs .');
  337. console.log(' 3) 本地预览:python3 -m http.server(或任意静态服务器)');