Files
links/static_src/jbot/data/expressions.js
T
junvandCopilot a87b4643fd feat: port JBOT AI robot face into links as Django app
- 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>
2026-05-17 14:13:37 +10:00

475 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (33.5) and offset (12) 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]));