Compare commits

1 Commits
Author SHA1 Message Date
evandro e08189f640 wip: snapshot das alterações não commitadas (2026-10-20)
Origem: D:/Documents/Github/LocalAiApp
Arquivos omitidos (segredos, bancos de dados, logs, >50 MB): 0
2026-10-20 00:48:09 -03:00
17 changed files with 2914 additions and 13236 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
node_modules/
.next/
dist/
build/
out/
coverage/
.git/
.pnpm-store/
.cache/
*.log
.env
.env.*
__pycache__/
*.pyc
.venv/
venv/
target/
*.exe
*.dll
*.so
*.dylib
+32
View File
@@ -0,0 +1,32 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<ThemedView style={styles.container}>
<ThemedText type="title">This screen doesn't exist.</ThemedText>
<Link href="/" style={styles.link}>
<ThemedText type="link">Go to home screen!</ThemedText>
</Link>
</ThemedView>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});
+1 -1
View File
@@ -28,7 +28,7 @@ export default function RootLayout() {
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="chat" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ presentation: 'modal', title: 'Settings' }} />
<Stack.Screen name="settings/index" options={{ presentation: 'modal', title: 'Settings' }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style={isDark ? 'light' : 'dark'} />
+24 -7
View File
@@ -26,6 +26,17 @@ export default function ChatScreen() {
const editMessage = useAppStore(state => state.editMessage);
const { sendMessage, stopStreaming, regenerateResponse, isStreaming, isConnected } = useLMStudio();
const flatListRef = useRef<FlatList>(null);
const isUserScrollingRef = useRef(false);
const isAutoScrollEnabled = useRef(true);
const prevMessagesLength = useRef(messages.length);
const handleScroll = useCallback((event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
// if user is within 100 pixels of the bottom, it's considered at the bottom
const paddingToBottom = 100;
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom;
isAutoScrollEnabled.current = isCloseToBottom;
}, []);
// Find the editing message content
const editingMessage = editingMessageId
@@ -69,6 +80,7 @@ export default function ChatScreen() {
}, 100);
} else {
sendMessage(text);
isAutoScrollEnabled.current = true; // explicitly re-enable on manual send
}
}, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, sendMessage]);
@@ -81,17 +93,19 @@ export default function ChatScreen() {
: null;
useEffect(() => {
const isNewMessage = messages.length > prevMessagesLength.current;
prevMessagesLength.current = messages.length;
if (messages.length > 0 && flatListRef.current) {
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: !isStreaming });
if ((isAutoScrollEnabled.current && !isUserScrollingRef.current) || isNewMessage) {
flatListRef.current?.scrollToEnd({ animated: true });
}
}, 100);
}
}, [messages.length, messages[messages.length - 1]?.content?.length, isStreaming]);
}, [messages.length, messages[messages.length - 1]?.content?.length]);
const showTypingIndicator = isStreaming &&
messages.length > 0 &&
messages[messages.length - 1]?.role === 'assistant' &&
messages[messages.length - 1]?.content === '';
return (
<KeyboardAvoidingView
@@ -137,6 +151,10 @@ export default function ChatScreen() {
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.id}
onScroll={handleScroll}
onScrollBeginDrag={() => isUserScrollingRef.current = true}
onScrollEndDrag={() => isUserScrollingRef.current = false}
scrollEventThrottle={16}
renderItem={({ item, index }) => (
<Animated.View entering={FadeInDown.duration(300).delay(index > messages.length - 4 ? 100 : 0)}>
<MessageBubble
@@ -151,7 +169,6 @@ export default function ChatScreen() {
)}
contentContainerStyle={styles.listContent}
/>
{showTypingIndicator && <TypingIndicator />}
</>
)}
+27 -1
View File
@@ -1,6 +1,7 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import * as Haptics from 'expo-haptics';
import { Send, Square, X } from 'lucide-react-native';
import { Globe, Send, Square, X } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import {
Platform,
@@ -22,6 +23,8 @@ interface ChatInputProps {
export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit }: ChatInputProps) {
const [text, setText] = useState('');
const colors = useThemeColor();
const isWebSearchEnabled = useAppStore(state => state.isWebSearchEnabled);
const setIsWebSearchEnabled = useAppStore(state => state.setIsWebSearchEnabled);
// When editingText changes, fill the input
useEffect(() => {
@@ -77,6 +80,20 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
{ backgroundColor: colors.inputBg, borderColor: colors.border, borderWidth: 1 },
isEditing && { borderColor: colors.tint, borderWidth: 1.5 }
]}>
<TouchableOpacity
style={styles.webSearchButton}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setIsWebSearchEnabled(!isWebSearchEnabled);
}}
activeOpacity={0.7}
>
<Globe
size={20}
color={isWebSearchEnabled ? colors.tint : colors.textMuted}
/>
</TouchableOpacity>
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Send a message..."
@@ -164,6 +181,15 @@ const styles = StyleSheet.create({
marginLeft: 8,
marginBottom: 2,
},
webSearchButton: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
marginRight: 4,
marginBottom: 2,
},
stopButton: {
width: 34,
height: 34,
+90 -9
View File
@@ -3,9 +3,10 @@ import { ChatMessage } from '@/store/useAppStore';
import Markdown from '@ronradtke/react-native-markdown-display';
import * as Clipboard from 'expo-clipboard';
import { Bot, Check, Copy, User } from 'lucide-react-native';
import React, { useMemo, useState } from 'react';
import React, { useMemo, useState, useEffect } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
import { MessageActions } from './MessageActions';
import { ThinkingBlock } from './ThinkingBlock';
interface MessageBubbleProps {
message: ChatMessage;
@@ -40,6 +41,37 @@ export function MessageBubble({
const showingStreaming = isStreaming && isLastAssistant;
const shouldShowActions = !isUser || showActions;
const [displayedContent, setDisplayedContent] = useState(message.content);
useEffect(() => {
if (!showingStreaming) {
setDisplayedContent(message.content);
return;
}
let animationFrameId: number;
let currentText = displayedContent;
const targetText = message.content;
if (targetText.length < currentText.length) {
setDisplayedContent(targetText);
return;
}
const animate = () => {
if (currentText.length < targetText.length) {
const diff = targetText.length - currentText.length;
const charsToAdd = Math.max(1, Math.ceil(diff / 4));
currentText = targetText.slice(0, currentText.length + charsToAdd);
setDisplayedContent(currentText);
animationFrameId = requestAnimationFrame(animate);
}
};
animationFrameId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrameId);
}, [message.content, showingStreaming]);
// UseMemo for dynamic colors
const markdownStyles = useMemo(() => ({
body: {
@@ -172,22 +204,56 @@ export function MessageBubble({
}), [copiedText]);
const renderContent = () => {
let activeContent = showingStreaming ? displayedContent : message.content;
if (showingStreaming && displayedContent === '' && message.content !== '') {
// Prevent empty bubble flicker on the first chunk
activeContent = message.content.slice(0, 1);
}
if (isUser) {
return (
<Text style={[styles.text, { color: colors.messageUserText }]}>
{message.content}
{activeContent}
</Text>
);
}
if (!message.content && showingStreaming) {
return null; // TypingIndicator handled externally
if (!activeContent && showingStreaming) {
return (
<View style={styles.dotsRow}>
<View style={[styles.dot, styles.dot1, { backgroundColor: colors.textMuted }]} />
<View style={[styles.dot, styles.dot2, { backgroundColor: colors.textMuted }]} />
<View style={[styles.dot, styles.dot3, { backgroundColor: colors.textMuted }]} />
</View>
);
}
const thinkMatch = activeContent.match(/<think>([\s\S]*?)(?:<\/think>|$)/);
let cleanContent = activeContent;
let thinkingContent = null;
let isThinkingComplete = false;
if (thinkMatch) {
thinkingContent = thinkMatch[1].trim();
isThinkingComplete = activeContent.includes('</think>');
cleanContent = activeContent.replace(thinkMatch[0], '').trim();
}
return (
<Markdown style={markdownStyles} rules={markdownRules}>
{message.content}
</Markdown>
<>
{thinkingContent !== null && (
<ThinkingBlock
content={thinkingContent}
isComplete={isThinkingComplete}
isStreaming={showingStreaming}
/>
)}
{cleanContent.length > 0 && (
<Markdown style={markdownStyles} rules={markdownRules}>
{cleanContent}
</Markdown>
)}
</>
);
};
@@ -234,10 +300,10 @@ export function MessageBubble({
</View>
{/* Action buttons */}
{(shouldShowActions || !isUser) && message.content.length > 0 && (
{(shouldShowActions || !isUser) && (showingStreaming ? displayedContent : message.content).length > 0 && (
<MessageActions
role={message.role}
content={message.content}
content={showingStreaming ? displayedContent : message.content}
onEdit={isUser ? onEdit : undefined}
onRegenerate={!isUser && isLastAssistant ? onRegenerate : undefined}
onDelete={onDelete}
@@ -357,4 +423,19 @@ const styles = StyleSheet.create({
fontSize: 13,
fontFamily: 'monospace',
},
dotsRow: {
flexDirection: 'row',
gap: 6,
alignItems: 'center',
minHeight: 22,
paddingHorizontal: 4,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
dot1: { opacity: 1.0 },
dot2: { opacity: 0.6 },
dot3: { opacity: 0.3 },
});
+127
View File
@@ -0,0 +1,127 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { BrainCircuit, ChevronDown, ChevronRight } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming
} from 'react-native-reanimated';
interface ThinkingBlockProps {
content: string;
isComplete: boolean;
isStreaming?: boolean;
}
export function ThinkingBlock({ content, isComplete, isStreaming }: ThinkingBlockProps) {
const colors = useThemeColor();
// Auto-expand while streaming/thinking, auto-collapse when complete
const [isExpanded, setIsExpanded] = useState(!isComplete);
// Auto collapse when it completes streaming
useEffect(() => {
if (isComplete && !isStreaming) {
setIsExpanded(false);
} else if (!isComplete && isStreaming) {
setIsExpanded(true);
}
}, [isComplete, isStreaming]);
// Pulse animation value
const pulseOpacity = useSharedValue(1);
useEffect(() => {
if (!isComplete && isStreaming) {
pulseOpacity.value = withRepeat(
withSequence(
withTiming(0.4, { duration: 800 }),
withTiming(1, { duration: 800 })
),
-1, // infinite
true // reverse
);
} else {
pulseOpacity.value = withTiming(1, { duration: 300 });
}
}, [isComplete, isStreaming, pulseOpacity]);
const animatedHeaderStyle = useAnimatedStyle(() => {
return {
opacity: pulseOpacity.value,
};
});
if (!content && isComplete) {
return null;
}
return (
<View style={[styles.container, { borderColor: colors.border }]}>
<TouchableOpacity
style={styles.header}
activeOpacity={0.7}
onPress={() => setIsExpanded(!isExpanded)}
>
<Animated.View style={[styles.titleRow, animatedHeaderStyle]}>
<BrainCircuit size={16} color={colors.textMuted} />
<Text style={[styles.titleText, { color: colors.textMuted }]}>
{!isComplete && isStreaming ? 'Pensando...' : 'Processo de Raciocínio'}
</Text>
</Animated.View>
{isExpanded ? (
<ChevronDown size={18} color={colors.textMuted} />
) : (
<ChevronRight size={18} color={colors.textMuted} />
)}
</TouchableOpacity>
{isExpanded && content.length > 0 && (
<View style={[styles.contentBlock, { borderTopColor: colors.border }]}>
<Text style={[styles.contentText, { color: colors.textMuted }]}>
{content}
</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
borderWidth: 1,
borderRadius: 8,
marginVertical: 8,
overflow: 'hidden',
width: '100%',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingVertical: 10,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
titleText: {
fontSize: 13,
fontWeight: '600',
},
contentBlock: {
paddingHorizontal: 12,
paddingVertical: 10,
borderTopWidth: 1,
},
contentText: {
fontSize: 14,
lineHeight: 20,
fontFamily: 'monospace',
},
});
+116 -14
View File
@@ -3,6 +3,7 @@ import { SYSTEM_PROMPT } from '../constants/SystemPrompt';
import * as api from '../services/lmStudioApi';
import { ChatMessage as ApiMessage } from '../services/types';
import { useAppStore } from '../store/useAppStore';
import { WebSearchLogger } from '../services/webSearchLogger';
export function useLMStudio() {
const serverConfig = useAppStore((s) => s.serverConfig);
@@ -47,18 +48,13 @@ export function useLMStudio() {
const sendMessage = useCallback(async (text: string) => {
if (isStreaming) return;
const pipelineStart = Date.now();
const store = useAppStore.getState();
const isWebSearchEnabled = store.isWebSearchEnabled;
addMessage({ role: 'user', content: text });
const currentMessages = useAppStore.getState().messages;
const apiMessages: ApiMessage[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...currentMessages.map((m) => ({
role: m.role,
content: m.content,
}))
];
const modelId = useAppStore.getState().activeModelId;
const modelId = store.activeModelId;
if (!modelId) {
addMessage({
role: 'assistant',
@@ -71,19 +67,128 @@ export function useLMStudio() {
setIsStreaming(true);
try {
let searchContext = '';
let searchSources: Array<{ title: string; url: string }> = [];
if (isWebSearchEnabled) {
// ── Step 1: Build search service URL ─────────────────────────────
// ServerConfig has { host, port } — construct the search service URL
// from the same host, but on port 3030.
const rawHost = store.serverConfig.host.replace(/\/+$/, '');
const hostWithProtocol = rawHost.startsWith('http') ? rawHost : `http://${rawHost}`;
const searchEndpoint = `${hostWithProtocol}:3030/search-and-extract`;
WebSearchLogger.pipelineStart(text, searchEndpoint);
WebSearchLogger.endpointResolved(
store.serverConfig.host,
store.serverConfig.port,
searchEndpoint,
);
appendToMessage(assistantId, '<think>Searching the web...</think>\n');
try {
const maxContextLength = 10000;
// 60s timeout: SearXNG + 3 páginas em paralelo (max ~8s cada) + parsing
// Sequential era ~24s worst case; paralelo cabe em ~10-15s. 60s é margem segura.
const SEARCH_TIMEOUT_MS = 60000;
const searchController = new AbortController();
const searchTimeout = setTimeout(() => {
searchController.abort();
}, SEARCH_TIMEOUT_MS);
WebSearchLogger.requestSent(text, maxContextLength, SEARCH_TIMEOUT_MS);
const searchRes = await fetch(searchEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: text,
maxContextLength,
}),
signal: searchController.signal,
});
clearTimeout(searchTimeout);
if (searchRes.ok) {
const searchData = await searchRes.json();
searchContext = searchData.context ?? '';
searchSources = searchData.sources ?? [];
WebSearchLogger.contextReceived(
searchContext,
searchSources,
searchData.meta,
);
} else {
WebSearchLogger.requestFailed(searchRes.status, searchRes.statusText);
}
} catch (error) {
const isTimeout = error instanceof Error && error.name === 'AbortError';
WebSearchLogger.serviceUnreachable(error, isTimeout);
}
// Clear the temporary "searching..." thought bubble
store.updateMessageContent(assistantId, '');
}
// ── Step 2: Build API messages ──────────────────────────────────────
const currentMessages = useAppStore.getState().messages;
// Exclude the empty assistant placeholder from the history
const historyToSend = currentMessages.slice(0, currentMessages.length - 1);
// Warn explicitly when web search was enabled but returned no context
if (isWebSearchEnabled && !searchContext) {
console.warn(
'[WebSearch/Fallback] ⚠️ Web search was enabled but returned NO context. ' +
'Proceeding with LLM response without web data. ' +
'Check if local-search-service is running at the correct address.',
);
}
const systemContent = SYSTEM_PROMPT + (searchContext ? `\n\n${searchContext}` : '');
const apiMessages: ApiMessage[] = [
{ role: 'system', content: systemContent },
...historyToSend.map((m) => ({
role: m.role,
content: m.content,
})),
];
// ── Step 3: Context Window Analysis ────────────────────────────────
if (isWebSearchEnabled) {
const historyChars = historyToSend.reduce((sum, m) => sum + m.content.length, 0);
WebSearchLogger.contextAnalysis(
systemContent.length,
historyChars,
searchContext.length,
);
}
// ── Step 4: Stream LLM response ────────────────────────────────────
WebSearchLogger.llmRequestStart(modelId, apiMessages.length);
let chunkCount = 0;
abortRef.current = api.chatCompletionStream(
serverConfig,
modelId,
apiMessages,
(chunk) => {
chunkCount++;
appendToMessage(assistantId, chunk);
},
() => {
WebSearchLogger.llmStreamComplete(assistantId, chunkCount);
WebSearchLogger.pipelineComplete(text, Date.now() - pipelineStart, isWebSearchEnabled);
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
},
(error) => {
WebSearchLogger.llmError(error);
const errorMsg = `\n\n⚠️ Error: ${error.message}`;
appendToMessage(assistantId, errorMsg);
setIsStreaming(false);
@@ -103,7 +208,6 @@ export function useLMStudio() {
if (isStreaming) return;
const msgs = useAppStore.getState().messages;
// Find the last user message
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') {
@@ -114,18 +218,16 @@ export function useLMStudio() {
if (lastUserIdx === -1) return;
const lastUserMsg = msgs[lastUserIdx];
// Remove messages after the last user message
const store = useAppStore.getState();
store.deleteMessagesAfter(lastUserMsg.id);
// Build API messages from remaining
const remaining = useAppStore.getState().messages;
const apiMessages: ApiMessage[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...remaining.map((m) => ({
role: m.role,
content: m.content,
}))
})),
];
const modelId = useAppStore.getState().activeModelId;
+83
View File
@@ -0,0 +1,83 @@
# Local AI Database Strategy (LanceDB + SQLite)
## Overview
A 100% offline-first local database architecture for storing AI memories in a React Native (Expo) application. The solution splits the data into two highly optimized engines:
1. **`expo-sqlite`**: Manages structured text, sessions, dates, and relationships.
2. **`LanceDB`**: Manages the high-dimensional vector embeddings, pointed by SQLite relationship IDs.
> [!WARNING] **Architectural Risk (LanceDB on Mobile)**
> LanceDB relies on robust Apache Arrow and native dependencies. Running it completely on-device in a React Native/Expo environment needs careful validation (e.g., verifying polyfills or WebAssembly compatibility), since most vector DBs are backend/Node.js native.
## Project Type
**MOBILE** (React Native / Expo)
## Success Criteria
- [ ] Both databases can correctly initialize on iOS and Android simulators/devices.
- [ ] A memory string can be successfully indexed (stored in SQLite + embedding stored in LanceDB).
- [ ] A semantic search query returns the correct related memories locally.
- [ ] Application does not crash due to native library incompatibilities.
## Tech Stack
- **`expo-sqlite`**: The official, lightweight SQLite solution for the Expo SDK, providing fully synchronous mapping properties.
- **`@lancedb/lancedb`** (or equivalent mobile-compatible API): Vector store for fast embedding similarity searches locally.
- **`zustand`** (Already installed): State management for caching the queries/memories if needed in the UI.
## File Structure
```
app/
...
services/
db/
sqliteClient.ts # SQLite connection and raw queries
lanceDbClient.ts # LanceDB vector search and connection
memoryService.ts # Unifies both DBs to create the "AI Memories" logic
hooks/
useAiMemories.ts # React Hook to expose database queries
types/
database.d.ts # TS interfaces for Tables and Documents
```
## Task Breakdown
### Task 1: Environment Research & Setup
- **Agent**: `mobile-developer`
- **Skill**: `mobile-design`
- **INPUT**: Current `package.json`
- **OUTPUT**: Installation of `expo-sqlite` and LanceDB dependencies. Verified bare React Native compatibility for LanceDB.
- **VERIFY**: Running a successful `npx expo start` and `npx expo run:android` without build failures.
### Task 2: Database Schema & Layer
- **Agent**: `database-architect` (simulated by `mobile-developer`)
- **Skill**: `database-design`
- **INPUT**: Dependencies installed.
- **OUTPUT**:
- `sqliteClient.ts`: `Sessions`, `Messages`, `Metadata` tables.
- `lanceDbClient.ts`: LanceDB table initialization with vector dimensions (e.g. 384 or 1536).
- **VERIFY**: Launching the app correctly fires the initialization queries (logs "DB Connected").
### Task 3: Unifying Memory Service
- **Agent**: `mobile-developer`
- **Skill**: `clean-code`
- **INPUT**: Initialized Database clients.
- **OUTPUT**: `memoryService.ts` containing functions like `saveMemory(text, embedding)` and `searchSimilar(embedding, limit)`.
- **VERIFY**: Hardcoding a mock vector search and getting the SQLite structured data back.
### Task 4: Hook UI Integration
- **Agent**: `mobile-developer`
- **Skill**: `mobile-design`
- **INPUT**: `memoryService.ts` logic.
- **OUTPUT**: `useAiMemories.ts` hook ready to be plugged into chat components.
- **VERIFY**: Test components renders data from the DB layer.
---
## ✅ PHASE X: Verification Checklist
*To be completed after Phase 4.*
- **Linting:** ⬜ Pass (`npm run lint` & `tsc`)
- **Build (Android):** ⬜ Pass (`npx expo run:android`)
- **Build (iOS):** ⬜ Pass (`npx expo run:ios`)
- **Feature Check:** ⬜ Vector search and relation query works offline.
- **Color/UI Rules:** ⬜ UI follows standard themes (Not applicable for DB layer but required by protocol).
- **Date:** TBD
+32
View File
@@ -0,0 +1,32 @@
# Local Search Service
This is the background Web Search extraction service for the **LocalAiApp**. It acts as a lightweight proxy and DOM parser that connects your mobile app (React Native) to SearXNG, ensuring the Local AI context is pristine Markdown.
## How it Works
1. Receives a search query from the LocalAiApp via `POST /search-and-extract`.
2. Asks your local `SearXNG` instance for the top results.
3. Fetches the HTML of the resulting pages.
4. Uses `linkedom` and `Readability` to strip out all menus, footers, scripts, and clutter.
5. Converts the clean article payload into `Markdown` using `node-html-markdown`.
6. Returns the pure Markdown text to the LocalAiApp to inject into the AI's prompt.
## Setup
### Requirements
- Node.js v18+
- A running instance of [SearXNG](https://github.com/searxng/searxng) (running locally at `http://127.0.0.1:8888` by default).
### Running
1. Install dependencies:
```bash
npm install
```
2. Start the service (runs on port `3030`):
```bash
npm start
```
## Integrations
Inside the main `LocalAiApp`, the chat input now has a Web Search toggle (the Globe icon). When activated, every prompt sent will first trigger the search service. The service fetches context online and feeds the result as an invisible pre-system-prompt to LM Studio!
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "local-search-service",
"version": "1.0.0",
"description": "Helper service for LocalAiApp to fetch and extract web context",
"scripts": {
"start": "ts-node server.ts",
"dev": "ts-node server.ts"
},
"dependencies": {
"@mozilla/readability": "^0.5.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"linkedom": "^0.18.5",
"node-html-markdown": "^1.3.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/mozilla-readability": "^0.2.1",
"@types/node": "^22.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.6.2"
}
}
+435
View File
@@ -0,0 +1,435 @@
import express, { Request, Response } from 'express';
import cors from 'cors';
import { parseHTML } from 'linkedom';
import { Readability } from '@mozilla/readability';
import { NodeHtmlMarkdown } from 'node-html-markdown';
const app = express();
const PORT = process.env.PORT || 3030;
app.use(cors());
app.use(express.json());
// ─── Logger ────────────────────────────────────────────────────────────────────
type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG';
function log(level: LogLevel, phase: string, message: string, meta?: Record<string, any>) {
const ts = new Date().toISOString();
const metaStr = meta ? ` | ${JSON.stringify(meta)}` : '';
const icons: Record<LogLevel, string> = { INFO: 'ℹ️', WARN: '⚠️', ERROR: '❌', DEBUG: '🔍' };
console.log(`[${ts}] ${icons[level]} [${phase}] ${message}${metaStr}`);
}
function logSection(title: string) {
const border = '═'.repeat(60);
console.log(`\n${border}`);
console.log(` ${title}`);
console.log(`${border}`);
}
// ─── Helpers ───────────────────────────────────────────────────────────────────
/** Rough token count estimate (~4 chars per token) */
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Context Analysis — scores each extracted source by:
* - content length (longer = more info)
* - keyword overlap with the query
* - penalizes sources with very short content (likely failed extractions)
*/
function scoreSource(
source: { title: string; url: string; content: string },
query: string,
): number {
const queryWords = query
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 3);
const contentLower = source.content.toLowerCase();
const titleLower = source.title.toLowerCase();
// Keyword relevance: count query words present in content/title
const keywordMatches = queryWords.filter(
(w) => contentLower.includes(w) || titleLower.includes(w),
).length;
const keywordScore = queryWords.length > 0 ? keywordMatches / queryWords.length : 0;
// Length score (0–1): prefer 500–3000 char range
const len = source.content.length;
const lengthScore = len < 100 ? 0.1 : len > 500 ? Math.min(1, len / 3000) : 0.5;
// Penalize if content is just a fallback snippet (very short)
const snippetPenalty = len < 200 ? 0.4 : 1.0;
const total = (keywordScore * 0.6 + lengthScore * 0.4) * snippetPenalty;
log('DEBUG', 'ContextAnalysis', `Scored source: "${source.title}"`, {
url: source.url,
contentLength: len,
keywordMatches,
keywordScore: keywordScore.toFixed(2),
lengthScore: lengthScore.toFixed(2),
snippetPenalty,
totalScore: total.toFixed(3),
});
return total;
}
/**
* Intelligent Context Pruning:
* 1. Sort sources by relevance score (descending).
* 2. Include full sources one by one until the budget is reached.
* 3. If a single source already exceeds budget, truncate it at a sentence boundary.
* This avoids cutting in the middle of a word / sentence.
*/
function buildAndPruneContext(
sources: Array<{ title: string; url: string; content: string }>,
query: string,
maxChars: number,
): { markdown: string; pruned: boolean; keptSources: number; droppedSources: number } {
logSection('🧠 Context Analysis & Pruning');
// Score and rank sources
const scored = sources
.map((src) => ({ ...src, score: scoreSource(src, query) }))
.sort((a, b) => b.score - a.score);
log('INFO', 'ContextPruning', `Ranked ${scored.length} sources by relevance`, {
ranking: scored.map((s, i) => ({
rank: i + 1,
title: s.title.substring(0, 50),
score: s.score.toFixed(3),
chars: s.content.length,
estimatedTokens: estimateTokens(s.content),
})),
});
const header = `# Web Search Context for: "${query}"\n\n`;
let markdown = header;
let budgetUsed = header.length;
let keptSources = 0;
let droppedSources = 0;
let pruned = false;
for (const src of scored) {
const entry =
`## Source ${keptSources + 1}: [${src.title}](${src.url})\n\n` +
`${src.content}\n\n` +
`---\n\n`;
if (budgetUsed + entry.length <= maxChars) {
markdown += entry;
budgetUsed += entry.length;
keptSources++;
log('INFO', 'ContextPruning', `✅ Included source (budget OK)`, {
rank: keptSources,
title: src.title.substring(0, 50),
entryChars: entry.length,
budgetUsed,
budgetRemaining: maxChars - budgetUsed,
});
} else {
const remaining = maxChars - budgetUsed;
if (keptSources === 0 && remaining > 200) {
// First source is already too large — truncate at last sentence boundary
const entryHeader =
`## Source 1: [${src.title}](${src.url})\n\n`;
const contentBudget = remaining - entryHeader.length - 60; // room for suffix
let truncatedContent = src.content.substring(0, contentBudget);
// Find last sentence boundary (. ! ?)
const lastSentence = Math.max(
truncatedContent.lastIndexOf('. '),
truncatedContent.lastIndexOf('! '),
truncatedContent.lastIndexOf('? '),
truncatedContent.lastIndexOf('\n'),
);
if (lastSentence > contentBudget * 0.5) {
truncatedContent = truncatedContent.substring(0, lastSentence + 1);
}
markdown +=
entryHeader +
truncatedContent +
'\n\n...[CONTENT TRUNCATED — token budget exceeded]...\n\n---\n\n';
keptSources++;
pruned = true;
droppedSources = scored.length - keptSources;
log('WARN', 'ContextPruning', `✂️ Truncated oversized first source at sentence boundary`, {
original: src.content.length,
truncated: truncatedContent.length,
budgetRemaining: remaining,
});
break;
}
// Drop this source entirely — not enough budget
droppedSources++;
pruned = true;
log('WARN', 'ContextPruning', `🗑️ Dropped source (insufficient budget)`, {
title: src.title.substring(0, 50),
entryChars: entry.length,
budgetRemaining: remaining,
score: src.score.toFixed(3),
});
}
}
return { markdown, pruned, keptSources, droppedSources };
}
// ─── Main Search Endpoint ──────────────────────────────────────────────────────
app.post('/search-and-extract', async (req: Request, res: Response): Promise<any> => {
const requestId = `req_${Date.now().toString(36)}`;
const startTime = Date.now();
try {
const {
query,
searxngUrl = 'http://127.0.0.1:8888',
topK = 3,
maxContextLength = 8000,
} = req.body;
logSection(`🌍 Web Search Pipeline — [${requestId}]`);
log('INFO', 'Request', `Received search request`, {
requestId,
query,
searxngUrl,
topK,
maxContextLength,
estimatedTokenBudget: estimateTokens(String(maxContextLength)),
});
if (!query) {
log('WARN', 'Validation', `Rejected: query is missing`, { requestId });
return res.status(400).json({ error: 'Query is missing' });
}
// ── Step 1: SearXNG Search ─────────────────────────────────────────────
log('INFO', 'SearXNG', `Querying SearXNG...`, { url: searxngUrl, query });
const searxStart = Date.now();
const searchRes = await fetch(
`${searxngUrl}/search?q=${encodeURIComponent(query)}&format=json`,
);
const searxLatency = Date.now() - searxStart;
if (!searchRes.ok) {
log('ERROR', 'SearXNG', `Search failed`, {
status: searchRes.status,
latencyMs: searxLatency,
});
throw new Error(
`SearXNG search failed. Status: ${searchRes.status}. ` +
`Make sure SearXNG is running and 'json' format is enabled in settings.yml.`,
);
}
const searchData = await searchRes.json();
const results = searchData.results || [];
const topResults = results.slice(0, topK).filter((r: any) => r.url);
log('INFO', 'SearXNG', `Search complete`, {
requestId,
latencyMs: searxLatency,
totalResultsFound: results.length,
processingTopK: topResults.length,
urls: topResults.map((r: any) => r.url),
});
// ── Step 2: Page Extraction (parallel) ────────────────────────────────
// All pages are fetched concurrently with Promise.allSettled so a single
// slow or failing page never blocks the others.
logSection('📄 Page Extraction (parallel)');
log('INFO', 'Extractor', `Starting parallel fetch for ${topResults.length} pages`, {
urls: topResults.map((r: any) => r.url),
});
async function extractPage(result: any): Promise<{
ctx: { title: string; url: string; content: string } | null;
stat: Record<string, any>;
}> {
const pageStart = Date.now();
log('INFO', 'Extractor', `Fetching page...`, { url: result.url });
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const pageRes = await fetch(result.url, {
signal: controller.signal,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; LocalSearchBot/1.0)' },
});
clearTimeout(timeoutId);
const pageLatency = Date.now() - pageStart;
if (!pageRes.ok) {
log('WARN', 'Extractor', `Skipping page — bad HTTP status`, {
url: result.url,
status: pageRes.status,
latencyMs: pageLatency,
});
return { ctx: null, stat: { url: result.url, status: 'skipped', reason: `HTTP ${pageRes.status}`, latencyMs: pageLatency } };
}
const html = await pageRes.text();
const htmlSize = html.length;
// ── Readability Extraction ──────────────────────────────────────
const { document } = parseHTML(html);
const reader = new Readability(document as any);
const article = reader.parse();
if (!article || !article.content) {
log('WARN', 'Extractor', `Readability failed — using snippet fallback`, {
url: result.url,
htmlSizeChars: htmlSize,
latencyMs: pageLatency,
});
const fallbackContent = result.content || 'Content could not be parsed.';
return {
ctx: { title: result.title || 'Unknown', url: result.url, content: fallbackContent },
stat: { url: result.url, status: 'fallback_snippet', contentChars: fallbackContent.length, latencyMs: pageLatency },
};
}
// ── HTML → Markdown ─────────────────────────────────────────────
const markdown = NodeHtmlMarkdown.translate(article.content);
const pageLatencyFinal = Date.now() - pageStart;
log('INFO', 'Extractor', `✅ Extracted successfully`, {
url: result.url,
title: (article.title || result.title).substring(0, 60),
htmlSizeChars: htmlSize,
markdownChars: markdown.length,
estimatedTokens: estimateTokens(markdown),
latencyMs: pageLatencyFinal,
});
return {
ctx: { title: article.title || result.title, url: result.url, content: markdown },
stat: { url: result.url, status: 'success', htmlChars: htmlSize, markdownChars: markdown.length, estimatedTokens: estimateTokens(markdown), latencyMs: pageLatencyFinal },
};
} catch (err: any) {
const pageLatency = Date.now() - pageStart;
const isTimeout = err.name === 'AbortError';
log('ERROR', 'Extractor', `Failed to process page`, {
url: result.url,
error: err.message,
isTimeout,
latencyMs: pageLatency,
});
return { ctx: null, stat: { url: result.url, status: isTimeout ? 'timeout' : 'error', error: err.message, latencyMs: pageLatency } };
}
}
// Run all extractions in parallel
const parallelStart = Date.now();
const settled = await Promise.allSettled(topResults.map((r: any) => extractPage(r)));
const parallelLatency = Date.now() - parallelStart;
const extractedContexts: Array<{ title: string; url: string; content: string }> = [];
const extractionStats: Array<Record<string, any>> = [];
for (const result of settled) {
if (result.status === 'fulfilled') {
if (result.value.ctx) extractedContexts.push(result.value.ctx);
extractionStats.push(result.value.stat);
} else {
// Promise itself rejected (shouldn't happen since extractPage catches internally)
extractionStats.push({ status: 'unexpected_rejection', error: result.reason?.message });
}
}
log('INFO', 'Extractor', `Extraction phase complete (parallel)`, {
requestId,
parallelLatencyMs: parallelLatency,
attempted: topResults.length,
succeeded: extractedContexts.length,
failed: topResults.length - extractedContexts.length,
extractionStats,
});
// ── Step 3: Context Analysis + Intelligent Pruning ────────────────────
const totalRawChars = extractedContexts.reduce((sum, c) => sum + c.content.length, 0);
const totalRawTokens = estimateTokens(totalRawChars.toString()) * 4; // rough
log('INFO', 'ContextAnalysis', `Pre-pruning context stats`, {
requestId,
sources: extractedContexts.length,
totalRawChars,
estimatedRawTokens: estimateTokens(extractedContexts.reduce((s, c) => s + c.content, '')),
maxContextLength,
maxContextTokensEstimate: estimateTokens('x'.repeat(maxContextLength)),
pruningNeeded: totalRawChars > maxContextLength,
});
const { markdown: combinedMarkdown, pruned, keptSources, droppedSources } =
buildAndPruneContext(extractedContexts, query, maxContextLength);
// ── Step 4: Final Stats & Response ────────────────────────────────────
const totalLatency = Date.now() - startTime;
const finalChars = combinedMarkdown.length;
const finalTokens = estimateTokens(combinedMarkdown);
logSection('📤 Pipeline Complete');
log('INFO', 'Response', `Sending context to LocalAiApp`, {
requestId,
query,
totalPipelineLatencyMs: totalLatency,
searxLatencyMs: searxLatency,
finalContextChars: finalChars,
finalContextEstimatedTokens: finalTokens,
sourcesKept: keptSources,
sourcesDropped: droppedSources,
pruned,
sourceTitles: extractedContexts.map((c) => c.title.substring(0, 50)),
});
console.log(`${'═'.repeat(60)}\n`);
return res.json({
success: true,
context: combinedMarkdown,
sources: extractedContexts.map((c) => ({ title: c.title, url: c.url })),
meta: {
requestId,
totalLatencyMs: totalLatency,
finalContextChars: finalChars,
finalContextEstimatedTokens: finalTokens,
sourcesKept: keptSources,
sourcesDropped: droppedSources,
pruned,
},
});
} catch (error: any) {
const totalLatency = Date.now() - startTime;
log('ERROR', 'Fatal', `Pipeline failed`, {
requestId,
error: error.message,
stack: error.stack?.split('\n').slice(0, 5),
totalLatencyMs: totalLatency,
});
return res.status(500).json({ error: error.message, requestId });
}
});
app.listen(Number(PORT), '0.0.0.0', () => {
log('INFO', 'Server', `Local Search Service started`, {
port: PORT,
address: `http://0.0.0.0:${PORT}`,
endpoints: ['/search-and-extract'],
});
});
-13203
View File
File diff suppressed because it is too large. Load diff
+290 -1
View File
@@ -8,9 +8,18 @@ importers:
.:
dependencies:
'@babel/plugin-transform-runtime':
specifier: ^7.29.0
version: 7.29.0(@babel/[email protected])
'@babel/runtime':
specifier: ^7.28.6
version: 7.28.6
'@expo/vector-icons':
specifier: ^15.0.3
version: 15.1.1([email protected]([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]))([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
'@react-native-async-storage/async-storage':
specifier: 2.2.0
version: 2.2.0([email protected](@babel/[email protected])(@types/[email protected])([email protected]))
'@react-navigation/bottom-tabs':
specifier: ^7.4.0
version: 7.15.5(@react-navigation/[email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]))([email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]))([email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]))([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
@@ -20,9 +29,15 @@ importers:
'@react-navigation/native':
specifier: ^7.1.8
version: 7.1.33([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
'@ronradtke/react-native-markdown-display':
specifier: ^8.1.0
version: 8.1.0([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
expo:
specifier: ~54.0.31
version: 54.0.33(@babel/[email protected])(@expo/[email protected])([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
expo-clipboard:
specifier: ^55.0.8
version: 55.0.9([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
expo-constants:
specifier: ~18.0.13
version: 18.0.13([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))
@@ -56,8 +71,14 @@ importers:
expo-web-browser:
specifier: ~15.0.10
version: 15.0.10([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))
immer:
specifier: ^11.1.4
version: 11.1.4
lucide-react-native:
specifier: ^0.576.0
version: 0.576.0([email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]))([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
react:
specifier: 19.1.0
specifier: ^19.1.0
version: 19.1.0
react-dom:
specifier: 19.1.0
@@ -77,12 +98,24 @@ importers:
react-native-screens:
specifier: ~4.16.0
version: 4.16.0([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
react-native-sse:
specifier: ^1.2.1
version: 1.2.1
react-native-svg:
specifier: 15.12.1
version: 15.12.1([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
react-native-web:
specifier: ~0.21.0
version: 0.21.2([email protected]([email protected]))([email protected])
react-native-worklets:
specifier: 0.5.1
version: 0.5.1(@babel/[email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
use-sync-external-store:
specifier: ^1.6.0
version: 1.6.0([email protected])
zustand:
specifier: ^5.0.11
version: 5.0.12(@types/[email protected])([email protected])([email protected])([email protected]([email protected]))
devDependencies:
'@types/react':
specifier: ~19.1.0
@@ -1087,6 +1120,11 @@ packages:
'@types/react':
optional: true
'@react-native-async-storage/[email protected]':
resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==}
peerDependencies:
react-native: ^0.0.0-0 || >=0.65 <1.0
'@react-native/[email protected]':
resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==}
engines: {node: '>= 20.19.4'}
@@ -1196,6 +1234,12 @@ packages:
'@react-navigation/[email protected]':
resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==}
'@ronradtke/[email protected]':
resolution: {integrity: sha512-pAtefWI76vpkxsEgIFivyq1q6ej8rDyR7oVM/cWAxUydyBej9LOvULjLAeFuFLbYAelHTNoYXmGxQOlFLBa0+w==}
peerDependencies:
react: '>=16.2.0'
react-native: '>=0.50.4'
'@rtsao/[email protected]':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@@ -1648,6 +1692,9 @@ packages:
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
engines: {node: '>=0.6'}
[email protected]:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
[email protected]:
resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==}
@@ -1715,6 +1762,9 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
[email protected]:
resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==}
@@ -1827,9 +1877,27 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
[email protected]:
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
engines: {node: '>=4'}
[email protected]:
resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==}
[email protected]:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
[email protected]:
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
[email protected]:
resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==}
engines: {node: '>=8.0.0'}
[email protected]:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -1919,6 +1987,19 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
[email protected]:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
[email protected]:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
[email protected]:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
[email protected]:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
engines: {node: '>=12'}
@@ -1948,6 +2029,14 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
[email protected]:
resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==}
engines: {node: '>=0.12'}
[email protected]:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
[email protected]:
resolution: {integrity: sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==}
engines: {node: '>=8'}
@@ -2142,6 +2231,13 @@ packages:
react: '*'
react-native: '*'
[email protected]:
resolution: {integrity: sha512-WJ9ougE8fEDu3/RV5Vz3gE5aNgnj1C0WByXCz3WUj8y/06bJyaIUDMq8FDLv3n0AO3guiErmkUunsD0EzSDUUw==}
peerDependencies:
expo: '*'
react: '*'
react-native: '*'
[email protected]:
resolution: {integrity: sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==}
peerDependencies:
@@ -2537,6 +2633,9 @@ packages:
engines: {node: '>=16.x'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
[email protected]:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -2644,6 +2743,10 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
[email protected]:
resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -2881,6 +2984,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
[email protected]:
resolution: {integrity: sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==}
[email protected]:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@@ -2916,9 +3022,20 @@ packages:
[email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
[email protected]:
resolution: {integrity: sha512-z38PwB7Rknhs1FrT1oMTp1MThnXpv//UH0usmhHAL8DrMoaBtKOKtRvdJOUleHmQZXMX8NcjgzOkelhCilUIlQ==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-native: '*'
react-native-svg: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0
[email protected]:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
[email protected]:
resolution: {integrity: sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
@@ -2926,12 +3043,22 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
[email protected]:
resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==}
[email protected]:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
[email protected]:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
[email protected]:
resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -3175,6 +3302,9 @@ packages:
resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==}
engines: {node: ^16.14.0 || >=18.0.0}
[email protected]:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
[email protected]:
resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==}
@@ -3428,6 +3558,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==}
[email protected]:
resolution: {integrity: sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==}
[email protected]:
resolution: {integrity: sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==}
peerDependencies:
@@ -3460,6 +3593,15 @@ packages:
react: '*'
react-native: '*'
[email protected]:
resolution: {integrity: sha512-zejanlScF+IB9tYnbdry0MT34qjBXbiV/E72qGz33W/tX1bx8MXsbB4lxiuPETc9v/008vYZ60yjIstW22VlVg==}
[email protected]:
resolution: {integrity: sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==}
peerDependencies:
react: '*'
react-native: '*'
[email protected]:
resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==}
peerDependencies:
@@ -3963,6 +4105,9 @@ packages:
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
[email protected]:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -4205,6 +4350,24 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@0no-co/[email protected]': {}
@@ -5485,6 +5648,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.17
'@react-native-async-storage/[email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))':
dependencies:
merge-options: 3.0.4
react-native: 0.81.5(@babel/[email protected])(@types/[email protected])([email protected])
'@react-native/[email protected]': {}
'@react-native/[email protected](@babel/[email protected])':
@@ -5669,6 +5837,15 @@ snapshots:
dependencies:
nanoid: 3.3.11
'@ronradtke/[email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])':
dependencies:
css-to-react-native: 3.2.0
markdown-it: 13.0.2
prop-types: 15.8.1
react: 19.1.0
react-native: 0.81.5(@babel/[email protected])(@types/[email protected])([email protected])
react-native-fit-image: 1.5.5
'@rtsao/[email protected]': {}
'@sinclair/[email protected]': {}
@@ -6197,6 +6374,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
stream-buffers: 2.2.0
@@ -6270,6 +6449,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -6400,10 +6581,33 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
[email protected]: {}
[email protected]:
dependencies:
hyphenate-style-name: 1.1.0
[email protected]:
dependencies:
boolbase: 1.0.0
css-what: 6.2.2
domhandler: 5.0.3
domutils: 3.2.2
nth-check: 2.1.1
[email protected]:
dependencies:
camelize: 1.0.1
css-color-keywords: 1.0.0
postcss-value-parser: 4.2.0
[email protected]:
dependencies:
mdn-data: 2.0.14
source-map: 0.6.1
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -6474,6 +6678,24 @@ snapshots:
dependencies:
esutils: 2.0.3
[email protected]:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
[email protected]: {}
[email protected]:
dependencies:
domelementtype: 2.3.0
[email protected]:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
[email protected]:
dependencies:
dotenv: 16.4.7
@@ -6496,6 +6718,10 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -6812,6 +7038,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected]([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]):
dependencies:
expo: 54.0.33(@babel/[email protected])(@expo/[email protected])([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
react: 19.1.0
react-native: 0.81.5(@babel/[email protected])(@types/[email protected])([email protected])
[email protected]([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected])):
dependencies:
'@expo/config': 12.0.13
@@ -7237,6 +7469,8 @@ snapshots:
dependencies:
queue: 6.0.2
[email protected]: {}
[email protected]:
dependencies:
parent-module: 1.0.1
@@ -7346,6 +7580,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
call-bound: 1.0.4
@@ -7595,6 +7831,10 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
uc.micro: 1.0.6
[email protected]:
dependencies:
p-locate: 4.1.0
@@ -7625,18 +7865,40 @@ snapshots:
dependencies:
yallist: 3.1.1
[email protected]([email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]))([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]):
dependencies:
react: 19.1.0
react-native: 0.81.5(@babel/[email protected])(@types/[email protected])([email protected])
react-native-svg: 15.12.1([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
[email protected]:
dependencies:
tmpl: 1.0.5
[email protected]:
dependencies:
argparse: 2.0.1
entities: 3.0.1
linkify-it: 4.0.1
mdurl: 1.0.1
uc.micro: 1.0.6
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
is-plain-obj: 2.1.0
[email protected]: {}
[email protected]:
@@ -8081,6 +8343,10 @@ snapshots:
semver: 7.7.4
validate-npm-package-name: 5.0.1
[email protected]:
dependencies:
boolbase: 1.0.0
[email protected]: {}
[email protected]:
@@ -8337,6 +8603,10 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
prop-types: 15.8.1
[email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]):
dependencies:
'@egjs/hammerjs': 2.0.17
@@ -8372,6 +8642,16 @@ snapshots:
react-native-is-edge-to-edge: 1.3.1([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
warn-once: 0.1.1
[email protected]: {}
[email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]):
dependencies:
css-select: 5.2.2
css-tree: 1.1.3
react: 19.1.0
react-native: 0.81.5(@babel/[email protected])(@types/[email protected])([email protected])
warn-once: 0.1.1
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
'@babel/runtime': 7.28.6
@@ -8973,6 +9253,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
call-bound: 1.0.4
@@ -9205,3 +9487,10 @@ snapshots:
yargs-parser: 21.1.1
[email protected]: {}
[email protected](@types/[email protected])([email protected])([email protected])([email protected]([email protected])):
optionalDependencies:
'@types/react': 19.1.17
immer: 11.1.4
react: 19.1.0
use-sync-external-store: 1.6.0([email protected])
+190
View File
@@ -0,0 +1,190 @@
/**
* webSearchLogger.ts
* Centralized structured logger for the web search pipeline on the client side.
* Outputs consistently formatted, emoji-tagged logs to the React Native / Metro console.
*/
type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG';
const ICONS: Record<LogLevel, string> = {
INFO: 'ℹ️ ',
WARN: '⚠️ ',
ERROR: '❌',
DEBUG: '🔍',
};
function formatMeta(meta?: Record<string, any>): string {
if (!meta) return '';
try {
return '\n ' + JSON.stringify(meta, null, 2).replace(/\n/g, '\n ');
} catch {
return '';
}
}
function log(level: LogLevel, phase: string, message: string, meta?: Record<string, any>) {
const ts = new Date().toISOString();
const metaStr = formatMeta(meta);
const prefix = `[${ts}] ${ICONS[level]} [WebSearch/${phase}]`;
const fullMsg = `${prefix} ${message}${metaStr}`;
switch (level) {
case 'ERROR':
console.error(fullMsg);
break;
case 'WARN':
console.warn(fullMsg);
break;
default:
console.log(fullMsg);
}
}
function logSection(title: string) {
const border = '─'.repeat(55);
console.log(`\n${border}`);
console.log(` ${title}`);
console.log(`${border}`);
}
/** Rough tokens estimate (~4 chars per token) */
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
// ─── Public API ────────────────────────────────────────────────────────────────
export const WebSearchLogger = {
pipelineStart(query: string, searchEndpoint: string) {
logSection(`🚀 Web Search Pipeline Started`);
log('INFO', 'Init', `New search initiated`, {
query,
searchEndpoint,
timestamp: new Date().toISOString(),
});
},
endpointResolved(host: string, port: number, searchEndpoint: string) {
log('INFO', 'Endpoint', `Search service URL resolved`, {
lmStudioHost: host,
lmStudioPort: port,
searchServiceEndpoint: searchEndpoint,
});
},
requestSent(query: string, maxContextLength: number, timeoutMs = 30000) {
log('INFO', 'Request', `Sending search request to local service`, {
query,
maxContextLength,
estimatedTokenBudget: estimateTokens('x'.repeat(maxContextLength)),
timeoutMs,
});
},
requestFailed(status: number, statusText: string) {
log('ERROR', 'Request', `Search service returned error`, {
httpStatus: status,
statusText,
});
},
serviceUnreachable(error: unknown, isTimeout = false) {
const err = error instanceof Error ? error : new Error(String(error));
const isConnectionRefused =
err.message.includes('Network request failed') ||
err.message.includes('ECONNREFUSED') ||
err.message.includes('Connection refused');
const message = isTimeout
? `Search service timeout (30s) — service may be overloaded or unreachable`
: isConnectionRefused
? `Search service is NOT running — connection refused`
: `Cannot reach local search service`;
log('ERROR', 'Request', message, {
error: err.message,
isTimeout,
isConnectionRefused,
diagnosis: isConnectionRefused
? 'The local-search-service Node.js process is not running on this machine'
: isTimeout
? 'Service is running but not responding within 30s (SearXNG may be slow)'
: 'Unknown network error',
hint: 'Run: cd local-search-service && npx ts-node server.ts',
});
},
contextReceived(
context: string,
sources: Array<{ title: string; url: string }>,
meta?: {
requestId?: string;
totalLatencyMs?: number;
finalContextChars?: number;
finalContextEstimatedTokens?: number;
sourcesKept?: number;
sourcesDropped?: number;
pruned?: boolean;
},
) {
logSection(`✅ Context Received from Search Service`);
log('INFO', 'Context', `Web search context ready`, {
contextChars: context.length,
estimatedTokens: estimateTokens(context),
sourcesReceived: sources.length,
sourceTitles: sources.map((s) => s.title.substring(0, 60)),
sourceUrls: sources.map((s) => s.url),
...(meta ?? {}),
});
},
contextAnalysis(systemPromptChars: number, historyChars: number, searchContextChars: number) {
logSection(`🧠 Context Window Analysis`);
const totalChars = systemPromptChars + historyChars + searchContextChars;
const totalTokens = estimateTokens('x'.repeat(totalChars));
log('INFO', 'Analysis', `Total context before sending to LLM`, {
systemPromptChars,
systemPromptTokens: estimateTokens('x'.repeat(systemPromptChars)),
chatHistoryChars: historyChars,
chatHistoryTokens: estimateTokens('x'.repeat(historyChars)),
searchContextChars,
searchContextTokens: estimateTokens('x'.repeat(searchContextChars)),
totalChars,
estimatedTotalTokens: totalTokens,
note: 'Token estimates are approximate (~4 chars/token). Actual tokenization varies by model.',
});
},
llmRequestStart(model: string, messageCount: number) {
logSection(`🤖 LLM Request`);
log('INFO', 'LLM', `Sending messages to LM Studio`, {
model,
totalMessages: messageCount,
streamingEnabled: true,
});
},
llmStreamComplete(assistantMessageId: string, chunkCount?: number) {
log('INFO', 'LLM', `Stream complete — response generated`, {
assistantMessageId,
...(chunkCount !== undefined ? { chunkCount } : {}),
});
},
llmError(error: Error) {
log('ERROR', 'LLM', `LLM stream error`, {
error: error.message,
stack: error.stack?.split('\n').slice(0, 3).join(' | '),
});
},
pipelineComplete(query: string, totalLatencyMs: number, withSearch: boolean) {
logSection(`🏁 Pipeline Complete`);
log('INFO', 'Done', `Full request cycle finished`, {
query,
totalLatencyMs,
webSearchUsed: withSearch,
});
console.log('─'.repeat(55) + '\n');
},
};
+8
View File
@@ -49,6 +49,10 @@ interface AppState {
isStreaming: boolean;
setIsStreaming: (streaming: boolean) => void;
// Web Search
isWebSearchEnabled: boolean;
setIsWebSearchEnabled: (enabled: boolean) => void;
// Edit mode
editingMessageId: string | null;
setEditingMessageId: (id: string | null) => void;
@@ -119,6 +123,9 @@ export const useAppStore = create<AppState>()(
isStreaming: false,
setIsStreaming: (isStreaming) => set({ isStreaming }),
isWebSearchEnabled: false,
setIsWebSearchEnabled: (isWebSearchEnabled) => set({ isWebSearchEnabled }),
editingMessageId: null,
setEditingMessageId: (editingMessageId) => set({ editingMessageId }),
@@ -171,6 +178,7 @@ export const useAppStore = create<AppState>()(
chatSessions: state.chatSessions,
messages: state.messages,
currentChatId: state.currentChatId,
isWebSearchEnabled: state.isWebSearchEnabled,
}),
}
)