agens-studio 0.1.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/config.js ADDED
@@ -0,0 +1,146 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ /**
9
+ * 外部配置文件路径
10
+ * - CLI 分发模式:~/.agens-cli/config.json(由首次运行的 setup wizard 创建)
11
+ * - 开发模式:同目录 config.local.json(手动创建,gitignore)
12
+ *
13
+ * 优先级:外部配置文件 > 环境变量 > 内置默认值
14
+ */
15
+ function loadExternalConfig() {
16
+ // 1. ~/.agens-cli/config.json(分发模式)
17
+ const homeConfig = path.join(os.homedir(), '.agens-cli', 'config.json');
18
+ // 2. 项目本地 config.local.json(开发模式)
19
+ const localConfig = path.join(__dirname, 'config.local.json');
20
+
21
+ for (const cfgPath of [homeConfig, localConfig]) {
22
+ try {
23
+ const raw = fs.readFileSync(cfgPath, 'utf8');
24
+ const parsed = JSON.parse(raw);
25
+ if (parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0) {
26
+ return { data: parsed, source: cfgPath };
27
+ }
28
+ } catch { /* 不存在或解析失败,尝试下一个 */ }
29
+ }
30
+ return { data: {}, source: null };
31
+ }
32
+
33
+ const ext = loadExternalConfig();
34
+ const ext_cfg = ext.data;
35
+
36
+ /**
37
+ * 全局配置
38
+ */
39
+ export const config = {
40
+ // 配置文件来源(调试用)
41
+ _configSource: ext.source,
42
+
43
+ // 服务端口
44
+ port: process.env.PORT || 5173,
45
+
46
+ // agens API 接入信息
47
+ agens: {
48
+ // 生图(agnes-image-2.1-flash)
49
+ image: {
50
+ baseUrl: ext_cfg.agens_image_base_url || process.env.AGENS_IMAGE_BASE_URL || 'https://apihub.agnes-ai.com/v1',
51
+ apiKey: ext_cfg.agens_api_key || process.env.AGENS_IMAGE_API_KEY || '',
52
+ model: ext_cfg.agens_image_model || 'agnes-image-2.1-flash',
53
+ endpoint: '/images/generations',
54
+ },
55
+ // 视频(agnes-video-v2.0)
56
+ video: {
57
+ baseUrl: ext_cfg.agens_video_base_url || process.env.AGENS_VIDEO_BASE_URL || 'https://apihub.agnes-ai.com/v1',
58
+ apiKey: ext_cfg.agens_api_key || process.env.AGENS_VIDEO_API_KEY || '',
59
+ model: ext_cfg.agens_video_model || 'agnes-video-v2.0',
60
+ createEndpoint: '/videos',
61
+ queryBaseUrl: process.env.AGENS_VIDEO_QUERY_BASE_URL || 'https://apihub.agnes-ai.com',
62
+ queryPath: '/agnesapi',
63
+ },
64
+ // 视频轮询配置
65
+ pollInterval: 5000,
66
+ pollMaxAttempts: 120,
67
+ },
68
+
69
+ // mimo 大模型(用于提示词优化)
70
+ mimo: {
71
+ baseUrl: ext_cfg.mimo_base_url || process.env.MIMO_BASE_URL || 'https://token-plan-cn.xiaomimimo.com/v1',
72
+ apiKey: ext_cfg.mimo_api_key || '',
73
+ model: ext_cfg.mimo_model || 'mimo-v2.5-pro',
74
+ visionModel: ext_cfg.mimo_vision_model || 'mimo-v2.5',
75
+ endpoint: '/chat/completions',
76
+ maxTokens: 4096,
77
+ temperature: 0.8,
78
+ topP: 0.95,
79
+ },
80
+
81
+ // 识图结果缓存
82
+ visionCache: {
83
+ file: path.join(__dirname, 'data', 'vision_cache.json'),
84
+ },
85
+
86
+ // 提示词记忆库
87
+ promptMemory: {
88
+ file: path.join(__dirname, 'data', 'prompt_memory.json'),
89
+ maxSamples: 500,
90
+ feedSamples: 5,
91
+ minSamplesForPrefs: 8,
92
+ },
93
+
94
+ // 目录
95
+ dirs: {
96
+ root: __dirname,
97
+ public: path.join(__dirname, 'public'),
98
+ assets: path.join(__dirname, 'assets'),
99
+ images: path.join(__dirname, 'assets', 'images'),
100
+ videos: path.join(__dirname, 'assets', 'videos'),
101
+ data: path.join(__dirname, 'data'),
102
+ indexFile: path.join(__dirname, 'data', 'index.json'),
103
+ uploads: path.join(__dirname, 'data', 'uploads'),
104
+ },
105
+
106
+ // 上传参考图限制
107
+ upload: {
108
+ maxBytes: 20 * 1024 * 1024,
109
+ allowedMime: ['image/png', 'image/jpeg', 'image/webp', 'image/jpg'],
110
+ },
111
+ };
112
+
113
+ /**
114
+ * 用户配置目录(~/.agens-cli/)
115
+ * 分发模式下 API Key 和配置存在这里
116
+ */
117
+ export const USER_CONFIG_DIR = path.join(os.homedir(), '.agens-cli');
118
+ export const USER_CONFIG_FILE = path.join(USER_CONFIG_DIR, 'config.json');
119
+
120
+ /**
121
+ * 保存用户配置到 ~/.agens-cli/config.json
122
+ * @param {object} cfg 配置对象
123
+ */
124
+ export function saveUserConfig(cfg) {
125
+ fs.mkdirSync(USER_CONFIG_DIR, { recursive: true });
126
+ fs.writeFileSync(USER_CONFIG_FILE, JSON.stringify(cfg, null, 2), 'utf8');
127
+ }
128
+
129
+ /**
130
+ * 读取用户配置(如果存在)
131
+ * @returns {object|null}
132
+ */
133
+ export function readUserConfig() {
134
+ try {
135
+ return JSON.parse(fs.readFileSync(USER_CONFIG_FILE, 'utf8'));
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * 检查 API Key 是否已配置(非默认空值)
143
+ */
144
+ export function isApiConfigured() {
145
+ return !!(config.agens.image.apiKey || config.mimo.apiKey);
146
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "agens-studio",
3
+ "version": "0.1.0",
4
+ "description": "Agens 创作工作台 —— AI 图片/视频生成 CLI + Web 工作台",
5
+ "type": "module",
6
+ "main": "server.js",
7
+ "bin": {
8
+ "agens-cli": "./cli.js"
9
+ },
10
+ "files": [
11
+ "cli.js",
12
+ "config.js",
13
+ "src/",
14
+ "README-CLI.md"
15
+ ],
16
+ "scripts": {
17
+ "start": "node server.js",
18
+ "dev": "node --watch server.js",
19
+ "cli": "node cli.js"
20
+ },
21
+ "keywords": [
22
+ "agens",
23
+ "ai",
24
+ "image-generation",
25
+ "video-generation",
26
+ "cli"
27
+ ],
28
+ "license": "MIT",
29
+ "engines": {
30
+ "node": ">=18.0.0"
31
+ },
32
+ "dependencies": {
33
+ "express": "^5.2.1"
34
+ }
35
+ }
package/server.js ADDED
@@ -0,0 +1,74 @@
1
+ import express from 'express';
2
+ import path from 'node:path';
3
+ import { config } from './config.js';
4
+ import generateRouter from './src/routes/generate.js';
5
+ import taskRouter from './src/routes/task.js';
6
+ import assetsRouter from './src/routes/assets.js';
7
+ import promptRouter from './src/routes/prompt.js';
8
+ import { init as initAssetStore } from './src/services/assetStore.js';
9
+ import { init as initPromptMemory } from './src/services/promptMemory.js';
10
+ import { init as initVisionCache } from './src/services/mimoVision.js';
11
+ import { isMockMode } from './src/services/agensClient.js';
12
+
13
+ const app = express();
14
+
15
+ // 中间件
16
+ app.use(express.json({ limit: '2mb' }));
17
+
18
+ // 开发期禁用静态文件缓存,保证改前端即时生效
19
+ function noCache(req, res, next) {
20
+ res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
21
+ res.set('Pragma', 'no-cache');
22
+ res.set('Expires', '0');
23
+ next();
24
+ }
25
+
26
+ // 静态目录:前端 + 生成物 + 上传
27
+ app.use(noCache, express.static(config.dirs.public));
28
+ app.use('/assets', express.static(config.dirs.assets));
29
+ app.use('/uploads', express.static(config.dirs.uploads));
30
+
31
+ // 路由
32
+ app.use('/api/generate', generateRouter);
33
+ app.use('/api/task', taskRouter);
34
+ app.use('/api/assets', assetsRouter);
35
+ app.use('/api/prompt', promptRouter);
36
+
37
+ // 健康检查 / 配置信息(前端用来判断是否 mock 模式)
38
+ app.get('/api/health', (req, res) => {
39
+ res.json({
40
+ ok: true,
41
+ mockMode: isMockMode(),
42
+ agensConfigured: !isMockMode(),
43
+ });
44
+ });
45
+
46
+ // 兜底:所有非 /api、非静态文件的请求都回 index.html(便于前端单页逻辑)
47
+ // 注:Express 5 不再支持 '*' 通配符,需使用命名捕获参数
48
+ app.get('{*path}', (req, res) => {
49
+ res.sendFile(path.join(config.dirs.public, 'index.html'));
50
+ });
51
+
52
+ async function main() {
53
+ await initAssetStore();
54
+ await initPromptMemory();
55
+ await initVisionCache();
56
+
57
+ app.listen(config.port, () => {
58
+ console.log('\n ┌──────────────────────────────────────────────┐');
59
+ console.log(' │ agens 本地创作工作台已启动 │');
60
+ console.log(` │ 地址: http://localhost:${config.port} │`);
61
+ if (isMockMode()) {
62
+ console.log(' │ ⚠️ Mock 模式(未配置 agens API,使用占位结果) │');
63
+ console.log(' │ 配好 config.js 里的 baseUrl / apiKey 后生效 │');
64
+ } else {
65
+ console.log(' │ ✅ 已接入 agens API │');
66
+ }
67
+ console.log(' └──────────────────────────────────────────────┘\n');
68
+ });
69
+ }
70
+
71
+ main().catch((err) => {
72
+ console.error('启动失败:', err);
73
+ process.exit(1);
74
+ });
@@ -0,0 +1,146 @@
1
+ import { Router } from 'express';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { config } from '../../config.js';
6
+ import * as store from '../services/assetStore.js';
7
+
8
+ const router = Router();
9
+
10
+ /** GET /api/assets —— 列资产 */
11
+ router.get('/', async (req, res) => {
12
+ const result = await store.listAssets({
13
+ type: req.query.type || 'all',
14
+ search: req.query.search || '',
15
+ keptOnly: req.query.keptOnly === '1' || req.query.keptOnly === 'true',
16
+ page: parseInt(req.query.page, 10) || 1,
17
+ pageSize: parseInt(req.query.pageSize, 10) || 60,
18
+ });
19
+ res.json(result);
20
+ });
21
+
22
+ /** GET /api/assets/:id —— 单个资产详情 */
23
+ router.get('/:id', async (req, res) => {
24
+ const item = await store.getAsset(req.params.id);
25
+ if (!item) return res.status(404).json({ error: '资产不存在' });
26
+ res.json(item);
27
+ });
28
+
29
+ /** PUT /api/assets/:id —— 更新(kept 保留标记 / note 备注) */
30
+ router.put('/:id', async (req, res) => {
31
+ const { kept, note } = req.body || {};
32
+ const patch = {};
33
+ if (typeof kept === 'boolean') patch.kept = kept;
34
+ if (typeof note === 'string') patch.note = note;
35
+ const item = await store.updateAsset(req.params.id, patch);
36
+ if (!item) return res.status(404).json({ error: '资产不存在' });
37
+ res.json(item);
38
+ });
39
+
40
+ /** DELETE /api/assets/:id —— 删除(文件 + 索引) */
41
+ router.delete('/:id', async (req, res) => {
42
+ const ok = await store.deleteAsset(req.params.id);
43
+ if (!ok) return res.status(404).json({ error: '资产不存在' });
44
+ res.json({ ok: true });
45
+ });
46
+
47
+ /**
48
+ * POST /api/upload —— 上传参考图(用于图生/多图/关键帧)
49
+ * multipart/form-data, 字段名 file
50
+ * 返回 { url } —— 供 /api/generate 的 imageUrls 使用
51
+ *
52
+ * 这里手写一个最简的 multipart 解析,避免引入 multer。
53
+ * 仅支持单文件、非流式,本地单机够用。
54
+ */
55
+ router.post('/upload', async (req, res) => {
56
+ try {
57
+ const contentType = req.headers['content-type'] || '';
58
+ if (!contentType.startsWith('multipart/form-data')) {
59
+ return res.status(400).json({ error: '需要 multipart/form-data' });
60
+ }
61
+
62
+ const boundary = extractBoundary(contentType);
63
+ if (!boundary) return res.status(400).json({ error: '无法解析 boundary' });
64
+
65
+ const chunks = [];
66
+ for await (const c of req) chunks.push(c);
67
+ const buf = Buffer.concat(chunks);
68
+ if (buf.length > config.upload.maxBytes) {
69
+ return res.status(413).json({ error: '文件过大(>20MB)' });
70
+ }
71
+
72
+ const parsed = parseMultipart(buf, boundary);
73
+ const file = parsed.find((p) => p.filename);
74
+ if (!file) return res.status(400).json({ error: '未找到文件' });
75
+
76
+ if (!config.upload.allowedMime.includes(file.contentType)) {
77
+ return res.status(415).json({ error: '仅支持 png/jpg/webp' });
78
+ }
79
+
80
+ const ext = guessExt(file.filename, file.contentType);
81
+ const id = randomUUID();
82
+ const filename = `${id}${ext}`;
83
+ const absPath = path.join(config.dirs.uploads, filename);
84
+ await fs.writeFile(absPath, file.data);
85
+
86
+ // 浏览器可访问的路径
87
+ const url = `/uploads/${filename}`;
88
+ res.json({ url, filename, size: file.data.length });
89
+ } catch (e) {
90
+ res.status(500).json({ error: '上传失败:' + e.message });
91
+ }
92
+ });
93
+
94
+ function extractBoundary(ct) {
95
+ const m = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
96
+ return m ? (m[1] || m[2]) : null;
97
+ }
98
+
99
+ function parseMultipart(buf, boundary) {
100
+ const sep = Buffer.from('--' + boundary);
101
+ const parts = [];
102
+ let start = 0;
103
+ while (true) {
104
+ const i = buf.indexOf(sep, start);
105
+ if (i === -1) break;
106
+ const next = buf.indexOf(sep, i + sep.length);
107
+ if (next === -1) break;
108
+ const block = buf.slice(i + sep.length, next);
109
+ parts.push(block);
110
+ start = next;
111
+ }
112
+ const result = [];
113
+ for (const block of parts) {
114
+ // 跳过结尾的 --
115
+ if (block.slice(0, 2).toString() === '--') continue;
116
+ const headerEnd = block.indexOf('\r\n\r\n');
117
+ if (headerEnd === -1) continue;
118
+ const headerText = block.slice(0, headerEnd).toString('utf8');
119
+ let body = block.slice(headerEnd + 4);
120
+ // 去掉末尾的 \r\n
121
+ if (body.length >= 2 && body[body.length - 2] === 0x0d && body[body.length - 1] === 0x0a) {
122
+ body = body.slice(0, -2);
123
+ }
124
+ const nameMatch = headerText.match(/name="([^"]+)"/);
125
+ const fileMatch = headerText.match(/filename="([^"]*)"/);
126
+ const ctMatch = headerText.match(/Content-Type:\s*([^\r\n]+)/i);
127
+ result.push({
128
+ name: nameMatch ? nameMatch[1] : null,
129
+ filename: fileMatch ? fileMatch[1] : null,
130
+ contentType: ctMatch ? ctMatch[1].trim() : 'application/octet-stream',
131
+ data: body,
132
+ });
133
+ }
134
+ return result;
135
+ }
136
+
137
+ function guessExt(filename, mime) {
138
+ if (filename) {
139
+ const m = filename.toLowerCase().match(/\.(png|jpe?g|webp)$/);
140
+ if (m) return '.' + m[1].replace('jpeg', 'jpg');
141
+ }
142
+ const map = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/webp': '.webp' };
143
+ return map[mime?.toLowerCase()] || '.png';
144
+ }
145
+
146
+ export default router;
@@ -0,0 +1,64 @@
1
+ import { Router } from 'express';
2
+ import { submitGenerateJob } from '../services/taskManager.js';
3
+
4
+ const router = Router();
5
+
6
+ const IMAGE_MODES = ['image', 'img2img'];
7
+ const VIDEO_MODES = ['text2video', 'image2video', 'multi2video', 'keyframe'];
8
+
9
+ /**
10
+ * POST /api/generate
11
+ * body: {
12
+ * type: 'image' | 'video',
13
+ * mode: 生图 'image' | 'img2img'
14
+ * 视频 'text2video' | 'image2video' | 'multi2video' | 'keyframe',
15
+ * prompt: string,
16
+ * params: object, // 比例、风格、时长等
17
+ * imageUrls: string[], // 图生/多图/关键帧需要的参考图(已上传后的本地路径)
18
+ * }
19
+ * 立即返回本地 taskId,前端轮询 /api/task/:id
20
+ */
21
+ router.post('/', async (req, res) => {
22
+ const { type, mode, prompt, params, imageUrls } = req.body || {};
23
+ if (!type || !['image', 'video'].includes(type)) {
24
+ return res.status(400).json({ error: 'type 必须是 image 或 video' });
25
+ }
26
+ if (!mode) {
27
+ return res.status(400).json({ error: '请指定 mode' });
28
+ }
29
+
30
+ // 模式合法性校验
31
+ if (type === 'image' && !IMAGE_MODES.includes(mode)) {
32
+ return res.status(400).json({ error: '未知的生图模式:' + mode });
33
+ }
34
+ if (type === 'video' && !VIDEO_MODES.includes(mode)) {
35
+ return res.status(400).json({ error: '未知的视频模式:' + mode });
36
+ }
37
+
38
+ // 参考图校验
39
+ const hasImages = Array.isArray(imageUrls) && imageUrls.length > 0;
40
+ if (mode === 'img2img' && !hasImages) {
41
+ return res.status(400).json({ error: '图生图需要上传一张原图' });
42
+ }
43
+ if (mode === 'image2video' && !hasImages) {
44
+ return res.status(400).json({ error: '该模式需要上传至少一张参考图' });
45
+ }
46
+ if (mode === 'multi2video' && (!hasImages || imageUrls.length < 2)) {
47
+ return res.status(400).json({ error: '多图生视频至少需要 2 张参考图' });
48
+ }
49
+ if (mode === 'keyframe' && (!hasImages || imageUrls.length < 2)) {
50
+ return res.status(400).json({ error: '关键帧动画至少需要首帧和尾帧(共 2 张)' });
51
+ }
52
+
53
+ const task = await submitGenerateJob({
54
+ type,
55
+ mode,
56
+ prompt: prompt || '',
57
+ params: params || {},
58
+ imageUrls: imageUrls || [],
59
+ });
60
+
61
+ res.json({ taskId: task.id });
62
+ });
63
+
64
+ export default router;