diff --git a/apps/web/src/lib/assets/favicon.svg b/apps/web/src/lib/assets/favicon.svg index cc5dc66..02b163d 100644 --- a/apps/web/src/lib/assets/favicon.svg +++ b/apps/web/src/lib/assets/favicon.svg @@ -1 +1 @@ -svelte-logo \ No newline at end of file +rubber-duck diff --git a/apps/web/src/lib/components/duck/ChatPanel.svelte b/apps/web/src/lib/components/duck/ChatPanel.svelte new file mode 100644 index 0000000..5639f41 --- /dev/null +++ b/apps/web/src/lib/components/duck/ChatPanel.svelte @@ -0,0 +1,231 @@ + + +
+
+ {#each messages as msg (msg.id)} +
+
+ {#if msg.role === 'duck'}🦆{/if} +
{msg.text}
+
+ {#if msg.role === 'duck' && msg.mood} +
tools/call(quack, mood={msg.mood}) · {moodLabel(msg.mood)} · симуляция
+ {/if} +
+ {/each} + {#if busy} +
+
🦆кря-кря...
+
+ {/if} +
+ +
+ {#each CHIPS as chip} + + {/each} +
+ +
+ + +
+
+ + diff --git a/apps/web/src/lib/components/duck/DuckAvatar.svelte b/apps/web/src/lib/components/duck/DuckAvatar.svelte new file mode 100644 index 0000000..d58fbdf --- /dev/null +++ b/apps/web/src/lib/components/duck/DuckAvatar.svelte @@ -0,0 +1,106 @@ + + + +
+ Резиновая утка + {#if tee.img} + + {/if} + +
+ + diff --git a/apps/web/src/lib/components/duck/Logo.svelte b/apps/web/src/lib/components/duck/Logo.svelte new file mode 100644 index 0000000..ca12b1f --- /dev/null +++ b/apps/web/src/lib/components/duck/Logo.svelte @@ -0,0 +1,26 @@ + + +{#if def} + {@html def.svg} +{/if} diff --git a/apps/web/src/lib/components/duck/SettingsPanel.svelte b/apps/web/src/lib/components/duck/SettingsPanel.svelte new file mode 100644 index 0000000..564ec72 --- /dev/null +++ b/apps/web/src/lib/components/duck/SettingsPanel.svelte @@ -0,0 +1,159 @@ + + +
+
+

Поведение

+ + +
+ +
+

Текущий образ

+
{outfitJson}
+
+ + +
+
+ +
+

О проекте

+

+ Утка-отладчик слушает и крякает. Вызов quack в чате — + локальная симуляция MCP-инструмента: без сети, с той же логикой ответа. + Настоящий MCP-эндпоинт живёт отдельно — /mcp. + Все логотипы принадлежат их правообладателям и используются + в иллюстративных целях в рамках добросовестного использования (fair use). +

+
+
+ + diff --git a/apps/web/src/lib/components/duck/WardrobePanel.svelte b/apps/web/src/lib/components/duck/WardrobePanel.svelte new file mode 100644 index 0000000..65a756a --- /dev/null +++ b/apps/web/src/lib/components/duck/WardrobePanel.svelte @@ -0,0 +1,285 @@ + + +
+
+

Кепка

+
+ {#each CAPS as cap (cap.id)} + + {/each} +
+
+ +
+

Футболка

+
+ {#each TEES as tee (tee.id)} + + {/each} +
+
+ +
+

Логотип на кепке

+ {#if outfit.cap === 'none'} +

Сначала наденьте кепку — печатать не на чем.

+ {:else} +
+ {#each LOGO_CATEGORIES as cat (cat.id)} +
+ {cat.label} +
+ + {#each LOGOS.filter((l) => l.category === cat.id) as logo (logo.id)} + + {/each} +
+
+ {/each} +
+ {/if} +
+ +
+

Логотип на футболке

+ {#if outfit.tee === 'none'} +

Сначала наденьте футболку — печатать не на чем.

+ {:else} +
+ {#each LOGO_CATEGORIES as cat (cat.id)} +
+ {cat.label} +
+ + {#each LOGOS.filter((l) => l.category === cat.id) as logo (logo.id)} + + {/each} +
+
+ {/each} +
+ {/if} +
+ + + +

+ {outfit.cap !== 'none' ? `кепка: ${capLabel()}` : 'без кепки'} · + {outfit.tee !== 'none' ? `футболка: ${teeLabel()}` : 'без футболки'} · + {capLogoLabel()}{teeLogoLabel()} +

+
+ + diff --git a/apps/web/src/lib/duck/logos.ts b/apps/web/src/lib/duck/logos.ts new file mode 100644 index 0000000..a1d8ae1 --- /dev/null +++ b/apps/web/src/lib/duck/logos.ts @@ -0,0 +1,168 @@ +export const LOGO_CATEGORIES = [ + { id: 'lang', label: 'Языки' }, + { id: 'tech', label: 'Технологии' }, + { id: 'org', label: 'Организации' } +] as const; + +/** категория логотипа — выводится из LOGO_CATEGORIES, отдельно не правится */ +export type LogoCategory = (typeof LOGO_CATEGORIES)[number]['id']; + +export interface LogoDef { + id: string; + label: string; + category: LogoCategory; + /** mono = рисуется цветом currentColor (подстраивается под тёмную/светлую поверхность) */ + mono?: boolean; + /** разметка под viewBox 0 0 24 24 */ + svg: string; +} + +const badge = (fill: string, label: string, fg: string, fontSize = 10) => + `` + + `${label}`; + +export const LOGOS: LogoDef[] = [ + // --- языки --- + { + id: 'js', + label: 'JavaScript', + category: 'lang', + svg: badge('#f7df1e', 'JS', '#1a1a1a') + }, + { + id: 'ts', + label: 'TypeScript', + category: 'lang', + svg: badge('#3178c6', 'TS', '#ffffff') + }, + { + id: 'go', + label: 'Go', + category: 'lang', + svg: badge('#00add8', 'GO', '#ffffff', 9) + }, + { + id: 'rust', + label: 'Rust', + category: 'lang', + svg: + `` + + [0, 45, 90, 135, 180, 225, 270, 315] + .map((a) => ``) + .join('') + + `` + + `` + + `R` + }, + { + id: 'python', + label: 'Python', + category: 'lang', + svg: + `` + + `` + }, + // --- технологии --- + { + id: 'nginx', + label: 'NGINX', + category: 'tech', + svg: badge('#009639', 'n', '#ffffff', 13) + }, + { + id: 'kafka', + label: 'Kafka', + category: 'tech', + mono: true, + svg: + `` + + `` + + `` + + `` + + `` + + `` + + `` + }, + { + id: 'docker', + label: 'Docker', + category: 'tech', + svg: + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + }, + { + id: 'kubernetes', + label: 'Kubernetes', + category: 'tech', + svg: + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + }, + { + id: 'redis', + label: 'Redis', + category: 'tech', + svg: + `` + + `` + + `` + }, + // --- организации --- + { + id: 'github', + label: 'GitHub', + category: 'org', + mono: true, + svg: + `` + + `` + + `` + }, + { + id: 'gitlab', + label: 'GitLab', + category: 'org', + svg: + `` + }, + { + id: 'bitbucket', + label: 'Bitbucket', + category: 'org', + svg: + `` + + `` + }, + { + id: 'git', + label: 'Git', + category: 'org', + svg: + `` + + `` + + `` + + `` + + `` + + `` + } +]; + +const byId = new Map(LOGOS.map((l) => [l.id, l])); + +export function logoById(id: string | null | undefined): LogoDef | undefined { + return id ? byId.get(id) : undefined; +} diff --git a/apps/web/src/lib/duck/quack.ts b/apps/web/src/lib/duck/quack.ts new file mode 100644 index 0000000..fd6a8c3 --- /dev/null +++ b/apps/web/src/lib/duck/quack.ts @@ -0,0 +1,103 @@ +export const MOODS = ['happy', 'confused', 'excited', 'sleepy'] as const; + +/** настроение утки — выводится из MOODS, отдельно не правится */ +export type Mood = (typeof MOODS)[number]; + +const MOOD_LABELS: Record = { + happy: 'довольная', + confused: 'в замешательстве', + excited: 'в восторге', + sleepy: 'сонная' +}; + +export function moodLabel(mood: Mood): string { + return MOOD_LABELS[mood]; +} + +export function moodFor(text: string): Mood { + const t = text.toLowerCase(); + if (/(устал|спать|сон|ночь|надоело|sleep|tired|boring)/.test(t)) return 'sleepy'; + if (/(ура|получилось|работает|наконец|супер|отлично|yay|works|fixed)/.test(t) || /!{2,}/.test(t)) + return 'excited'; + if (/\?|почему|как|не работает|сломал|баг|ошибк|bug|why|how|странно/.test(t)) return 'confused'; + return 'happy'; +} + +/** те же пулы фраз, что у настоящего quack-инструмента (src/lib/mcp.ts) */ +const QUACKS: Record = { + happy: ['QUACK! 🎉', 'Quack quack! 😄', 'QUAAACK! 🦆✨'], + confused: ['Qu...ack?', 'Quack? 🤔', 'Quaaack...?'], + excited: ['QUACK QUACK QUACK!', 'QUAAACK!! 🔥', 'Quack! Quack! Quack!'], + sleepy: ['quack... 😴', 'quaack... 💤', 'quack. *yawn*'], + default: ['Quack!', 'Quack quack!', 'QUACK!', 'Quaaack!', 'Quack. 🦆'] +}; + +function randomFrom(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +/** Подсказки утки-наставника — то, ради чего к ней идут. */ +export const DUCK_TIPS = [ + 'Расскажи, что ты ожидаешь увидеть — и что видишь вместо этого.', + 'Разбей проблему на шаги и проговори каждый вслух. Я слушаю.', + 'А что приходит на входе? Проверь значения по одному.', + 'Прочитай ошибку вслух, медленно. Что она говорит на самом деле?', + 'Объясни код строка за строкой — где-то на середине станет ясно.', + 'Что изменилось с тех пор, как всё работало?', + 'Сделай минимальный пример, который воспроизводит проблему.', + 'Проверь границы: пустые значения, нули, пустые строки.', + 'Отдохни пять минут и вернись свежим взглядом.', + 'Веришь своему логу — или своим догадкам? Проверь.' +]; + +export function randomTip(): string { + return randomFrom(DUCK_TIPS); +} + +export interface SimulatedToolCall { + tool: 'quack'; + arguments: { mood: Mood }; + /** результат в формате MCP content-блоков — как вернул бы настоящий инструмент */ + content: { type: 'text'; text: string }[]; +} + +/** Симуляция вызова MCP-инструмента quack: без сети, с «задержкой сети» и тем же ответом. */ +export async function simulateMcpQuack(message: string): Promise { + const mood = moodFor(message); + await new Promise((r) => setTimeout(r, 350 + Math.random() * 550)); + return { + tool: 'quack', + arguments: { mood }, + content: [{ type: 'text', text: randomFrom(QUACKS[mood]) }] + }; +} + +let audioCtx: AudioContext | undefined; + +/** Синтезированное «кря» через WebAudio (без аудио-ассетов). */ +export function playQuack() { + try { + audioCtx ??= new AudioContext(); + const ctx = audioCtx; + const t = ctx.currentTime; + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(420, t); + osc.frequency.exponentialRampToValueAtTime(130, t + 0.18); + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.value = 1200; + filter.Q.value = 6; + const gain = ctx.createGain(); + gain.gain.setValueAtTime(0.0001, t); + gain.gain.exponentialRampToValueAtTime(0.2, t + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.22); + osc.connect(filter); + filter.connect(gain); + gain.connect(ctx.destination); + osc.start(t); + osc.stop(t + 0.25); + } catch { + // нет WebAudio — просто молча крякаем + } +} diff --git a/apps/web/src/lib/duck/state.svelte.ts b/apps/web/src/lib/duck/state.svelte.ts new file mode 100644 index 0000000..41167c0 --- /dev/null +++ b/apps/web/src/lib/duck/state.svelte.ts @@ -0,0 +1,51 @@ +import type { CapId, TeeId } from './wardrobe'; + +export interface Outfit { + cap: CapId; + tee: TeeId; + capLogo: string | null; + teeLogo: string | null; + bob: boolean; + sound: boolean; +} + +const STORAGE_KEY = 'duck-outfit-v1'; + +function defaultOutfit(): Outfit { + return { + cap: 'baseball', + tee: 'white', + capLogo: null, + teeLogo: 'js', + bob: true, + sound: true + }; +} + +export const outfit = $state(defaultOutfit()); +export const duckState = $state({ speaking: false }); + +if (typeof window !== 'undefined') { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const saved = JSON.parse(raw) as Partial; + Object.assign(outfit, saved); + } + } catch { + // битый localStorage — используем дефолтный образ + } +} + +export function saveOutfit() { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(outfit)); + } catch { + // приватный режим / переполнение — молча игнорируем + } +} + +export function resetOutfit() { + Object.assign(outfit, defaultOutfit()); +} diff --git a/apps/web/src/lib/duck/wardrobe.ts b/apps/web/src/lib/duck/wardrobe.ts new file mode 100644 index 0000000..f277d5f --- /dev/null +++ b/apps/web/src/lib/duck/wardrobe.ts @@ -0,0 +1,206 @@ +export interface CapShape { + color: string; + dark: boolean; + /** позиция логотипа на кепке (координаты канваса утки 800x920) */ + logo: { x: number; y: number; s: number }; + /** разметка кепки, рисуется поверх головы утки */ + svg: string; +} + +export interface TeeDef { + id: TeeId; + label: string; + /** PNG-слой футболки (перекрашен из TShirt.jfif), null = без футболки */ + img: string | null; + dark: boolean; + /** цвет для монохромных логотипов */ + mono: string; +} + +const shade = (opacity: number) => `rgba(0,0,0,${opacity})`; + +// ───────────────────────────────────────────────────────────────────────────── +// КАПКИ — как подогнать под себя +// +// Утка лежит на канвасе 800×920: центр головы x=400, макушка y≈60, +// глаза занимают полосу y≈178…258 (белки начинаются на y=178). +// Кепка рисуется поверх головы, поэтому: +// • baseY (нижний край купола) держите в диапазоне 130…176 — ниже кепка +// начнёт закрывать глаза; +// • domeW больше ~420 будет шире головы; +// • позиция логотипа считается автоматически, но можно задать вручную +// параметром logo: { x, y, s } (s — сторона квадрата логотипа). +// Правьте числа в вызовах baseball(…) / beanie(…) / flatcap(…) / tophat(…) +// в списке CAPS ниже — утка в браузере обновится сразу (dev-режим). +// ───────────────────────────────────────────────────────────────────────────── + +const CX = 400; // центр утки по горизонтали + +/** купол: нижний край baseY, ширина domeW, высота domeH */ +function dome(baseY: number, domeW: number, domeH: number): string { + const x0 = CX - domeW / 2; + const x1 = CX + domeW / 2; + const top = baseY - domeH; + return ( + `M ${x0} ${baseY} C ${x0} ${top + 32} ${CX - domeW * 0.3} ${top} ${CX} ${top} ` + + `C ${CX + domeW * 0.3} ${top} ${x1} ${top + 32} ${x1} ${baseY} Z` + ); +} + +/** бейсболка: купол + козырёк-эллипс + швы + пуговка */ +function baseball(opts: { + color: string; + baseY?: number; // нижний край купола + domeW?: number; // ширина купола + domeH?: number; // высота купола + brimRx?: number; // козырёк: полуширина + brimRy?: number; // козырёк: полувысота + logo?: { x: number; y: number; s: number }; +}): CapShape { + const { color, baseY = 146, domeW = 376, domeH = 140, brimRx = 212, brimRy = 28 } = opts; + const top = baseY - domeH; + const seam = + (d: 1 | -1) => + `M ${CX} ${top} C ${CX + d * domeW * 0.053} ${top + 44} ${CX + d * domeW * 0.062} ${baseY - 46} ${CX + d * domeW * 0.058} ${baseY - 4}`; + const svg = + `` + + `` + + `` + + `` + + ``; + return { + color, + dark: true, + logo: opts.logo ?? { x: CX, y: Math.round(top + domeH / 2), s: Math.round(domeH * 0.48) }, + svg + }; +} + +/** бини: купол + помпон + отворот-полоса (логотип живёт на отвороте) */ +function beanie(opts: { + color: string; + baseY?: number; + domeW?: number; + domeH?: number; + bandH?: number; // высота отворота + pomR?: number; // радиус помпона + logo?: { x: number; y: number; s: number }; +}): CapShape { + const { color, baseY = 148, domeW = 360, domeH = 140, bandH = 60, pomR = 24 } = opts; + const top = baseY - domeH; + const bw = domeW + 32; + const svg = + `` + + `` + + `` + + `` + + ``; + return { + color, + dark: true, + logo: opts.logo ?? { x: CX, y: Math.round(baseY - 36 + bandH / 2), s: Math.round(bandH * 0.8) }, + svg + }; +} + +/** восьмиклинка: плоский купол + маленький козырёк + клинья-швы */ +function flatcap(opts: { + color: string; + baseY?: number; + domeW?: number; + domeH?: number; + brimRx?: number; + brimRy?: number; + seamDx?: number; // отступ клиньев от центра + logo?: { x: number; y: number; s: number }; +}): CapShape { + const { color, baseY = 150, domeW = 372, domeH = 132, brimRx = 158, brimRy = 26, seamDx = 60 } = opts; + const top = baseY - domeH; + const seam = + (d: 1 | -1) => + `M ${CX + d * seamDx} ${top + 10} C ${CX + d * (seamDx - 12)} ${top + 54} ${CX + d * (seamDx - 16)} ${baseY - 42} ${CX + d * (seamDx - 12)} ${baseY - 2}`; + const svg = + `` + + `` + + `` + + ``; + return { + color, + dark: false, + logo: opts.logo ?? { x: CX, y: Math.round(top + domeH * 0.52), s: Math.round(domeH * 0.44) }, + svg + }; +} + +/** цилиндр: широкие поля + тулья + лента */ +function tophat(opts: { + color: string; + baseY?: number; + crownW?: number; // ширина тульи + crownH?: number; // высота тульи + brimRx?: number; // поля: полуширина + brimRy?: number; // поля: полувысота + logo?: { x: number; y: number; s: number }; +}): CapShape { + const { color, baseY = 146, crownW = 232, crownH = 132, brimRx = 242, brimRy = 30 } = opts; + const crownTop = baseY - crownH; + const svg = + `` + + `` + + `` + + `` + + `` + + ``; + return { + color, + dark: true, + logo: opts.logo ?? { x: CX, y: Math.round(crownTop + crownH * 0.36), s: Math.round(crownH * 0.44) }, + svg + }; +} + +const CAP_LIST = [ + { id: 'none', label: 'Без кепки', color: '', dark: false, logo: { x: 0, y: 0, s: 0 }, svg: '' }, + { id: 'baseball', label: 'Бейсболка', ...baseball({ color: '#2563eb' }) }, + { id: 'beanie', label: 'Бини', ...beanie({ color: '#ef4444' }) }, + { id: 'flatcap', label: 'Восьмиклинка', ...flatcap({ color: '#16a34a' }) }, + { id: 'tophat', label: 'Цилиндр', ...tophat({ color: '#4b5b70' }) } +] as const; + +/** id всех кепок — выводится из CAP_LIST, отдельно не правится */ +export type CapId = (typeof CAP_LIST)[number]['id']; + +export interface CapDef extends CapShape { + id: CapId; + label: string; +} + +export const CAPS: readonly CapDef[] = CAP_LIST; + +const TEE_LIST = [ + { id: 'none', label: 'Без футболки', img: null, dark: false, mono: '' }, + { id: 'white', label: 'Белая', img: '/duck/tee-white.png', dark: false, mono: '#0f172a' }, + { id: 'black', label: 'Чёрная', img: '/duck/tee-black.png', dark: true, mono: '#f8fafc' }, + { id: 'green', label: 'Терминальная', img: '/duck/tee-green.png', dark: false, mono: '#0f172a' }, + { id: 'orange', label: 'Оранжевая', img: '/duck/tee-orange.png', dark: false, mono: '#0f172a' }, + { id: 'blue', label: 'Голубая', img: '/duck/tee-blue.png', dark: false, mono: '#0f172a' } +] as const; + +/** id всех футболок — выводится из TEE_LIST, отдельно не правится */ +export type TeeId = (typeof TEE_LIST)[number]['id']; + +export const TEES: readonly TeeDef[] = TEE_LIST; + +/** позиция логотипа на груди футболки (канвас 800x920) */ +export const TEE_LOGO = { x: 400, y: 645, s: 96 }; + +const capMap = new Map(CAPS.map((c) => [c.id, c])); +const teeMap = new Map(TEES.map((t) => [t.id, t])); + +export function capById(id: string | null | undefined): CapDef { + return (id && capMap.get(id as CapId)) || CAPS[0]; +} + +export function teeById(id: string | null | undefined): TeeDef { + return (id && teeMap.get(id as TeeId)) || TEES[0]; +} diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index dc527d7..ac4fb85 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,2 +1,244 @@ -

🦆 Rubber Duck Debugging as a Service

-

Главная страница в разработке. А пока — смотрите отчёты тестирования и MCP-сервер.

+ + + + Rubber Duck — отладка с уткой + + + +
+
+

Rubber Duck_

+

+ Проговори проблему вслух — утка выслушает. Пока говоришь, решение часто находится само. +

+
+
+ debugging-as-a-service + local-first + quack · симуляция +
+
+ +
+
+
+ duck_id: RU-1337 + status: listening + uptime: ∞ +
+
+ +
+
+
$ duck outfit --json
+
{outfitLine}
+
+
+ + +
+ + diff --git a/apps/web/static/Duck.jfif b/apps/web/static/Duck.jfif new file mode 100644 index 0000000..9369ce8 Binary files /dev/null and b/apps/web/static/Duck.jfif differ diff --git a/apps/web/static/TShirt.jfif b/apps/web/static/TShirt.jfif new file mode 100644 index 0000000..eecc876 Binary files /dev/null and b/apps/web/static/TShirt.jfif differ diff --git a/apps/web/static/duck/duck.png b/apps/web/static/duck/duck.png new file mode 100644 index 0000000..2434a25 Binary files /dev/null and b/apps/web/static/duck/duck.png differ diff --git a/apps/web/static/duck/tee-black.png b/apps/web/static/duck/tee-black.png new file mode 100644 index 0000000..fd0cee1 Binary files /dev/null and b/apps/web/static/duck/tee-black.png differ diff --git a/apps/web/static/duck/tee-blue.png b/apps/web/static/duck/tee-blue.png new file mode 100644 index 0000000..60cfc60 Binary files /dev/null and b/apps/web/static/duck/tee-blue.png differ diff --git a/apps/web/static/duck/tee-green.png b/apps/web/static/duck/tee-green.png new file mode 100644 index 0000000..a473936 Binary files /dev/null and b/apps/web/static/duck/tee-green.png differ diff --git a/apps/web/static/duck/tee-orange.png b/apps/web/static/duck/tee-orange.png new file mode 100644 index 0000000..1b1abd4 Binary files /dev/null and b/apps/web/static/duck/tee-orange.png differ diff --git a/apps/web/static/duck/tee-white.png b/apps/web/static/duck/tee-white.png new file mode 100644 index 0000000..b72e492 Binary files /dev/null and b/apps/web/static/duck/tee-white.png differ