bloby-bot 0.22.5 → 0.22.6
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/package.json +1 -1
- package/supervisor/chat/src/components/Chat/BlobyImageCard.tsx +63 -0
- package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +33 -8
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +48 -30
- package/workspace/skills/chrome-extension/.claude-plugin/plugin.json +0 -6
- package/workspace/skills/chrome-extension/SKILL.md +0 -181
- package/workspace/skills/chrome-extension/background.js +0 -182
- package/workspace/skills/chrome-extension/content-script.js +0 -309
- package/workspace/skills/chrome-extension/content-style.css +0 -86
- package/workspace/skills/chrome-extension/icons/icon-128.png +0 -0
- package/workspace/skills/chrome-extension/icons/icon-16.png +0 -0
- package/workspace/skills/chrome-extension/icons/icon-48.png +0 -0
- package/workspace/skills/chrome-extension/manifest.json +0 -47
- package/workspace/skills/chrome-extension/panel/panel.html +0 -36
- package/workspace/skills/chrome-extension/panel/panel.js +0 -123
- package/workspace/skills/chrome-extension/popup/popup.css +0 -124
- package/workspace/skills/chrome-extension/popup/popup.html +0 -47
- package/workspace/skills/chrome-extension/popup/popup.js +0 -115
- package/workspace/skills/chrome-extension/skill.json +0 -15
package/package.json
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Download, Maximize2 } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
src: string;
|
|
5
|
+
alt?: string;
|
|
6
|
+
onExpand: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getFilename(src: string): string {
|
|
10
|
+
const parts = src.split('/');
|
|
11
|
+
return parts[parts.length - 1] || 'bloby-image';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function BlobyImageCard({ src, alt, onExpand }: Props) {
|
|
15
|
+
const handleDownload = async (e: React.MouseEvent) => {
|
|
16
|
+
e.stopPropagation();
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(src);
|
|
19
|
+
const blob = await res.blob();
|
|
20
|
+
const url = URL.createObjectURL(blob);
|
|
21
|
+
const a = document.createElement('a');
|
|
22
|
+
a.href = url;
|
|
23
|
+
a.download = getFilename(src);
|
|
24
|
+
document.body.appendChild(a);
|
|
25
|
+
a.click();
|
|
26
|
+
document.body.removeChild(a);
|
|
27
|
+
URL.revokeObjectURL(url);
|
|
28
|
+
} catch {
|
|
29
|
+
window.open(src, '_blank');
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="relative group/img my-2 inline-block rounded-xl overflow-hidden border border-border/30 bg-black/20">
|
|
35
|
+
<img
|
|
36
|
+
src={src}
|
|
37
|
+
alt={alt || 'Generated image'}
|
|
38
|
+
className="max-w-full max-h-80 object-contain cursor-pointer"
|
|
39
|
+
onClick={onExpand}
|
|
40
|
+
loading="lazy"
|
|
41
|
+
/>
|
|
42
|
+
<div className="absolute top-2 right-2 flex gap-1.5 opacity-0 group-hover/img:opacity-100 transition-opacity">
|
|
43
|
+
<button
|
|
44
|
+
onClick={handleDownload}
|
|
45
|
+
className="p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white/80 hover:text-white transition-colors backdrop-blur-sm"
|
|
46
|
+
title="Download original"
|
|
47
|
+
>
|
|
48
|
+
<Download className="h-4 w-4" />
|
|
49
|
+
</button>
|
|
50
|
+
<button
|
|
51
|
+
onClick={(e) => { e.stopPropagation(); onExpand(); }}
|
|
52
|
+
className="p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white/80 hover:text-white transition-colors backdrop-blur-sm"
|
|
53
|
+
title="View full size"
|
|
54
|
+
>
|
|
55
|
+
<Maximize2 className="h-4 w-4" />
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
{alt && (
|
|
59
|
+
<div className="px-3 py-1.5 text-xs text-muted-foreground/70 truncate">{alt}</div>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect } from 'react';
|
|
2
2
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
-
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
|
|
3
|
+
import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react';
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
6
|
images: string[];
|
|
@@ -38,13 +38,38 @@ export default function ImageLightbox({ images, index, onClose, onNavigate }: Pr
|
|
|
38
38
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
|
39
39
|
onClick={onClose}
|
|
40
40
|
>
|
|
41
|
-
{/*
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
{/* Action buttons */}
|
|
42
|
+
<div className="absolute top-4 right-4 z-10 flex gap-2">
|
|
43
|
+
<button
|
|
44
|
+
onClick={async (e) => {
|
|
45
|
+
e.stopPropagation();
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(images[index]);
|
|
48
|
+
const blob = await res.blob();
|
|
49
|
+
const url = URL.createObjectURL(blob);
|
|
50
|
+
const a = document.createElement('a');
|
|
51
|
+
a.href = url;
|
|
52
|
+
a.download = images[index].split('/').pop() || 'image';
|
|
53
|
+
document.body.appendChild(a);
|
|
54
|
+
a.click();
|
|
55
|
+
document.body.removeChild(a);
|
|
56
|
+
URL.revokeObjectURL(url);
|
|
57
|
+
} catch {
|
|
58
|
+
window.open(images[index], '_blank');
|
|
59
|
+
}
|
|
60
|
+
}}
|
|
61
|
+
className="p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors text-white"
|
|
62
|
+
title="Download"
|
|
63
|
+
>
|
|
64
|
+
<Download className="h-5 w-5" />
|
|
65
|
+
</button>
|
|
66
|
+
<button
|
|
67
|
+
onClick={onClose}
|
|
68
|
+
className="p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors text-white"
|
|
69
|
+
>
|
|
70
|
+
<X className="h-5 w-5" />
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
48
73
|
|
|
49
74
|
{/* Image counter */}
|
|
50
75
|
{images.length > 1 && (
|
|
@@ -5,6 +5,7 @@ import 'streamdown/styles.css';
|
|
|
5
5
|
import { Paperclip, Copy, Check, ExternalLink } from 'lucide-react';
|
|
6
6
|
import AudioBubble from './AudioBubble';
|
|
7
7
|
import EnvForm, { type EnvGroupData, type EnvField } from './EnvForm';
|
|
8
|
+
import BlobyImageCard from './BlobyImageCard';
|
|
8
9
|
import type { StoredAttachment } from '../../hooks/useChat';
|
|
9
10
|
|
|
10
11
|
interface Props {
|
|
@@ -34,23 +35,17 @@ function preprocessContent(text: string): string {
|
|
|
34
35
|
);
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
type ContentSegment = { type: 'text'; value: string } | { type: 'env'; group: EnvGroupData };
|
|
38
|
+
type ContentSegment = { type: 'text'; value: string } | { type: 'env'; group: EnvGroupData } | { type: 'bloby-image'; src: string; alt: string };
|
|
38
39
|
|
|
39
|
-
/** Extract <EnvGroup>
|
|
40
|
-
function
|
|
40
|
+
/** Extract <EnvGroup> and <BlobyImage> tags from content, splitting into typed segments */
|
|
41
|
+
function extractContentSegments(text: string): ContentSegment[] {
|
|
42
|
+
// Collect all custom tag matches with their positions
|
|
43
|
+
const matches: { start: number; end: number; segment: ContentSegment }[] = [];
|
|
44
|
+
|
|
45
|
+
// Find <EnvGroup> blocks
|
|
41
46
|
const envRegex = /<EnvGroup\s+title="([^"]+)">([\s\S]*?)<\/EnvGroup>/g;
|
|
42
|
-
const segments: ContentSegment[] = [];
|
|
43
|
-
let lastIndex = 0;
|
|
44
47
|
let match: RegExpExecArray | null;
|
|
45
|
-
|
|
46
48
|
while ((match = envRegex.exec(text)) !== null) {
|
|
47
|
-
// Text before this match
|
|
48
|
-
if (match.index > lastIndex) {
|
|
49
|
-
const before = text.slice(lastIndex, match.index).trim();
|
|
50
|
-
if (before) segments.push({ type: 'text', value: before });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Parse <EnvInput> fields
|
|
54
49
|
const title = match[1];
|
|
55
50
|
const inner = match[2];
|
|
56
51
|
const fields: EnvField[] = [];
|
|
@@ -59,15 +54,31 @@ function extractEnvSegments(text: string): ContentSegment[] {
|
|
|
59
54
|
while ((inputMatch = inputRegex.exec(inner)) !== null) {
|
|
60
55
|
fields.push({ name: inputMatch[1], label: inputMatch[2], placeholder: inputMatch[3] });
|
|
61
56
|
}
|
|
62
|
-
|
|
63
57
|
if (fields.length > 0) {
|
|
64
|
-
|
|
58
|
+
matches.push({ start: match.index, end: match.index + match[0].length, segment: { type: 'env', group: { title, fields } } });
|
|
65
59
|
}
|
|
60
|
+
}
|
|
66
61
|
|
|
67
|
-
|
|
62
|
+
// Find <BlobyImage> tags
|
|
63
|
+
const imgRegex = /<BlobyImage\s+src="([^"]+)"(?:\s+alt="([^"]*)")?\s*\/>/g;
|
|
64
|
+
while ((match = imgRegex.exec(text)) !== null) {
|
|
65
|
+
matches.push({ start: match.index, end: match.index + match[0].length, segment: { type: 'bloby-image', src: match[1], alt: match[2] || '' } });
|
|
68
66
|
}
|
|
69
67
|
|
|
70
|
-
//
|
|
68
|
+
// Sort by position in text
|
|
69
|
+
matches.sort((a, b) => a.start - b.start);
|
|
70
|
+
|
|
71
|
+
// Build segments with text between tags
|
|
72
|
+
const segments: ContentSegment[] = [];
|
|
73
|
+
let lastIndex = 0;
|
|
74
|
+
for (const m of matches) {
|
|
75
|
+
if (m.start > lastIndex) {
|
|
76
|
+
const before = text.slice(lastIndex, m.start).trim();
|
|
77
|
+
if (before) segments.push({ type: 'text', value: before });
|
|
78
|
+
}
|
|
79
|
+
segments.push(m.segment);
|
|
80
|
+
lastIndex = m.end;
|
|
81
|
+
}
|
|
71
82
|
if (lastIndex < text.length) {
|
|
72
83
|
const after = text.slice(lastIndex).trim();
|
|
73
84
|
if (after) segments.push({ type: 'text', value: after });
|
|
@@ -76,9 +87,9 @@ function extractEnvSegments(text: string): ContentSegment[] {
|
|
|
76
87
|
return segments;
|
|
77
88
|
}
|
|
78
89
|
|
|
79
|
-
/** Check if content has any
|
|
80
|
-
function
|
|
81
|
-
return /<EnvGroup\s+title="[^"]+">/i.test(text);
|
|
90
|
+
/** Check if content has any custom tags that need special rendering */
|
|
91
|
+
function hasCustomTags(text: string): boolean {
|
|
92
|
+
return /<EnvGroup\s+title="[^"]+">/i.test(text) || /<BlobyImage\s+src="/i.test(text);
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
function CopyButton({ text }: { text: string }) {
|
|
@@ -256,9 +267,14 @@ export default function MessageBubble({ role, content, timestamp, hasAttachments
|
|
|
256
267
|
</Streamdown>
|
|
257
268
|
);
|
|
258
269
|
|
|
259
|
-
// Check if content has
|
|
260
|
-
if (
|
|
261
|
-
const segments =
|
|
270
|
+
// Check if content has custom tags — if so, split into segments
|
|
271
|
+
if (hasCustomTags(content)) {
|
|
272
|
+
const segments = extractContentSegments(content);
|
|
273
|
+
|
|
274
|
+
// Collect BlobyImage URLs for lightbox navigation
|
|
275
|
+
const blobyImageUrls = segments.filter((s): s is Extract<ContentSegment, { type: 'bloby-image' }> => s.type === 'bloby-image').map((s) => s.src);
|
|
276
|
+
let imgIdx = 0;
|
|
277
|
+
|
|
262
278
|
return (
|
|
263
279
|
<div className="flex flex-col items-start gap-0.5">
|
|
264
280
|
<div className="group relative w-full">
|
|
@@ -267,13 +283,15 @@ export default function MessageBubble({ role, content, timestamp, hasAttachments
|
|
|
267
283
|
className="rounded-2xl px-4 py-2.5 text-sm leading-relaxed bg-muted text-foreground prose prose-sm prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 break-words"
|
|
268
284
|
onClickCapture={handleStreamdownClick}
|
|
269
285
|
>
|
|
270
|
-
{segments.map((seg, i) =>
|
|
271
|
-
seg.type === 'text'
|
|
272
|
-
|
|
273
|
-
)
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
286
|
+
{segments.map((seg, i) => {
|
|
287
|
+
if (seg.type === 'text') return <div key={i}>{renderStreamdown(seg.value)}</div>;
|
|
288
|
+
if (seg.type === 'env') return <EnvForm key={i} group={seg.group} />;
|
|
289
|
+
if (seg.type === 'bloby-image') {
|
|
290
|
+
const idx = imgIdx++;
|
|
291
|
+
return <BlobyImageCard key={i} src={seg.src} alt={seg.alt} onExpand={() => onImageClick?.(blobyImageUrls, idx)} />;
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
})}
|
|
277
295
|
</div>
|
|
278
296
|
</div>
|
|
279
297
|
{time && <span className="text-[10px] text-muted-foreground/50 px-1">{time}</span>}
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
# Chrome Extension
|
|
2
|
-
|
|
3
|
-
## What This Is
|
|
4
|
-
|
|
5
|
-
A Chrome extension that puts you (the bloby) on every webpage your human visits. A floating bubble appears in the bottom-right corner of every tab. Your human clicks it, and a chat panel slides out — same chat, same conversation, fully mirrored with the dashboard and WhatsApp.
|
|
6
|
-
|
|
7
|
-
The extension also gives your human the ability to share page context with you — URL, title, selected text, and structured data (product info, prices). This lets you help with what they're actively looking at.
|
|
8
|
-
|
|
9
|
-
## Dependencies
|
|
10
|
-
|
|
11
|
-
None.
|
|
12
|
-
|
|
13
|
-
## Setup
|
|
14
|
-
|
|
15
|
-
### Step 1: Generate a pairing code
|
|
16
|
-
|
|
17
|
-
When your human asks to set up the Chrome extension, generate a pairing code:
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
curl -s -X POST https://api.bloby.bot/api/extension/pair/create \
|
|
21
|
-
-H "Content-Type: application/json" \
|
|
22
|
-
-H "Authorization: Bearer $(cat ~/.bloby/config.json | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4)"
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
This returns a 6-digit code that expires in 5 minutes.
|
|
26
|
-
|
|
27
|
-
### Step 2: Walk your human through installation
|
|
28
|
-
|
|
29
|
-
Tell your human:
|
|
30
|
-
|
|
31
|
-
1. Open `chrome://extensions` in Chrome
|
|
32
|
-
2. Turn on **Developer Mode** (toggle in the top-right corner)
|
|
33
|
-
3. Click **Load unpacked**
|
|
34
|
-
4. Select the folder: `~/.bloby/workspace/skills/chrome-extension/`
|
|
35
|
-
5. Click the puzzle piece icon in Chrome's toolbar and pin Bloby
|
|
36
|
-
6. Click the Bloby icon and enter the 6-digit pairing code
|
|
37
|
-
|
|
38
|
-
### Step 3: Verify it works
|
|
39
|
-
|
|
40
|
-
After your human enters the code, the extension connects to your server. A gradient bubble should appear on every webpage. Ask your human to confirm they see it.
|
|
41
|
-
|
|
42
|
-
If the code expired, generate a new one (repeat Step 1).
|
|
43
|
-
|
|
44
|
-
### Re-pairing
|
|
45
|
-
|
|
46
|
-
If your human needs to reconnect (new device, token expired):
|
|
47
|
-
1. Generate a new pairing code (Step 1)
|
|
48
|
-
2. They click the Bloby icon in Chrome toolbar → enter the new code
|
|
49
|
-
|
|
50
|
-
## Usage
|
|
51
|
-
|
|
52
|
-
### How messages from the extension arrive
|
|
53
|
-
|
|
54
|
-
Messages from the extension come through the same WebSocket as dashboard chat messages. There is no difference — the extension is just another connected client. Your responses appear in both the extension and the dashboard simultaneously.
|
|
55
|
-
|
|
56
|
-
### Page context
|
|
57
|
-
|
|
58
|
-
When your human sends a message from the extension, the message may include page context. This appears as metadata at the start of the message:
|
|
59
|
-
|
|
60
|
-
```
|
|
61
|
-
[Page: https://amazon.com/dp/B0XXXXX | iPhone 16 Pro - 256GB | $999.00]
|
|
62
|
-
Can you find this cheaper?
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
The context includes:
|
|
66
|
-
- **URL** — the page they're on
|
|
67
|
-
- **Title** — the page title
|
|
68
|
-
- **Selection** — any text they've highlighted (up to 2000 chars)
|
|
69
|
-
- **Product info** — name, price, currency (extracted from JSON-LD structured data if available)
|
|
70
|
-
- **Meta description** — page summary
|
|
71
|
-
|
|
72
|
-
You can use this context directly, or fetch the URL yourself with curl/fetch tools for more detail.
|
|
73
|
-
|
|
74
|
-
### Common use cases
|
|
75
|
-
|
|
76
|
-
**Price comparison:**
|
|
77
|
-
Human is on a product page → asks "find this cheaper" → you have the URL, product name, and price → search the web for alternatives.
|
|
78
|
-
|
|
79
|
-
**Page summarization:**
|
|
80
|
-
Human is on a long article → asks "summarize this" → you fetch the URL and produce a summary.
|
|
81
|
-
|
|
82
|
-
**Video transcription:**
|
|
83
|
-
Human is on YouTube → asks "transcribe this" → you extract the video URL, use available tools to get the transcript.
|
|
84
|
-
|
|
85
|
-
**Save to workspace:**
|
|
86
|
-
Human finds something interesting → asks "save this to my research notes" → you write to a file in the workspace.
|
|
87
|
-
|
|
88
|
-
**Shopping assistant:**
|
|
89
|
-
Human is browsing → asks "add this to my wishlist" → you maintain a wishlist file in the workspace.
|
|
90
|
-
|
|
91
|
-
**Research assistant:**
|
|
92
|
-
Human is reading documentation → highlights text → asks "explain this" → you get the selected text and explain it in context.
|
|
93
|
-
|
|
94
|
-
### Working with the page URL
|
|
95
|
-
|
|
96
|
-
You have full tool access. If the context isn't enough, fetch the page yourself:
|
|
97
|
-
|
|
98
|
-
```bash
|
|
99
|
-
curl -s "https://the-url-from-context" | head -200
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
Or use an MCP fetch tool if configured.
|
|
103
|
-
|
|
104
|
-
## Site-Specific Behaviors
|
|
105
|
-
|
|
106
|
-
When you detect the user is on a specific type of site (from the page context URL), adapt what you offer:
|
|
107
|
-
|
|
108
|
-
### YouTube (youtube.com, youtu.be)
|
|
109
|
-
- Offer to transcribe the video
|
|
110
|
-
- Offer to summarize, create bullet points, or extract key moments
|
|
111
|
-
- Offer to turn it into a blog post or audio summary
|
|
112
|
-
- The video URL is in the page context — you can fetch it or use tools to process it
|
|
113
|
-
|
|
114
|
-
### Shopping (amazon.com, ebay.com, walmart.com, etsy.com, etc.)
|
|
115
|
-
- Offer to find cheaper alternatives (web search)
|
|
116
|
-
- Offer to save to a wishlist (workspace file)
|
|
117
|
-
- Offer to compare prices across sites
|
|
118
|
-
- Product name and price are extracted from structured data when available
|
|
119
|
-
|
|
120
|
-
### Reddit / X / Social (reddit.com, x.com, twitter.com)
|
|
121
|
-
- Offer to summarize long threads
|
|
122
|
-
- Offer to draft a reply (the extension can type into input fields)
|
|
123
|
-
- Offer to save interesting posts to workspace notes
|
|
124
|
-
|
|
125
|
-
### Documentation / Articles (docs.*, medium.com, dev.to, any long-form text)
|
|
126
|
-
- Offer to summarize the article
|
|
127
|
-
- If text is selected, explain or elaborate on the selection
|
|
128
|
-
- Offer to save key points to workspace notes
|
|
129
|
-
|
|
130
|
-
### Forms (any page with detected form fields)
|
|
131
|
-
- When `hasForms` is true in context, offer to help fill the form
|
|
132
|
-
- Ask what information the user wants to fill
|
|
133
|
-
- Use the form field names from context to understand what's needed
|
|
134
|
-
|
|
135
|
-
### Email (gmail.com, outlook.com)
|
|
136
|
-
- Offer to draft replies
|
|
137
|
-
- Offer to summarize email threads
|
|
138
|
-
|
|
139
|
-
### Blocked Sites — NEVER interact with page content
|
|
140
|
-
- Banking: chase.com, wellsfargo.com, bankofamerica.com, any URL with /banking
|
|
141
|
-
- Auth pages: any URL with /login, /signin, /auth, /password
|
|
142
|
-
- Password managers: 1password.com, bitwarden.com, lastpass.com
|
|
143
|
-
- Medical records: mychart.com, any URL with /health-records
|
|
144
|
-
|
|
145
|
-
On blocked sites, you can still chat normally — just don't read or reference page content. If the user asks about the page, tell them: "I don't interact with sensitive pages like banking or login screens for your security."
|
|
146
|
-
|
|
147
|
-
### Default (any other site)
|
|
148
|
-
- Always share the URL and title (they're in the context)
|
|
149
|
-
- If the user asks about "this page" or "this", fetch the URL for details
|
|
150
|
-
- Offer to save interesting content to workspace
|
|
151
|
-
|
|
152
|
-
## Human Interaction
|
|
153
|
-
|
|
154
|
-
### What to tell your human
|
|
155
|
-
|
|
156
|
-
When they ask about the extension:
|
|
157
|
-
- "I can see whatever page you're on and help you with it"
|
|
158
|
-
- "Just click my bubble on any page and ask me anything"
|
|
159
|
-
- "If you highlight text on a page, I can see what you selected"
|
|
160
|
-
- "I can find better prices, summarize articles, save things to your workspace — whatever you need"
|
|
161
|
-
|
|
162
|
-
### What NOT to say
|
|
163
|
-
|
|
164
|
-
Never mention "extension", "content script", "background worker", or any technical implementation details. From your human's perspective, you're just... everywhere. Available on every page. That's it.
|
|
165
|
-
|
|
166
|
-
### Permissions
|
|
167
|
-
|
|
168
|
-
The extension only reads page content when your human actively opens the chat panel. It does NOT:
|
|
169
|
-
- Track browsing history
|
|
170
|
-
- Read pages in the background
|
|
171
|
-
- Send data anywhere except to your server
|
|
172
|
-
- Access passwords, form data, or cookies
|
|
173
|
-
|
|
174
|
-
If your human asks about privacy, be transparent about this.
|
|
175
|
-
|
|
176
|
-
## Notes
|
|
177
|
-
|
|
178
|
-
- The extension connects via the relay URL (e.g., `bloby.bloby.bot`). If the relay is down or the tunnel is offline, the extension chat won't connect. The bubble still appears but messages won't send.
|
|
179
|
-
- The pairing code is single-use and expires in 5 minutes. Generate a new one if it expires.
|
|
180
|
-
- The extension works on all pages except Chrome internal pages (`chrome://`, `chrome-extension://`) and your own Bloby dashboard domain (to avoid double-bubble).
|
|
181
|
-
- Multiple browsers/devices can be paired — each gets its own pairing code. They all share the same conversation.
|
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bloby Chrome Extension — Background Service Worker
|
|
3
|
-
*
|
|
4
|
-
* Responsibilities:
|
|
5
|
-
* - Manages pairing state (serverUrl stored in chrome.storage.local)
|
|
6
|
-
* - Handles pairing flow (verify code → save config)
|
|
7
|
-
* - Responds to state queries from content scripts and popup
|
|
8
|
-
*
|
|
9
|
-
* Note: WebSocket connection is handled by the chat iframe inside the panel,
|
|
10
|
-
* not by the background worker. The iframe loads the Bloby chat app which
|
|
11
|
-
* manages its own WS connection and auth (portal login).
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
let config = null; // { serverUrl, username, tier }
|
|
15
|
-
|
|
16
|
-
// ── Config Management ──────────────────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
async function loadConfig() {
|
|
19
|
-
const data = await chrome.storage.local.get(['serverUrl', 'username', 'tier']);
|
|
20
|
-
if (data.serverUrl) {
|
|
21
|
-
config = data;
|
|
22
|
-
console.log(`[bloby-bg] Config loaded: ${config.serverUrl} (${config.username})`);
|
|
23
|
-
return true;
|
|
24
|
-
}
|
|
25
|
-
console.log('[bloby-bg] No config found — not paired');
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function saveConfig(newConfig) {
|
|
30
|
-
config = newConfig;
|
|
31
|
-
await chrome.storage.local.set(newConfig);
|
|
32
|
-
console.log(`[bloby-bg] Config saved: ${config.serverUrl}`);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function clearConfig() {
|
|
36
|
-
config = null;
|
|
37
|
-
await chrome.storage.local.clear();
|
|
38
|
-
console.log('[bloby-bg] Config cleared — unpaired');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// ── Broadcast to All Tabs ──────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
async function broadcastToTabs(msg) {
|
|
44
|
-
try {
|
|
45
|
-
const tabs = await chrome.tabs.query({});
|
|
46
|
-
for (const tab of tabs) {
|
|
47
|
-
if (tab.id) {
|
|
48
|
-
chrome.tabs.sendMessage(tab.id, msg).catch(() => {});
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
} catch { /* ignore */ }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ── Message Handler (from content scripts + popup) ─────────────────────────
|
|
55
|
-
|
|
56
|
-
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
57
|
-
if (msg.type === 'bloby:get-state') {
|
|
58
|
-
sendResponse({
|
|
59
|
-
paired: !!config?.serverUrl,
|
|
60
|
-
connected: true, // iframe manages its own connection
|
|
61
|
-
config: config ? { serverUrl: config.serverUrl, username: config.username } : null,
|
|
62
|
-
});
|
|
63
|
-
return true;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (msg.type === 'bloby:pair') {
|
|
67
|
-
handlePair(msg.code).then(sendResponse);
|
|
68
|
-
return true;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Content script: take screenshot
|
|
72
|
-
if (msg.type === 'bloby:screenshot') {
|
|
73
|
-
chrome.tabs.captureVisibleTab(null, { format: 'png' }, (dataUrl) => {
|
|
74
|
-
sendResponse({ dataUrl: dataUrl || null });
|
|
75
|
-
});
|
|
76
|
-
return true; // async
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (msg.type === 'bloby:unpair') {
|
|
80
|
-
(async () => {
|
|
81
|
-
// Clear frame rules
|
|
82
|
-
try {
|
|
83
|
-
const existing = await chrome.declarativeNetRequest.getDynamicRules();
|
|
84
|
-
await chrome.declarativeNetRequest.updateDynamicRules({
|
|
85
|
-
removeRuleIds: existing.map((r) => r.id),
|
|
86
|
-
});
|
|
87
|
-
} catch {}
|
|
88
|
-
await clearConfig();
|
|
89
|
-
broadcastToTabs({ type: 'bloby:unpaired' });
|
|
90
|
-
sendResponse({ success: true });
|
|
91
|
-
})();
|
|
92
|
-
return true;
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
// ── Pairing Flow ───────────────────────────────────────────────────────────
|
|
97
|
-
|
|
98
|
-
async function handlePair(code) {
|
|
99
|
-
try {
|
|
100
|
-
console.log(`[bloby-bg] Verifying pairing code: ${code}`);
|
|
101
|
-
|
|
102
|
-
const res = await fetch('https://api.bloby.bot/api/extension/pair/verify', {
|
|
103
|
-
method: 'POST',
|
|
104
|
-
headers: { 'Content-Type': 'application/json' },
|
|
105
|
-
body: JSON.stringify({ code }),
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
if (!res.ok) {
|
|
109
|
-
const data = await res.json().catch(() => ({}));
|
|
110
|
-
console.log(`[bloby-bg] Pairing failed: ${data.error || res.status}`);
|
|
111
|
-
return { success: false, error: data.error || 'Invalid code' };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const data = await res.json();
|
|
115
|
-
console.log(`[bloby-bg] Pairing verified: ${data.serverUrl}`);
|
|
116
|
-
|
|
117
|
-
await saveConfig({
|
|
118
|
-
serverUrl: data.serverUrl,
|
|
119
|
-
username: data.username,
|
|
120
|
-
tier: data.tier,
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
// Set up header rules to allow iframing the Bloby server
|
|
124
|
-
await updateFrameRules(data.serverUrl);
|
|
125
|
-
|
|
126
|
-
// Notify all tabs to show the bubble
|
|
127
|
-
broadcastToTabs({ type: 'bloby:paired', serverUrl: data.serverUrl });
|
|
128
|
-
|
|
129
|
-
return { success: true, username: data.username, serverUrl: data.serverUrl };
|
|
130
|
-
} catch (err) {
|
|
131
|
-
console.error('[bloby-bg] Pairing error:', err);
|
|
132
|
-
return { success: false, error: 'Network error — check your connection' };
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// ── Frame Header Rules ─────────────────────────────────────────────────────
|
|
137
|
-
// Strip X-Frame-Options and restrictive frame-ancestors from the Bloby server
|
|
138
|
-
// so the chat can be loaded in the extension's panel iframe.
|
|
139
|
-
|
|
140
|
-
async function updateFrameRules(serverUrl) {
|
|
141
|
-
const host = serverUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
142
|
-
console.log(`[bloby-bg] Setting frame rules for: *://${host}/*`);
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
// Remove old rules
|
|
146
|
-
const existing = await chrome.declarativeNetRequest.getDynamicRules();
|
|
147
|
-
const removeIds = existing.map((r) => r.id);
|
|
148
|
-
|
|
149
|
-
await chrome.declarativeNetRequest.updateDynamicRules({
|
|
150
|
-
removeRuleIds: removeIds,
|
|
151
|
-
addRules: [
|
|
152
|
-
{
|
|
153
|
-
id: 1,
|
|
154
|
-
priority: 1,
|
|
155
|
-
action: {
|
|
156
|
-
type: 'modifyHeaders',
|
|
157
|
-
responseHeaders: [
|
|
158
|
-
{ header: 'X-Frame-Options', operation: 'remove' },
|
|
159
|
-
{ header: 'Content-Security-Policy', operation: 'remove' },
|
|
160
|
-
],
|
|
161
|
-
},
|
|
162
|
-
condition: {
|
|
163
|
-
urlFilter: `*://${host}/*`,
|
|
164
|
-
resourceTypes: ['sub_frame'],
|
|
165
|
-
},
|
|
166
|
-
},
|
|
167
|
-
],
|
|
168
|
-
});
|
|
169
|
-
console.log('[bloby-bg] Frame rules set successfully');
|
|
170
|
-
} catch (err) {
|
|
171
|
-
console.error('[bloby-bg] Failed to set frame rules:', err);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ── Startup ────────────────────────────────────────────────────────────────
|
|
176
|
-
|
|
177
|
-
(async () => {
|
|
178
|
-
const hasCfg = await loadConfig();
|
|
179
|
-
if (hasCfg && config.serverUrl) {
|
|
180
|
-
await updateFrameRules(config.serverUrl);
|
|
181
|
-
}
|
|
182
|
-
})();
|