feat(chat): enhance chat functionality with web search and article integration

- Updated ChatMessage and ChatWindow components to support inline web search results and articles.
- Integrated new web search and RSS plugins into the chat system for real-time information retrieval.
- Enhanced useContentPanel to manage web search results alongside existing media types.
- Added ArticleOverlay component for displaying selected articles from search results.
- Improved UI elements and styles for better user interaction with web search features.

Made-with: Cursor
This commit is contained in:
Dorian
2026-03-02 21:29:50 +00:00
parent 49ec6c09b6
commit 2d056f9498
36 changed files with 2616 additions and 303 deletions
+234 -5
View File
@@ -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<string> {
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<void> {
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<string, unknown> = {
model: apiModel,
max_tokens: 4096,
system,
messages: turnMessages,
tools: [SEARCH_WEB_TOOL],
}
const headers: Record<string, string> = {
'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`)
}
})