Two independent plans for spec 2026-05-07; each yields its own PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
30 KiB
Custom Bot Avatar 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: Let users upload a fixed avatar per bot. When avatarEnabled=true, the avatar follows the song cover during playback and reverts to the custom avatar (instead of clearing) on stop. When avatarEnabled=false and a custom avatar exists, the bot always shows the custom avatar.
Architecture: New SQLite column stores a relative file path; bytes live on disk under data/avatars/<botId>.<ext> (mirrors data/cookies/). BotProfileManager gains a customAvatar Buffer; the existing clearAvatar() becomes "restore custom or clear"; onConnect() immediately applies the custom avatar when sync is off. Three new REST endpoints (GET/PUT/DELETE) under /api/bot/:id/avatar accept base64 JSON (avoids adding multer; bump express.json() limit).
Tech Stack: Node 20 + TS + Express 5, better-sqlite3, Vue 3 + axios. No new runtime deps.
Spec Reference
docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md — section "自定义头像".
File Structure
| File | Action | Responsibility |
|---|---|---|
src/data/database.ts |
Modify | Add custom_avatar_path column + migration + accessor methods |
src/data/avatars.ts |
Create | Read/write/delete avatar files under data/avatars/ |
src/bot/profile.ts |
Modify | customAvatar field, setCustomAvatar, applyIdleAvatar, modify clearAvatar, modify onConnect |
src/bot/instance.ts |
Modify | Load custom avatar on start, pass to ProfileManager |
src/web/api/bot.ts |
Modify | Add GET/PUT/DELETE /avatar endpoints |
src/web/server.ts |
Modify | Bump express.json() limit to 400kb |
src/index.ts |
Modify | Pass AVATAR_DIR to bot manager / API router |
src/data/database.test.ts |
Modify | Test custom avatar path persistence + migration idempotency |
src/data/avatars.test.ts |
Create | Unit tests for avatar store |
src/bot/profile.test.ts |
Create | Tests for new precedence logic with a mock TS3Client |
web/src/components/AvatarUpload.vue |
Create | Reusable avatar picker + preview + delete |
web/src/views/Settings.vue |
Modify | Add custom avatar row in profile features list; insert into create-bot and edit-bot forms |
Conventions
- TDD: failing test → implement → verify → commit, every step.
- Commits use conventional format:
feat(profile):,feat(api):,feat(web):,test(...). Each task ends with one commit. - Tests live in vitest (
npm test). - All paths absolute or relative to repo root.
Task 1: DB migration + getter/setter for custom avatar path
Files:
-
Modify:
src/data/database.ts -
Modify:
src/data/database.test.ts -
Step 1: Write the failing test
Add to src/data/database.test.ts after the existing tests (find the closing }); of the last test case in the describe block, insert before it):
it("persists and clears customAvatarPath on a bot instance", () => {
const inst = {
id: "bot-1",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(inst);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
botDb.setCustomAvatarPath("bot-1", "avatars/bot-1.png");
expect(botDb.getCustomAvatarPath("bot-1")).toBe("avatars/bot-1.png");
botDb.setCustomAvatarPath("bot-1", null);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
});
- Step 2: Run test to verify it fails
Run: npx vitest run src/data/database.test.ts
Expected: FAIL — botDb.getCustomAvatarPath is not a function
- Step 3: Add the column to migration + interface + statements
In src/data/database.ts:
- Find
BotDatabaseinterface (~line 54), add two methods beforeclose():
getCustomAvatarPath(botId: string): string | null;
setCustomAvatarPath(botId: string, path: string | null): void;
- Find
migrateSchema()(~line 66). After thefor (const col of profileCols)loop, append:
if (!names.includes("custom_avatar_path")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
}
- In
createDatabase()after the existingprepare(...)calls (~line 180), add:
const selectCustomAvatar = db.prepare(
`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`,
);
const updateCustomAvatar = db.prepare(
`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`,
);
- Inside the returned object, add (before
close()):
getCustomAvatarPath(botId) {
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
return row?.custom_avatar_path ?? null;
},
setCustomAvatarPath(botId, path) {
updateCustomAvatar.run(path, botId);
},
- Step 4: Run test to verify it passes
Run: npx vitest run src/data/database.test.ts
Expected: PASS — all tests including the new one
- Step 5: Commit
git add src/data/database.ts src/data/database.test.ts
git commit -m "feat(db): custom_avatar_path column + accessors"
Task 2: Avatar storage helper
Files:
-
Create:
src/data/avatars.ts -
Create:
src/data/avatars.test.ts -
Step 1: Write the failing test
Create src/data/avatars.test.ts:
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createAvatarStore } from "./avatars.js";
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "avatar-test-"));
});
describe("createAvatarStore", () => {
it("write returns a relative path under the store dir", () => {
const store = createAvatarStore(dir);
const buf = Buffer.from("fake-png");
const rel = store.write("bot-1", "image/png", buf);
expect(rel).toBe("bot-1.png");
expect(readFileSync(join(dir, "bot-1.png")).equals(buf)).toBe(true);
});
it("write picks correct extension for jpeg / webp", () => {
const store = createAvatarStore(dir);
expect(store.write("a", "image/jpeg", Buffer.from(""))).toBe("a.jpg");
expect(store.write("b", "image/webp", Buffer.from(""))).toBe("b.webp");
});
it("write rejects unsupported MIME types", () => {
const store = createAvatarStore(dir);
expect(() => store.write("c", "image/gif", Buffer.from(""))).toThrow(
/unsupported/i,
);
});
it("read returns the bytes for an existing file", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("hello"));
const buf = store.read("bot-1.png");
expect(buf?.equals(Buffer.from("hello"))).toBe(true);
});
it("read returns null when path is missing", () => {
const store = createAvatarStore(dir);
expect(store.read("missing.png")).toBeNull();
});
it("remove deletes the file (idempotent)", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("x"));
store.remove("bot-1.png");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(() => store.remove("bot-1.png")).not.toThrow();
});
it("write replaces any existing file for the same botId regardless of old extension", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("old"));
const rel = store.write("bot-1", "image/jpeg", Buffer.from("new"));
expect(rel).toBe("bot-1.jpg");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(existsSync(join(dir, "bot-1.jpg"))).toBe(true);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run src/data/avatars.test.ts
Expected: FAIL — module not found
- Step 3: Implement the store
Create src/data/avatars.ts:
import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const MIME_TO_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
};
export interface AvatarStore {
/** Returns the relative path written (e.g. "bot-1.png"). */
write(botId: string, mime: string, buffer: Buffer): string;
read(relPath: string): Buffer | null;
remove(relPath: string): void;
getDir(): string;
}
export function createAvatarStore(dir: string): AvatarStore {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return {
write(botId, mime, buffer) {
const ext = MIME_TO_EXT[mime];
if (!ext) throw new Error(`unsupported avatar MIME: ${mime}`);
// Remove any existing avatar for this bot regardless of extension.
for (const name of readdirSync(dir)) {
if (name.startsWith(`${botId}.`)) rmSync(join(dir, name), { force: true });
}
const rel = `${botId}.${ext}`;
writeFileSync(join(dir, rel), buffer);
return rel;
},
read(relPath) {
const full = join(dir, relPath);
if (!existsSync(full)) return null;
return readFileSync(full);
},
remove(relPath) {
rmSync(join(dir, relPath), { force: true });
},
getDir() {
return dir;
},
};
}
- Step 4: Run test to verify it passes
Run: npx vitest run src/data/avatars.test.ts
Expected: PASS — all 7 tests
- Step 5: Commit
git add src/data/avatars.ts src/data/avatars.test.ts
git commit -m "feat(data): avatar file store helper"
Task 3: BotProfileManager — custom avatar precedence
Files:
-
Modify:
src/bot/profile.ts -
Create:
src/bot/profile.test.ts -
Step 1: Write the failing test
Create src/bot/profile.test.ts:
import { describe, it, expect, beforeEach, vi } from "vitest";
import { BotProfileManager } from "./profile.js";
import type { TS3Client } from "../ts-protocol/client.js";
function makeMockTs(): TS3Client & {
uploadCalls: Buffer[];
clearCalls: number;
} {
const calls: Buffer[] = [];
let clears = 0;
const ts: any = {
uploadCalls: calls,
get clearCalls() { return clears; },
getHost: () => "127.0.0.1",
getHttpQuery: () => null,
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
uploadFileData: vi.fn().mockImplementation(async (_h, _i, stream: any) => {
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(c as Buffer);
calls.push(Buffer.concat(chunks));
}),
fileTransferDeleteFile: vi.fn().mockResolvedValue(undefined),
sendCommandNoWait: vi.fn().mockImplementation(async (cmd: string) => {
if (/client_flag_avatar=$/.test(cmd)) clears++;
}),
};
return ts;
}
const noopLogger: any = { child: () => noopLogger, info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
const cfgOn = { avatarEnabled: true, descriptionEnabled: false, nicknameEnabled: false, awayStatusEnabled: false, channelDescEnabled: false, nowPlayingMsgEnabled: false };
const cfgOff = { ...cfgOn, avatarEnabled: false };
describe("BotProfileManager custom avatar precedence", () => {
let ts: ReturnType<typeof makeMockTs>;
beforeEach(() => { ts = makeMockTs(); });
it("on stop with custom avatar set + sync on, uploads custom (does not clear)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
const custom = Buffer.from([1, 2, 3, 4]);
pm.setCustomAvatar(custom);
await pm.onSongChange(null);
expect(ts.uploadCalls.at(-1)?.equals(custom)).toBe(true);
expect(ts.clearCalls).toBe(0);
});
it("on stop with no custom avatar, falls back to clear", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
await pm.onSongChange(null);
expect(ts.clearCalls).toBe(1);
expect(ts.uploadCalls.length).toBe(0);
});
it("on connect with sync off + custom avatar set, applies custom immediately", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
pm.setCustomAvatar(Buffer.from([9, 9]));
await pm.onConnect();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([9, 9]))).toBe(true);
});
it("on connect with sync off + no custom avatar, does not touch avatar", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
await pm.onConnect();
expect(ts.uploadCalls.length).toBe(0);
expect(ts.clearCalls).toBe(0);
});
it("setCustomAvatar(null) makes subsequent onSongChange(null) clear again", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([1]));
pm.setCustomAvatar(null);
await pm.onSongChange(null);
expect(ts.clearCalls).toBe(1);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run src/bot/profile.test.ts
Expected: FAIL — pm.setCustomAvatar is not a function and/or onConnect not exported
- Step 3: Look at the existing profile.ts to understand
onConnectshape
Run: grep -n 'onConnect\|public async\|public ' src/bot/profile.ts | head -10
onConnect likely already exists; if not, locate where reconnect resets state. Add or extend it.
- Step 4: Implement
customAvatar,setCustomAvatar,applyIdleAvatar; modifyclearAvatarandonConnect
In src/bot/profile.ts:
- Inside the class, add fields next to
defaultNickname(around line 27):
private customAvatar: Buffer | null = null;
- After the
constructor, add:
/** Set/clear the persistent idle avatar. Pass null to remove. */
setCustomAvatar(buffer: Buffer | null): void {
this.customAvatar = buffer;
}
- Find
clearAvatar()(~line 173). Change the body so that ifthis.customAvataris set, we upload it instead of clearing the flag. Replace the existing method with:
private async clearAvatar(gen: number): Promise<void> {
if (this.customAvatar && this.customAvatar.length > 0) {
await this.applyIdleAvatar(gen);
return;
}
try {
await this.withTimeout(
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
FILE_TRANSFER_TIMEOUT_MS,
);
} catch {
// File may not exist or transfer timed out — that's fine
}
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
- Add a new private method right below
clearAvatar:
private async applyIdleAvatar(gen: number): Promise<void> {
if (!this.customAvatar || this.customAvatar.length === 0) return;
if (this.permDenied.avatar) return;
try {
await this.withTimeout(this.doAvatarUpload(this.customAvatar), FILE_TRANSFER_TIMEOUT_MS);
if (this.generation !== gen) return;
this.logger.info({ bytes: this.customAvatar.length }, "Idle (custom) avatar applied");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
- Find
onConnect(the existing method that resets per-feature flags). At its end, immediately after thepermDeniedreset, add:
if (!this.config.avatarEnabled && this.customAvatar) {
const gen = ++this.generation;
void this.applyIdleAvatar(gen);
}
If onConnect does not exist as a method, search for where reconnect resets permDenied and add the block there.
- Step 5: Run test to verify it passes
Run: npx vitest run src/bot/profile.test.ts
Expected: PASS — 5/5
Run also: npx vitest run src/audio src/data src/bot — confirm no regressions.
- Step 6: Commit
git add src/bot/profile.ts src/bot/profile.test.ts
git commit -m "feat(profile): custom avatar with idle/playback precedence"
Task 4: Wire avatar load on bot start
Files:
-
Modify:
src/bot/instance.ts -
Modify:
src/bot/manager.ts(if it constructs the instance) -
Modify:
src/index.ts -
Step 1: Confirm where
BotProfileManageris constructed and howBotInstancereceives DB
Run: grep -n 'new BotProfileManager\|profileManager =\|database\|botDb' src/bot/instance.ts src/bot/manager.ts | head -20
Identify the BotInstance constructor params and verify that the DB and the avatar dir can flow in.
- Step 2: Add
AVATAR_DIRconstant +avatarStoretosrc/index.ts
Find where COOKIE_DIR / createCookieStore are set up (~line 48 in src/index.ts) and add directly after:
const AVATAR_DIR = process.env.AVATAR_DIR ?? join(DATA_DIR, "avatars");
const avatarStore = createAvatarStore(AVATAR_DIR);
(import as needed: import { createAvatarStore } from "./data/avatars.js";)
Pass avatarStore through to whatever constructs BotManager (and from there to BotInstance).
- Step 3: In
BotInstance, afterprofileManageris created, load the avatar from disk if any
In src/bot/instance.ts, after this.profileManager = new BotProfileManager(...):
const relPath = this.botDb.getCustomAvatarPath(this.id);
if (relPath) {
const buf = this.avatarStore.read(relPath);
if (buf) this.profileManager.setCustomAvatar(buf);
}
(Add private botDb: BotDatabase and private avatarStore: AvatarStore constructor params; thread them down from BotManager.createBot() / BotManager constructor.)
- Step 4: Add
getProfileManager()accessor if not present
If grep already shows getProfileManager(): BotProfileManager, skip. Otherwise add a public method that returns this.profileManager.
- Step 5: Build and run the existing tests
Run: npx tsc --noEmit
Expected: no TS errors
Run: npm test
Expected: all green
- Step 6: Commit
git add src/index.ts src/bot/instance.ts src/bot/manager.ts
git commit -m "feat(bot): load custom avatar on instance startup"
Task 5: REST endpoints for avatar upload / fetch / delete
Files:
-
Modify:
src/web/server.ts(json size limit) -
Modify:
src/web/api/bot.ts -
Step 1: Bump express.json size limit
In src/web/server.ts, find app.use(express.json()) (~line 46) and change to:
app.use(express.json({ limit: "400kb" }));
(Avatar payload is base64-encoded ≤200 KB → ~270 KB on the wire; 400 KB gives margin.)
- Step 2: Write a failing API test (use supertest if not present, otherwise inline fetch)
Run: grep -E '"supertest"|"vitest"' package.json
If supertest is not present, write the test using node:http raw client or skip API integration test and rely on manual + unit tests on Task 7. Don't add new deps unless approved.
If supertest IS present, add src/web/api/bot.test.ts:
import { describe, it, expect } from "vitest";
import request from "supertest";
import express from "express";
import { createBotRouter } from "./bot.js";
// ... build minimal app with mocked manager + DB + avatarStore
If not present: skip Step 2, jump to Step 3 and verify by manual curl in Step 5.
- Step 3: Add the three endpoints
In src/web/api/bot.ts, modify the factory signature to accept avatarStore and botDb:
export function createBotRouter(
botManager: BotManager,
config: BotConfig,
configPath: string,
logger: Logger,
botDb: BotDatabase,
avatarStore: AvatarStore,
): Router {
Inside the router, after the existing /:id/config GET, add:
router.get("/:id/avatar", (req, res) => {
const path = botDb.getCustomAvatarPath(req.params.id);
if (!path) { res.status(404).end(); return; }
const buf = avatarStore.read(path);
if (!buf) { res.status(404).end(); return; }
const ext = path.split(".").pop()!;
const mime = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
res.set("Content-Type", mime);
res.set("Cache-Control", "no-cache");
res.send(buf);
});
router.put("/:id/avatar", (req, res) => {
const bot = botManager.getBot(req.params.id);
if (!bot && !botDb.getBotInstances().some((b) => b.id === req.params.id)) {
res.status(404).json({ error: "Bot not found" });
return;
}
const { dataUrl } = req.body as { dataUrl?: string };
if (typeof dataUrl !== "string") {
res.status(400).json({ error: "dataUrl required" });
return;
}
const m = /^data:(image\/(png|jpeg|webp));base64,(.+)$/.exec(dataUrl);
if (!m) {
res.status(400).json({ error: "dataUrl must be image/png|jpeg|webp base64" });
return;
}
const mime = m[1];
const buf = Buffer.from(m[3], "base64");
if (buf.length > 200 * 1024) {
res.status(413).json({ error: "avatar exceeds 200KB limit" });
return;
}
const rel = avatarStore.write(req.params.id, mime, buf);
botDb.setCustomAvatarPath(req.params.id, rel);
bot?.getProfileManager().setCustomAvatar(buf);
res.json({ path: rel });
});
router.delete("/:id/avatar", (req, res) => {
const path = botDb.getCustomAvatarPath(req.params.id);
if (path) avatarStore.remove(path);
botDb.setCustomAvatarPath(req.params.id, null);
const bot = botManager.getBot(req.params.id);
bot?.getProfileManager().setCustomAvatar(null);
res.status(204).end();
});
- Step 4: Update the call site that constructs the router
Search: grep -n 'createBotRouter' src/
In the call site (likely src/web/server.ts or src/index.ts), pass the new args. Fix the call signature.
- Step 5: Manual smoke test
Run: npm run build && npm run start
In another terminal:
# create a small valid PNG (1x1) base64
B64=$(node -e "console.log(Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,8,153,99,248,255,255,63,0,5,254,2,254,205,250,236,184,0,0,0,0,73,69,78,68,174,66,96,130]).toString('base64'))")
curl -X PUT http://localhost:3000/api/bot/<BOT_ID>/avatar \
-H 'Content-Type: application/json' \
-d "{\"dataUrl\":\"data:image/png;base64,$B64\"}"
curl http://localhost:3000/api/bot/<BOT_ID>/avatar -o /tmp/x.png
file /tmp/x.png
curl -X DELETE http://localhost:3000/api/bot/<BOT_ID>/avatar -i
Expected: PUT returns {"path":"<id>.png"}, GET returns the bytes, DELETE returns 204.
- Step 6: Commit
git add src/web/server.ts src/web/api/bot.ts src/index.ts
git commit -m "feat(api): /api/bot/:id/avatar GET/PUT/DELETE"
Task 6: Frontend — AvatarUpload.vue component
Files:
-
Create:
web/src/components/AvatarUpload.vue -
Step 1: Create the component
<template>
<div class="avatar-upload">
<div class="preview" :class="{ empty: !previewUrl }">
<img v-if="previewUrl" :src="previewUrl" alt="avatar" />
<Icon v-else icon="mdi:account-circle-outline" />
</div>
<div class="actions">
<input
ref="fileInput"
type="file"
accept="image/png,image/jpeg,image/webp"
class="hidden"
@change="onFile"
/>
<button type="button" class="btn-sm" @click="fileInput?.click()">
{{ previewUrl ? '更换' : '上传' }}
</button>
<button v-if="previewUrl" type="button" class="btn-sm btn-danger" @click="clear">
删除
</button>
</div>
<p v-if="error" class="hint error">{{ error }}</p>
<p v-else class="hint">PNG / JPG / WebP,≤200 KB</p>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Icon } from '@iconify/vue';
const props = defineProps<{ modelValue: string | null }>();
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>();
const previewUrl = ref<string | null>(props.modelValue);
const error = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
watch(() => props.modelValue, (v) => { previewUrl.value = v; });
function onFile(ev: Event) {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
error.value = '仅支持 PNG / JPG / WebP';
return;
}
if (file.size > 200 * 1024) {
error.value = `图片 ${(file.size / 1024).toFixed(0)} KB 超过 200 KB 上限`;
return;
}
error.value = null;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
previewUrl.value = dataUrl;
emit('update:modelValue', dataUrl);
};
reader.readAsDataURL(file);
}
function clear() {
previewUrl.value = null;
emit('update:modelValue', null);
if (fileInput.value) fileInput.value.value = '';
}
</script>
<style lang="scss" scoped>
.avatar-upload { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.preview {
width: 80px; height: 80px; border-radius: 50%;
background: var(--bg-card); display: flex; align-items: center; justify-content: center;
overflow: hidden;
img { width: 100%; height: 100%; object-fit: cover; }
&.empty :deep(svg) { font-size: 48px; opacity: 0.4; }
}
.actions { display: flex; gap: 8px; }
.hidden { display: none; }
.hint { font-size: 12px; opacity: 0.6; margin: 0; }
.hint.error { color: var(--color-danger, #e85060); opacity: 1; }
.btn-danger { color: var(--color-danger, #e85060); }
</style>
- Step 2: Verify the component compiles
Run: cd web && npx vue-tsc --noEmit
Expected: no errors
- Step 3: Commit
git add web/src/components/AvatarUpload.vue
git commit -m "feat(web): AvatarUpload component"
Task 7: Wire AvatarUpload into Settings.vue (create + edit + standalone row)
Files:
-
Modify:
web/src/views/Settings.vue -
Step 1: Read the relevant Settings.vue regions
grep -n '同步头像\|openEditBot\|saveEditBot\|createBot\|create-bot\|profile-features\|features.find' web/src/views/Settings.vue | head -20
Identify:
-
Create-bot form template region (
<div class="create-bot">block) -
Edit-bot modal/dialog template region
-
The profile features table where
avatarEnabledrow lives -
Step 2: Add component import + reactive state for avatar dataUrl on the create-bot form
In the script setup region, near other newBot* refs:
import AvatarUpload from '../components/AvatarUpload.vue';
const newBotAvatar = ref<string | null>(null);
- Step 3: Insert
<AvatarUpload v-model="newBotAvatar" />into the create-bot form template
In the <div class="create-bot"> block, right before <button class="btn-primary" @click="createBot">创建</button>, add:
<div class="form-row">
<label>自定义头像(可选)</label>
<AvatarUpload v-model="newBotAvatar" />
</div>
- Step 4: After successful
createBot(), PUT the avatar if set
Find the createBot async function. After the POST resolves and the bot id is known (res.data.id or similar), append:
if (newBotAvatar.value) {
await axios.put(`/api/bot/${res.data.id}/avatar`, { dataUrl: newBotAvatar.value });
}
newBotAvatar.value = null;
- Step 5: Add an "自定义头像" row in the per-bot profile features table
Find the profile-features table render (look for the features array iteration). The cleanest path: add a custom row OUTSIDE the array (since it isn't a boolean toggle). Right before </template> of the bot row, add:
<div class="feature-row">
<div class="feature-label">自定义头像</div>
<div class="feature-control">
<CustomAvatarRow :bot-id="bot.id" />
</div>
</div>
Where CustomAvatarRow is an inline-defined component or a small file web/src/components/CustomAvatarRow.vue that:
- Mounts →
axios.get(/api/bot/<id>/avatar, { responseType: 'blob' })→ previews if 200, ignore 404 - Wraps
<AvatarUpload>and onupdate:modelValue:- If string →
axios.put(/avatar, { dataUrl }) - If null →
axios.delete(/avatar)
- If string →
Create web/src/components/CustomAvatarRow.vue with that logic; keep its body small (~50 lines).
- Step 6: Build and visually verify
Run: cd web && npm run build → no errors. Then npm run dev → open create-instance, upload PNG, create — verify the avatar appears on the bot in TS3 once it connects. Check edit/Settings flow.
- Step 7: Commit
git add web/src/views/Settings.vue web/src/components/CustomAvatarRow.vue
git commit -m "feat(web): custom avatar in create-bot + Settings"
Task 8: Open PR
- Step 1: Push the branch
git checkout -b feat/custom-bot-avatar
git push -u origin feat/custom-bot-avatar
(If commits were already on main, instead create the branch from the first relevant commit and reset main: git branch feat/custom-bot-avatar HEAD && git reset --hard origin/main && git checkout feat/custom-bot-avatar. The exact sequence depends on the working state when starting.)
- Step 2: Create the PR
gh pr create --title "feat(profile): custom bot avatar" --body "Closes part of #51 (avatar half).
## Summary
- New /api/bot/:id/avatar GET/PUT/DELETE
- BotProfileManager: custom avatar acts as idle image; cover sync still wins during playback when avatarEnabled=true
- AvatarUpload component used in create-bot form and Settings per-bot row
- Bump express.json limit to 400kb to allow base64 payload
## Behavior matrix
| avatarEnabled | custom set | playing | stopped |
|---|---|---|---|
| ✓ | ✓ | cover | restore custom |
| ✓ | ✗ | cover | clear |
| ✗ | ✓ | custom | custom |
| ✗ | ✗ | no-op | no-op |
## Test plan
- [x] vitest covers DB, avatar store, ProfileManager precedence
- [x] Manual: upload PNG → bot avatar shows; play song → cover; stop → custom; delete → cleared
🤖 Generated with [Claude Code](https://claude.com/claude-code)"
Self-Review Checklist
- Each spec section has at least one task: precedence matrix → Task 3; storage → Task 2; DB → Task 1; API → Task 5; UI → Task 6+7
- No "TBD" / "fill in" / "implement later" text in any step
- Type names consistent:
AvatarStore/createAvatarStore/getCustomAvatarPath/setCustomAvatarPath/setCustomAvatar(singular per call site) - All code blocks compile under existing TS/Vue config (express 5, vitest, vue 3 + iconify already in use)