feat: add support for ChatGPT, Grok, Gemini, and OpenWebUI

Extend Silent Send to intercept API requests on all major AI chat
services. Each service has different API shapes:

- ChatGPT: /backend-api/conversation with content.parts arrays
- Grok: GraphQL + /2/grok/add_response with message field
- Gemini: form-encoded f.req with nested arrays (+ generateContent)
- OpenWebUI: /api/chat and /ollama/api/chat (self-hosted)

All services share the same substitution pipeline. Manifests updated
for both Chrome and Firefox with host_permissions for all domains.
OpenWebUI supported via localhost/127.0.0.1 for self-hosted instances.

https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw
This commit is contained in:
Claude
2026-03-26 00:27:07 +00:00
parent 34f1131c23
commit 6e3249b8ff
6 changed files with 220 additions and 50 deletions
+11 -1
View File
@@ -1,6 +1,16 @@
# Silent Send
A browser extension (Chrome + Firefox) that intercepts personal information and substitutes it with user-defined replacements before sending to Claude.ai.
A browser extension (Chrome + Firefox) that intercepts personal information and substitutes it with user-defined replacements before sending to AI services.
### Supported services
| Service | Domains |
|---------|---------|
| Claude | claude.ai, claude.ai/code |
| ChatGPT | chatgpt.com, chat.openai.com |
| Grok | grok.x.ai, x.com/i/grok |
| Gemini | gemini.google.com |
| OpenWebUI | localhost, 127.0.0.1 (self-hosted) |
## How it works
+29 -4
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Silent Send",
"version": "0.1.0",
"version": "0.2.0",
"description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.",
"browser_specific_settings": {
"gecko": {
@@ -15,7 +15,14 @@
"scripting"
],
"host_permissions": [
"https://claude.ai/*"
"https://claude.ai/*",
"https://chatgpt.com/*",
"https://chat.openai.com/*",
"https://grok.x.ai/*",
"https://x.com/i/grok*",
"https://gemini.google.com/*",
"http://localhost/*",
"http://127.0.0.1/*"
],
"background": {
"scripts": ["src/background/service-worker.js"],
@@ -23,7 +30,16 @@
},
"content_scripts": [
{
"matches": ["https://claude.ai/*"],
"matches": [
"https://claude.ai/*",
"https://chatgpt.com/*",
"https://chat.openai.com/*",
"https://grok.x.ai/*",
"https://x.com/i/grok*",
"https://gemini.google.com/*",
"http://localhost/*",
"http://127.0.0.1/*"
],
"js": ["src/content/injector.js"],
"css": ["src/content/content.css"],
"run_at": "document_start",
@@ -50,7 +66,16 @@
"web_accessible_resources": [
{
"resources": ["src/content/content.js"],
"matches": ["https://claude.ai/*"]
"matches": [
"https://claude.ai/*",
"https://chatgpt.com/*",
"https://chat.openai.com/*",
"https://grok.x.ai/*",
"https://x.com/*",
"https://gemini.google.com/*",
"http://localhost/*",
"http://127.0.0.1/*"
]
}
]
}
+29 -4
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Silent Send",
"version": "0.1.0",
"version": "0.2.0",
"description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.",
"permissions": [
"storage",
@@ -9,7 +9,14 @@
"scripting"
],
"host_permissions": [
"https://claude.ai/*"
"https://claude.ai/*",
"https://chatgpt.com/*",
"https://chat.openai.com/*",
"https://grok.x.ai/*",
"https://x.com/i/grok*",
"https://gemini.google.com/*",
"http://localhost/*",
"http://127.0.0.1/*"
],
"background": {
"service_worker": "src/background/service-worker.js",
@@ -17,7 +24,16 @@
},
"content_scripts": [
{
"matches": ["https://claude.ai/*"],
"matches": [
"https://claude.ai/*",
"https://chatgpt.com/*",
"https://chat.openai.com/*",
"https://grok.x.ai/*",
"https://x.com/i/grok*",
"https://gemini.google.com/*",
"http://localhost/*",
"http://127.0.0.1/*"
],
"js": ["src/content/injector.js"],
"css": ["src/content/content.css"],
"run_at": "document_start",
@@ -41,7 +57,16 @@
"web_accessible_resources": [
{
"resources": ["src/content/content.js"],
"matches": ["https://claude.ai/*"]
"matches": [
"https://claude.ai/*",
"https://chatgpt.com/*",
"https://chat.openai.com/*",
"https://grok.x.ai/*",
"https://x.com/*",
"https://gemini.google.com/*",
"http://localhost/*",
"http://127.0.0.1/*"
]
}
]
}
+19 -7
View File
@@ -88,13 +88,25 @@ const messageHandlers = {
async 'update:settings'(message) {
await Storage.saveSettings(message.settings);
// Broadcast to content scripts
const tabs = await api.tabs.query({ url: 'https://claude.ai/*' });
for (const tab of tabs) {
api.tabs.sendMessage(tab.id, {
type: 'settings:updated',
settings: message.settings,
}).catch(() => {});
// Broadcast to content scripts on all supported sites
const SUPPORTED_URLS = [
'https://claude.ai/*',
'https://chatgpt.com/*',
'https://chat.openai.com/*',
'https://grok.x.ai/*',
'https://x.com/i/grok*',
'https://gemini.google.com/*',
'http://localhost/*',
'http://127.0.0.1/*',
];
for (const urlPattern of SUPPORTED_URLS) {
const tabs = await api.tabs.query({ url: urlPattern }).catch(() => []);
for (const tab of tabs) {
api.tabs.sendMessage(tab.id, {
type: 'settings:updated',
settings: message.settings,
}).catch(() => {});
}
}
},
};
+131 -33
View File
@@ -283,7 +283,7 @@
}
// ============================================================
// Process a JSON body — handles all known Claude API shapes
// Process a JSON body — handles all known AI service API shapes
// ============================================================
function processBody(body) {
let modified = false;
@@ -298,44 +298,94 @@
return r;
}
// Shape 1: { prompt: "..." }
if (typeof body.prompt === 'string') {
const r = processText(body.prompt);
if (r.modified) body.prompt = r.text;
}
// Shape 2: { content: [{ type: "text", text: "..." }] }
if (Array.isArray(body.content)) {
for (let i = 0; i < body.content.length; i++) {
const item = body.content[i];
if (item.type === 'text' && typeof item.text === 'string') {
const r = processText(item.text);
if (r.modified) body.content[i] = { ...item, text: r.text };
}
// Walk any string values that look like user content
function walkAndSubstitute(obj, key) {
if (typeof obj[key] === 'string' && obj[key].length > 0) {
const r = processText(obj[key]);
if (r.modified) obj[key] = r.text;
}
}
// Shape 3: { messages: [{ role: "user", content: "..." }] }
// --- Claude: { prompt: "..." } ---
if (typeof body.prompt === 'string') {
walkAndSubstitute(body, 'prompt');
}
// --- Claude / OpenAI / OpenWebUI: { messages: [{ role, content }] } ---
if (Array.isArray(body.messages)) {
for (const msg of body.messages) {
if (msg.role !== 'user' && msg.role !== 'human') continue;
if (typeof msg.content === 'string') {
const r = processText(msg.content);
if (r.modified) msg.content = r.text;
walkAndSubstitute(msg, 'content');
}
if (Array.isArray(msg.content)) {
for (let j = 0; j < msg.content.length; j++) {
if (msg.content[j].type === 'text') {
const r = processText(msg.content[j].text);
if (r.modified) msg.content[j] = { ...msg.content[j], text: r.text };
const part = msg.content[j];
if (typeof part === 'string') {
const r = processText(part);
if (r.modified) msg.content[j] = r.text;
} else if (part?.type === 'text' && typeof part.text === 'string') {
walkAndSubstitute(part, 'text');
}
}
}
}
}
// --- Claude: { content: [{ type: "text", text }] } ---
if (Array.isArray(body.content) && !Array.isArray(body.messages)) {
for (let i = 0; i < body.content.length; i++) {
const item = body.content[i];
if (item.type === 'text' && typeof item.text === 'string') {
walkAndSubstitute(item, 'text');
}
}
}
// --- ChatGPT: { action: "next", messages: [{ content: { parts: ["..."] } }] } ---
if (Array.isArray(body.messages)) {
for (const msg of body.messages) {
if (msg.content?.parts && Array.isArray(msg.content.parts)) {
for (let i = 0; i < msg.content.parts.length; i++) {
if (typeof msg.content.parts[i] === 'string') {
const r = processText(msg.content.parts[i]);
if (r.modified) msg.content.parts[i] = r.text;
}
}
}
}
}
// --- Gemini: nested prompts in f.req batch RPC (text strings in arrays) ---
// Gemini uses a complex nested array format. We recursively find strings.
if (Array.isArray(body) || body?.fReq) {
function walkArray(arr) {
for (let i = 0; i < arr.length; i++) {
if (typeof arr[i] === 'string' && arr[i].length > 2) {
const r = processText(arr[i]);
if (r.modified) arr[i] = r.text;
} else if (Array.isArray(arr[i])) {
walkArray(arr[i]);
}
}
}
if (Array.isArray(body)) walkArray(body);
}
// --- Grok: { message: "...", messages: [...] } ---
if (typeof body.message === 'string') {
walkAndSubstitute(body, 'message');
}
// --- OpenWebUI: { prompt: "...", messages: [...], model: "..." } ---
// Already handled by 'prompt' and 'messages' above
// --- Generic: { query: "..." } or { input: "..." } ---
if (typeof body.query === 'string') walkAndSubstitute(body, 'query');
if (typeof body.input === 'string') walkAndSubstitute(body, 'input');
return { modified, replacements: allReplacements };
}
@@ -350,6 +400,34 @@
(identity.phones || []).length > 0;
}
// ============================================================
// API URL Detection — matches known AI service endpoints
// ============================================================
const API_PATTERNS = [
// Claude
/\/api\/organizations\/.*\/(chat_conversations|completion|messages)/,
// ChatGPT / OpenAI
/\/backend-api\/conversation/,
/\/api\/conversation/,
/\/v1\/chat\/completions/,
// Grok
/\/i\/api\/graphql.*grok/i,
/grok.*\/api\//,
/\/2\/grok\/add_response/,
// Gemini
/\/_\/BardChatUi\/data\//,
/\/google\.internal\.gemini/,
/generativelanguage.*generateContent/,
// OpenWebUI (self-hosted, various paths)
/\/api\/chat\/?/,
/\/ollama\/api\/chat/,
/\/api\/v1\/chat\/completions/,
];
function isTargetApiUrl(url) {
return API_PATTERNS.some(pattern => pattern.test(url));
}
// ============================================================
// Fetch Interception
// ============================================================
@@ -361,14 +439,10 @@
}
const urlStr = typeof url === 'string' ? url : url?.url || '';
const isTargetApi =
urlStr.includes('/api/organizations/') &&
(urlStr.includes('/chat_conversations/') ||
urlStr.includes('/completion') ||
urlStr.includes('/messages'));
if (isTargetApi && options?.body && typeof options.body === 'string') {
if (isTargetApiUrl(urlStr) && options?.body && typeof options.body === 'string') {
try {
// Try JSON first (most services)
const body = JSON.parse(options.body);
const { modified, replacements } = processBody(body);
@@ -380,7 +454,19 @@
);
}
} catch (e) {
// Not JSON — pass through
// Not JSON — try form-encoded (Gemini uses f.req param)
try {
if (options.body.includes('f.req=') || options.body.includes('at=')) {
const result = substituteAll(options.body);
if (result.modified) {
options = { ...options, body: result.text };
notifySubstitutions(result.replacements);
console.log(
`[Silent Send] Substituted ${result.replacements.length} value(s) in form request`
);
}
}
} catch (e2) { /* pass through */ }
}
}
@@ -402,9 +488,7 @@
if (
settings.enabled && hasSubstitutions() &&
typeof body === 'string' && this._ssUrl &&
(this._ssUrl.includes('/chat_conversations/') ||
this._ssUrl.includes('/completion') ||
this._ssUrl.includes('/messages'))
isTargetApiUrl(this._ssUrl)
) {
try {
const parsed = JSON.parse(body);
@@ -429,13 +513,27 @@
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
// Response container selectors for all supported services
const RESPONSE_SELECTORS = [
// Claude
'[data-is-streaming]', '.font-claude-message',
// ChatGPT
'[data-message-author-role="assistant"]', '.markdown',
// Grok
'[class*="message-bubble"]', '[class*="response"]',
// Gemini
'.model-response-text', '.response-content', 'message-content',
// Generic
'.prose', '[class*="Message"]', '[class*="assistant"]',
].join(', ');
const responseEls = node.querySelectorAll
? node.querySelectorAll('[data-is-streaming], .font-claude-message, .prose, [class*="Message"]')
? node.querySelectorAll(RESPONSE_SELECTORS)
: [];
for (const el of responseEls) revealInElement(el);
if (node.matches?.('[data-is-streaming], .font-claude-message, .prose, [class*="Message"]')) {
if (node.matches?.(RESPONSE_SELECTORS)) {
revealInElement(node);
}
}
+1 -1
View File
@@ -142,7 +142,7 @@
<!-- Test Tab -->
<section class="tab-content" id="tab-test">
<p class="help-text">Type text containing your real values to see what would be sent.</p>
<textarea id="testInput" class="textarea" placeholder="Try typing: My name is John Smith and my email is john@example.com" rows="4"></textarea>
<textarea id="testInput" class="textarea" placeholder="Try: My name is John Smith, email john@gmail.com, logged in as jsmith@macbook-pro" rows="4"></textarea>
<div class="diff-view" id="diffView">
<div class="diff-label">What gets sent:</div>
<div class="diff-output" id="diffOutput"></div>