Browse Source

docs(plan): post media assets implementation plan

Made-with: Cursor
main
npmrun 11 hours ago
parent
commit
d5cc3a8bba
  1. 565
      docs/superpowers/plans/2026-04-18-post-media-assets-implementation-plan.md

565
docs/superpowers/plans/2026-04-18-post-media-assets-implementation-plan.md

@ -0,0 +1,565 @@
# Post Media Assets(元数据、WebP 优化、孤儿审查与自动清扫)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 按 `docs/superpowers/specs/2026-04-18-post-media-assets-design.md` 实现文章图片的 `media_assets` / `post_media_refs`、保存文章时同步引用、上传后 Sharp 转 WebP 与限宽、`/me` 孤儿审查与手动删除、管理员全站自动清扫开关 + 定时任务;默认不自动删文件。
**Architecture:** 引用真相在 `post_media_refs`;`media_assets` 存 `storage_key`(与 `/public/assets/<filename>` 一一对应)、`first_referenced_at` / `dereferenced_at` 用于宽限期判定(从未被引用用 `created_at`,曾被引用用 `dereferenced_at`)。`createPost` / `updatePost` / `deletePost` 后调用同一套 `syncPostMediaRefs` + `reconcileAssetReferenceTimestamps`。上传走 multer 落盘后经 Sharp 写出 `.webp` 主文件并插入 DB。定时任务通过 `getGlobalConfigValue("mediaOrphanAutoSweepEnabled")` 判断是否执行删除,与手动删除共用 `purgeDeletableOrphans` 核心逻辑。
**Tech Stack:** Nuxt 4.4 / Nitro、Drizzle + SQLite(`drizzle-pkg`)、Bun、`sharp`(图片处理)、现有 `multer` 上传、`#server/service/config` 全局配置、`requireAdmin` / `event.context.auth.requireUser()`
**Spec:** `docs/superpowers/specs/2026-04-18-post-media-assets-design.md`
**测试说明:** 以 spec 第 12 节 **手动验收** 为主;仓库无现成 HTTP 集成测试框架,计划中不虚构「先写失败 E2E」步骤。每任务完成后至少 `bun run build` 或通过 `bun run dev` 冒烟。
---
## 文件结构(将创建 / 修改)
| 路径 | 职责 |
|------|------|
| `packages/drizzle-pkg/database/sqlite/schema/content.ts` | 新增 `mediaAssets`、`postMediaRefs` 表定义 |
| `packages/drizzle-pkg/migrations/*` | 由 `db:generate` 生成 + 本地 `db:migrate` |
| `packages/drizzle-pkg/lib/schema/content.ts` | `export` 新表 |
| `server/constants/media.ts` | 本站资源 URL 前缀、宽限期小时数、最大宽度、WebP 质量 |
| `server/utils/post-media-urls.ts` | 从 `bodyMarkdown` + `coverUrl` 解析本站图片 URL;`url` → `storageKey` |
| `server/service/media/index.ts` | 插入 asset、同步 refs、reconcile 时间戳、列表孤儿、按用户删除、全站可删清扫 |
| `server/service/posts/index.ts` | 在 create/update/delete 后调用 media 同步;`createPost` 需拿到新 `id` 再同步 |
| `server/api/file/upload.post.ts` | `requireUser`、Sharp 流水线、写最终 `.webp`、`insert media_assets`、响应字段与现契约一致 |
| `server/api/me/media/orphans.get.ts` | 分页 + 筛选 `all \| deletable \| cooling` |
| `server/api/me/media/orphans-delete.post.ts` | 批量删除(仅本人 asset + 仅 deletable) |
| `server/service/config/registry.ts` | `mediaOrphanAutoSweepEnabled`(bool,默认 false)、`mediaOrphanAutoSweepIntervalMinutes`(number,默认 60,范围 15–1440) |
| `app/pages/me/admin/config/index.vue` | 加载/保存新全局项;文案说明全站风险 |
| `server/tasks/media/orphan-sweep.ts` | Nitro `defineTask`:读配置,为真则调用清扫 |
| `nuxt.config.ts` | `nitro.experimental.tasks`、`nitro.scheduledTasks`(cron 使用短间隔占位,任务内再读「间隔」配置可选:首版可 **固定每小时** 触发任务,任务内若距离上次执行不足 `intervalMinutes` 则跳过——实现计划 Step 中给出伪代码) |
| `app/pages/me/media/orphans.vue` | 审查列表、筛选、二次确认、批量删 |
| `app/pages/me/index.vue` | 入口卡片链到 `/me/media/orphans` |
| `package.json` / `bun.lock` | 依赖 `sharp` |
---
### Task 1: Drizzle 表与迁移
**Files:**
- Modify: `packages/drizzle-pkg/database/sqlite/schema/content.ts`
- Modify: `packages/drizzle-pkg/lib/schema/content.ts`(导出)
- Create: `packages/drizzle-pkg/migrations/0003_*.sql`(由 generate 生成)
- Modify: `packages/drizzle-pkg/migrations/meta/_journal.json`(通常由 generate 写入)
- [ ] **Step 1: 在 `content.ts` 追加表定义**
`packages/drizzle-pkg/database/sqlite/schema/content.ts` 现有 `drizzle-orm/sqlite-core` import 中 **追加** `primaryKey`(不要重复整段 import)。
`posts` 表定义 **之后**、`timelineEvents` **之前**插入(顺序便于阅读;`postMediaRefs` 引用 `posts``mediaAssets`):
```typescript
export const mediaAssets = sqliteTable(
"media_assets",
{
id: integer().primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
storageKey: text("storage_key").notNull(),
mime: text().notNull(),
sizeBytes: integer("size_bytes").notNull(),
sha256: text("sha256"),
variantsJson: text("variants_json"),
status: text().notNull().default("ready"),
firstReferencedAt: integer("first_referenced_at", { mode: "timestamp_ms" }),
dereferencedAt: integer("dereferenced_at", { mode: "timestamp_ms" }),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("media_assets_storage_key_unique").on(table.storageKey),
index("media_assets_user_id_idx").on(table.userId),
],
);
export const postMediaRefs = sqliteTable(
"post_media_refs",
{
postId: integer("post_id")
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
assetId: integer("asset_id")
.notNull()
.references(() => mediaAssets.id, { onDelete: "cascade" }),
},
(table) => [
primaryKey({ columns: [table.postId, table.assetId] }),
index("post_media_refs_asset_id_idx").on(table.assetId),
],
);
```
- [ ] **Step 2: 导出**
`packages/drizzle-pkg/lib/schema/content.ts` 中把导出改为(保留原有符号):
```typescript
export { mediaAssets, postComments, postMediaRefs, posts, timelineEvents } from "../../database/sqlite/schema/content";
```
- [ ] **Step 3: 生成并应用迁移**
Run:
```bash
cd /home/dash/projects/person-panel && bun run db:generate -- --name media-assets
```
Expected: `packages/drizzle-pkg/migrations/` 下出现新 SQL,`meta/_journal.json` 更新。
Run:
```bash
cd /home/dash/projects/person-panel && bun run db:migrate
```
Expected: 无报错;本地 `db.sqlite``media_assets`、`post_media_refs`。
- [ ] **Step 4: Commit**
```bash
cd /home/dash/projects/person-panel && git add packages/drizzle-pkg && git commit -m "feat(db): media_assets and post_media_refs for post media"
```
---
### Task 2: 常量与 URL 解析工具
**Files:**
- Create: `server/constants/media.ts`
- Create: `server/utils/post-media-urls.ts`
- [ ] **Step 1: 新增 `server/constants/media.ts`**
```typescript
/** 与 `upload` 返回及静态路径一致,无前导 host */
export const POST_MEDIA_PUBLIC_PREFIX = "/public/assets/";
/** 从未引用:created_at 起算;曾引用:dereferenced_at 起算 */
export const MEDIA_ORPHAN_GRACE_HOURS_NEVER_REF = 24;
export const MEDIA_ORPHAN_GRACE_HOURS_AFTER_DEREF = 24;
export const MEDIA_IMAGE_MAX_WIDTH_PX = 1920;
export const MEDIA_WEBP_QUALITY = 82;
export const RELATIVE_ASSETS_DIR = "public/assets";
```
- [ ] **Step 2: 新增 `server/utils/post-media-urls.ts`**
```typescript
import { POST_MEDIA_PUBLIC_PREFIX } from "#server/constants/media";
const MD_IMG_RE = /!\[[^\]]*]\(([^)]+)\)/g;
function normalizeUrl(raw: string): string {
const t = raw.trim().replace(/^<|>$/g, "").split(/\s+/)[0] ?? "";
if (t.startsWith(POST_MEDIA_PUBLIC_PREFIX)) {
return t;
}
return "";
}
/** 从 markdown 中提取本站图片 URL(去重,顺序稳定) */
export function extractMediaUrlsFromMarkdown(markdown: string): string[] {
const out: string[] = [];
const seen = new Set<string>();
let m: RegExpExecArray | null;
const re = new RegExp(MD_IMG_RE.source, MD_IMG_RE.flags);
while ((m = re.exec(markdown)) !== null) {
const u = normalizeUrl(m[1] ?? "");
if (u && !seen.has(u)) {
seen.add(u);
out.push(u);
}
}
return out;
}
export function extractMediaUrlsFromCover(coverUrl: string | null | undefined): string[] {
if (!coverUrl) {
return [];
}
const u = normalizeUrl(coverUrl);
return u ? [u] : [];
}
export function mergePostMediaUrls(bodyMarkdown: string, coverUrl: string | null | undefined): string[] {
const a = extractMediaUrlsFromMarkdown(bodyMarkdown);
const b = extractMediaUrlsFromCover(coverUrl);
const seen = new Set<string>(a);
for (const u of b) {
if (!seen.has(u)) {
seen.add(u);
a.push(u);
}
}
return a;
}
/** `/public/assets/foo.webp``foo.webp` */
export function publicAssetUrlToStorageKey(url: string): string | null {
const t = url.trim();
if (!t.startsWith(POST_MEDIA_PUBLIC_PREFIX)) {
return null;
}
const key = t.slice(POST_MEDIA_PUBLIC_PREFIX.length).replace(/^\/+/, "");
if (!key || key.includes("..") || key.includes("/") || key.includes("\\")) {
return null;
}
return key;
}
```
- [ ] **Step 3: Commit**
```bash
cd /home/dash/projects/person-panel && git add server/constants/media.ts server/utils/post-media-urls.ts && git commit -m "feat(media): constants and markdown/cover URL extraction"
```
---
### Task 3: Media 领域服务(同步引用、宽限期、删除)
**Files:**
- Create: `server/service/media/index.ts`
- [ ] **Step 1: 实现服务文件**
创建 `server/service/media/index.ts`,实现以下导出(签名可微调,但行为须一致):
- `insertMediaAssetRow(...)`:插入一行(上传成功后调用)。
- `syncPostMediaRefs(userId, postId, bodyMarkdown, coverUrl)`:先读该 `postId` 现有 `assetId` 列表为 `beforeIds`;`delete from post_media_refs where post_id = ?`;再按 URL 解析 `storageKey`,`select id from media_assets where storage_key in (...)` 且 `user_id = userId`,插入新 `post_media_refs`;若某 URL 无对应行(旧数据或未登记文件)**跳过**不抛错;最后对 `beforeIds ∪ afterIds` 调用 `reconcileAssetTimestampsAfterRefChange`
- `reconcileAssetTimestampsAfterRefChange(assetIds: number[])`:对每个 `assetId` 查当前是否存在 `post_media_refs`;若存在且 `first_referenced_at` 为空则设为 `now`,`dereferenced_at` 置 `null`;若不存在任何 ref:若 `first_referenced_at` 仍为空则不动(从未引用,靠 `created_at`);若曾引用则设 `dereferenced_at = now`(若已为非空可保留首次时间,避免反复刷新时间——**首版保留首次 dereference 时间**:仅当 `dereferenced_at` 为 null 时写入)。
- `listOrphanCandidatesForUser(userId, filter, page, pageSize)`:返回 `{ items, total }`;`filter`: `all`(所有无 ref)、`deletable`、`cooling`。
- **无 ref** 定义:左连接 `post_media_refs` 分组 `having count(ref)=0``not exists`
- **deletable**:无 ref 且(`(first_referenced_at is null and created_at + grace1)` 或 `(first_referenced_at is not null and dereferenced_at is not null and dereferenced_at + grace2)`)。
- **cooling**:无 ref 且非 deletable。
- `deleteMediaAssetsForUser(userId, ids: number[])`:逐条校验 `userId`、`deletable`、无 ref,再删磁盘文件(`path.join(process.cwd(), RELATIVE_ASSETS_DIR, storageKey)`)及 `variants_json` 内路径(首版可为空),最后 `delete from media_assets`
- `purgeAllDeletableOrphansGlobally(limit: number)`:不分用户枚举可删行,同上删除逻辑;供定时任务调用。
使用 `dbGlobal`、`eq`、`and`、`sql`、`inArray`、`desc` 等 Drizzle API;时间比较用 `Date.now()` 与 grace 小时换算毫秒。
- [ ] **Step 2: `bun run build`**
Run:
```bash
cd /home/dash/projects/person-panel && bun run build
```
Expected: TypeScript 通过(若 `#server` 别名未覆盖新路径,按现有 server 文件修正 import)。
- [ ] **Step 3: Commit**
```bash
cd /home/dash/projects/person-panel && git add server/service/media/index.ts && git commit -m "feat(media): sync refs, orphan eligibility, delete helpers"
```
---
### Task 4: 接入 Post CRUD
**Files:**
- Modify: `server/service/posts/index.ts`
- [ ] **Step 1: 在 `createPost` 成功插入后同步 refs**
**推荐实现(与 Task 3 一致)**:在 `server/service/media/index.ts` 中实现 `syncPostMediaRefs`,在函数内部:
1. 查询该 `postId` **变更前**`assetId` 集合为 `beforeIds`
2. 删除该 post 的全部 `post_media_refs`,按合并后的 `bodyMarkdown` / `coverUrl` 插入新 refs,得到 **变更后** `afterIds`
3. 对集合 `new Set([...beforeIds, ...afterIds])` 调用 `reconcileAssetTimestampsAfterRefChange([...])`
然后在 `createPost``insert(...values)` 之后调用:
```typescript
await syncPostMediaRefs(userId, id, input.bodyMarkdown, input.coverUrl ?? null);
```
**禁止** 在 `posts/index.ts` 里复制粘贴 URL 解析逻辑;一律走 `syncPostMediaRefs`
- [ ] **Step 2: `updatePost``.update` 成功后调用同一 `syncPostMediaRefs`**
使用 **最终** `bodyMarkdown` / `coverUrl`(patch 与 existing 合并后的值)调用。
- [ ] **Step 3: `deletePost`**
`delete(posts)` **之前**
```typescript
const { dbGlobal } = await import("drizzle-pkg/lib/db");
const { postMediaRefs } = await import("drizzle-pkg/lib/schema/content");
const { eq } = await import("drizzle-orm");
const refRows = await dbGlobal
.select({ assetId: postMediaRefs.assetId })
.from(postMediaRefs)
.where(eq(postMediaRefs.postId, id));
const touched = refRows.map((r) => r.assetId);
```
删除 post 后:
```typescript
const { reconcileAssetTimestampsAfterRefChange } = await import("#server/service/media");
await reconcileAssetTimestampsAfterRefChange(touched);
```
- [ ] **Step 4: Commit**
```bash
cd /home/dash/projects/person-panel && git add server/service/posts/index.ts server/service/media/index.ts && git commit -m "feat(posts): sync media refs on create/update/delete"
```
(若仅改 posts,只 add 该文件。)
---
### Task 5: 上传 + Sharp + `requireUser`
**Files:**
- Modify: `package.json` / `bun.lock`
- Modify: `server/api/file/upload.post.ts`
- [ ] **Step 1: 安装 sharp**
```bash
cd /home/dash/projects/person-panel && bun add sharp
```
- [ ] **Step 2: 改写上传 handler(逻辑要点)**
1. `const user = await event.context.auth.requireUser();`(与 `server/api/me/posts.post.ts` 一致)。
2. multer **仍用 `diskStorage`**`public/assets`,先得到原始扩展名文件。
3. 对每个已上传文件:
- `sharp(path).rotate()`(尊重 EXIF)
- `.resize({ width: MEDIA_IMAGE_MAX_WIDTH_PX, height: MEDIA_IMAGE_MAX_WIDTH_PX, fit: "inside", withoutEnlargement: true })`
- `.webp({ quality: MEDIA_WEBP_QUALITY })`
- 目标文件名:`${uniqueSuffix}-${baseName}.webp`(与现命名规则一致,扩展名固定 `.webp`)。
4. 写成功后 **删除** 原始 multer 文件(若与目标路径不同)。
5. `insertMediaAssetRow({ userId: user.id, storageKey: filename, mime: "image/webp", sizeBytes: stat.size, ... })`
6. 若 Sharp 抛错:**删除** 已生成的部分文件,不插入 DB,整请求失败。
7. 响应 `url``/public/assets/<filename>.webp`,`mimeType` / `size` 为 WebP。
- [ ] **Step 3: `bun run build`**
- [ ] **Step 4: Commit**
```bash
cd /home/dash/projects/person-panel && git add package.json bun.lock server/api/file/upload.post.ts && git commit -m "feat(upload): require auth, sharp webp pipeline, media_assets row"
```
---
### Task 6: `GET/POST` 孤儿审查 API
**Files:**
- Create: `server/api/me/media/orphans.get.ts`
- Create: `server/api/me/media/orphans-delete.post.ts`
- [ ] **Step 1: `orphans.get.ts`**
- `requireUser`
- Query:`filter`(默认 `all`)、`page`(默认 1)、`pageSize`(默认 20,最大 100)。
- 返回 `{ items: [...], total }`;每项含 `id`, `storageKey`, `publicUrl`, `sizeBytes`, `createdAt`, `firstReferencedAt`, `dereferencedAt`, `state: 'deletable' | 'cooling'`
- [ ] **Step 2: `orphans-delete.post.ts`**
- `requireUser`
- Body:`{ ids: number[] }`,最多 50。
- 调用 `deleteMediaAssetsForUser(user.id, ids)`;若包含非 deletable id,返回 **400** 与明确 `statusMessage`
- [ ] **Step 3: Commit**
```bash
cd /home/dash/projects/person-panel && git add server/api/me/media && git commit -m "feat(api): me media orphans list and batch delete"
```
---
### Task 7: 全局配置注册 + 管理端 UI
**Files:**
- Modify: `server/service/config/registry.ts`
- Modify: `app/pages/me/admin/config/index.vue`
- [ ] **Step 1: 注册两个 key**
`CONFIG_REGISTRY` 中增加:
```typescript
mediaOrphanAutoSweepEnabled: defineConfig<boolean>({
key: "mediaOrphanAutoSweepEnabled",
scope: "global",
valueType: "boolean",
defaultValue: false,
userOverridable: false,
}),
mediaOrphanAutoSweepIntervalMinutes: defineConfig<number>({
key: "mediaOrphanAutoSweepIntervalMinutes",
scope: "global",
valueType: "number",
defaultValue: 60,
userOverridable: false,
validate: (value: number) => Number.isInteger(value) && value >= 15 && value <= 1440,
}),
```
- [ ] **Step 2: 管理页加载/保存**
- 扩展 `GlobalConfigPayload` 类型包含上述字段。
- 增加 `UCheckbox`(自动清扫)与 `UInputNumber``UInput type=number`(间隔分钟)。
- `save()``putKey('mediaOrphanAutoSweepEnabled', ...)``putKey('mediaOrphanAutoSweepIntervalMinutes', ...)`
- 文案说明:**全站**、仅已过宽限期且无引用资源、建议配合审查页。
- [ ] **Step 3: Commit**
```bash
cd /home/dash/projects/person-panel && git add server/service/config/registry.ts app/pages/me/admin/config/index.vue && git commit -m "feat(config): admin toggles for media orphan auto-sweep"
```
---
### Task 8: 定时任务 + `nuxt.config`
**Files:**
- Create: `server/tasks/media/orphan-sweep.ts`
- Modify: `nuxt.config.ts`
- [ ] **Step 1: 任务实现**
```typescript
import { getGlobalConfigValue } from "#server/service/config";
import { purgeAllDeletableOrphansGlobally } from "#server/service/media";
export default defineTask({
meta: {
name: "media:orphan-sweep",
description: "Delete deletable orphan media when admin global switch is on",
},
async run() {
const enabled = await getGlobalConfigValue("mediaOrphanAutoSweepEnabled");
if (!enabled) {
return { result: "skipped: disabled" };
}
const deleted = await purgeAllDeletableOrphansGlobally(50);
return { result: "ok", deletedCount: deleted };
},
});
```
`purgeAllDeletableOrphansGlobally` 应返回本批删除条数;若 Task 3 未设计返回值,改为 void 并在任务内记录日志即可。
**间隔配置**:首版可在任务内用内存变量记录 `lastRunAt`,若当前时间与 `lastRunAt` 差小于 `getGlobalConfigValue("mediaOrphanAutoSweepIntervalMinutes")` 分钟则 `{ result: "skipped: throttle" }`。进程重启后节流重置——可接受;若需持久化,二期再写 DB。
- [ ] **Step 2: `nuxt.config.ts`**
在现有 `nitro` 对象中合并(保留 `typescript`):
```typescript
nitro: {
experimental: {
tasks: true,
},
scheduledTasks: {
"5 * * * *": ["media:orphan-sweep"],
},
typescript: {
// ...existing
},
},
```
`bun run dev``experimental.tasks` 无效,查阅当前 Nuxt 文档调整键名;以 **能跑通 `orphan-sweep` 手动触发** 为验收底线:`npx nuxt task run media:orphan-sweep`(以官方 CLI 为准)。
- [ ] **Step 3: Commit**
```bash
cd /home/dash/projects/person-panel && git add nuxt.config.ts server/tasks/media/orphan-sweep.ts && git commit -m "feat(nitro): scheduled media orphan sweep task"
```
---
### Task 9: 审查页 + 控制台入口
**Files:**
- Create: `app/pages/me/media/orphans.vue`
- Modify: `app/pages/me/index.vue`
- [ ] **Step 1: `orphans.vue`**
- `definePageMeta({ title: '图片孤儿审查' })`
- 使用 `UTabs``USelect` 切换 `filter`
- `UTable` 或卡片列表展示;`UButton` 删除单条;`UCheckbox` 多选 + 批量删除。
- 使用 `UModal` 二次确认。
- 调用 `/api/me/media/orphans``POST /api/me/media/orphans-delete`(路径以 Step 6 实际为准)。
- [ ] **Step 2: 控制台卡片**
`app/pages/me/index.vue` 的 grid 中增加一张卡片,链接 `to="/me/media/orphans"`,说明孤儿图片审查与清理。
- [ ] **Step 3: Commit**
```bash
cd /home/dash/projects/person-panel && git add app/pages/me/media/orphans.vue app/pages/me/index.vue && git commit -m "feat(ui): media orphans review page and dashboard link"
```
---
### Task 10: 全量验收
- [ ] **Step 1: 构建**
```bash
cd /home/dash/projects/person-panel && bun run build
```
Expected: 成功。
- [ ] **Step 2: 手动走 spec 第 12 节**(上传、保存带图文章、ref、审查列表、冷却/可删、手动删、管理员开关节流、自动清扫)
- [ ] **Step 3: 若有文档修正**(仅当发现 spec 与实现不一致时更新 `docs/superpowers/specs/2026-04-18-post-media-assets-design.md`
---
## Self-review(对照 spec)
| Spec 章节 | 对应 Task |
|-----------|-----------|
| 4 数据模型 | Task 1 |
| 5 引用解析 | Task 2 |
| 6.0–6.3 孤儿与删除 | Task 3、6、8 |
| 6.4 管理员全站开关 | Task 7、8 |
| 6.5 审查入口 | Task 9 |
| 7 WebP 优化 | Task 5 |
| 8 上传 | Task 5 |
| 9 Post 事务 | Task 4 |
| 10 安全 | Task 3/6(userId 校验) |
**占位符扫描:** 计划中任务级代码已给出;`purgeAllDeletableOrphansGlobally` 若在 Task 3 未定义返回值,实现时补全并消除歧义。
**类型一致:** `KnownConfigKey` 随 registry 自动扩展;管理页 TS 类型需同步新增 key。
---
**计划已保存至 `docs/superpowers/plans/2026-04-18-post-media-assets-implementation-plan.md`。**
**执行方式二选一:**
1. **Subagent-Driven(推荐)** — 每任务派生子代理,任务间人工过一遍。
2. **Inline Execution** — 本会话按任务顺序直接改代码,检查点处停顿。
你想用哪一种?
Loading…
Cancel
Save