skill-image.mjs 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. // Copyright (c) 未来飞马
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this
  5. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  6. //
  7. // Trademark Notice:
  8. // The MPL-2.0 license grants copyright permissions for source code only.
  9. // It does NOT grant any rights to use trademarks including "未来飞马",
  10. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  11. // Any use of these trademarks requires separate written permission.
  12. // skill-image v0.2.1 — 纯ESM零依赖版
  13. // 架构图/应用界面/场景插图/整页PPT生成器
  14. import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
  15. import { homedir } from 'node:os';
  16. import { join, dirname } from 'node:path';
  17. import { fileURLToPath } from 'node:url';
  18. const __dirname = dirname(fileURLToPath(import.meta.url));
  19. // ===== 模式注册表 =====
  20. export const MODES = {
  21. '--arch': {
  22. prefix: '纯白背景扁平风格架构图。',
  23. suffix: '简洁扁平商务风格,纯白背景适合PPT。',
  24. size: '1792x1024', cost: 0.5, desc: '架构/分层/闭环图',
  25. },
  26. '--app': {
  27. prefix: '现代SaaS应用界面截图,浅色主题。',
  28. suffix: '界面细节清晰锐利,中文界面文字,像真实在运行的产品。',
  29. size: '1792x1024', cost: 0.5, desc: '产品界面(表现力最强)',
  30. },
  31. '--explode': {
  32. prefix: '技术爆炸图,内部分层剖开并标注数据流向。',
  33. suffix: '扁平科技风格,纯白背景,层级与流向清晰。',
  34. size: '1792x1024', cost: 0.5, desc: '内部运转/拆解图',
  35. },
  36. '--product': {
  37. prefix: '产品设计效果图,纯白背景,多角度组合渲染。',
  38. suffix: '工业设计质感,材质细腻,白底适合PPT,写实渲染。',
  39. size: '1792x1024', cost: 0.5, desc: '产品设计图(硬件/工业)',
  40. },
  41. '--scene': {
  42. prefix: '扁平插画场景。',
  43. suffix: '清新明亮风格。',
  44. size: '1024x1024', cost: 0.3, desc: '场景/人物/痛点插图',
  45. },
  46. '--detail': {
  47. prefix: '精密细节特写图。',
  48. suffix: '写实质感,清晰锐利。',
  49. size: '1024x1024', cost: 0.3, desc: '零件/材质/实物特写',
  50. },
  51. '--slide': {
  52. prefix: '一页16:9演示幻灯片。',
  53. suffix: '留白充足,字号层级分明,商务演示风格。',
  54. size: '1792x1024', cost: 0.5, desc: '整页PPT直接使用',
  55. },
  56. };
  57. // ===== 凭据自动获取 =====
  58. export function getApiKey() {
  59. let key = process.env.FMODE_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || '';
  60. if (key && !key.includes('***') && key.length > 10) return key;
  61. try {
  62. const p = join(homedir(), '.fmode', 'config.json');
  63. if (existsSync(p)) {
  64. const c = JSON.parse(readFileSync(p, 'utf-8'));
  65. for (const k of ['api_key', 'FMODE_API_KEY', 'fmodeApiToken']) if (c[k]) return c[k];
  66. }
  67. } catch {}
  68. try {
  69. const p = join(homedir(), '.fmode', 'config.yaml');
  70. if (existsSync(p)) {
  71. for (const l of readFileSync(p, 'utf-8').split('\n')) {
  72. for (const kw of ['api_key', 'FMODE_API_KEY', 'fmodeApiToken']) {
  73. if (l.includes(kw)) {
  74. const v = l.split(':').slice(1).join(':').trim().replace(/["']/g, '');
  75. if (v && v !== '${FMODE_API_KEY}') return v;
  76. }
  77. }
  78. }
  79. }
  80. } catch {}
  81. const envDir = process.env.FMODE_DATA_DIR || '/opt/data';
  82. for (const p of [`${envDir}/.env`, '.env']) {
  83. try {
  84. if (existsSync(p)) {
  85. for (const l of readFileSync(p, 'utf-8').split('\n')) {
  86. if (l.includes('FMODE_API_KEY')) {
  87. const v = l.split('=').slice(1).join('=').trim().replace(/["']/g, '');
  88. if (v && !v.includes('***') && v.length > 10) return v;
  89. }
  90. }
  91. }
  92. } catch {}
  93. }
  94. throw new Error('No API Key found. Set FMODE_API_KEY env or ~/.fmode/config.json');
  95. }
  96. // ===== API调用(纯fetch,零依赖) =====
  97. export async function callImageAPI(prompt, size) {
  98. const key = getApiKey();
  99. const resp = await fetch('https://api.fmode.cn/v1/images/generations', {
  100. method: 'POST',
  101. headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' },
  102. body: JSON.stringify({ model: 'gpt-image-2.5-sunburst', prompt, n: 1, size }),
  103. signal: AbortSignal.timeout(900000),
  104. });
  105. if (!resp.ok) throw new Error(`API ${resp.status}: ${(await resp.text().catch(()=>'')).slice(0,200)}`);
  106. const data = await resp.json();
  107. if (data.data?.[0]?.b64_json) return Buffer.from(data.data[0].b64_json, 'base64');
  108. if (data.data?.[0]?.url) {
  109. const r = await fetch(data.data[0].url, { signal: AbortSignal.timeout(60000) });
  110. if (!r.ok) throw new Error(`Download failed: ${r.status}`);
  111. return Buffer.from(await r.arrayBuffer());
  112. }
  113. throw new Error('Response has no image data');
  114. }
  115. // ===== 统一生成 =====
  116. export async function gen(mode, prompt, name = 'output') {
  117. const cfg = MODES[mode];
  118. if (!cfg) throw new Error(`Unknown mode: ${mode}`);
  119. const fullPrompt = `${cfg.prefix}${prompt} ${cfg.suffix}`;
  120. const outdir = process.env.SKILL_IMAGE_OUTPUT || '.';
  121. mkdirSync(outdir, { recursive: true });
  122. const outPath = join(outdir, `${name}.png`);
  123. const imgData = await callImageAPI(fullPrompt, cfg.size);
  124. writeFileSync(outPath, imgData);
  125. console.log(` OK ${cfg.desc} | ${Math.round(imgData.length/1024)}KB | ${cfg.size} | ${outPath}`);
  126. return outPath;
  127. }
  128. export const genArch = (p, n) => gen('--arch', p, n);
  129. export const genApp = (p, n) => gen('--app', p, n);
  130. export const genExplode = (p, n) => gen('--explode', p, n);
  131. export const genProduct = (p, n) => gen('--product', p, n);
  132. export const genScene = (p, n) => gen('--scene', p, n);
  133. export const genDetail = (p, n) => gen('--detail', p, n);
  134. export const genSlide = (p, n) => gen('--slide', p, n);
  135. export async function batch(tasks) {
  136. let total = 0;
  137. for (const [mode, name, prompt] of tasks) {
  138. process.stdout.write(`${name}: `);
  139. try { await gen(mode, prompt, name); total += MODES[mode].cost; }
  140. catch (e) { console.error(` FAIL: ${e.message}`); }
  141. }
  142. console.log(`\nTotal cost: ¥${total.toFixed(1)}`);
  143. }
  144. async function main() {
  145. const args = process.argv.slice(2);
  146. if (!args.length || args[0] === '--help' || args[0] === '-h') {
  147. console.log(`
  148. skill-image v0.2.1 — AI架构图和场景插图生成器
  149. 用法:
  150. node <技能目录>/bin/skill-image.js --arch "prompt" [name] 架构图 ¥0.5
  151. node <技能目录>/bin/skill-image.js --app "prompt" [name] 应用界面 ¥0.5
  152. node <技能目录>/bin/skill-image.js --scene "prompt" [name] 场景插图 ¥0.3
  153. node <技能目录>/bin/skill-image.js --slide "prompt" [name] 整页PPT ¥0.5
  154. node <技能目录>/bin/skill-image.js --explode "prompt" [name] 爆炸图 ¥0.5
  155. node <技能目录>/bin/skill-image.js --detail "prompt" [name] 细节图 ¥0.3
  156. `);
  157. process.exit(0);
  158. }
  159. const mode = args[0];
  160. if (!MODES[mode]) { console.error(`Unknown mode: ${mode}`); process.exit(1); }
  161. const prompt = args.slice(1, -1).join(' ') || args[1] || '';
  162. const name = args.length > 2 ? args[args.length - 1] : 'output';
  163. if (!prompt) { console.error('Error: prompt required'); process.exit(1); }
  164. try { await gen(mode, prompt, name); }
  165. catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
  166. }
  167. if (process.argv[1] === fileURLToPath(import.meta.url)) main();