diff --git a/.env.example b/.env.example index dbef59cc..9ebfb0c0 100644 --- a/.env.example +++ b/.env.example @@ -5,11 +5,14 @@ # Get your key at: https://openrouter.ai/keys VITE_OPENROUTER_API_KEY=sk-or-your-key-here -# Anthropic Claude — use ONE of these: -# Option 1: OAuth token from Claude Code CLI (run: claude auth login) -VITE_ANTHROPIC_TOKEN=sk-ant-oat01-your-token-here -# Option 2: API key from console (https://console.anthropic.com/settings/keys) -VITE_ANTHROPIC_API_KEY=sk-ant-your-key-here +# Anthropic Claude — for live web search (Claude invokes search mid-response): +# Option 1: OAuth token from Max subscription (run: claude setup-token, save output) +# → No extra cost; uses your existing Max subscription. +ANTHROPIC_TOKEN=sk-ant-oat01-your-token-here +# Option 2: API key from https://console.anthropic.com/settings/keys +ANTHROPIC_API_KEY=sk-ant-your-key-here +# +# Without either: proxy uses CLI with built-in WebSearch + pre-fetched context. # TMDB API (free, fetches posters on-demand when images fail) # Get your key at: https://www.themoviedb.org/settings/api @@ -19,6 +22,11 @@ TMDB_API_KEY=your-tmdb-key-here # Get your client_id at: https://devportal.jamendo.com/ JAMENDO_CLIENT_ID=your-jamendo-client-id +# SearXNG instance for web search (optional) +# Uses public instances by default; falls back to DuckDuckGo when they fail. +# For reliable dev: host your own (https://docs.searxng.org/) or rely on DDG fallback. +# SEARXNG_URL=https://your-searxng.instance + # Development flags VITE_DEV_MODE=true VITE_MOCK_MEDIA_SOURCES=true diff --git a/packages/app/package.json b/packages/app/package.json index bf49aa54..93bdb15b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -26,7 +26,9 @@ "devDependencies": { "@tailwindcss/vite": "latest", "@vitejs/plugin-vue": "latest", + "duck-duck-scrape": "^2.2.7", "eslint": "latest", + "rss-parser": "^3.13.0", "tailwindcss": "latest", "tsx": "^4.21.0", "typescript": "~5.8.0", diff --git a/packages/app/public/assets/icons/microphone.svg b/packages/app/public/assets/icons/microphone.svg new file mode 100644 index 00000000..2221b5cd --- /dev/null +++ b/packages/app/public/assets/icons/microphone.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/server/claude-proxy.ts b/packages/app/server/claude-proxy.ts index a609648c..41166c8f 100644 --- a/packages/app/server/claude-proxy.ts +++ b/packages/app/server/claude-proxy.ts @@ -1,9 +1,206 @@ import { spawn } from 'child_process' import { createServer } from 'http' -import { resolve } from 'path' +import { readFileSync, existsSync } from 'fs' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +// Load .env.local from workspace root (monorepo) or cwd +function loadEnv() { + for (const base of [resolve(__dirname, '../../..'), process.cwd()]) { + const path = resolve(base, '.env.local') + if (existsSync(path)) { + try { + const buf = readFileSync(path, 'utf8') + for (const line of buf.split('\n')) { + const m = line.match(/^([^#=]+)=(.*)$/) + if (m) { + const key = m[1].trim() + const val = m[2].trim().replace(/^["']|["']$/g, '') + if (!process.env[key]) process.env[key] = val + } + } + break + } catch { + /* ignore */ + } + } + } +} +loadEnv() const PORT = 3141 const CLAUDE_BIN = resolve(process.env.HOME ?? '', '.local/bin/claude') +const APP_URL = process.env.APP_URL ?? 'http://localhost:5173' + +/** API key (sk-ant-api03-...) or OAuth token (sk-ant-oat...) from Max subscription */ +function getAnthropicCredential(): string | undefined { + const fromEnv = process.env.ANTHROPIC_API_KEY + ?? process.env.VITE_ANTHROPIC_API_KEY + ?? process.env.ANTHROPIC_TOKEN + ?? process.env.VITE_ANTHROPIC_TOKEN + if (fromEnv) return fromEnv + const home = process.env.HOME ?? '' + const settingsPath = resolve(home, '.claude/settings.json') + if (home && existsSync(settingsPath)) { + try { + const json = JSON.parse(readFileSync(settingsPath, 'utf8')) + const env = json?.env + if (env && typeof env === 'object') { + const t = env.ANTHROPIC_TOKEN ?? env.VITE_ANTHROPIC_TOKEN ?? env.ANTHROPIC_API_KEY ?? env.VITE_ANTHROPIC_API_KEY + if (typeof t === 'string') return t + } + } catch { /* ignore */ } + } + return undefined +} + +const ANTHROPIC_CREDENTIAL = getAnthropicCredential() +const isOAuthToken = (s: string) => /^sk-ant-oat/.test(s) + +const SEARCH_WEB_TOOL = { + name: 'search_web', + description: 'Search the web for current information. Use this when the user asks for news, recent events, facts you are unsure about, or any information that may have changed. Perform one search per distinct topic. Returns titles, URLs, and snippets.', + input_schema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query (e.g. "Bitcoin price March 2025", "latest news AI regulation")', + }, + }, + required: ['query'], + }, +} + +function mapModelToApi(model: string): string { + if (model?.includes('opus')) return 'claude-opus-4-20250514' + if (model?.includes('haiku')) return 'claude-3-5-haiku-20241022' + return 'claude-sonnet-4-20250514' +} + +async function runSearchWeb(query: string): Promise { + const url = `${APP_URL.replace(/\/$/, '')}/api/web-search?${new URLSearchParams({ q: query })}` + try { + const res = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(10000), + }) + if (!res.ok) return `Search failed: ${res.status}` + const data = (await res.json()) as { results?: { title?: string; url?: string; content?: string }[] } + const results = data.results ?? [] + if (results.length === 0) return 'No results found.' + return results + .map((r, i) => `${i + 1}. [${r.title ?? 'Unknown'}](${r.url ?? ''})${r.content ? ` — ${r.content.slice(0, 150)}${r.content.length > 150 ? '…' : ''}` : ''}`) + .join('\n') + } catch (err) { + return `Search error: ${err instanceof Error ? err.message : String(err)}` + } +} + +async function streamViaAnthropicApi( + model: string, + system: string | undefined, + messages: { role: string; content: string }[], + res: import('http').ServerResponse, +): Promise { + const apiModel = mapModelToApi(model) + const apiMessages = messages.map((m) => ({ + role: m.role === 'assistant' ? 'assistant' : 'user', + content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content), + })) + + let clientDisconnected = false + res.on('close', () => { clientDisconnected = true }) + + const sendDelta = (text: string) => { + if (!clientDisconnected) { + res.write(`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text } })}\n\n`) + } + } + + const sendError = (msg: string) => { + if (!clientDisconnected) { + res.write(`data: ${JSON.stringify({ type: 'error', error: { message: msg } })}\n\n`) + } + } + + let turnMessages = [...apiMessages] + const maxToolRounds = 5 + let rounds = 0 + + while (rounds < maxToolRounds) { + rounds++ + const body: Record = { + model: apiModel, + max_tokens: 4096, + system, + messages: turnMessages, + tools: [SEARCH_WEB_TOOL], + } + + const headers: Record = { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + } + if (isOAuthToken(ANTHROPIC_CREDENTIAL!)) { + headers['Authorization'] = `Bearer ${ANTHROPIC_CREDENTIAL}` + headers['anthropic-beta'] = 'oauth-2025-04-20' + } else { + headers['x-api-key'] = ANTHROPIC_CREDENTIAL! + } + + const apiRes = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(120000), + }) + + if (!apiRes.ok) { + const errBody = await apiRes.text() + sendError(`Anthropic API ${apiRes.status}: ${errBody.slice(0, 200)}`) + break + } + + const data = (await apiRes.json()) as { + content?: { type: string; text?: string; id?: string; name?: string; input?: { query?: string } }[] + stop_reason?: string + } + + const content = data.content ?? [] + const toolUses = content.filter((b) => b.type === 'tool_use') + const textBlocks = content.filter((b) => b.type === 'text') + + if (data.stop_reason === 'tool_use' && toolUses.length > 0) { + const toolResults: { type: string; tool_use_id: string; content: string }[] = [] + for (const tu of toolUses) { + if (tu.name === 'search_web' && tu.id && tu.input?.query) { + console.log('[proxy] tool search_web:', tu.input.query) + const result = await runSearchWeb(tu.input.query) + toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: result }) + } + } + turnMessages = [ + ...turnMessages, + { role: 'assistant' as const, content }, + { role: 'user' as const, content: toolResults }, + ] + continue + } + + for (const block of textBlocks) { + if (block.text) sendDelta(block.text) + } + break + } + + if (!clientDisconnected) { + res.write('data: [DONE]\n\n') + res.end() + } +} const server = createServer((req, res) => { if (req.method === 'OPTIONS') { @@ -26,7 +223,26 @@ const server = createServer((req, res) => { req.on('data', (chunk) => { body += chunk }) req.on('end', () => { try { - const { model, messages, system } = JSON.parse(body) + const payload = JSON.parse(body) + const { model, messages, system, webSearch } = payload + + const useTools = webSearch === true && !!ANTHROPIC_CREDENTIAL + + if (useTools) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'X-Accel-Buffering': 'no', + }) + streamViaAnthropicApi(model, system, messages ?? [], res) + return + } + + if (webSearch === true && !ANTHROPIC_CREDENTIAL) { + console.log('[proxy] webSearch: using CLI built-in WebSearch + pre-fetched context') + } const modelFlag = model?.includes('opus') ? 'opus' : model?.includes('haiku') ? 'haiku' @@ -50,13 +266,21 @@ const server = createServer((req, res) => { const args = ['-p', '--model', modelFlag] if (systemPrompt) args.push('--system-prompt', systemPrompt) + if (webSearch === true) { + args.push('--allowed-tools', 'WebSearch', 'WebFetch') + args.push('--permission-mode', 'dontAsk') + } args.push('--', lastUserMsg) - console.log(`[proxy] → claude -p --model ${modelFlag} "${lastUserMsg.slice(0, 60)}..."`) + console.log(`[proxy] → claude -p --model ${modelFlag}${webSearch ? ' [WebSearch]' : ''} "${lastUserMsg.slice(0, 60)}..."`) + const procEnv = { ...process.env, NO_COLOR: '1', TERM: 'dumb' } + if (webSearch === true) { + delete procEnv.DISALLOWED_TOOLS + } const proc = spawn(CLAUDE_BIN, args, { stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' }, + env: procEnv, detached: false, }) @@ -135,5 +359,10 @@ const server = createServer((req, res) => { server.listen(PORT, () => { console.log(`\n Claude proxy → http://localhost:${PORT}`) console.log(` Binary: ${CLAUDE_BIN}`) - console.log(` Using your Max subscription\n`) + if (ANTHROPIC_CREDENTIAL) { + const mode = isOAuthToken(ANTHROPIC_CREDENTIAL) ? 'OAuth (Max)' : 'API key' + console.log(` Tool use (search_web): enabled (${mode})\n`) + } else { + console.log(` Tool use: add ANTHROPIC_TOKEN (Max) or ANTHROPIC_API_KEY to .env.local\n`) + } }) diff --git a/packages/app/src/App.vue b/packages/app/src/App.vue index acf20cfc..70d79e45 100644 --- a/packages/app/src/App.vue +++ b/packages/app/src/App.vue @@ -1,6 +1,7 @@ @@ -8,6 +9,7 @@ import { onMounted } from 'vue' import { RouterView } from 'vue-router' import { useTheme } from '@/composables/useTheme' +import ArticleOverlay from '@/components/content/ArticleOverlay.vue' const { currentTheme, initTheme } = useTheme() diff --git a/packages/app/src/components/chat/ChatHeader.vue b/packages/app/src/components/chat/ChatHeader.vue index ed496c59..98a769a9 100644 --- a/packages/app/src/components/chat/ChatHeader.vue +++ b/packages/app/src/components/chat/ChatHeader.vue @@ -1,104 +1,43 @@