diff --git a/packages/app/scripts/dev.sh b/packages/app/scripts/dev.sh index 6f9e0922..f4b26539 100755 --- a/packages/app/scripts/dev.sh +++ b/packages/app/scripts/dev.sh @@ -10,7 +10,7 @@ APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$APP_DIR" # Generate a dev API token if not already set -if [ -z "$VITE_DEV_API_TOKEN" ]; then +if [ -z "${VITE_DEV_API_TOKEN:-}" ]; then export VITE_DEV_API_TOKEN=$(openssl rand -hex 16) fi @@ -23,13 +23,14 @@ trap cleanup EXIT INT TERM # Use local node_modules binaries BIN="$APP_DIR/node_modules/.bin" -# Start Claude proxy in background (skip gracefully if already running on :3141) +# Kill stale proxy from previous session (it has a different auth token) if lsof -ti:3141 > /dev/null 2>&1; then - echo " Claude proxy already running on :3141 — skipping" -else - "$BIN/tsx" server/claude-proxy.ts & + echo " Killing stale Claude proxy on :3141" + kill $(lsof -ti:3141) 2>/dev/null || true sleep 0.3 fi +"$BIN/tsx" server/claude-proxy.ts & +sleep 0.3 # Start Vite dev server in background (host binding controlled by vite.config.ts / VITE_HOST) "$BIN/vite" & diff --git a/packages/app/server/claude-proxy.ts b/packages/app/server/claude-proxy.ts index 9fcb3a65..187327e3 100644 --- a/packages/app/server/claude-proxy.ts +++ b/packages/app/server/claude-proxy.ts @@ -1,4 +1,4 @@ -import { spawn } from 'child_process' +import { spawn, execSync } from 'child_process' import { createServer } from 'http' import { readFileSync, existsSync } from 'fs' import { resolve, dirname } from 'path' @@ -54,6 +54,21 @@ function getAnthropicCredential(): string | undefined { } } catch { /* ignore */ } } + // macOS keychain: Claude Code stores OAuth credentials here + if (process.platform === 'darwin') { + try { + const raw = execSync( + 'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', + { encoding: 'utf8', timeout: 3000 }, + ).trim() + const creds = JSON.parse(raw) + const oauthToken = creds?.claudeAiOauth?.accessToken + if (typeof oauthToken === 'string' && oauthToken.startsWith('sk-ant-')) { + console.log('[proxy] Found Claude OAuth token in macOS keychain') + return oauthToken + } + } catch { /* keychain not available or no entry */ } + } return undefined } @@ -104,13 +119,16 @@ async function runSearchWeb(query: string): Promise { async function streamViaAnthropicApi( model: string, system: string | undefined, - messages: { role: string; content: string }[], + messages: { role: string; content: unknown }[], res: import('http').ServerResponse, + useTools: boolean, + credential: string, + maxTokens?: number, ): 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), + content: typeof m.content === 'string' ? m.content : m.content, })) let clientDisconnected = false @@ -128,79 +146,143 @@ async function streamViaAnthropicApi( } } - 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 buildHeaders = (): Record => { const headers: Record = { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', } - if (isOAuthToken(ANTHROPIC_CREDENTIAL!)) { - headers['Authorization'] = `Bearer ${ANTHROPIC_CREDENTIAL}` + if (isOAuthToken(credential)) { + headers['Authorization'] = `Bearer ${credential}` headers['anthropic-beta'] = 'oauth-2025-04-20' } else { - headers['x-api-key'] = ANTHROPIC_CREDENTIAL! + headers['x-api-key'] = credential } + return headers + } - const apiRes = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers, - body: JSON.stringify(body), - signal: AbortSignal.timeout(120000), - }) + // Tool use loop (non-streaming — needs to collect tool calls) + if (useTools) { + let turnMessages = [...apiMessages] + const maxToolRounds = 5 + let rounds = 0 - if (!apiRes.ok) { - const errBody = await apiRes.text() - sendError(`Anthropic API ${apiRes.status}: ${errBody.slice(0, 200)}`) + while (rounds < maxToolRounds) { + rounds++ + const body: Record = { + model: apiModel, + max_tokens: maxTokens ?? 4096, + system, + messages: turnMessages, + tools: [SEARCH_WEB_TOOL], + } + + const apiRes = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: buildHeaders(), + 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 } - const data = (await apiRes.json()) as { - content?: { type: string; text?: string; id?: string; name?: string; input?: { query?: string } }[] - stop_reason?: string + if (!clientDisconnected) { + res.write('data: [DONE]\n\n') + res.end() } - - 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 + return } - if (!clientDisconnected) { - res.write('data: [DONE]\n\n') - res.end() + // Streaming path (no tools) + const body: Record = { + model: apiModel, + max_tokens: maxTokens ?? 4096, + stream: true, + messages: apiMessages, + } + if (system) body.system = system + + const apiRes = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: buildHeaders(), + 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)}`) + if (!clientDisconnected) { + res.write('data: [DONE]\n\n') + res.end() + } + return + } + + // Pipe SSE stream from Anthropic to client + const reader = apiRes.body?.getReader() + if (!reader) { + sendError('No response body from API') + if (!clientDisconnected) { + res.write('data: [DONE]\n\n') + res.end() + } + return + } + + const decoder = new TextDecoder() + try { + while (true) { + if (clientDisconnected) break + const { done, value } = await reader.read() + if (done) break + const chunk = decoder.decode(value, { stream: true }) + res.write(chunk) + } + } catch (err) { + if (!clientDisconnected) { + sendError(`Stream error: ${err instanceof Error ? err.message : String(err)}`) + } + } finally { + reader.cancel().catch(() => {}) + if (!clientDisconnected) { + res.end() + } } } @@ -314,26 +396,30 @@ const server = createServer((req, res) => { } try { const payload = JSON.parse(body) - const { model, messages, system, webSearch } = payload + const { model, messages, system, webSearch, max_tokens } = payload - const useTools = webSearch === true && !!ANTHROPIC_CREDENTIAL + // Use client-provided API key if present, otherwise fall back to server credential + const clientKey = req.headers['x-api-key'] as string | undefined + const credential = clientKey || ANTHROPIC_CREDENTIAL + + if (credential) { + // Direct API — fast streaming + const useTools = webSearch === true + const apiModel = mapModelToApi(model) + console.log(`[proxy] → Anthropic API ${apiModel}${useTools ? ' [tools]' : ' [stream]'}`) - if (useTools) { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': 'http://localhost:5173', + 'Access-Control-Allow-Origin': '*', 'X-Accel-Buffering': 'no', }) - streamViaAnthropicApi(model, system, messages ?? [], res) + streamViaAnthropicApi(model, system, messages ?? [], res, useTools, credential, max_tokens) return } - if (webSearch === true && !ANTHROPIC_CREDENTIAL) { - console.log('[proxy] webSearch: using CLI built-in WebSearch + pre-fetched context') - } - + // CLI fallback — no API credential available const modelFlag = model?.includes('opus') ? 'opus' : model?.includes('haiku') ? 'haiku' : 'sonnet' @@ -362,10 +448,9 @@ const server = createServer((req, res) => { } args.push('--', lastUserMsg) - console.log(`[proxy] → claude -p --model ${modelFlag}${webSearch ? ' [WebSearch]' : ''} "${lastUserMsg.slice(0, 60)}..."`) + console.log(`[proxy] → claude CLI --model ${modelFlag}${webSearch ? ' [WebSearch]' : ''} "${lastUserMsg.slice(0, 60)}..."`) const procEnv = { ...process.env, NO_COLOR: '1', TERM: 'dumb' } - // Allow CLI to spawn even when dev server is started from inside Claude Code delete procEnv.CLAUDECODE delete procEnv.CLAUDE_CODE delete procEnv.ANTHROPIC_CLAUDE_CODE @@ -385,7 +470,7 @@ const server = createServer((req, res) => { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': 'http://localhost:5173', + 'Access-Control-Allow-Origin': '*', 'X-Accel-Buffering': 'no', }) @@ -395,7 +480,6 @@ const server = createServer((req, res) => { proc.stdout.on('data', (chunk: Buffer) => { const text = chunk.toString() fullOutput += text - console.log(`[proxy] stdout +${text.length}b total=${fullOutput.length}b`) if (!clientDisconnected) { const sseData = { diff --git a/packages/app/server/dev-auth.ts b/packages/app/server/dev-auth.ts index 114caf33..f51699df 100644 --- a/packages/app/server/dev-auth.ts +++ b/packages/app/server/dev-auth.ts @@ -5,13 +5,12 @@ */ import type { IncomingMessage, ServerResponse } from 'http' -const DEV_TOKEN = process.env.VITE_DEV_API_TOKEN ?? '' - /** Validate Authorization header. Returns true if authorized, false if rejected (response already sent). */ export function validateDevAuth(req: IncomingMessage, res: ServerResponse): boolean { - if (!DEV_TOKEN) return true // No token configured, skip auth + const token = process.env.VITE_DEV_API_TOKEN ?? '' + if (!token) return true // No token configured, skip auth const auth = req.headers.authorization - if (auth === `Bearer ${DEV_TOKEN}`) return true + if (auth === `Bearer ${token}`) return true res.writeHead(401, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Unauthorized' })) return false diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts index 2212faf9..56a71c11 100644 --- a/packages/app/src/composables/useAI.ts +++ b/packages/app/src/composables/useAI.ts @@ -551,17 +551,22 @@ export function useAI() { chatStore.isStreaming = true let systemPrompt = buildSystemPrompt(chatStore) + let clientSearchSucceeded = false if (chatStore.webSearchEnabled && userText.trim()) { const results = await searchWeb(userText) if (results.length > 0) { systemPrompt += formatWebSearchContext(results) chatStore.setMessageWebResults(cid, assistantMsg.id, results) + clientSearchSucceeded = true console.log('[AIUI] Injected', results.length, 'web search results into context') } else { - console.warn('[AIUI] Web search enabled but 0 results — check browser console for [AIUI web-search] logs') + console.warn('[AIUI] Web search enabled but 0 results — proxy will handle search') } } + // If client-side search succeeded, don't ask the proxy to search again + const proxyWebSearch = chatStore.webSearchEnabled && !clientSearchSucceeded + const history: ChatMessage[] = chatStore.messages .filter((m) => m.id !== assistantMsg.id) .map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, images: m.images })) @@ -576,7 +581,7 @@ export function useAI() { try { if (provider === 'claude') { - await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled, signal, genParams) + await streamClaude(history, onToken, onError, systemPrompt, proxyWebSearch, signal, genParams) } else if (provider === 'openrouter') { await streamOpenRouter(history, onToken, onError, systemPrompt, signal) } else { diff --git a/packages/app/vite-rss.ts b/packages/app/vite-rss.ts index 62cdf332..2e42652e 100644 --- a/packages/app/vite-rss.ts +++ b/packages/app/vite-rss.ts @@ -142,10 +142,10 @@ async function fetchRssFromUrls(urls: string[]): Promise { function createRssMiddleware() { return async (req: Connect.IncomingMessage, res: any, next: () => void) => { if (req.method !== 'GET') return next() - if (!validateDevAuth(req, res)) return - if (!checkRateLimit(req, res)) return const requestUrl = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`) if (!requestUrl.pathname.startsWith('/api/rss-articles')) return next() + if (!validateDevAuth(req, res)) return + if (!checkRateLimit(req, res)) return const urls = requestUrl.searchParams.getAll('url').map((u) => u.trim()).filter(Boolean) diff --git a/packages/app/vite-web-search.ts b/packages/app/vite-web-search.ts index aa22b8f4..9aa10704 100644 --- a/packages/app/vite-web-search.ts +++ b/packages/app/vite-web-search.ts @@ -33,7 +33,7 @@ async function fetchFromSearXNG( try { const searchRes = await fetch(searchUrl, { headers: { Accept: 'application/json', 'User-Agent': 'AIUI/1.0' }, - signal: AbortSignal.timeout(6000), + signal: AbortSignal.timeout(3000), }) if (!searchRes.ok) return null const text = await searchRes.text() @@ -121,7 +121,9 @@ function createWebSearchMiddleware(searxUrl: string | undefined, braveApiKey: st let data: { results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] } | null = null let lastError = '' - for (let i = 0; i < instances.length; i++) { + // Try at most 3 instances to avoid long waits + const maxTries = Math.min(3, instances.length) + for (let i = 0; i < maxTries; i++) { const baseUrl = instances[(startIdx + i) % instances.length].replace(/\/$/, '') const searchUrl = `${baseUrl}/search?${new URLSearchParams({ q, format: 'json', pageno: '1' })}` console.log('[web-search]', q.slice(0, 40), '→', baseUrl) diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index d4ccb16c..5653b264 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -131,7 +131,7 @@ export default defineConfig({ }, server: { port: 5173, - host: process.env.VITE_HOST || 'localhost', + host: process.env.VITE_HOST === 'false' ? 'localhost' : true, open: false, proxy: { '/api/claude': {