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.
@@ -0,0 +1,224 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { config } from '../../config.js';
5
+
6
+ /**
7
+ * 资产管理服务
8
+ * - 生成物下载到 assets/{images|videos}
9
+ * - 元数据维护在 data/index.json
10
+ * - 支持列表 / 删除 / 保留标记 / 备注
11
+ */
12
+
13
+ let _cache = null; // 内存缓存,避免每次请求都读文件
14
+
15
+ async function ensureDirs() {
16
+ await fs.mkdir(config.dirs.images, { recursive: true });
17
+ await fs.mkdir(config.dirs.videos, { recursive: true });
18
+ await fs.mkdir(config.dirs.data, { recursive: true });
19
+ await fs.mkdir(config.dirs.uploads, { recursive: true });
20
+ try {
21
+ await fs.access(config.dirs.indexFile);
22
+ } catch {
23
+ await fs.writeFile(config.dirs.indexFile, '[]', 'utf8');
24
+ }
25
+ }
26
+
27
+ async function loadIndex() {
28
+ if (_cache) return _cache;
29
+ try {
30
+ const raw = await fs.readFile(config.dirs.indexFile, 'utf8');
31
+ _cache = JSON.parse(raw);
32
+ if (!Array.isArray(_cache)) _cache = [];
33
+ } catch {
34
+ _cache = [];
35
+ }
36
+ return _cache;
37
+ }
38
+
39
+ async function saveIndex() {
40
+ if (!_cache) return;
41
+ await fs.writeFile(config.dirs.indexFile, JSON.stringify(_cache, null, 2), 'utf8');
42
+ }
43
+
44
+ /** 提取文件扩展名(带点),默认根据 mime */
45
+ function extFromMime(mime) {
46
+ const map = {
47
+ 'image/png': '.png',
48
+ 'image/jpeg': '.jpg',
49
+ 'image/webp': '.webp',
50
+ 'image/jpg': '.jpg',
51
+ 'video/mp4': '.mp4',
52
+ 'video/quicktime': '.mov',
53
+ 'video/webm': '.webm',
54
+ };
55
+ return map[mime?.toLowerCase()] || '';
56
+ }
57
+
58
+ /** 从 URL 推断扩展名,否则用 mime */
59
+ function extFromUrl(url, mime) {
60
+ try {
61
+ const u = new URL(url);
62
+ const m = u.pathname.match(/\.(png|jpe?g|webp|mp4|mov|webm)$/i);
63
+ if (m) return '.' + m[1].toLowerCase().replace('jpeg', 'jpg');
64
+ } catch { /* ignore */ }
65
+ return extFromMime(mime);
66
+ }
67
+
68
+ /**
69
+ * 下载远程文件到本地并登记为资产
70
+ * @param {object} opts
71
+ * @param {string} opts.url 远程文件 URL
72
+ * @param {'image'|'video'} opts.kind
73
+ * @param {string} [opts.mime] 可选,明确 mime
74
+ * @param {object} opts.meta 元数据(prompt/params/mode/taskId 等)
75
+ * @returns {Promise<object>} 新建资产记录
76
+ */
77
+ export async function downloadAndRegister({ url, kind, mime, meta = {} }) {
78
+ await ensureDirs();
79
+ // 下载重试:视频 URL 可能临时不可访问(CDN 未同步),最多重试 3 次
80
+ let res;
81
+ let lastErr;
82
+ for (let attempt = 0; attempt < 3; attempt++) {
83
+ try {
84
+ res = await fetch(url);
85
+ if (res.ok) break;
86
+ lastErr = new Error(`HTTP ${res.status}`);
87
+ } catch (e) {
88
+ lastErr = e;
89
+ }
90
+ if (attempt < 2) await new Promise((r) => setTimeout(r, 2000 * (attempt + 1))); // 2s, 4s
91
+ }
92
+ if (!res || !res.ok) throw new Error(`下载失败(重试3次): ${lastErr?.message || res?.status || '未知'}`);
93
+ const contentType = mime || res.headers.get('content-type') || '';
94
+ const buf = Buffer.from(await res.arrayBuffer());
95
+ const ext = extFromUrl(url, contentType);
96
+ const id = randomUUID();
97
+ const ts = Date.now();
98
+ const filename = `${id}${ext}`;
99
+ const subdir = kind === 'video' ? 'videos' : 'images';
100
+ const absPath = path.join(config.dirs[subdir], filename);
101
+ await fs.writeFile(absPath, buf);
102
+
103
+ const record = {
104
+ id,
105
+ type: kind, // 'image' | 'video'
106
+ filename,
107
+ // 浏览器可访问的相对路径
108
+ path: `/assets/${subdir}/${filename}`,
109
+ size: buf.length,
110
+ mime: contentType,
111
+ prompt: meta.prompt || '',
112
+ mode: meta.mode || '',
113
+ params: meta.params || {},
114
+ taskId: meta.taskId || null,
115
+ kept: true, // 默认保留,false 表示待清理
116
+ note: '',
117
+ createdAt: ts,
118
+ };
119
+
120
+ const list = await loadIndex();
121
+ list.unshift(record);
122
+ await saveIndex();
123
+ return record;
124
+ }
125
+
126
+ /**
127
+ * 登记一个本地已存在的文件(例如用户上传后用作参考帧)
128
+ */
129
+ export async function registerLocal({ absPath, kind, meta = {} }) {
130
+ await ensureDirs();
131
+ const id = randomUUID();
132
+ const ts = Date.now();
133
+ const filename = path.basename(absPath);
134
+ const subdir = kind === 'video' ? 'videos' : 'images';
135
+ const record = {
136
+ id,
137
+ type: kind,
138
+ filename,
139
+ path: `/assets/${subdir}/${filename}`,
140
+ size: (await fs.stat(absPath)).size,
141
+ mime: '',
142
+ prompt: meta.prompt || '',
143
+ mode: meta.mode || '',
144
+ params: meta.params || {},
145
+ taskId: meta.taskId || null,
146
+ kept: true,
147
+ note: '',
148
+ createdAt: ts,
149
+ };
150
+ const list = await loadIndex();
151
+ list.unshift(record);
152
+ await saveIndex();
153
+ return record;
154
+ }
155
+
156
+ /**
157
+ * 列出资产
158
+ * @param {object} q
159
+ * @param {'image'|'video'|'all'} [q.type]
160
+ * @param {string} [q.search] 搜索 prompt / note
161
+ * @param {boolean} [q.keptOnly] 只看保留的
162
+ * @param {number} [q.page]
163
+ * @param {number} [q.pageSize]
164
+ */
165
+ export async function listAssets({ type = 'all', search = '', keptOnly = false, page = 1, pageSize = 60 } = {}) {
166
+ const list = await loadIndex();
167
+ let filtered = list;
168
+ if (type && type !== 'all') filtered = filtered.filter((a) => a.type === type);
169
+ if (keptOnly) filtered = filtered.filter((a) => a.kept);
170
+ if (search) {
171
+ const s = search.toLowerCase();
172
+ filtered = filtered.filter(
173
+ (a) =>
174
+ (a.prompt || '').toLowerCase().includes(s) ||
175
+ (a.note || '').toLowerCase().includes(s)
176
+ );
177
+ }
178
+ // 已按 createdAt 倒序(新在前)
179
+ const total = filtered.length;
180
+ const start = (page - 1) * pageSize;
181
+ const items = filtered.slice(start, start + pageSize);
182
+ return { items, total, page, pageSize };
183
+ }
184
+
185
+ /** 根据 id 查单个资产 */
186
+ export async function getAsset(id) {
187
+ const list = await loadIndex();
188
+ return list.find((a) => a.id === id) || null;
189
+ }
190
+
191
+ /**
192
+ * 更新资产:保留标记 / 备注
193
+ */
194
+ export async function updateAsset(id, patch) {
195
+ const list = await loadIndex();
196
+ const item = list.find((a) => a.id === id);
197
+ if (!item) return null;
198
+ if (typeof patch.kept === 'boolean') item.kept = patch.kept;
199
+ if (typeof patch.note === 'string') item.note = patch.note;
200
+ await saveIndex();
201
+ return item;
202
+ }
203
+
204
+ /**
205
+ * 删除资产(同时删文件)
206
+ */
207
+ export async function deleteAsset(id) {
208
+ const list = await loadIndex();
209
+ const idx = list.findIndex((a) => a.id === id);
210
+ if (idx === -1) return false;
211
+ const [item] = list.splice(idx, 1);
212
+ await saveIndex();
213
+ try {
214
+ const subdir = item.type === 'video' ? 'videos' : 'images';
215
+ await fs.unlink(path.join(config.dirs[subdir], item.filename));
216
+ } catch { /* 文件可能已不在 */ }
217
+ return true;
218
+ }
219
+
220
+ /** 启动时调用,确保目录就绪 */
221
+ export async function init() {
222
+ await ensureDirs();
223
+ await loadIndex();
224
+ }