mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-05 21:50:46 +08:00
### What this PR does Before this PR: - Provider/model icons were scattered image imports (PNG/WebP) with no unified API - Avatar primitive was based on HeroUI with hardcoded `shadow-lg` and `border-[0.5px]` - Full-bleed and padded avatar variants used different rendering approaches - Multiple files duplicated IIFE patterns for rendering CompoundIcon vs string logos - No type-safe icon catalogs After this PR: - **Compound Icon API**: Each icon exposes `.Color`, `.Mono`, and `.Avatar` sub-components via a unified `CompoundIcon` interface - **Auto-generated catalogs**: `PROVIDER_ICON_CATALOG` and `MODEL_ICON_CATALOG` with `resolveProviderIcon` / `resolveModelIcon` helpers - **SVG pipeline**: Codegen processes SVGs → generates Color/Mono/Avatar components - **Avatar migrated to shadcn/radix**: Replaced HeroUI Avatar with `Avatar` + `AvatarFallback` pattern, removed hardcoded shadow/border - **EmojiAvatar moved**: From `primitives/Avatar/` to `composites/EmojiAvatar/` - **LogoAvatar component**: Reusable component replacing repeated IIFE patterns across 5+ files - **getMCPProviderLogo helper**: Centralized MCP provider icon mapping - 80+ monochrome icon components, stroke attribute support, deprecated logos cleanup <img width="714" height="820" alt="image" src="https://github.com/user-attachments/assets/a3f14348-5781-494a-8c3b-1f40391e2ec0" /> <img width="1008" height="593" alt="image" src="https://github.com/user-attachments/assets/8ba7fa42-fa33-4e49-ba76-647ba1438e0c" /> ### Why we need it and why it was done in this way The v2 refactoring requires moving away from HeroUI toward shadcn/radix primitives, and needs a scalable, type-safe icon system to replace scattered image imports. The compound icon pattern (`Icon.Color`, `Icon.Mono`, `Icon.Avatar`) provides a consistent API while enabling tree-shaking. The Avatar primitive now uses radix-based `Avatar` + `AvatarFallback`, aligning with the project's shadcn migration. The following tradeoffs were made: - Each icon is a separate TSX file for tree-shaking and lazy loading support - Avatar components use `AvatarFallback` to render icons — no image loading overhead The following alternatives were considered: - Runtime SVG color manipulation — rejected for better performance and consistency - Keeping HeroUI Avatar — rejected as it conflicts with v2 shadcn migration goals ### Breaking changes - Avatar primitive API changed: `HeroUI Avatar` → shadcn `Avatar` + `AvatarFallback` + `AvatarImage` - `EmojiAvatar` moved from `primitives/Avatar` to `composites/EmojiAvatar` - `shadow-lg` and `border-[0.5px]` removed from generated avatars — now opt-in via `className` ### Special notes for your reviewer - ~214 files changed, but the bulk are auto-generated avatar/icon components under `packages/ui/src/components/icons/` - Key files to review: - `packages/ui/src/components/primitives/avatar.tsx` — new shadcn Avatar primitive - `packages/ui/scripts/codegen.ts` — avatar generation using AvatarFallback - `src/renderer/src/components/Icons/LogoAvatar.tsx` — reusable logo renderer - Renderer files using the new Avatar API (Sidebar, UserPopup, ModelAvatar, etc.) ### Checklist - [x] PR: The PR description is expressive enough and will help future contributors - [x] Code: Write code that humans can understand and Keep it simple - [x] Refactor: You have left the code cleaner than you found it (Boy Scout Rule) - [ ] Upgrade: Impact of this change on upgrade flows was considered and addressed if required - [ ] Documentation: N/A - internal component changes ### Release note ```release-note NONE ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: suyao <sy20010504@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: icarus <eurfelux@gmail.com>
113 lines
3.1 KiB
TypeScript
113 lines
3.1 KiB
TypeScript
/**
|
|
* Normalize SVG width/height to 32x32.
|
|
*
|
|
* Updates the width and height attributes on the <svg> tag to 32,
|
|
* keeping the viewBox unchanged so SVG content scales naturally.
|
|
*
|
|
* Usage:
|
|
* pnpm tsx scripts/normalize-viewbox.ts --dir=providers
|
|
* pnpm tsx scripts/normalize-viewbox.ts --dir=models
|
|
* pnpm tsx scripts/normalize-viewbox.ts --dir=providers --dry-run
|
|
*/
|
|
import fs from 'fs/promises'
|
|
import path from 'path'
|
|
|
|
const TARGET_SIZE = 32
|
|
|
|
type SourceDir = 'providers' | 'models'
|
|
|
|
function parseArgs(): { dir: SourceDir; dryRun: boolean } {
|
|
const dirArg = process.argv.find((a) => a.startsWith('--dir='))
|
|
const dir = (dirArg?.split('=')[1] as SourceDir) || 'providers'
|
|
const dryRun = process.argv.includes('--dry-run')
|
|
return { dir, dryRun }
|
|
}
|
|
|
|
function normalizeSvg(content: string): { result: string; changed: boolean; reason?: string } {
|
|
const svgTagMatch = content.match(/<svg([^>]*)>/s)
|
|
if (!svgTagMatch) {
|
|
return { result: content, changed: false, reason: 'no <svg> tag' }
|
|
}
|
|
|
|
let attrs = svgTagMatch[1]
|
|
let changed = false
|
|
|
|
// Update width
|
|
const wMatch = attrs.match(/\bwidth="([^"]*)"/)
|
|
if (wMatch && wMatch[1] !== String(TARGET_SIZE)) {
|
|
attrs = attrs.replace(/\bwidth="[^"]*"/, `width="${TARGET_SIZE}"`)
|
|
changed = true
|
|
} else if (!wMatch) {
|
|
attrs += ` width="${TARGET_SIZE}"`
|
|
changed = true
|
|
}
|
|
|
|
// Update height
|
|
const hMatch = attrs.match(/\bheight="([^"]*)"/)
|
|
if (hMatch && hMatch[1] !== String(TARGET_SIZE)) {
|
|
attrs = attrs.replace(/\bheight="[^"]*"/, `height="${TARGET_SIZE}"`)
|
|
changed = true
|
|
} else if (!hMatch) {
|
|
attrs += ` height="${TARGET_SIZE}"`
|
|
changed = true
|
|
}
|
|
|
|
if (!changed) {
|
|
return { result: content, changed: false, reason: 'already 32x32' }
|
|
}
|
|
|
|
const result = content.replace(/<svg[^>]*>/s, `<svg${attrs}>`)
|
|
return { result, changed: true }
|
|
}
|
|
|
|
async function main() {
|
|
const { dir, dryRun } = parseArgs()
|
|
const baseDir = path.join(__dirname, '../icons', dir)
|
|
|
|
console.log(
|
|
`Normalizing SVG dimensions to ${TARGET_SIZE}x${TARGET_SIZE} (source: ${dir})${dryRun ? ' [DRY RUN]' : ''}...\n`
|
|
)
|
|
|
|
let files: string[]
|
|
try {
|
|
files = (await fs.readdir(baseDir)).filter((f) => f.endsWith('.svg')).sort()
|
|
} catch {
|
|
console.error(`Directory not found: ${baseDir}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
console.log('No SVG files found.')
|
|
return
|
|
}
|
|
|
|
console.log(`Found ${files.length} SVG files\n`)
|
|
|
|
let changedCount = 0
|
|
let skippedCount = 0
|
|
|
|
for (const file of files) {
|
|
const filePath = path.join(baseDir, file)
|
|
const content = await fs.readFile(filePath, 'utf-8')
|
|
const { result, changed, reason } = normalizeSvg(content)
|
|
|
|
if (changed) {
|
|
if (!dryRun) {
|
|
await fs.writeFile(filePath, result, 'utf-8')
|
|
}
|
|
console.log(` + ${file}${dryRun ? ' (would change)' : ''}`)
|
|
changedCount++
|
|
} else {
|
|
console.log(` - ${file} (skipped: ${reason})`)
|
|
skippedCount++
|
|
}
|
|
}
|
|
|
|
console.log(`\nDone: ${changedCount} changed, ${skippedCount} skipped`)
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err)
|
|
process.exit(1)
|
|
})
|