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:
@@ -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
@@ -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
@@ -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/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user