279 lines
10 KiB
JavaScript
279 lines
10 KiB
JavaScript
const express = require('express');
|
|
const { spawn } = require('child_process');
|
|
const https = require('https');
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
|
|
// Try multiple sources for Brave API Key (in order of priority):
|
|
// 1. Environment variable
|
|
// 2. .env file in proxy directory
|
|
// 3. ~/.claude/settings.json (Claude Code config)
|
|
function getBraveApiKey() {
|
|
// 1. Check environment variable first
|
|
if (process.env.BRAVE_SEARCH_API_KEY) {
|
|
return process.env.BRAVE_SEARCH_API_KEY;
|
|
}
|
|
|
|
// 2. Check .env file in proxy directory
|
|
const envPath = path.join(__dirname, '.env');
|
|
if (fs.existsSync(envPath)) {
|
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
|
const match = envContent.match(/BRAVE_SEARCH_API_KEY\s*=\s*(.+)/m);
|
|
if (match) {
|
|
return match[1].trim();
|
|
}
|
|
}
|
|
|
|
// 3. Check Claude Code settings.json
|
|
const claudeSettingsPath = path.join(process.env.HOME || '/root', '.claude', 'settings.json');
|
|
if (fs.existsSync(claudeSettingsPath)) {
|
|
try {
|
|
const settings = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf8'));
|
|
if (settings.env?.BRAVE_SEARCH_API_KEY) {
|
|
return settings.env.BRAVE_SEARCH_API_KEY;
|
|
}
|
|
} catch (e) {
|
|
// Ignore JSON parse errors
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
// Health check endpoint
|
|
app.get('/ping', (req, res) => {
|
|
const status = {
|
|
status: 'pong',
|
|
braveSearchConfigured: !!BRAVE_API_KEY,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
res.json(status);
|
|
});
|
|
|
|
// Main inference endpoint (OpenAI-compatible format)
|
|
app.post('/v1/messages', async (req, res) => {
|
|
const { messages, stream } = req.body;
|
|
|
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
return res.status(400).json({
|
|
error: {
|
|
message: 'Invalid request: messages array required'
|
|
}
|
|
});
|
|
}
|
|
|
|
// Check if web search is requested (via X-Search-Enabled header)
|
|
const webSearchEnabled = req.headers['x-search-enabled'] === 'true';
|
|
const searchQuery = req.headers['x-search-query'] || '';
|
|
|
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
console.log('Request from:', req.ip);
|
|
console.log('Web Search:', webSearchEnabled ? 'ENABLED' : 'disabled');
|
|
if (searchQuery) {
|
|
console.log('Search Query:', searchQuery.substring(0, 100) + '...');
|
|
}
|
|
const braveApiKey = getBraveApiKey();
|
|
console.log('Brave API Key:', braveApiKey ? 'CONFIGURED' : 'NOT SET');
|
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
|
|
// If web search is enabled and we have a query, fetch search results first
|
|
let searchContext = '';
|
|
if (webSearchEnabled && searchQuery && braveApiKey) {
|
|
console.log('Fetching web search results...');
|
|
try {
|
|
searchContext = await fetchBraveSearchResults(searchQuery, braveApiKey);
|
|
console.log('Search results fetched:', searchContext.length, 'chars');
|
|
} catch (err) {
|
|
console.error('Search error:', err.message);
|
|
}
|
|
}
|
|
|
|
// Build conversation context from messages array
|
|
// Include previous messages for context continuity
|
|
let conversationPrompt = '';
|
|
for (const msg of messages) {
|
|
const role = msg.role === 'assistant' ? 'Assistant' : 'User';
|
|
conversationPrompt += `${role}: ${msg.content}\n\n`;
|
|
}
|
|
|
|
let prompt = conversationPrompt.trim();
|
|
|
|
// Prepend search context if available
|
|
if (searchContext) {
|
|
prompt = `WEB SEARCH RESULTS:\n${searchContext}\n\n---\n\nUSER QUERY:\n${prompt}\n\nPlease answer based on the search results above when relevant.`;
|
|
}
|
|
|
|
console.log('Prompt length:', prompt.length, 'chars');
|
|
console.log('Prompt preview:', prompt.substring(0, 150) + '...');
|
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
|
|
// Spawn Claude CLI process
|
|
const claude = spawn('claude', [], {
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
let output = '';
|
|
let errorOutput = '';
|
|
|
|
claude.stdout.on('data', (data) => {
|
|
output += data.toString();
|
|
process.stdout.write('.');
|
|
});
|
|
|
|
claude.stderr.on('data', (data) => {
|
|
errorOutput += data.toString();
|
|
console.error('Claude stderr:', data.toString());
|
|
});
|
|
|
|
claude.on('close', (code) => {
|
|
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
console.log('Claude exit code:', code);
|
|
console.log('Response length:', output.length, 'chars');
|
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
|
|
if (code !== 0 || !output.trim()) {
|
|
return res.status(500).json({
|
|
error: {
|
|
message: 'Claude CLI error',
|
|
details: errorOutput || 'No response from Claude'
|
|
}
|
|
});
|
|
}
|
|
|
|
// Return OpenAI-compatible response format
|
|
res.json({
|
|
id: 'local-' + Date.now(),
|
|
object: 'chat.completion',
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: 'claude-local',
|
|
choices: [{
|
|
index: 0,
|
|
message: {
|
|
role: 'assistant',
|
|
content: output.trim()
|
|
},
|
|
finish_reason: 'stop'
|
|
}],
|
|
usage: {
|
|
prompt_tokens: 0,
|
|
completion_tokens: 0,
|
|
total_tokens: 0
|
|
}
|
|
});
|
|
});
|
|
|
|
claude.on('error', (err) => {
|
|
console.error('Failed to spawn Claude CLI:', err);
|
|
res.status(500).json({
|
|
error: {
|
|
message: 'Failed to spawn Claude CLI',
|
|
details: err.message
|
|
}
|
|
});
|
|
});
|
|
|
|
// Send prompt to Claude after brief pause
|
|
setTimeout(() => {
|
|
claude.stdin.write(prompt + '\n');
|
|
claude.stdin.end();
|
|
}, 100);
|
|
});
|
|
|
|
/**
|
|
* Fetch search results from Brave Search API
|
|
*/
|
|
async function fetchBraveSearchResults(query, apiKey, count = 5) {
|
|
return new Promise((resolve, reject) => {
|
|
const encodedQuery = encodeURIComponent(query);
|
|
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodedQuery}&count=${count}`;
|
|
|
|
const options = {
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-Subscription-Token': apiKey
|
|
}
|
|
};
|
|
|
|
const protocol = url.startsWith('https') ? https : http;
|
|
const request = protocol.get(url, options, (response) => {
|
|
let data = '';
|
|
|
|
response.on('data', (chunk) => {
|
|
data += chunk;
|
|
});
|
|
|
|
response.on('end', () => {
|
|
if (response.statusCode !== 200) {
|
|
return reject(new Error(`Brave API error: ${response.statusCode}`));
|
|
}
|
|
|
|
try {
|
|
const json = JSON.parse(data);
|
|
const results = json.web?.results || [];
|
|
|
|
if (results.length === 0) {
|
|
return resolve('No search results found.');
|
|
}
|
|
|
|
// Format results for LLM consumption
|
|
let formatted = 'Search Results:\n\n';
|
|
|
|
results.forEach((result, i) => {
|
|
formatted += `${i + 1}. **${result.title}**\n`;
|
|
formatted += ` URL: ${result.url}\n`;
|
|
if (result.description) {
|
|
formatted += ` Summary: ${result.description}\n`;
|
|
}
|
|
formatted += '\n';
|
|
});
|
|
|
|
resolve(formatted);
|
|
} catch (err) {
|
|
reject(new Error('Failed to parse Brave response'));
|
|
}
|
|
});
|
|
});
|
|
|
|
request.on('error', (err) => {
|
|
reject(err);
|
|
});
|
|
|
|
request.setTimeout(10000, () => {
|
|
request.destroy();
|
|
reject(new Error('Brave search timeout'));
|
|
});
|
|
});
|
|
}
|
|
|
|
const PORT = process.env.PORT || 8080;
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
const braveApiKey = getBraveApiKey();
|
|
console.log('═══════════════════════════════════════════════════');
|
|
console.log('🚀 Agentic Writer Local Backend v1.1.0');
|
|
console.log('═══════════════════════════════════════════════════');
|
|
console.log(`Local: http://localhost:${PORT}`);
|
|
console.log(`Network: http://YOUR-IP:${PORT}`);
|
|
console.log('');
|
|
console.log('Plugin Configuration:');
|
|
console.log(` Base URL: http://YOUR-IP:${PORT}`);
|
|
console.log(` API Key: dummy`);
|
|
console.log(` Model: claude-local`);
|
|
console.log('');
|
|
console.log('Brave Search:');
|
|
console.log(` API Key: ${braveApiKey ? 'CONFIGURED' : 'NOT SET'}`);
|
|
console.log('');
|
|
console.log('Web search works when Brave API key is found from:');
|
|
console.log(' 1. Environment: export BRAVE_SEARCH_API_KEY="key"');
|
|
console.log(' 2. .env file: BRAVE_SEARCH_API_KEY=key');
|
|
console.log(' 3. ~/.claude/settings.json env.BRAVE_SEARCH_API_KEY');
|
|
console.log('');
|
|
console.log('Restart proxy after adding key: ./stop-proxy.sh && ./start-proxy.sh');
|
|
console.log('');
|
|
console.log('Health check: GET /ping');
|
|
console.log('Inference: POST /v1/messages');
|
|
console.log('═══════════════════════════════════════════════════');
|
|
}); |