bloby-bot 0.70.12 → 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +234 -48
- package/dist-bloby/assets/{bloby-DSNB0g4w.js → bloby-es6cZJzs.js} +6 -6
- package/dist-bloby/assets/globals-DBqwNiJV.css +2 -0
- package/dist-bloby/assets/{globals-B3cTbITX.js → globals-DN3F0CQE.js} +1 -1
- package/dist-bloby/assets/{highlighted-body-OFNGDK62-BLforpkr.js → highlighted-body-OFNGDK62-8PiOHw9p.js} +1 -1
- package/dist-bloby/assets/mermaid-GHXKKRXX-BJWX8urU.js +1 -0
- package/dist-bloby/assets/{onboard-Dn2Ws_G2.js → onboard-BKgy17OU.js} +1 -1
- package/dist-bloby/bloby.html +3 -3
- package/dist-bloby/onboard.html +3 -3
- package/package.json +3 -4
- package/scripts/install +156 -41
- package/scripts/install.ps1 +146 -29
- package/scripts/install.sh +156 -41
- package/shared/config.ts +37 -2
- package/shared/relay.ts +3 -1
- package/supervisor/channels/manager.ts +84 -44
- package/supervisor/channels/telegram.ts +57 -16
- package/supervisor/channels/types.ts +4 -1
- package/supervisor/channels/whatsapp.ts +57 -10
- package/supervisor/chat/OnboardWizard.tsx +0 -15
- package/supervisor/chat/src/components/Chat/AudioBubble.tsx +1 -1
- package/supervisor/chat/src/components/Chat/AuthedImage.tsx +16 -3
- package/supervisor/chat/src/components/Chat/BlobyImageCard.tsx +2 -2
- package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +25 -8
- package/supervisor/chat/src/components/Chat/InputBar.tsx +62 -7
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +37 -18
- package/supervisor/chat/src/components/Chat/MessageList.tsx +3 -3
- package/supervisor/chat/src/hooks/useChat.ts +52 -0
- package/supervisor/chat/src/lib/authedFile.ts +24 -12
- package/supervisor/file-saver.ts +92 -19
- package/supervisor/harnesses/attachment-policy.ts +111 -0
- package/supervisor/harnesses/claude.ts +62 -15
- package/supervisor/harnesses/codex.ts +69 -43
- package/supervisor/harnesses/pi/index.ts +367 -112
- package/supervisor/harnesses/pi/providers/humanize-error.ts +27 -2
- package/supervisor/harnesses/pi/providers/retry.ts +31 -0
- package/supervisor/harnesses/pi/providers/stream-anthropic.ts +31 -3
- package/supervisor/harnesses/pi/providers/stream-google.ts +26 -3
- package/supervisor/harnesses/pi/providers/stream-openai-completions.ts +32 -9
- package/supervisor/harnesses/pi/providers/types.ts +29 -1
- package/supervisor/harnesses/pi/session.ts +143 -3
- package/supervisor/harnesses/pi/test-completion.ts +56 -0
- package/supervisor/harnesses/pi/tools/bash.ts +198 -22
- package/supervisor/harnesses/pi/tools/glob.ts +79 -0
- package/supervisor/harnesses/pi/tools/grep.ts +0 -0
- package/supervisor/harnesses/pi/tools/registry.ts +18 -6
- package/supervisor/harnesses/pi/tools/todo-write.ts +45 -0
- package/supervisor/harnesses/pi/tools/web-fetch.ts +129 -0
- package/supervisor/index.ts +93 -18
- package/supervisor/widget.js +19 -5
- package/worker/db.ts +2 -0
- package/worker/index.ts +18 -1
- package/worker/prompts/bloby-system-prompt-codex.txt +1 -1
- package/worker/prompts/bloby-system-prompt-pi.txt +6 -24
- package/worker/prompts/bloby-system-prompt.txt +1 -1
- package/workspace/client/src/components/Dashboard/DashboardPage.tsx +4 -117
- package/workspace/client/src/components/Dashboard/deleteme_placeholders.tsx +194 -0
- package/workspace/client/src/components/Layout/Sidebar.tsx +52 -30
- package/workspace/client/src/components/deleteme_onboarding/WorkspaceTour.tsx +25 -15
- package/workspace/client/src/components/deleteme_onboarding/tour-theme.css +24 -0
- package/workspace/skills/mac/SKILL.md +13 -4
- package/dist-bloby/assets/globals-DyeW509Y.css +0 -2
- package/dist-bloby/assets/mermaid-GHXKKRXX-C1H_fSCU.js +0 -1
- package/supervisor/public/headphones_spritesheet.webp +0 -0
- package/supervisor/public/spritesheet.webp +0 -0
package/supervisor/index.ts
CHANGED
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
warmUpForLiveConversation,
|
|
24
24
|
type RecentMessage,
|
|
25
25
|
} from './bloby-agent.js';
|
|
26
|
-
import { ensureFileDirs, saveAttachment, type SavedFile } from './file-saver.js';
|
|
26
|
+
import { ensureFileDirs, saveAttachment, saveAudio, MAX_ATTACHMENTS_PER_MESSAGE, MAX_TOTAL_ATTACHMENT_BYTES, type SavedFile } from './file-saver.js';
|
|
27
|
+
import { approxBase64Bytes } from './harnesses/attachment-policy.js';
|
|
27
28
|
import { startViteDevServers, stopViteDevServers } from './vite-dev.js';
|
|
28
29
|
import { startScheduler, stopScheduler, readPulseConfig, readCronsConfig, nextRunISO, describeCron } from './scheduler.js';
|
|
29
30
|
import { execSync, spawn as cpSpawn } from 'child_process';
|
|
@@ -2588,9 +2589,15 @@ ${alreadyLinked ? '' : `
|
|
|
2588
2589
|
}
|
|
2589
2590
|
|
|
2590
2591
|
let savedFiles: SavedFile[] = [];
|
|
2592
|
+
// Bounded subset that saved within the caps — handed to the harness so the
|
|
2593
|
+
// model sees exactly what's persisted/shown (parity with the PWA path).
|
|
2594
|
+
const acceptedAttachments: any[] = [];
|
|
2591
2595
|
if (Array.isArray(body.attachments) && body.attachments.length) {
|
|
2592
|
-
|
|
2593
|
-
|
|
2596
|
+
let totalBytes = 0;
|
|
2597
|
+
for (const att of body.attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE)) {
|
|
2598
|
+
totalBytes += approxBase64Bytes(att?.data || '');
|
|
2599
|
+
if (totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { log.warn(`[workspace-chat] attachment total exceeds cap — dropping remaining`); break; }
|
|
2600
|
+
try { savedFiles.push(saveAttachment(att)); acceptedAttachments.push(att); }
|
|
2594
2601
|
catch (err: any) { log.warn(`[workspace-chat] attachment save: ${err.message}`); }
|
|
2595
2602
|
}
|
|
2596
2603
|
}
|
|
@@ -2670,9 +2677,7 @@ ${alreadyLinked ? '' : `
|
|
|
2670
2677
|
);
|
|
2671
2678
|
}
|
|
2672
2679
|
|
|
2673
|
-
const agentAttachments =
|
|
2674
|
-
? body.attachments
|
|
2675
|
-
: undefined;
|
|
2680
|
+
const agentAttachments = acceptedAttachments.length ? acceptedAttachments : undefined;
|
|
2676
2681
|
|
|
2677
2682
|
// Mirror to WhatsApp self-chat if connected (same behaviour as the widget).
|
|
2678
2683
|
const waStatus = channelManager.getStatus('whatsapp');
|
|
@@ -3051,11 +3056,16 @@ ${alreadyLinked ? '' : `
|
|
|
3051
3056
|
req.pipe(proxy);
|
|
3052
3057
|
});
|
|
3053
3058
|
|
|
3059
|
+
// Bound WS frames so a single message can't stream an unbounded blob into memory
|
|
3060
|
+
// (ws default is ~100MiB). Sized to comfortably hold the per-message attachment caps
|
|
3061
|
+
// (MAX_TOTAL_ATTACHMENT_BYTES of decoded bytes ≈ 1.33× as base64) plus JSON overhead.
|
|
3062
|
+
const WS_MAX_PAYLOAD = 80 * 1024 * 1024;
|
|
3063
|
+
|
|
3054
3064
|
// WebSocket: Bloby chat + proxy worker WS
|
|
3055
|
-
const blobyWss = new WebSocketServer({ noServer: true });
|
|
3065
|
+
const blobyWss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD });
|
|
3056
3066
|
|
|
3057
3067
|
// WebSocket: App API proxy (routes /app/api calls through WS to avoid tunnel POST issues)
|
|
3058
|
-
const appWss = new WebSocketServer({ noServer: true });
|
|
3068
|
+
const appWss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD });
|
|
3059
3069
|
|
|
3060
3070
|
appWss.on('connection', (ws) => {
|
|
3061
3071
|
// An 'error' event with no listener is rethrown by Node as an uncaught exception,
|
|
@@ -3536,8 +3546,14 @@ ${alreadyLinked ? '' : `
|
|
|
3536
3546
|
// New protocol: { type: 'user:message', data: { content, conversationId? } }
|
|
3537
3547
|
if (msg.type === 'user:message') {
|
|
3538
3548
|
const data = msg.data || {};
|
|
3539
|
-
const
|
|
3540
|
-
|
|
3549
|
+
const hasAtts = Array.isArray(data.attachments) && data.attachments.length > 0;
|
|
3550
|
+
const hasAudio = typeof data.audioData === 'string' && data.audioData.length > 0;
|
|
3551
|
+
// Allow attachment-only / voice-only turns (a pasted image with no caption, or a
|
|
3552
|
+
// voice note whose transcription came back empty) — previously these were dropped,
|
|
3553
|
+
// leaving a ghost optimistic bubble. Fall back to a placeholder so title/prompt work.
|
|
3554
|
+
let content: string = typeof data.content === 'string' ? data.content : '';
|
|
3555
|
+
if (!content.trim() && !hasAtts && !hasAudio) return;
|
|
3556
|
+
if (!content.trim()) content = hasAtts ? '(attached files)' : '(voice message)';
|
|
3541
3557
|
// Note: we intentionally ignore data.conversationId from the client.
|
|
3542
3558
|
// The server is the authority on which DB conversation this WS belongs to —
|
|
3543
3559
|
// honoring a client-supplied id let stale browser state drive messages into
|
|
@@ -3560,17 +3576,38 @@ ${alreadyLinked ? '' : `
|
|
|
3560
3576
|
if (freshConfig.ai.provider === 'anthropic' || freshConfig.ai.provider === 'openai' || freshConfig.ai.provider === 'pi') {
|
|
3561
3577
|
// Server-side persistence: create or reuse DB conversation, save user message
|
|
3562
3578
|
(async () => {
|
|
3563
|
-
// Save attachments to disk (before try so it's accessible in startBlobyAgentQuery below)
|
|
3579
|
+
// Save attachments to disk (before try so it's accessible in startBlobyAgentQuery below).
|
|
3580
|
+
// saveAttachment enforces a per-file cap + content sniff; we additionally bound count + total.
|
|
3564
3581
|
let savedFiles: SavedFile[] = [];
|
|
3565
|
-
|
|
3566
|
-
|
|
3582
|
+
// The bounded subset of raw attachments that actually saved within the caps —
|
|
3583
|
+
// this (not the full client array) is what the harness inlines, so the model
|
|
3584
|
+
// sees exactly what gets persisted + shown in chat (no over-cap divergence).
|
|
3585
|
+
const acceptedAttachments: any[] = [];
|
|
3586
|
+
if (hasAtts) {
|
|
3587
|
+
let totalBytes = 0;
|
|
3588
|
+
for (const att of data.attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE)) {
|
|
3589
|
+
totalBytes += approxBase64Bytes(att?.data || '');
|
|
3590
|
+
if (totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) {
|
|
3591
|
+
log.warn(`[bloby] attachment total exceeds cap — dropping remaining`);
|
|
3592
|
+
break;
|
|
3593
|
+
}
|
|
3567
3594
|
try {
|
|
3568
3595
|
savedFiles.push(saveAttachment(att));
|
|
3596
|
+
acceptedAttachments.push(att);
|
|
3569
3597
|
} catch (err: any) {
|
|
3570
3598
|
log.warn(`[bloby] File save error: ${err.message}`);
|
|
3571
3599
|
}
|
|
3572
3600
|
}
|
|
3573
3601
|
}
|
|
3602
|
+
// Persist the voice clip (if any) so the chat can replay it after a refresh.
|
|
3603
|
+
let savedAudioPath: string | undefined;
|
|
3604
|
+
if (hasAudio) {
|
|
3605
|
+
try {
|
|
3606
|
+
savedAudioPath = saveAudio(data.audioData).relPath;
|
|
3607
|
+
} catch (err: any) {
|
|
3608
|
+
log.warn(`[bloby] Audio save error: ${err.message}`);
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3574
3611
|
|
|
3575
3612
|
try {
|
|
3576
3613
|
// Check if we have an existing conversation for this client
|
|
@@ -3594,18 +3631,19 @@ ${alreadyLinked ? '' : `
|
|
|
3594
3631
|
}
|
|
3595
3632
|
convId = dbConvId!;
|
|
3596
3633
|
|
|
3597
|
-
// Save user message to DB (include attachment metadata as JSON string)
|
|
3634
|
+
// Save user message to DB (include attachment + audio metadata as JSON string)
|
|
3598
3635
|
const meta: any = { model: freshConfig.ai.model };
|
|
3599
3636
|
if (savedFiles.length) {
|
|
3600
3637
|
meta.attachments = JSON.stringify(savedFiles.map((f) => ({
|
|
3601
3638
|
type: f.type, name: f.name, mediaType: f.mediaType, filePath: f.relPath,
|
|
3602
3639
|
})));
|
|
3603
3640
|
}
|
|
3641
|
+
if (savedAudioPath) meta.audio_data = savedAudioPath;
|
|
3604
3642
|
await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
3605
3643
|
role: 'user', content, meta,
|
|
3606
3644
|
});
|
|
3607
3645
|
|
|
3608
|
-
// Broadcast user message to other clients (include saved attachment metadata)
|
|
3646
|
+
// Broadcast user message to other clients (include saved attachment + audio metadata)
|
|
3609
3647
|
broadcastBlobyExcept(ws, 'chat:sync', {
|
|
3610
3648
|
conversationId: convId,
|
|
3611
3649
|
message: {
|
|
@@ -3615,6 +3653,9 @@ ${alreadyLinked ? '' : `
|
|
|
3615
3653
|
attachments: savedFiles.length
|
|
3616
3654
|
? savedFiles.map((f) => ({ type: f.type, name: f.name, mediaType: f.mediaType, filePath: f.relPath }))
|
|
3617
3655
|
: undefined,
|
|
3656
|
+
// snake_case to match the client reader (useChat chat:sync handler),
|
|
3657
|
+
// the persisted meta.audio_data, and the DB-reload loader — one canonical name.
|
|
3658
|
+
audio_data: savedAudioPath,
|
|
3618
3659
|
},
|
|
3619
3660
|
});
|
|
3620
3661
|
} catch (err: any) {
|
|
@@ -3686,7 +3727,7 @@ ${alreadyLinked ? '' : `
|
|
|
3686
3727
|
convId,
|
|
3687
3728
|
{ surface: 'chat', waSendTo: waMirrorTo, isSelfChat: true },
|
|
3688
3729
|
alreadyTagged ? content : `[PWA]\n${content}`,
|
|
3689
|
-
|
|
3730
|
+
acceptedAttachments.length ? acceptedAttachments : undefined,
|
|
3690
3731
|
savedFiles,
|
|
3691
3732
|
);
|
|
3692
3733
|
})();
|
|
@@ -4311,6 +4352,23 @@ ${alreadyLinked ? '' : `
|
|
|
4311
4352
|
log.warn('Local server readiness probe timed out');
|
|
4312
4353
|
}
|
|
4313
4354
|
|
|
4355
|
+
// Register the (stable) named-tunnel URL with the relay and start heartbeats —
|
|
4356
|
+
// mirrors the quick branch. Without this, a bot that holds a relay handle but runs
|
|
4357
|
+
// a named tunnel is never marked online: its <user>.bloby.bot handle goes stale and
|
|
4358
|
+
// 503s on both page loads and WS, even though the named domain itself works.
|
|
4359
|
+
if (config.relay?.token) {
|
|
4360
|
+
try {
|
|
4361
|
+
await updateTunnelUrl(config.relay.token, tunnelUrl);
|
|
4362
|
+
startHeartbeat(config.relay.token, tunnelUrl);
|
|
4363
|
+
if (config.relay.url) {
|
|
4364
|
+
log.ok(`Relay: ${config.relay.url}`);
|
|
4365
|
+
console.log(`__RELAY_URL__=${config.relay.url}`);
|
|
4366
|
+
}
|
|
4367
|
+
} catch (err) {
|
|
4368
|
+
log.warn(`Relay: ${err instanceof Error ? err.message : err}`);
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4314
4372
|
console.log('__READY__');
|
|
4315
4373
|
} catch (err) {
|
|
4316
4374
|
log.warn(`Named tunnel: ${err instanceof Error ? err.message : err}`);
|
|
@@ -4331,14 +4389,31 @@ ${alreadyLinked ? '' : `
|
|
|
4331
4389
|
lastTick = now; // Update immediately so concurrent ticks don't see a stale gap
|
|
4332
4390
|
|
|
4333
4391
|
if (wakeGap || periodicCheck) {
|
|
4334
|
-
|
|
4392
|
+
// A wake-gap (a tick delayed >60s) means the host was suspended — laptop/Mac slept.
|
|
4393
|
+
// For a QUICK tunnel, cloudflared almost always survives suspension as a live process
|
|
4394
|
+
// while its *.trycloudflare.com edge hostname has already been reclaimed. isTunnelAlive()
|
|
4395
|
+
// only checks process liveness + localhost health (never the public URL), so it returns
|
|
4396
|
+
// a false positive and the bot would stay unreachable with no self-heal. So on a wake-gap
|
|
4397
|
+
// we force a fresh quick-tunnel rotation instead of trusting the local probe. Named
|
|
4398
|
+
// tunnels keep a stable URL and auto-reconnect, so they take the normal liveness path;
|
|
4399
|
+
// the routine periodicCheck also stays on isTunnelAlive (don't churn the URL every 5min).
|
|
4400
|
+
const forceQuickRotation = wakeGap && config.tunnel.mode === 'quick';
|
|
4401
|
+
const alive = forceQuickRotation ? false : await isTunnelAlive(tunnelUrl!, config.port);
|
|
4335
4402
|
if (!alive) {
|
|
4336
|
-
log.warn(
|
|
4403
|
+
log.warn(forceQuickRotation
|
|
4404
|
+
? 'Wake detected — rotating quick tunnel (edge hostname likely stale)...'
|
|
4405
|
+
: 'Tunnel dead, restarting...');
|
|
4337
4406
|
try {
|
|
4338
4407
|
if (config.tunnel.mode === 'named') {
|
|
4339
4408
|
// Named tunnel: restart process, URL doesn't change
|
|
4340
4409
|
await restartNamedTunnel(config.tunnel.configPath!, config.tunnel.name!);
|
|
4341
4410
|
log.ok(`Named tunnel restored: ${tunnelUrl}`);
|
|
4411
|
+
// Re-assert online with the relay after the restart (URL is stable; the
|
|
4412
|
+
// heartbeat keeps running, but this immediately clears any stale offline flag).
|
|
4413
|
+
const latestCfg = loadConfig();
|
|
4414
|
+
if (latestCfg.relay?.token) {
|
|
4415
|
+
await updateTunnelUrl(latestCfg.relay.token, tunnelUrl!);
|
|
4416
|
+
}
|
|
4342
4417
|
} else {
|
|
4343
4418
|
// Quick tunnel: restart and get new URL
|
|
4344
4419
|
const newUrl = await restartTunnel(config.port);
|
package/supervisor/widget.js
CHANGED
|
@@ -173,9 +173,20 @@
|
|
|
173
173
|
function easeInOutCubic(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2; }
|
|
174
174
|
|
|
175
175
|
// ── Splash guard ──
|
|
176
|
+
// The travel splash plays exactly ONCE per device, ever. A device counts as "already saw it"
|
|
177
|
+
// when EITHER the persisted flag is set OR a portal token is saved — the latter means the user
|
|
178
|
+
// has logged in here before (i.e. "the password is saved"), so this is a returning device. We
|
|
179
|
+
// use localStorage (not sessionStorage) so the flag survives PWA relaunches, and reading the
|
|
180
|
+
// token (same shared key as chat/src/lib/auth.ts, same origin) also stops existing returning
|
|
181
|
+
// devices from replaying the splash once this change ships.
|
|
182
|
+
// Caveat: storage eviction (iOS/Safari ITP 7-day cap on non-installed PWAs, "clear website
|
|
183
|
+
// data", low-disk, private mode) can purge both keys together — a rare, legitimate-looking
|
|
184
|
+
// "fresh device" replay, not a regression. Installed/standalone PWAs are largely exempt.
|
|
176
185
|
var SPLASH_KEY = 'bloby_splash_played';
|
|
186
|
+
var TOKEN_KEY = 'bloby_token';
|
|
177
187
|
var splashSeen = false;
|
|
178
|
-
try { splashSeen =
|
|
188
|
+
try { splashSeen = localStorage.getItem(SPLASH_KEY) === '1' || !!localStorage.getItem(TOKEN_KEY); } catch(e) {}
|
|
189
|
+
function markSplashPlayed() { try { localStorage.setItem(SPLASH_KEY, '1'); } catch(e) {} }
|
|
179
190
|
|
|
180
191
|
// ── Canvas setup ──
|
|
181
192
|
var canvas = document.createElement('canvas');
|
|
@@ -535,7 +546,7 @@
|
|
|
535
546
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
536
547
|
hpState = 'idle';
|
|
537
548
|
hpFrame = HP_IDLE_FRAME;
|
|
538
|
-
|
|
549
|
+
markSplashPlayed();
|
|
539
550
|
pmark('widget:bubble');
|
|
540
551
|
loadHpIdle(); // full sheet defers to the first long-press (beginHpAnimation loads it)
|
|
541
552
|
if (hideAfterTransition) canvas.style.display = 'none';
|
|
@@ -695,6 +706,7 @@
|
|
|
695
706
|
if (!appReady || animState !== 'idle' || canvasPhase !== 'splash') return;
|
|
696
707
|
var elapsed = Date.now() - canvasCreatedAt;
|
|
697
708
|
if (elapsed < MIN_SPLASH_MS) { setTimeout(maybeTransition, MIN_SPLASH_MS - elapsed); return; }
|
|
709
|
+
markSplashPlayed(); // the travel is about to start — mark now so a mid-travel quit never replays it
|
|
698
710
|
canvasPhase = 'transitioning';
|
|
699
711
|
var tx = W - BUBBLE_MARGIN - BUBBLE_SIZE / 2;
|
|
700
712
|
var ty = H - BUBBLE_MARGIN - BUBBLE_SIZE / 2;
|
|
@@ -723,8 +735,8 @@
|
|
|
723
735
|
var onboardActive = false;
|
|
724
736
|
|
|
725
737
|
// ── Boot sequence ─────────────────────────────────────────────────
|
|
726
|
-
// Warm
|
|
727
|
-
//
|
|
738
|
+
// Warm boot (splash already played on this device, or a portal token is saved — known
|
|
739
|
+
// synchronously from localStorage): jump straight to bubble mode with the tiny idle frame. No settings RTT,
|
|
728
740
|
// no 500 KB splash sheet — the old code downloaded the full sheet behind TWO serial fetches
|
|
729
741
|
// (settings → sprite config → sheet) just to skip past it.
|
|
730
742
|
var loopStarted = false;
|
|
@@ -737,6 +749,7 @@
|
|
|
737
749
|
if (splashSeen) {
|
|
738
750
|
skipSplash = true;
|
|
739
751
|
skipToBubble();
|
|
752
|
+
markSplashPlayed(); // persist "seen" even when it was inferred only from the saved token, so a later logout/expiry can't resurface the splash
|
|
740
753
|
startLoop();
|
|
741
754
|
}
|
|
742
755
|
|
|
@@ -747,6 +760,7 @@
|
|
|
747
760
|
if (splashSeen || !settingsDone || spriteOutcome === 'pending') return;
|
|
748
761
|
if (spriteOutcome === 'fail') { canvas.style.display = 'none'; canvasPhase = 'disabled'; return; }
|
|
749
762
|
if (skipSplash) { skipToBubble(); if (onboardActive) canvas.style.display = 'none'; }
|
|
763
|
+
else markSplashPlayed(); // committed to the travel splash (idle frame is already on screen) — mark before app-ready so a quit during the hold can't replay it. Sprite-fail returned above (no mark), and onboarding takes the skip branch (deferred to the post-onboarding launch).
|
|
750
764
|
startLoop();
|
|
751
765
|
maybeTransition();
|
|
752
766
|
}
|
|
@@ -785,7 +799,7 @@
|
|
|
785
799
|
function toggle() {
|
|
786
800
|
if (canvasPhase === 'splash' || canvasPhase === 'transitioning') {
|
|
787
801
|
skipToBubble();
|
|
788
|
-
|
|
802
|
+
markSplashPlayed();
|
|
789
803
|
}
|
|
790
804
|
if (canvasPhase !== 'bubble') return;
|
|
791
805
|
// If headphones animation is running, reset to idle
|
package/worker/db.ts
CHANGED
|
@@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS messages (
|
|
|
19
19
|
tokens_in INTEGER,
|
|
20
20
|
tokens_out INTEGER,
|
|
21
21
|
model TEXT,
|
|
22
|
+
audio_data TEXT,
|
|
23
|
+
attachments TEXT,
|
|
22
24
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
23
25
|
);
|
|
24
26
|
CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conversation_id, created_at);
|
package/worker/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAc
|
|
|
17
17
|
import { checkAvailability, registerHandle, claimReservedHandle, releaseHandle, updateTunnelUrl, startHeartbeat, stopHeartbeat } from '../shared/relay.js';
|
|
18
18
|
import { ensureFileDirs } from '../supervisor/file-saver.js';
|
|
19
19
|
import { readPiAuth, writePiAuth, clearPiAuth, getPiAuthStatus } from '../supervisor/harnesses/pi/auth-storage.js';
|
|
20
|
-
import { runPiTestCompletion } from '../supervisor/harnesses/pi/test-completion.js';
|
|
20
|
+
import { runPiTestCompletion, runPiStreamProbe } from '../supervisor/harnesses/pi/test-completion.js';
|
|
21
21
|
import { PI_SUB_PROVIDERS, getPiSubProvider } from '../supervisor/harnesses/pi/sub-providers.js';
|
|
22
22
|
|
|
23
23
|
// ── Password hashing (scrypt) ──
|
|
@@ -284,6 +284,23 @@ app.post('/api/auth/pi/test', async (req, res) => {
|
|
|
284
284
|
}
|
|
285
285
|
const prompt = 'Reply with the single word OK so we can confirm this LLM endpoint is reachable.';
|
|
286
286
|
const result = await runPiTestCompletion({ subProvider, apiKey, baseUrl, modelId, prompt });
|
|
287
|
+
if (!result.ok) {
|
|
288
|
+
res.json(result);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// Second tier (audit C-4): real turns stream SSE with the full tool schema —
|
|
292
|
+
// free-form model ids (Ollama/LM Studio/custom/OpenRouter) can pass the basic
|
|
293
|
+
// call and then fail the first actual message. Probe the real wire shape so
|
|
294
|
+
// the wizard's green check means chat will actually work.
|
|
295
|
+
const probe = await runPiStreamProbe({ subProvider, apiKey, baseUrl, modelId, prompt });
|
|
296
|
+
if (!probe.ok) {
|
|
297
|
+
res.json({
|
|
298
|
+
...probe,
|
|
299
|
+
ok: false,
|
|
300
|
+
error: `The endpoint responds, but streaming with tools failed (live chat would break): ${probe.error}`,
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
287
304
|
res.json(result);
|
|
288
305
|
});
|
|
289
306
|
|
|
@@ -280,7 +280,7 @@ If your human asks you to update a skill's behavior, edit the files INSIDE `skil
|
|
|
280
280
|
You can communicate through several surfaces at once. The two built-in ones are:
|
|
281
281
|
|
|
282
282
|
- **`[PWA]`** — the chat bubble in the dashboard (web app / PWA). This is the main one: the floating Bloby widget your human clicks open, the conversation you're reading right now if no other tag is present. Treat it as your home base.
|
|
283
|
-
- **`[Mac]`** — the Morphy native Mac app living in the MacBook notch. Your human held a hotkey, spoke, and Morphy sent the transcript here. Screenshots of their screen may be attached. Keep replies concise — they'll be spoken aloud via TTS. No markdown, no bullet lists — plain spoken sentences only.
|
|
283
|
+
- **`[Mac]`** — the Morphy native Mac app living in the MacBook notch. Your human held a hotkey, spoke, and Morphy sent the transcript here. Screenshots of their screen may be attached. Keep replies concise — they'll be spoken aloud via TTS. No markdown, no bullet lists — plain spoken sentences only. **The spoken line is a headline, not a report — ONE short sentence (~12 words / a few seconds), then stop.** Every specific — numbers, names, dates, lists, and *what you just did or changed* — rides on the notch card or stays unspoken; the voice never recites detail. **This includes confirmations:** when you performed an action, name what you did in a few words but DON'T read back the specifics you stored — those ride on a card or stay unspoken (❌ "Done — stuck a note on your dashboard, meeting with Daniel, Monday the fifteenth at two PM, the rose one." → ✓ "Done, Bruno. Sticky note's on your dashboard."). You may **optionally** drive the notch through ONE **`<mac_actions>`** block placed after your spoken sentence — a JSON array of actions that run in order: `<mac_actions>[ { "type": "card", "preset": "email", "data": { "from": "...", "subject": "...", "time": "...", "body": "..." } } ]</mac_actions>`. Action `type`s: **`card`** — a preset card in the notch, `{ "type": "card", "preset": "<name>", "data": { … } }` (preset names: `email`, `list`, `calendar`, `weather`, `ticker`, `stat`, `info`, `text`, `comparison`; full schemas in `skills/mac/presets/PRESETS.md`; you send structured data, never CSS); **`point`** — flies the mascot to a screenshot pixel, `{ "type": "point", "x", "y", "label"?, "screen"? }`; **`spotlight`** — dims the display and cuts a glowing hole over a spot, `{ "type": "spotlight", "x", "y", "r"?, "label"?, "screen"? }`. Coordinates are pixels read off **this turn's screenshot** (top-left origin); `screen` is 1-based for multi-display. For a **custom card** no preset fits, hand-write real markup in a separate **`<notch_html>…</notch_html>`** tag (lowercase, exactly that spelling — NOT `<NotchHTML>`, NOT `<notch>`); for long prose / a "read me this" / a summary use the `text` preset instead — never dump plain paragraphs into `<notch_html>`, it renders unstyled and edge-to-edge. Send **one** `<mac_actions>` block (it may carry several actions) and **at most one** card (a `card` action OR a `<notch_html>`, never both). Every block is stripped from TTS, so card/action contents are **never** spoken. Canvas is **fixed 383 × 147 pt, transparent over BLACK**; custom cards use light/white text, no external assets (no `<img src>`, no fonts, no JS network) and no interactive elements (clicks do nothing) — though long content IS scrollable, the user scrolls it with the trackpad, so letting content overflow is fine. **CRITICAL: never speak the contents of your card.** If the card carries a list / calendar / email / news / comparison / any structured answer, the spoken text MUST be a short lead-in ONLY ("Here are today's top five, Bruno.") and then stop — do not recite the items, the human is already looking at them. If the answer fits in one spoken sentence, send NO card. Voice and card are complementary, never redundant — pick which one carries the detail per reply, and let the other be brief or absent. When in doubt, **open `skills/mac/SKILL.md` and follow it** — it has the preset catalog, examples (including the canonical bad/good comparison of the speak-the-card duplication failure), custom templates under `skills/mac/frequentSnippets/`, and a pre-send checklist. **Mis-spelling a tag means the markup reaches TTS and gets spoken at the human** — that's a visible failure, not a silent one.
|
|
284
284
|
- **`[workspace]`** — a chat-shaped widget your human placed somewhere inside their dashboard *workspace*. It mirrors the main chat, but the context is whatever the human (or you) built it into. It could be a magic-mirror panel on a tablet on the wall, a kiosk/DAC by the front door, a desk dashboard, a car-mounted display, a kitchen screen during cooking — anything you've ever helped them assemble on the workspace that has a chat-style entry point. **Check `MEMORY.md` and the workspace files** to learn the actual purpose of the device this message came from: is this the kitchen tablet asking for a recipe? The hallway mirror asking what's on today's schedule? The garage panel asking about the car? Tailor tone, brevity, and content to that role. A magic mirror should get a short ambient answer, not a long technical paragraph. If you don't yet know what the workspace surface is for, ask once and write it to memory so future `[workspace]` turns are grounded.
|
|
285
285
|
|
|
286
286
|
Beyond those, your human can install additional channels (WhatsApp, Telegram, Discord, Alexa…) as **skills** from the Bloby Marketplace. Each channel skill teaches you the conventions for that surface.
|
|
@@ -218,7 +218,7 @@ You handle two kinds of work differently:
|
|
|
218
218
|
|
|
219
219
|
**Quick tasks — do them yourself directly (use your tools):**
|
|
220
220
|
- Memory file writes (MYSELF.md, MYHUMAN.md, MEMORY.md, daily notes)
|
|
221
|
-
- Config edits (PULSE.json, CRONS.json
|
|
221
|
+
- Config edits (PULSE.json, CRONS.json)
|
|
222
222
|
- Channel configuration (curl commands)
|
|
223
223
|
- Simple file reads or status checks
|
|
224
224
|
- Conversational responses, chitchat, questions
|
|
@@ -280,7 +280,7 @@ If your human asks you to update a skill's behavior, edit the files INSIDE `skil
|
|
|
280
280
|
You can communicate through several surfaces at once. The two built-in ones are:
|
|
281
281
|
|
|
282
282
|
- **`[PWA]`** — the chat bubble in the dashboard (web app / PWA). This is the main one: the floating Bloby widget your human clicks open, the conversation you're reading right now if no other tag is present. Treat it as your home base.
|
|
283
|
-
- **`[Mac]`** — the Morphy native Mac app living in the MacBook notch. Your human held a hotkey, spoke, and Morphy sent the transcript here. Screenshots of their screen may be attached. Keep replies concise — they'll be spoken aloud via TTS. No markdown, no bullet lists — plain spoken sentences only.
|
|
283
|
+
- **`[Mac]`** — the Morphy native Mac app living in the MacBook notch. Your human held a hotkey, spoke, and Morphy sent the transcript here. Screenshots of their screen may be attached. Keep replies concise — they'll be spoken aloud via TTS. No markdown, no bullet lists — plain spoken sentences only. **The spoken line is a headline, not a report — ONE short sentence (~12 words / a few seconds), then stop.** Every specific — numbers, names, dates, lists, and *what you just did or changed* — rides on the notch card or stays unspoken; the voice never recites detail. **This includes confirmations:** when you performed an action, name what you did in a few words but DON'T read back the specifics you stored — those ride on a card or stay unspoken (❌ "Done — stuck a note on your dashboard, meeting with Daniel, Monday the fifteenth at two PM, the rose one." → ✓ "Done, Bruno. Sticky note's on your dashboard."). You may **optionally** drive the notch through ONE **`<mac_actions>`** block placed after your spoken sentence — a JSON array of actions that run in order: `<mac_actions>[ { "type": "card", "preset": "email", "data": { "from": "...", "subject": "...", "time": "...", "body": "..." } } ]</mac_actions>`. Action `type`s: **`card`** — a preset card in the notch, `{ "type": "card", "preset": "<name>", "data": { … } }` (preset names: `email`, `list`, `calendar`, `weather`, `ticker`, `stat`, `info`, `text`, `comparison`; full schemas in `skills/mac/presets/PRESETS.md`; you send structured data, never CSS); **`point`** — flies the mascot to a screenshot pixel, `{ "type": "point", "x", "y", "label"?, "screen"? }`; **`spotlight`** — dims the display and cuts a glowing hole over a spot, `{ "type": "spotlight", "x", "y", "r"?, "label"?, "screen"? }`. Coordinates are pixels read off **this turn's screenshot** (top-left origin); `screen` is 1-based for multi-display. For a **custom card** no preset fits, hand-write real markup in a separate **`<notch_html>…</notch_html>`** tag (lowercase, exactly that spelling — NOT `<NotchHTML>`, NOT `<notch>`); for long prose / a "read me this" / a summary use the `text` preset instead — never dump plain paragraphs into `<notch_html>`, it renders unstyled and edge-to-edge. Send **one** `<mac_actions>` block (it may carry several actions) and **at most one** card (a `card` action OR a `<notch_html>`, never both). Every block is stripped from TTS, so card/action contents are **never** spoken. Canvas is **fixed 383 × 147 pt, transparent over BLACK**; custom cards use light/white text, no external assets (no `<img src>`, no fonts, no JS network) and no interactive elements (clicks do nothing) — though long content IS scrollable, the user scrolls it with the trackpad, so letting content overflow is fine. **CRITICAL: never speak the contents of your card.** If the card carries a list / calendar / email / news / comparison / any structured answer, the spoken text MUST be a short lead-in ONLY ("Here are today's top five, Bruno.") and then stop — do not recite the items, the human is already looking at them. If the answer fits in one spoken sentence, send NO card. Voice and card are complementary, never redundant — pick which one carries the detail per reply, and let the other be brief or absent. When in doubt, **open `skills/mac/SKILL.md` and follow it** — it has the preset catalog, examples (including the canonical bad/good comparison of the speak-the-card duplication failure), custom templates under `skills/mac/frequentSnippets/`, and a pre-send checklist. **Mis-spelling a tag means the markup reaches TTS and gets spoken at the human** — that's a visible failure, not a silent one.
|
|
284
284
|
- **`[workspace]`** — a chat-shaped widget your human placed somewhere inside their dashboard *workspace*. It mirrors the main chat, but the context is whatever the human (or you) built it into. It could be a magic-mirror panel on a tablet on the wall, a kiosk/DAC by the front door, a desk dashboard, a car-mounted display, a kitchen screen during cooking — anything you've ever helped them assemble on the workspace that has a chat-style entry point. **Check `MEMORY.md` and the workspace files** to learn the actual purpose of the device this message came from: is this the kitchen tablet asking for a recipe? The hallway mirror asking what's on today's schedule? The garage panel asking about the car? Tailor tone, brevity, and content to that role. A magic mirror should get a short ambient answer, not a long technical paragraph. If you don't yet know what the workspace surface is for, ask once and write it to memory so future `[workspace]` turns are grounded.
|
|
285
285
|
|
|
286
286
|
Beyond those, your human can install additional channels (WhatsApp, Telegram, Discord, Alexa…) as **skills** from the Bloby Marketplace. Each channel skill teaches you the conventions for that surface.
|
|
@@ -615,27 +615,9 @@ It restarts the backend and BLOCKS until the port is healthy, then returns `{"ok
|
|
|
615
615
|
|
|
616
616
|
## MCP Servers (Model Context Protocol)
|
|
617
617
|
|
|
618
|
-
|
|
618
|
+
MCP servers are NOT yet supported on this provider. The `MCP.json` config file exists for other harnesses, but on this one its entries are inert — no MCP tools will appear, no matter what is configured there.
|
|
619
619
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
```json
|
|
623
|
-
{
|
|
624
|
-
"server-name": {
|
|
625
|
-
"command": "npx",
|
|
626
|
-
"args": ["-y", "@some/mcp-server"],
|
|
627
|
-
"env": {}
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
```
|
|
631
|
-
|
|
632
|
-
The file is a JSON object where each key is a server name and the value has `command`, optional `args`, and optional `env`. Use `-y` in npx args to skip install prompts. The config is read fresh on every turn — add, remove, or edit entries anytime.
|
|
633
|
-
|
|
634
|
-
**Your human can ask you to add MCP servers.** When they do, read `MCP.json` (create it if missing), add the new server entry, and write it back. Common examples:
|
|
635
|
-
- **Playwright** (browser control): `"playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--headless", "--browser", "chromium"] }`
|
|
636
|
-
- **Fetch** (HTTP requests): `"fetch": { "command": "npx", "args": ["-y", "@anthropic-ai/mcp-fetch@latest"] }`
|
|
637
|
-
|
|
638
|
-
When an MCP server is configured, its tools appear alongside your built-in tools. Use them naturally — no special syntax needed.
|
|
620
|
+
If your human asks for a capability that usually comes from an MCP server (browser control, third-party APIs, databases), be honest that MCP isn't available here, then offer the closest alternative with your real tools — for example a CLI via Bash (`npx playwright`, `curl`, a database client) or WebFetch for HTTP APIs. Never claim an MCP tool worked.
|
|
639
621
|
|
|
640
622
|
## Sacred Files — NEVER Modify
|
|
641
623
|
- `supervisor/` — chat UI, proxy, process management
|
|
@@ -684,7 +666,7 @@ Only redesign the workspace layout if your human explicitly asks you to. Otherwi
|
|
|
684
666
|
|
|
685
667
|
**Be genuinely helpful, not performatively helpful.** Skip the filler. Actions speak louder than words. Just help.
|
|
686
668
|
|
|
687
|
-
**Be resourceful.** Before asking your human a question, try to answer it yourself. Read the files. Check the code.
|
|
669
|
+
**Be resourceful.** Before asking your human a question, try to answer it yourself. Read the files. Check the code. Fetch docs and pages with WebFetch. Come back with answers, not questions. Ask when you're genuinely stuck, not when you're being lazy.
|
|
688
670
|
|
|
689
671
|
**Have opinions.** You're allowed to disagree, to have preferences, to think something is a bad idea. An agent with no opinions is just autocomplete. If your human asks "what do you think?" — actually think.
|
|
690
672
|
|
|
@@ -700,7 +682,7 @@ Only redesign the workspace layout if your human explicitly asks you to. Otherwi
|
|
|
700
682
|
|
|
701
683
|
**Safe to do freely (internal):**
|
|
702
684
|
- Read files, explore, organize, learn
|
|
703
|
-
-
|
|
685
|
+
- Fetch documentation and web pages with WebFetch (you have no search engine — derive likely URLs or ask for one)
|
|
704
686
|
- Work within the workspace
|
|
705
687
|
- Write and update your own memory files
|
|
706
688
|
|
|
@@ -280,7 +280,7 @@ If your human asks you to update a skill's behavior, edit the files INSIDE `skil
|
|
|
280
280
|
You can communicate through several surfaces at once. The two built-in ones are:
|
|
281
281
|
|
|
282
282
|
- **`[PWA]`** — the chat bubble in the dashboard (web app / PWA). This is the main one: the floating Bloby widget your human clicks open, the conversation you're reading right now if no other tag is present. Treat it as your home base.
|
|
283
|
-
- **`[Mac]`** — the Morphy native Mac app living in the MacBook notch. Your human held a hotkey, spoke, and Morphy sent the transcript here. Screenshots of their screen may be attached. Keep replies concise — they'll be spoken aloud via TTS. No markdown, no bullet lists — plain spoken sentences only.
|
|
283
|
+
- **`[Mac]`** — the Morphy native Mac app living in the MacBook notch. Your human held a hotkey, spoke, and Morphy sent the transcript here. Screenshots of their screen may be attached. Keep replies concise — they'll be spoken aloud via TTS. No markdown, no bullet lists — plain spoken sentences only. **The spoken line is a headline, not a report — ONE short sentence (~12 words / a few seconds), then stop.** Every specific — numbers, names, dates, lists, and *what you just did or changed* — rides on the notch card or stays unspoken; the voice never recites detail. **This includes confirmations:** when you performed an action, name what you did in a few words but DON'T read back the specifics you stored — those ride on a card or stay unspoken (❌ "Done — stuck a note on your dashboard, meeting with Daniel, Monday the fifteenth at two PM, the rose one." → ✓ "Done, Bruno. Sticky note's on your dashboard."). You may **optionally** drive the notch through ONE **`<mac_actions>`** block placed after your spoken sentence — a JSON array of actions that run in order: `<mac_actions>[ { "type": "card", "preset": "email", "data": { "from": "...", "subject": "...", "time": "...", "body": "..." } } ]</mac_actions>`. Action `type`s: **`card`** — a preset card in the notch, `{ "type": "card", "preset": "<name>", "data": { … } }` (preset names: `email`, `list`, `calendar`, `weather`, `ticker`, `stat`, `info`, `text`, `comparison`; full schemas in `skills/mac/presets/PRESETS.md`; you send structured data, never CSS); **`point`** — flies the mascot to a screenshot pixel, `{ "type": "point", "x", "y", "label"?, "screen"? }`; **`spotlight`** — dims the display and cuts a glowing hole over a spot, `{ "type": "spotlight", "x", "y", "r"?, "label"?, "screen"? }`. Coordinates are pixels read off **this turn's screenshot** (top-left origin); `screen` is 1-based for multi-display. For a **custom card** no preset fits, hand-write real markup in a separate **`<notch_html>…</notch_html>`** tag (lowercase, exactly that spelling — NOT `<NotchHTML>`, NOT `<notch>`); for long prose / a "read me this" / a summary use the `text` preset instead — never dump plain paragraphs into `<notch_html>`, it renders unstyled and edge-to-edge. Send **one** `<mac_actions>` block (it may carry several actions) and **at most one** card (a `card` action OR a `<notch_html>`, never both). Every block is stripped from TTS, so card/action contents are **never** spoken. Canvas is **fixed 383 × 147 pt, transparent over BLACK**; custom cards use light/white text, no external assets (no `<img src>`, no fonts, no JS network) and no interactive elements (clicks do nothing) — though long content IS scrollable, the user scrolls it with the trackpad, so letting content overflow is fine. **CRITICAL: never speak the contents of your card.** If the card carries a list / calendar / email / news / comparison / any structured answer, the spoken text MUST be a short lead-in ONLY ("Here are today's top five, Bruno.") and then stop — do not recite the items, the human is already looking at them. If the answer fits in one spoken sentence, send NO card. Voice and card are complementary, never redundant — pick which one carries the detail per reply, and let the other be brief or absent. When in doubt, **open `skills/mac/SKILL.md` and follow it** — it has the preset catalog, examples (including the canonical bad/good comparison of the speak-the-card duplication failure), custom templates under `skills/mac/frequentSnippets/`, and a pre-send checklist. **Mis-spelling a tag means the markup reaches TTS and gets spoken at the human** — that's a visible failure, not a silent one.
|
|
284
284
|
- **`[workspace]`** — a chat-shaped widget your human placed somewhere inside their dashboard *workspace*. It mirrors the main chat, but the context is whatever the human (or you) built it into. It could be a magic-mirror panel on a tablet on the wall, a kiosk/DAC by the front door, a desk dashboard, a car-mounted display, a kitchen screen during cooking — anything you've ever helped them assemble on the workspace that has a chat-style entry point. **Check `MEMORY.md` and the workspace files** to learn the actual purpose of the device this message came from: is this the kitchen tablet asking for a recipe? The hallway mirror asking what's on today's schedule? The garage panel asking about the car? Tailor tone, brevity, and content to that role. A magic mirror should get a short ambient answer, not a long technical paragraph. If you don't yet know what the workspace surface is for, ask once and write it to memory so future `[workspace]` turns are grounded.
|
|
285
285
|
|
|
286
286
|
Beyond those, your human can install additional channels (WhatsApp, Telegram, Discord, Alexa…) as **skills** from the Bloby Marketplace. Each channel skill teaches you the conventions for that surface.
|
|
@@ -1,23 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { AreaChart, Area, BarChart, Bar, ResponsiveContainer } from 'recharts';
|
|
1
|
+
import { PlaceholderWidgets } from './deleteme_placeholders';
|
|
3
2
|
|
|
4
3
|
const GRADIENT = 'linear-gradient(to right, #0166FF 10%, #009AFE 55%, #4AEEFF 100%)';
|
|
5
|
-
const CARD = 'relative rounded-xl overflow-hidden';
|
|
6
|
-
const BORDER = 'absolute inset-0 rounded-xl bg-gradient-to-b from-white/[0.08] via-white/[0.02] to-transparent pointer-events-none';
|
|
7
|
-
const INNER = 'relative rounded-xl bg-[#141414] m-px p-3.5 h-full';
|
|
8
|
-
|
|
9
|
-
const rev = [{ v: 82 }, { v: 89 }, { v: 94 }, { v: 101 }, { v: 108 }, { v: 112 }, { v: 125 }];
|
|
10
|
-
const fol = [{ v: 12 }, { v: 18 }, { v: 9 }, { v: 24 }, { v: 31 }, { v: 19 }, { v: 27 }];
|
|
11
|
-
|
|
12
|
-
function StripeSvg() {
|
|
13
|
-
return <svg className="h-3.5 w-3.5 text-[#635BFF]" viewBox="0 0 24 24" fill="currentColor"><path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.591-7.305z" /></svg>;
|
|
14
|
-
}
|
|
15
|
-
function GmailSvg() {
|
|
16
|
-
return <svg className="h-3.5 w-3.5 text-[#EA4335]" viewBox="0 0 24 24" fill="currentColor"><path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z" /></svg>;
|
|
17
|
-
}
|
|
18
|
-
function XSvg() {
|
|
19
|
-
return <svg className="h-3.5 w-3.5 text-white" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /></svg>;
|
|
20
|
-
}
|
|
21
4
|
|
|
22
5
|
export default function DashboardPage() {
|
|
23
6
|
return (
|
|
@@ -30,105 +13,9 @@ export default function DashboardPage() {
|
|
|
30
13
|
</h1>
|
|
31
14
|
<p className="text-muted-foreground text-xs mb-6">Your workspace at a glance.</p>
|
|
32
15
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<div className={`${CARD} col-span-2`}>
|
|
37
|
-
<div className={BORDER} />
|
|
38
|
-
<div className={INNER}>
|
|
39
|
-
<div className="flex items-center justify-between">
|
|
40
|
-
<div className="flex items-center gap-2">
|
|
41
|
-
<div className="h-7 w-7 rounded-lg bg-[#635BFF]/10 flex items-center justify-center"><StripeSvg /></div>
|
|
42
|
-
<span className="text-xs font-bold">Stripe</span>
|
|
43
|
-
</div>
|
|
44
|
-
<div className="flex items-center gap-1 text-emerald-500">
|
|
45
|
-
<TrendingUp className="h-3 w-3" />
|
|
46
|
-
<span className="text-[10px] font-bold">+12.5%</span>
|
|
47
|
-
</div>
|
|
48
|
-
</div>
|
|
49
|
-
<p className="text-2xl font-bold tracking-tight mt-2">$12,480</p>
|
|
50
|
-
<p className="text-[10px] text-muted-foreground/50 mb-1">MRR</p>
|
|
51
|
-
<div className="h-12 overflow-hidden">
|
|
52
|
-
<ResponsiveContainer width="100%" height={48}>
|
|
53
|
-
<AreaChart data={rev}>
|
|
54
|
-
<defs>
|
|
55
|
-
<linearGradient id="sg" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stopColor="#0166FF" /><stop offset="50%" stopColor="#009AFE" /><stop offset="100%" stopColor="#4AEEFF" /></linearGradient>
|
|
56
|
-
<linearGradient id="sf" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="#009AFE" stopOpacity={0.12} /><stop offset="100%" stopColor="#009AFE" stopOpacity={0} /></linearGradient>
|
|
57
|
-
</defs>
|
|
58
|
-
<Area type="monotone" dataKey="v" stroke="url(#sg)" strokeWidth={1.5} fill="url(#sf)" />
|
|
59
|
-
</AreaChart>
|
|
60
|
-
</ResponsiveContainer>
|
|
61
|
-
</div>
|
|
62
|
-
</div>
|
|
63
|
-
</div>
|
|
64
|
-
|
|
65
|
-
{/* X — 1 col */}
|
|
66
|
-
<div className={`${CARD} col-span-1`}>
|
|
67
|
-
<div className={BORDER} />
|
|
68
|
-
<div className={INNER}>
|
|
69
|
-
<div className="flex items-center gap-2 mb-2">
|
|
70
|
-
<div className="h-7 w-7 rounded-lg bg-white/[0.06] flex items-center justify-center"><XSvg /></div>
|
|
71
|
-
<span className="text-xs font-bold">X</span>
|
|
72
|
-
</div>
|
|
73
|
-
<p className="text-2xl font-bold tracking-tight">24.8K</p>
|
|
74
|
-
<div className="flex items-center gap-1 text-emerald-500 mb-1">
|
|
75
|
-
<TrendingUp className="h-2.5 w-2.5" />
|
|
76
|
-
<span className="text-[10px] font-bold">+1.4K</span>
|
|
77
|
-
</div>
|
|
78
|
-
<div className="h-10 overflow-hidden">
|
|
79
|
-
<ResponsiveContainer width="100%" height={40}>
|
|
80
|
-
<BarChart data={fol} barCategoryGap="25%">
|
|
81
|
-
<defs>
|
|
82
|
-
<linearGradient id="xg" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="#009AFE" stopOpacity={0.3} /><stop offset="100%" stopColor="#0166FF" stopOpacity={0.05} /></linearGradient>
|
|
83
|
-
</defs>
|
|
84
|
-
<Bar dataKey="v" fill="url(#xg)" radius={[2, 2, 0, 0]} />
|
|
85
|
-
</BarChart>
|
|
86
|
-
</ResponsiveContainer>
|
|
87
|
-
</div>
|
|
88
|
-
</div>
|
|
89
|
-
</div>
|
|
90
|
-
|
|
91
|
-
{/* Gmail — 1 col */}
|
|
92
|
-
<div className={`${CARD} col-span-1`}>
|
|
93
|
-
<div className={BORDER} />
|
|
94
|
-
<div className={INNER}>
|
|
95
|
-
<div className="flex items-center gap-2 mb-2.5">
|
|
96
|
-
<div className="h-7 w-7 rounded-lg bg-[#EA4335]/10 flex items-center justify-center"><GmailSvg /></div>
|
|
97
|
-
<span className="text-xs font-bold">Gmail</span>
|
|
98
|
-
<span className="ml-auto text-[10px] text-muted-foreground/50">3 new</span>
|
|
99
|
-
</div>
|
|
100
|
-
{['Sarah Chen', 'Stripe', 'Alex R.'].map((n) => (
|
|
101
|
-
<div key={n} className="flex items-center gap-2 py-1.5">
|
|
102
|
-
<div className="h-5 w-5 rounded-full bg-white/[0.06] text-[9px] font-bold flex items-center justify-center shrink-0">{n[0]}</div>
|
|
103
|
-
<span className="text-[11px] truncate">{n}</span>
|
|
104
|
-
</div>
|
|
105
|
-
))}
|
|
106
|
-
</div>
|
|
107
|
-
</div>
|
|
108
|
-
|
|
109
|
-
{/* Research — 2 cols */}
|
|
110
|
-
<div className={`${CARD} col-span-2`}>
|
|
111
|
-
<div className={BORDER} />
|
|
112
|
-
<div className={INNER}>
|
|
113
|
-
<div className="flex items-center gap-2 mb-2.5">
|
|
114
|
-
<div className="h-7 w-7 rounded-lg bg-[#9235F9]/10 flex items-center justify-center"><Search className="h-3.5 w-3.5 text-[#9235F9]" /></div>
|
|
115
|
-
<span className="text-xs font-bold">Research</span>
|
|
116
|
-
<span className="ml-auto text-[10px] font-bold text-muted-foreground bg-white/[0.06] px-2 py-0.5 rounded-full">3</span>
|
|
117
|
-
</div>
|
|
118
|
-
{[
|
|
119
|
-
{ t: 'Competitor pricing', s: 'Done', c: 'text-emerald-500 bg-emerald-500/10' },
|
|
120
|
-
{ t: 'Market trends Q1', s: 'Done', c: 'text-emerald-500 bg-emerald-500/10' },
|
|
121
|
-
{ t: 'User feedback', s: 'Review', c: 'text-orange-400 bg-orange-400/10' },
|
|
122
|
-
].map((r) => (
|
|
123
|
-
<div key={r.t} className="flex items-center justify-between py-1.5">
|
|
124
|
-
<span className="text-[11px]">{r.t}</span>
|
|
125
|
-
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded-full ${r.c}`}>{r.s}</span>
|
|
126
|
-
</div>
|
|
127
|
-
))}
|
|
128
|
-
</div>
|
|
129
|
-
</div>
|
|
130
|
-
|
|
131
|
-
</div>
|
|
16
|
+
{/* ▼▼▼ EXAMPLE PLACEHOLDERS — safe to delete. See deleteme_placeholders.tsx. ▼▼▼ */}
|
|
17
|
+
<PlaceholderWidgets />
|
|
18
|
+
{/* ▲▲▲ EXAMPLE PLACEHOLDERS ▲▲▲ */}
|
|
132
19
|
</div>
|
|
133
20
|
);
|
|
134
21
|
}
|