mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
- Add jbot Django app (jbot/__init__.py, apps.py, urls.py, api_urls.py) - Python backend (jbot/views.py): TTS 2.0, ASR 1.0 BigASR, LLM/memory via OpenRouter - React SPA frontend at /jbot/ (static_src/jbot/): RobotFace, ChatPanel, expressions, voices, push-to-talk, history, memory, settings pages - Vite entry jbot: static_src/jbot/main.jsx → dist/jbot.js + dist/jbot.css - react-router-dom added; BrowserRouter basename=/jbot for SPA routing - core/settings.py: added jbot to INSTALLED_APPS - core/urls.py: /jbot/ + /api/jbot/ URL includes - pyproject.toml: websocket-client>=1.9.0 for BigASR binary WS protocol - Dockerfile: ffmpeg added to production apt-get for WebM→PCM audio conversion - k8s/manifest.yaml: VOLC_APP_ID, VOLC_ACCESS_TOKEN, OPENROUTER_API_KEY env vars via jbot-credentials Secret; LLM_MODEL, VOLC_TTS_VOICE, VOLC_ASR_RESOURCE defaults Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
475 lines
15 KiB
JavaScript
475 lines
15 KiB
JavaScript
export const G = 6;
|
||
export const DOT_R = 2.6;
|
||
export const CANVAS = 240;
|
||
export const FACE = { LE: [10, 13], RE: [29, 13], MO: [20, 27] };
|
||
|
||
// ── Core geometry helpers ─────────────────────────────────────────
|
||
|
||
function circleFill(cx, cy, r) {
|
||
const pts = [];
|
||
const ri = Math.ceil(r);
|
||
for (let dx = -ri; dx <= ri; dx++)
|
||
for (let dy = -ri; dy <= ri; dy++)
|
||
if (dx * dx + dy * dy <= r * r) pts.push([cx + dx, cy + dy]);
|
||
return pts;
|
||
}
|
||
|
||
function arcPts(cx, cy, r, a1, a2, step = 5) {
|
||
const pts = [], seen = new Set();
|
||
for (let a = a1; a <= a2; a += step) {
|
||
const rad = (a * Math.PI) / 180;
|
||
const x = Math.round(cx + r * Math.cos(rad));
|
||
const y = Math.round(cy + r * Math.sin(rad));
|
||
const k = `${x},${y}`;
|
||
if (!seen.has(k)) { seen.add(k); pts.push([x, y]); }
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
function hLine(cx, cy, half) {
|
||
const pts = [];
|
||
for (let x = Math.round(cx - half); x <= Math.round(cx + half); x++)
|
||
pts.push([x, Math.round(cy)]);
|
||
return pts;
|
||
}
|
||
|
||
function waveLine(cx, cy, half, amp) {
|
||
const pts = [], seen = new Set();
|
||
const h = Math.round(half);
|
||
for (let dx = -h; dx <= h; dx++) {
|
||
const t = (dx / h) * Math.PI;
|
||
const y = Math.round(cy + amp * Math.sin(t));
|
||
const k = `${cx + dx},${y}`;
|
||
if (!seen.has(k)) { seen.add(k); pts.push([cx + dx, y]); }
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
function zigzag(cx, cy, half, amp) {
|
||
const pts = [];
|
||
const h = Math.round(half);
|
||
for (let dx = -h; dx <= h; dx++) {
|
||
const seg = dx + h;
|
||
pts.push([cx + dx, Math.round(cy + (seg % 4 < 2 ? amp : -amp))]);
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
function sub(a, b) {
|
||
const set = new Set(b.map(([x, y]) => `${x},${y}`));
|
||
return a.filter(([x, y]) => !set.has(`${x},${y}`));
|
||
}
|
||
|
||
function heart(cx, cy) {
|
||
return [
|
||
[-1, -2], [1, -2],
|
||
[-2, -1], [-1, -1], [0, -1], [1, -1], [2, -1],
|
||
[-2, 0], [-1, 0], [0, 0], [1, 0], [2, 0],
|
||
[-1, 1], [0, 1], [1, 1],
|
||
[0, 2],
|
||
].map(([dx, dy]) => [cx + dx, cy + dy]);
|
||
}
|
||
|
||
function spiral(cx, cy, maxR, turns) {
|
||
const pts = [], seen = new Set();
|
||
const steps = Math.round(turns * 2 * Math.PI * 8);
|
||
for (let i = 0; i <= steps; i++) {
|
||
const angle = (i / steps) * turns * 2 * Math.PI;
|
||
const r = (i / steps) * maxR;
|
||
const x = Math.round(cx + r * Math.cos(angle));
|
||
const y = Math.round(cy + r * Math.sin(angle));
|
||
const k = `${x},${y}`;
|
||
if (!seen.has(k)) { seen.add(k); pts.push([x, y]); }
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
function star(cx, cy, r) {
|
||
const pts = [], seen = new Set();
|
||
for (let i = 0; i < 8; i++) {
|
||
const angle = (i * 45 * Math.PI) / 180;
|
||
for (let d = 0; d <= r; d += 0.5) {
|
||
const x = Math.round(cx + d * Math.cos(angle));
|
||
const y = Math.round(cy + d * Math.sin(angle));
|
||
const k = `${x},${y}`;
|
||
if (!seen.has(k)) { seen.add(k); pts.push([x, y]); }
|
||
}
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
function makeZ(cx, cy) {
|
||
return [
|
||
[cx - 1, cy - 1], [cx, cy - 1], [cx + 1, cy - 1],
|
||
[cx, cy],
|
||
[cx - 1, cy + 1], [cx, cy + 1], [cx + 1, cy + 1],
|
||
];
|
||
}
|
||
|
||
function questionMark(cx, cy) {
|
||
return [
|
||
...arcPts(cx, cy - 1, 1.5, 200, 360, 30),
|
||
[cx, cy + 1],
|
||
[cx, cy + 3],
|
||
];
|
||
}
|
||
|
||
// ── Standard (interaction) eye builders ──────────────────────────
|
||
|
||
function femEye(cx, cy) {
|
||
return {
|
||
iris: circleFill(cx, cy, 3),
|
||
pupil: circleFill(cx, cy, 1.5),
|
||
hl: [[cx - 1, cy - 1]],
|
||
lash: hLine(cx, cy - 3, 3),
|
||
};
|
||
}
|
||
|
||
function wideEye(cx, cy) {
|
||
return {
|
||
iris: circleFill(cx, cy, 4),
|
||
pupil: circleFill(cx, cy, 2),
|
||
hl: [[cx - 1, cy - 1], [cx - 2, cy - 2]],
|
||
lash: hLine(cx, cy - 4, 4),
|
||
};
|
||
}
|
||
|
||
function squintEye(cx, cy) {
|
||
const full = circleFill(cx, cy, 3);
|
||
return {
|
||
iris: full.filter(([, y]) => y >= cy - 1 && y <= cy + 1),
|
||
pupil: [[cx, cy]],
|
||
lash: hLine(cx, cy - 1, 2),
|
||
};
|
||
}
|
||
|
||
function sleepyEye(cx, cy) {
|
||
const full = circleFill(cx, cy, 3);
|
||
return {
|
||
iris: full.filter(([, y]) => y >= cy),
|
||
pupil: [[cx - 1, cy], [cx, cy], [cx + 1, cy]],
|
||
lash: hLine(cx, cy, 3),
|
||
};
|
||
}
|
||
|
||
function downcastEye(cx, cy) {
|
||
return {
|
||
iris: circleFill(cx, cy + 1, 3),
|
||
pupil: circleFill(cx, cy + 2, 1.5),
|
||
hl: [[cx - 1, cy]],
|
||
lash: hLine(cx, cy - 2, 2),
|
||
};
|
||
}
|
||
|
||
function sideEye(cx, cy, xOff = 2) {
|
||
return {
|
||
iris: circleFill(cx, cy, 3),
|
||
pupil: circleFill(cx + xOff, cy, 1.5),
|
||
hl: [[cx + xOff - 1, cy - 1]],
|
||
lash: hLine(cx, cy - 3, 3),
|
||
};
|
||
}
|
||
|
||
function winkEye(cx, cy) {
|
||
return { wink: arcPts(cx, cy + 1, 3, 200, 340, 15) };
|
||
}
|
||
|
||
// ── Micro-expression eye builders (pts-based, minimalist) ────────
|
||
|
||
// Circle ring outline
|
||
function ringEye(cx, cy, r = 3) {
|
||
return { pts: sub(circleFill(cx, cy, r), circleFill(cx, cy, Math.max(0.5, r - 1.5))) };
|
||
}
|
||
|
||
// ∩ upward arch
|
||
function archEye(cx, cy, r = 3) {
|
||
return { pts: arcPts(cx, cy, r, 200, 340, 10) };
|
||
}
|
||
|
||
// Horizontal dash bar
|
||
function dashEye(cx, cy, half = 3) {
|
||
return { pts: hLine(cx, cy, half) };
|
||
}
|
||
|
||
// ^ peak/tent shape
|
||
function peakEye(cx, cy) {
|
||
return { pts: arcPts(cx, cy, 2.5, 228, 312, 12) };
|
||
}
|
||
|
||
// Filled block shifted sideways
|
||
function blockSideEye(cx, cy, xOff = 2) {
|
||
const pts = [];
|
||
for (let dx = -1; dx <= 1; dx++)
|
||
for (let dy = -2; dy <= 2; dy++)
|
||
pts.push([cx + dx + xOff, cy + dy]);
|
||
return { pts };
|
||
}
|
||
|
||
// + cross shape (left-right scanning)
|
||
function crossEye(cx, cy) {
|
||
const h = hLine(cx, cy, 2);
|
||
const seen = new Set(h.map(([x, y]) => `${x},${y}`));
|
||
const v = [];
|
||
for (let dy = -2; dy <= 2; dy++) {
|
||
const k = `${cx},${cy + dy}`;
|
||
if (!seen.has(k)) v.push([cx, cy + dy]);
|
||
}
|
||
return { pts: [...h, ...v] };
|
||
}
|
||
|
||
// ── Mouth builders ────────────────────────────────────────────────
|
||
|
||
function smileMouth(cx, cy) { return arcPts(cx, cy, 5, 25, 155); }
|
||
function wideMouth(cx, cy) { return arcPts(cx, cy, 7, 15, 165); }
|
||
function straightMouth(cx, cy) { return hLine(cx, cy, 5); }
|
||
|
||
function openMouth(cx, cy, openness) {
|
||
const b = Math.max(1, openness);
|
||
const top = arcPts(cx, cy - b, 4, 185, 355, 10);
|
||
const bot = arcPts(cx, cy + b, 4, 5, 175, 10);
|
||
const seen = new Set(top.map(([x, y]) => `${x},${y}`));
|
||
return [...top, ...bot.filter(([x, y]) => !seen.has(`${x},${y}`))];
|
||
}
|
||
|
||
function smirkMouth(cx, cy) {
|
||
const main = arcPts(cx - 2, cy, 4, 30, 120, 10);
|
||
const dip = arcPts(cx + 3, cy + 2, 2.5, 270, 360, 15);
|
||
const seen = new Set(main.map(([x, y]) => `${x},${y}`));
|
||
return [...main, ...dip.filter(([x, y]) => !seen.has(`${x},${y}`))];
|
||
}
|
||
|
||
function poutyMouth(cx, cy) {
|
||
const lft = arcPts(cx - 2, cy, 2, 200, 340, 20);
|
||
const rgt = arcPts(cx + 2, cy, 2, 200, 340, 20);
|
||
const bot = hLine(cx, cy + 2, 3);
|
||
const seen = new Set(lft.map(([x, y]) => `${x},${y}`));
|
||
return [...lft, ...rgt.filter(([x, y]) => !seen.has(`${x},${y}`)), ...bot];
|
||
}
|
||
|
||
// ── Animated speaking mouth ───────────────────────────────────────
|
||
// Three-step animation: closed smile → slight open → more open
|
||
// Uses smaller radius (3–3.5) and offset (1–2) for a natural look.
|
||
export function getSpeakMouth(mx, my, t) {
|
||
const phase = Math.abs(Math.sin(t * 1.4)); // 0 = closed, 1 = max open
|
||
if (phase < 0.25) {
|
||
return arcPts(mx, my, 4, 20, 160, 8); // closed: gentle smile
|
||
}
|
||
const offset = phase < 0.65 ? 1 : 2;
|
||
const top = arcPts(mx, my - offset, 3.5, 190, 350, 8);
|
||
const bot = arcPts(mx, my + offset, 3.0, 10, 170, 8);
|
||
const seen = new Set(top.map(([x, y]) => `${x},${y}`));
|
||
return [...top, ...bot.filter(([x, y]) => !seen.has(`${x},${y}`))];
|
||
}
|
||
|
||
// ── Anchor coordinates ────────────────────────────────────────────
|
||
|
||
const [LEx, LEy] = FACE.LE;
|
||
const [REx, REy] = FACE.RE;
|
||
const [MOx, MOy] = FACE.MO;
|
||
|
||
const MC = '#22CCFF'; // micro-expression LED teal
|
||
|
||
// ── 8 Interaction Expressions ────────────────────────────────────
|
||
|
||
const INTERACTION_EXPRS = [
|
||
{
|
||
id: 'idle', label: '😐 待机',
|
||
color: '#00FFEE', category: 'interaction', canBlink: true,
|
||
leftEye: femEye(LEx, LEy), rightEye: femEye(REx, REy),
|
||
mouth: smileMouth(MOx, MOy),
|
||
},
|
||
{
|
||
id: 'listening', label: '👂 聆听',
|
||
color: '#44AAFF', category: 'interaction', canBlink: true,
|
||
leftEye: wideEye(LEx, LEy), rightEye: wideEye(REx, REy),
|
||
mouth: arcPts(MOx, MOy, 3, 0, 180),
|
||
},
|
||
{
|
||
id: 'thinking', label: '🤔 思考',
|
||
color: '#FFCC00', category: 'interaction', canBlink: true,
|
||
leftEye: squintEye(LEx, LEy), rightEye: femEye(REx, REy),
|
||
mouth: waveLine(MOx, MOy, 5, 1),
|
||
extras: [{ dots: questionMark(LEx - 1, LEy - 6), color: '#FFCC00', alpha: 0.75 }],
|
||
},
|
||
{
|
||
id: 'speaking', label: '💬 说话',
|
||
color: '#FFFFFF', pupilColor: '#002233',
|
||
category: 'interaction', canBlink: true,
|
||
leftEye: femEye(LEx, LEy), rightEye: femEye(REx, REy),
|
||
speakingMouth: true,
|
||
},
|
||
{
|
||
id: 'happy', label: '😊 开心',
|
||
color: '#FF44AA', category: 'interaction', canBlink: true,
|
||
leftEye: { heart: heart(LEx, LEy) },
|
||
rightEye: { heart: heart(REx, REy) },
|
||
mouth: wideMouth(MOx, MOy),
|
||
},
|
||
{
|
||
id: 'error', label: '😵 错误',
|
||
color: '#FF4400', category: 'interaction', canBlink: true,
|
||
leftEye: { spiral: spiral(LEx, LEy, 4, 2) },
|
||
rightEye: { spiral: spiral(REx, REy, 4, 2) },
|
||
mouth: zigzag(MOx, MOy, 5, 1.5),
|
||
},
|
||
{
|
||
id: 'confused', label: '😕 困惑',
|
||
color: '#BB44FF', category: 'interaction', canBlink: true,
|
||
leftEye: wideEye(LEx, LEy), rightEye: squintEye(REx, REy),
|
||
mouth: waveLine(MOx, MOy, 5, 1.5),
|
||
extras: [{ dots: questionMark(REx + 1, REy - 6), color: '#BB44FF', alpha: 0.75 }],
|
||
},
|
||
{
|
||
id: 'dreaming', label: '💤 做梦',
|
||
color: '#5566FF', category: 'interaction', canBlink: true,
|
||
leftEye: sleepyEye(LEx, LEy), rightEye: sleepyEye(REx, REy),
|
||
mouth: smileMouth(MOx, MOy),
|
||
extras: [
|
||
{ dots: makeZ(24, 10), color: '#5566FF', alpha: 0.85, zzzIndex: 0 },
|
||
{ dots: makeZ(28, 7), color: '#7788FF', alpha: 0.75, zzzIndex: 1 },
|
||
{ dots: makeZ(32, 4), color: '#99AAFF', alpha: 0.65, zzzIndex: 2 },
|
||
],
|
||
},
|
||
];
|
||
|
||
// ── 16 Micro Auto-Expressions ─────────────────────────────────────
|
||
// All use the same full iris+pupil+lash eye builders as interaction
|
||
// expressions, giving them the same visual weight and size.
|
||
|
||
const AUTO_EXPRS = [
|
||
{
|
||
// Natural resting smile — standard open eyes + gentle upward arc
|
||
id: 'natural_smile', label: '01 自然微笑',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: femEye(LEx, LEy),
|
||
rightEye: femEye(REx, REy),
|
||
mouth: smileMouth(MOx, MOy),
|
||
},
|
||
{
|
||
// Calm / relaxed — half-closed sleepy eyes + neutral straight line
|
||
id: 'calm_relax', label: '02 平静放松',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: sleepyEye(LEx, LEy),
|
||
rightEye: sleepyEye(REx, REy),
|
||
mouth: straightMouth(MOx, MOy),
|
||
},
|
||
{
|
||
// Curious observing — wide-open eyes + medium smile
|
||
id: 'curious_observe', label: '03 好奇观察',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: wideEye(LEx, LEy),
|
||
rightEye: wideEye(REx, REy),
|
||
mouth: arcPts(MOx, MOy, 4, 30, 150, 8),
|
||
},
|
||
{
|
||
// Light joy — happy squint (crescent) + wide smile
|
||
id: 'light_joy', label: '04 轻松愉悦',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: squintEye(LEx, LEy),
|
||
rightEye: squintEye(REx, REy),
|
||
mouth: wideMouth(MOx, MOy),
|
||
},
|
||
{
|
||
// Side-eye thinking — pupils shifted sideways + thinking wave
|
||
id: 'side_think', label: '05 侧目思考',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: sideEye(LEx, LEy, 2),
|
||
rightEye: sideEye(REx, REy, 2),
|
||
mouth: waveLine(MOx, MOy, 4, 1),
|
||
},
|
||
{
|
||
// Warm & friendly — downcast soft gaze + gentle smile
|
||
id: 'warm_friendly', label: '06 温和友善',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: downcastEye(LEx, LEy),
|
||
rightEye: downcastEye(REx, REy),
|
||
mouth: smileMouth(MOx, MOy),
|
||
},
|
||
{
|
||
// Quietly listening — attentive standard eyes + neutral mouth
|
||
id: 'quiet_listen', label: '07 静静聆听',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: femEye(LEx, LEy),
|
||
rightEye: femEye(REx, REy),
|
||
mouth: hLine(MOx, MOy, 4),
|
||
},
|
||
{
|
||
// Focused gaze — wide intent eyes + flat straight mouth
|
||
id: 'focus_gaze', label: '08 专注注视',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: wideEye(LEx, LEy),
|
||
rightEye: wideEye(REx, REy),
|
||
mouth: straightMouth(MOx, MOy),
|
||
},
|
||
{
|
||
// Quietly waiting — sleepy patient eyes + tiny patient smile
|
||
id: 'quiet_wait', label: '09 安静等待',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: sleepyEye(LEx, LEy),
|
||
rightEye: sleepyEye(REx, REy),
|
||
mouth: arcPts(MOx, MOy, 4, 30, 150, 8),
|
||
},
|
||
{
|
||
// Slow blink / drowsy — happy squint + content smile
|
||
id: 'slow_blink', label: '10 慢眨眼',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: squintEye(LEx, LEy),
|
||
rightEye: squintEye(REx, REy),
|
||
mouth: smileMouth(MOx, MOy),
|
||
},
|
||
{
|
||
// Look around — pupils shifted left + neutral mouth
|
||
id: 'look_around', label: '11 左右扫视',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: sideEye(LEx, LEy, -2),
|
||
rightEye: sideEye(REx, REy, -2),
|
||
mouth: hLine(MOx, MOy, 4),
|
||
},
|
||
{
|
||
// Playful — one wink + wide eye + smirk
|
||
id: 'playful', label: '12 小小调皮',
|
||
color: MC, category: 'auto', canBlink: false,
|
||
leftEye: winkEye(LEx, LEy),
|
||
rightEye: wideEye(REx, REy),
|
||
mouth: smirkMouth(MOx, MOy),
|
||
},
|
||
{
|
||
// Joyful & expectant — big wide excited eyes + wide smile
|
||
id: 'joyful_wait', label: '13 愉悦期待',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: wideEye(LEx, LEy),
|
||
rightEye: wideEye(REx, REy),
|
||
mouth: wideMouth(MOx, MOy),
|
||
},
|
||
{
|
||
// Slight surprise — wide eyes + small open-O mouth
|
||
id: 'slight_surprise', label: '14 轻微惊喜',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: wideEye(LEx, LEy),
|
||
rightEye: wideEye(REx, REy),
|
||
mouth: arcPts(MOx, MOy, 3, 0, 180, 10),
|
||
},
|
||
{
|
||
// Gentle gaze — downcast warm eyes + soft small smile
|
||
id: 'gentle_gaze', label: '15 温柔注视',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: downcastEye(LEx, LEy),
|
||
rightEye: downcastEye(REx, REy),
|
||
mouth: arcPts(MOx, MOy, 4, 30, 150, 8),
|
||
},
|
||
{
|
||
// Spaced out / daydreaming — very droopy sleepy eyes + pouty mouth
|
||
id: 'spaceout', label: '16 发呆放空',
|
||
color: MC, category: 'auto', canBlink: true,
|
||
leftEye: sleepyEye(LEx, LEy),
|
||
rightEye: sleepyEye(REx, REy),
|
||
mouth: poutyMouth(MOx, MOy),
|
||
},
|
||
];
|
||
|
||
// ── Exports ───────────────────────────────────────────────────────
|
||
|
||
export const EXPRESSIONS = [...INTERACTION_EXPRS, ...AUTO_EXPRS];
|
||
export const ALL_AUTO_IDS = AUTO_EXPRS.map(e => e.id);
|
||
export const byId = Object.fromEntries(EXPRESSIONS.map(e => [e.id, e]));
|