Origem: D:/Documents/Github/LocalAiApp Arquivos omitidos (segredos, bancos de dados, logs, >50 MB): 0
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
import { useCallback, useRef } from 'react';
|
|
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);
|
|
const isConnected = useAppStore((s) => s.isConnected);
|
|
const setIsConnected = useAppStore((s) => s.setIsConnected);
|
|
const availableModels = useAppStore((s) => s.availableModels);
|
|
const setAvailableModels = useAppStore((s) => s.setAvailableModels);
|
|
const activeModelId = useAppStore((s) => s.activeModelId);
|
|
const setActiveModelId = useAppStore((s) => s.setActiveModelId);
|
|
const messages = useAppStore((s) => s.messages);
|
|
const addMessage = useAppStore((s) => s.addMessage);
|
|
const appendToMessage = useAppStore((s) => s.appendToMessage);
|
|
const isStreaming = useAppStore((s) => s.isStreaming);
|
|
const setIsStreaming = useAppStore((s) => s.setIsStreaming);
|
|
|
|
const abortRef = useRef<{ abort: () => void } | null>(null);
|
|
|
|
const checkConnection = useCallback(async () => {
|
|
const ok = await api.testConnection(serverConfig);
|
|
setIsConnected(ok);
|
|
return ok;
|
|
}, [serverConfig, setIsConnected]);
|
|
|
|
const fetchModels = useCallback(async () => {
|
|
try {
|
|
const currentConfig = useAppStore.getState().serverConfig;
|
|
const models = await api.getModels(currentConfig);
|
|
setAvailableModels(models);
|
|
setIsConnected(true);
|
|
|
|
if (!activeModelId && models.length > 0) {
|
|
setActiveModelId(models[0].id);
|
|
}
|
|
return models;
|
|
} catch {
|
|
setIsConnected(false);
|
|
setAvailableModels([]);
|
|
return [];
|
|
}
|
|
}, [activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
|
|
|
|
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 modelId = store.activeModelId;
|
|
if (!modelId) {
|
|
addMessage({
|
|
role: 'assistant',
|
|
content: '⚠️ No model selected. Please select a model first.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const assistantId = addMessage({ role: 'assistant', content: '' });
|
|
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);
|
|
abortRef.current = null;
|
|
useAppStore.getState().saveCurrentChat();
|
|
},
|
|
);
|
|
} catch (err) {
|
|
const errorMsg = `⚠️ Connection failed: ${(err as Error).message}`;
|
|
appendToMessage(assistantId, errorMsg);
|
|
setIsStreaming(false);
|
|
useAppStore.getState().saveCurrentChat();
|
|
}
|
|
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
|
|
|
|
const regenerateResponse = useCallback(async () => {
|
|
if (isStreaming) return;
|
|
|
|
const msgs = useAppStore.getState().messages;
|
|
let lastUserIdx = -1;
|
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
if (msgs[i].role === 'user') {
|
|
lastUserIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
if (lastUserIdx === -1) return;
|
|
|
|
const lastUserMsg = msgs[lastUserIdx];
|
|
const store = useAppStore.getState();
|
|
store.deleteMessagesAfter(lastUserMsg.id);
|
|
|
|
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;
|
|
if (!modelId) return;
|
|
|
|
const assistantId = addMessage({ role: 'assistant', content: '' });
|
|
setIsStreaming(true);
|
|
|
|
try {
|
|
abortRef.current = api.chatCompletionStream(
|
|
serverConfig,
|
|
modelId,
|
|
apiMessages,
|
|
(chunk) => appendToMessage(assistantId, chunk),
|
|
() => {
|
|
setIsStreaming(false);
|
|
abortRef.current = null;
|
|
useAppStore.getState().saveCurrentChat();
|
|
},
|
|
(error) => {
|
|
appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
|
|
setIsStreaming(false);
|
|
abortRef.current = null;
|
|
useAppStore.getState().saveCurrentChat();
|
|
},
|
|
);
|
|
} catch (err) {
|
|
appendToMessage(assistantId, `⚠️ Connection failed: ${(err as Error).message}`);
|
|
setIsStreaming(false);
|
|
useAppStore.getState().saveCurrentChat();
|
|
}
|
|
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
|
|
|
|
const stopStreaming = useCallback(() => {
|
|
if (abortRef.current) {
|
|
abortRef.current.abort();
|
|
abortRef.current = null;
|
|
setIsStreaming(false);
|
|
}
|
|
}, [setIsStreaming]);
|
|
|
|
return {
|
|
isConnected,
|
|
isStreaming,
|
|
availableModels,
|
|
activeModelId,
|
|
messages,
|
|
sendMessage,
|
|
stopStreaming,
|
|
fetchModels,
|
|
checkConnection,
|
|
regenerateResponse,
|
|
};
|
|
}
|