Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c366eecb0 | ||
|
|
6cdc8d4294 | ||
|
|
0d19f496bb | ||
|
|
e53e49c8f8 | ||
|
|
ba03131a78 | ||
|
|
1a64f5aa18 |
No files matched your search
@@ -0,0 +1,185 @@
|
|||||||
|
name: Build and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
TAURI_VERSION: "2.9.4"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: 'windows-latest'
|
||||||
|
target: 'x86_64-pc-windows-msvc'
|
||||||
|
bundle: 'msi'
|
||||||
|
- platform: 'macos-latest'
|
||||||
|
target: 'aarch64-apple-darwin'
|
||||||
|
bundle: 'dmg'
|
||||||
|
- platform: 'macos-latest'
|
||||||
|
target: 'x86_64-apple-darwin'
|
||||||
|
bundle: 'dmg'
|
||||||
|
- platform: 'ubuntu-latest'
|
||||||
|
target: 'x86_64-unknown-linux-gnu'
|
||||||
|
bundle: 'appimage'
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Install dependencies (Ubuntu only)
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate changelog
|
||||||
|
run: pnpm generate-changelog
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
run: |
|
||||||
|
cd src-tauri
|
||||||
|
cargo build --release --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Build Tauri bundle
|
||||||
|
env:
|
||||||
|
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_UPDATER_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
cd src-tauri
|
||||||
|
if [ -n "$TAURI_PRIVATE_KEY" ]; then
|
||||||
|
echo "$TAURI_PRIVATE_KEY" > /tmp/private.key
|
||||||
|
cargo tauri build --target ${{ matrix.target }} --config '{"updater":{"active":true}}'
|
||||||
|
rm /tmp/private.key
|
||||||
|
else
|
||||||
|
cargo tauri build --target ${{ matrix.target }}
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.platform }}-${{ matrix.target }}
|
||||||
|
path: |
|
||||||
|
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.${{ matrix.bundle }}
|
||||||
|
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.exe
|
||||||
|
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.AppImage
|
||||||
|
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.deb
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
create-release:
|
||||||
|
needs: build
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Generate changelog for release
|
||||||
|
run: pnpm generate-changelog
|
||||||
|
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Extract release notes
|
||||||
|
id: extract_notes
|
||||||
|
run: |
|
||||||
|
if [ -f "data/changelog.json" ]; then
|
||||||
|
NOTES=$(node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
try {
|
||||||
|
const changelog = JSON.parse(fs.readFileSync('data/changelog.json', 'utf-8'));
|
||||||
|
const latestRelease = changelog.releases[0];
|
||||||
|
if (latestRelease) {
|
||||||
|
let notes = '## ' + (latestRelease.version === 'unreleased' ? 'Próxima Versão' : 'v' + latestRelease.version) + '\\n\\n';
|
||||||
|
notes += '**Data:** ' + latestRelease.date + '\\n\\n';
|
||||||
|
notes += '### Mudanças\\n\\n';
|
||||||
|
latestRelease.commits.forEach(commit => {
|
||||||
|
const icon = commit.type === 'feat' ? '✨' : '🐛';
|
||||||
|
notes += '- ' + icon + ' **' + (commit.type === 'feat' ? 'Nova Funcionalidade' : 'Correção') + ':** ' + commit.message + '\\n';
|
||||||
|
});
|
||||||
|
console.log(notes);
|
||||||
|
} else {
|
||||||
|
console.log('## Release ${{ steps.get_version.outputs.version }}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('## Release ${{ steps.get_version.outputs.version }}');
|
||||||
|
}
|
||||||
|
")
|
||||||
|
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "notes=## Release ${{ steps.get_version.outputs.version }}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.get_version.outputs.tag }}
|
||||||
|
name: Release ${{ steps.get_version.outputs.tag }}
|
||||||
|
body: |
|
||||||
|
${{ steps.extract_notes.outputs.notes }}
|
||||||
|
|
||||||
|
## Downloads
|
||||||
|
|
||||||
|
Baixe o instalador para sua plataforma nos assets abaixo.
|
||||||
|
draft: false
|
||||||
|
files: artifacts/**/*
|
||||||
|
generate_release_notes: false
|
||||||
|
|
||||||
@@ -39,3 +39,4 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
~/.tauri/ollahub.key
|
||||||
+237
-5
@@ -48,11 +48,14 @@ export default function ChatPage() {
|
|||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
loadSessionHistory,
|
loadSessionHistory,
|
||||||
|
loadMoreMessages,
|
||||||
|
paginationState,
|
||||||
saveSession,
|
saveSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
isGeneratingTitle,
|
isGeneratingTitle,
|
||||||
searchSessions,
|
searchSessions,
|
||||||
isSearching
|
isSearching,
|
||||||
|
searchQuery
|
||||||
} = useChatStorage();
|
} = useChatStorage();
|
||||||
|
|
||||||
const { isDownloading, progress } = useAutoLabelingModel();
|
const { isDownloading, progress } = useAutoLabelingModel();
|
||||||
@@ -121,6 +124,7 @@ export default function ChatPage() {
|
|||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastSavedMessagesRef = useRef<number>(0);
|
const lastSavedMessagesRef = useRef<number>(0);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [showContextDebug, setShowContextDebug] = useState(false);
|
const [showContextDebug, setShowContextDebug] = useState(false);
|
||||||
const [showProcessDebug, setShowProcessDebug] = useState(false);
|
const [showProcessDebug, setShowProcessDebug] = useState(false);
|
||||||
const [lastWebContext, setLastWebContext] = useState('');
|
const [lastWebContext, setLastWebContext] = useState('');
|
||||||
@@ -128,6 +132,14 @@ export default function ChatPage() {
|
|||||||
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
|
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
|
||||||
const [logsCopied, setLogsCopied] = useState(false);
|
const [logsCopied, setLogsCopied] = useState(false);
|
||||||
const [lastUserQuery, setLastUserQuery] = useState('');
|
const [lastUserQuery, setLastUserQuery] = useState('');
|
||||||
|
const [searchMatchIndex, setSearchMatchIndex] = useState(0);
|
||||||
|
|
||||||
|
// Resetar índice quando busca é limpa ou sessão muda
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchQuery || searchQuery.trim().length < 2) {
|
||||||
|
setSearchMatchIndex(0);
|
||||||
|
}
|
||||||
|
}, [searchQuery, currentSessionId]);
|
||||||
|
|
||||||
// Auto-select first model
|
// Auto-select first model
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -246,6 +258,34 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
}, [settings.queryPreprocessing, webSearch.isEnabled, preprocess]);
|
}, [settings.queryPreprocessing, webSearch.isEnabled, preprocess]);
|
||||||
|
|
||||||
|
// ========== EXECUÇÃO ESPECULATIVA DE BUSCA ==========
|
||||||
|
// Heurística rápida para detectar se a query provavelmente precisa de busca web
|
||||||
|
const mightNeedWebSearch = useCallback((text: string): boolean => {
|
||||||
|
if (!webSearch.isEnabled) return false;
|
||||||
|
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
|
||||||
|
// Padrões que indicam alta probabilidade de precisar de busca
|
||||||
|
const questionPatterns = /\b(o que|como|quando|onde|quem|porque|por que|qual|quais|quanto|quantos|me explique|me diga|existe|há)\b/i;
|
||||||
|
const recentPatterns = /\b(hoje|ontem|esta semana|este mês|este ano|atualmente|recentemente|últimas notícias|novo|novos|última|últimas|2024|2025)\b/i;
|
||||||
|
const searchPatterns = /\?$|buscar|pesquisar|encontrar|procurar|descobrir/i;
|
||||||
|
|
||||||
|
// Se contém padrões de pergunta ou busca, provavelmente precisa de pesquisa
|
||||||
|
if (questionPatterns.test(lowerText) || recentPatterns.test(lowerText) || searchPatterns.test(lowerText)) {
|
||||||
|
chatLog.debug('[SpeculativeSearch] Query might need web search (pattern match)');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se tem mais de 5 palavras e termina com ?, provavelmente é uma pergunta
|
||||||
|
const wordCount = text.trim().split(/\s+/).length;
|
||||||
|
if (wordCount > 5 && text.trim().endsWith('?')) {
|
||||||
|
chatLog.debug('[SpeculativeSearch] Query might need web search (question detected)');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [webSearch.isEnabled]);
|
||||||
|
|
||||||
const handleSend = async (content: string) => {
|
const handleSend = async (content: string) => {
|
||||||
if (!selectedModel) return;
|
if (!selectedModel) return;
|
||||||
|
|
||||||
@@ -262,6 +302,22 @@ export default function ChatPage() {
|
|||||||
// Limpar erros anteriores
|
// Limpar erros anteriores
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// ========== EXECUÇÃO ESPECULATIVA ==========
|
||||||
|
// Iniciar busca web especulativamente se a query parecer precisar
|
||||||
|
// Isso reduz latência sobrepondo busca com pré-processamento
|
||||||
|
let speculativeSearchPromise: Promise<ScrapedContent[]> | null = null;
|
||||||
|
const shouldSpeculate = mightNeedWebSearch(content);
|
||||||
|
|
||||||
|
if (shouldSpeculate) {
|
||||||
|
chatLog.info('[SpeculativeSearch] Starting speculative web search in parallel with preprocessing');
|
||||||
|
// Iniciar busca sem await - será usada depois se confirmada
|
||||||
|
speculativeSearchPromise = webSearch.smartSearchRag(content, settings.webSearch?.maxResults || 5)
|
||||||
|
.catch(err => {
|
||||||
|
chatLog.debug(`[SpeculativeSearch] Speculative search failed (will use normal flow): ${err}`);
|
||||||
|
return [] as ScrapedContent[];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ========== PRÉ-PROCESSAMENTO ==========
|
// ========== PRÉ-PROCESSAMENTO ==========
|
||||||
let preprocessed: PreprocessedQuery;
|
let preprocessed: PreprocessedQuery;
|
||||||
let finalContent: string;
|
let finalContent: string;
|
||||||
@@ -330,7 +386,8 @@ export default function ChatPage() {
|
|||||||
// Função auxiliar para executar pesquisa web
|
// Função auxiliar para executar pesquisa web
|
||||||
const executeWebResearch = async (
|
const executeWebResearch = async (
|
||||||
content: string,
|
content: string,
|
||||||
preprocessed: PreprocessedQuery
|
preprocessed: PreprocessedQuery,
|
||||||
|
speculativeResults?: ScrapedContent[] | null
|
||||||
): Promise<{
|
): Promise<{
|
||||||
knowledgeBaseContext: string;
|
knowledgeBaseContext: string;
|
||||||
scrapedSources: ScrapedContent[];
|
scrapedSources: ScrapedContent[];
|
||||||
@@ -344,6 +401,12 @@ export default function ChatPage() {
|
|||||||
if (webSearch.isEnabled && preprocessed.shouldSearch) {
|
if (webSearch.isEnabled && preprocessed.shouldSearch) {
|
||||||
chatLog.info('\n--- STARTING DEEP RESEARCH PIPELINE ---');
|
chatLog.info('\n--- STARTING DEEP RESEARCH PIPELINE ---');
|
||||||
|
|
||||||
|
// Se temos resultados especulativos, usá-los como seed inicial
|
||||||
|
if (speculativeResults && speculativeResults.length > 0) {
|
||||||
|
chatLog.info(`[SpeculativeSearch] Using ${speculativeResults.length} speculative results as seed`);
|
||||||
|
deepResearch.addToKnowledgeBase('speculative_seed', speculativeResults);
|
||||||
|
}
|
||||||
|
|
||||||
// Adicionar mensagem de processo: Pesquisa Web
|
// Adicionar mensagem de processo: Pesquisa Web
|
||||||
addThinkingMessage('web-research', 'Pesquisando na Web...', 'running');
|
addThinkingMessage('web-research', 'Pesquisando na Web...', 'running');
|
||||||
const webResearchStart = Date.now();
|
const webResearchStart = Date.now();
|
||||||
@@ -605,10 +668,25 @@ export default function ChatPage() {
|
|||||||
// ========== LÓGICA DE DEEP RESEARCH (Knowledge Base Aggregation) ==========
|
// ========== LÓGICA DE DEEP RESEARCH (Knowledge Base Aggregation) ==========
|
||||||
// Removido: setThinkingStep e setProcessSteps - agora usando mensagens thinking agrupadas
|
// Removido: setThinkingStep e setProcessSteps - agora usando mensagens thinking agrupadas
|
||||||
|
|
||||||
// Executar pesquisa web
|
// Aguardar resultado da busca especulativa se ela foi iniciada e shouldSearch é true
|
||||||
|
let speculativeResults: ScrapedContent[] | null = null;
|
||||||
|
if (speculativeSearchPromise && preprocessed.shouldSearch) {
|
||||||
|
chatLog.info('[SpeculativeSearch] Waiting for speculative search results...');
|
||||||
|
try {
|
||||||
|
speculativeResults = await speculativeSearchPromise;
|
||||||
|
chatLog.info(`[SpeculativeSearch] Got ${speculativeResults.length} speculative results`);
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.debug(`[SpeculativeSearch] Failed to get speculative results: ${err}`);
|
||||||
|
}
|
||||||
|
} else if (speculativeSearchPromise && !preprocessed.shouldSearch) {
|
||||||
|
chatLog.info('[SpeculativeSearch] Discarding speculative search (preprocessing determined search not needed)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executar pesquisa web (usando resultados especulativos como seed se disponíveis)
|
||||||
const { knowledgeBaseContext, scrapedSources, validationReport } = await executeWebResearch(
|
const { knowledgeBaseContext, scrapedSources, validationReport } = await executeWebResearch(
|
||||||
content,
|
content,
|
||||||
preprocessed
|
preprocessed,
|
||||||
|
speculativeResults
|
||||||
);
|
);
|
||||||
|
|
||||||
// Passo 5: Formular resposta
|
// Passo 5: Formular resposta
|
||||||
@@ -803,8 +881,94 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
setMessages(history);
|
setMessages(history);
|
||||||
setCurrentSessionId(id);
|
setCurrentSessionId(id);
|
||||||
lastSavedMessagesRef.current = history.length;
|
lastSavedMessagesRef.current = history.length;
|
||||||
|
|
||||||
|
// Se há busca ativa, encontrar matches e resetar índice
|
||||||
|
if (searchQuery && searchQuery.trim().length >= 2) {
|
||||||
|
setSearchMatchIndex(0);
|
||||||
|
// Os matches serão encontrados quando as mensagens renderizarem
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handler para scroll infinito reverso (carregar mensagens mais antigas)
|
||||||
|
const handleScroll = useCallback(async () => {
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
if (!container || !currentSessionId || paginationState.isLoadingMore) return;
|
||||||
|
|
||||||
|
// Verificar se o usuário está próximo do topo (threshold de 100px)
|
||||||
|
const isNearTop = container.scrollTop < 100;
|
||||||
|
|
||||||
|
if (isNearTop && paginationState.hasMore) {
|
||||||
|
// Salvar posição atual do scroll e altura do conteúdo
|
||||||
|
const scrollHeightBefore = container.scrollHeight;
|
||||||
|
|
||||||
|
// Carregar mais mensagens
|
||||||
|
const olderMessages = await loadMoreMessages(currentSessionId);
|
||||||
|
|
||||||
|
if (olderMessages.length > 0) {
|
||||||
|
// Prepend as mensagens antigas
|
||||||
|
setMessages(prev => [...olderMessages, ...prev]);
|
||||||
|
|
||||||
|
// Restaurar posição do scroll para manter o contexto visual
|
||||||
|
// (compensar o novo conteúdo adicionado no topo)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (container) {
|
||||||
|
const scrollHeightAfter = container.scrollHeight;
|
||||||
|
const heightDiff = scrollHeightAfter - scrollHeightBefore;
|
||||||
|
container.scrollTop = container.scrollTop + heightDiff;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentSessionId, paginationState.hasMore, paginationState.isLoadingMore, loadMoreMessages, setMessages]);
|
||||||
|
|
||||||
|
// Função para navegar entre matches
|
||||||
|
const handleNavigateMatch = useCallback((sessionId: string, direction: 'prev' | 'next') => {
|
||||||
|
if (sessionId !== currentSessionId || !searchQuery || searchQuery.trim().length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encontrar todos os matches nas mensagens
|
||||||
|
const query = searchQuery.trim();
|
||||||
|
const matches: Array<{ messageIndex: number; matchIndex: number }> = [];
|
||||||
|
|
||||||
|
messages.forEach((msg, msgIdx) => {
|
||||||
|
if (msg.role === 'user' || msg.role === 'assistant') {
|
||||||
|
const content = msg.content;
|
||||||
|
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||||
|
let match;
|
||||||
|
let matchIdx = 0;
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
matches.push({ messageIndex: msgIdx, matchIndex: matchIdx });
|
||||||
|
matchIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
|
||||||
|
// Navegar
|
||||||
|
setSearchMatchIndex(prev => {
|
||||||
|
const newIndex = direction === 'next'
|
||||||
|
? (prev + 1) % matches.length
|
||||||
|
: (prev - 1 + matches.length) % matches.length;
|
||||||
|
|
||||||
|
// Scroll para o match após renderização
|
||||||
|
setTimeout(() => {
|
||||||
|
const messageElement = document.querySelector(`[data-message-index="${matches[newIndex].messageIndex}"]`);
|
||||||
|
if (messageElement) {
|
||||||
|
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
// Destacar o match atual visualmente
|
||||||
|
const marks = messageElement.querySelectorAll('mark');
|
||||||
|
if (marks.length > matches[newIndex].matchIndex) {
|
||||||
|
marks[matches[newIndex].matchIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
return newIndex;
|
||||||
|
});
|
||||||
|
}, [currentSessionId, searchQuery, messages]);
|
||||||
|
|
||||||
const handleNewChat = () => {
|
const handleNewChat = () => {
|
||||||
stop();
|
stop();
|
||||||
clearChat();
|
clearChat();
|
||||||
@@ -947,6 +1111,8 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
onNewChat={handleNewChat}
|
onNewChat={handleNewChat}
|
||||||
onSearch={searchSessions}
|
onSearch={searchSessions}
|
||||||
isSearching={isSearching}
|
isSearching={isSearching}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onNavigateMatch={handleNavigateMatch}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
@@ -1050,8 +1216,29 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
{/* Messages Area with Floating Input Layout */}
|
{/* Messages Area with Floating Input Layout */}
|
||||||
<div className="flex-1 relative h-full overflow-hidden flex flex-col">
|
<div className="flex-1 relative h-full overflow-hidden flex flex-col">
|
||||||
{/* Scrollable Content Area */}
|
{/* Scrollable Content Area */}
|
||||||
<div className="flex-1 overflow-y-auto scroll-smooth w-full min-h-0">
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="flex-1 overflow-y-auto scroll-smooth w-full min-h-0"
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
<div className="max-w-3xl mx-auto w-full px-3 sm:px-4 md:px-6 pt-4 sm:pt-6 pb-4 transition-all duration-300" style={{ paddingLeft: 'clamp(12px, 4vw, 24px)', paddingRight: 'clamp(12px, 4vw, 24px)' }}>
|
<div className="max-w-3xl mx-auto w-full px-3 sm:px-4 md:px-6 pt-4 sm:pt-6 pb-4 transition-all duration-300" style={{ paddingLeft: 'clamp(12px, 4vw, 24px)', paddingRight: 'clamp(12px, 4vw, 24px)' }}>
|
||||||
|
{/* Indicador de carregamento de mensagens antigas */}
|
||||||
|
{paginationState.isLoadingMore && (
|
||||||
|
<div className="flex items-center justify-center py-4 mb-4">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground mr-2" />
|
||||||
|
<span className="text-sm text-muted-foreground">Carregando mensagens anteriores...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Indicador de que há mais mensagens para carregar */}
|
||||||
|
{paginationState.hasMore && !paginationState.isLoadingMore && messages.length > 0 && (
|
||||||
|
<div className="flex items-center justify-center py-2 mb-4">
|
||||||
|
<span className="text-xs text-muted-foreground/60">
|
||||||
|
Role para cima para ver mensagens anteriores ({paginationState.totalCount - messages.length} restantes)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="min-h-[50vh] flex flex-col items-center justify-center text-muted-foreground space-y-6">
|
<div className="min-h-[50vh] flex flex-col items-center justify-center text-muted-foreground space-y-6">
|
||||||
<div className="p-6 rounded-2xl bg-muted/30 ring-1 ring-border/50 shadow-sm">
|
<div className="p-6 rounded-2xl bg-muted/30 ring-1 ring-border/50 shadow-sm">
|
||||||
@@ -1086,6 +1273,13 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
'type' in msg.metadata &&
|
'type' in msg.metadata &&
|
||||||
msg.metadata.type === 'thinking';
|
msg.metadata.type === 'thinking';
|
||||||
|
|
||||||
|
// Pular mensagens de sistema que não são thinking (system prompt)
|
||||||
|
// Essas mensagens nunca devem aparecer no chat
|
||||||
|
const isSystemPrompt = msg.role === 'system' && !isThinkingMessage;
|
||||||
|
if (isSystemPrompt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (isThinkingMessage) {
|
if (isThinkingMessage) {
|
||||||
const thinkingMeta = msg.metadata as ThinkingMessageMetadata;
|
const thinkingMeta = msg.metadata as ThinkingMessageMetadata;
|
||||||
currentThinkingGroup.push(thinkingMeta);
|
currentThinkingGroup.push(thinkingMeta);
|
||||||
@@ -1132,6 +1326,25 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calcular posições de todos os matches uma vez
|
||||||
|
const allMatches: Array<{ messageIndex: number; matchIndex: number }> = [];
|
||||||
|
if (searchQuery && searchQuery.trim().length >= 2) {
|
||||||
|
const query = searchQuery.trim();
|
||||||
|
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||||
|
|
||||||
|
messages.forEach((msg, msgIdx) => {
|
||||||
|
if (msg.role === 'user' || msg.role === 'assistant') {
|
||||||
|
const content = msg.content;
|
||||||
|
let match;
|
||||||
|
let matchIdx = 0;
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
allMatches.push({ messageIndex: msgIdx, matchIndex: matchIdx });
|
||||||
|
matchIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return groupedMessages.map((group, idx) => {
|
return groupedMessages.map((group, idx) => {
|
||||||
if (group.type === 'thinking-group' && group.thinkingSteps) {
|
if (group.type === 'thinking-group' && group.thinkingSteps) {
|
||||||
return (
|
return (
|
||||||
@@ -1143,11 +1356,30 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (group.type === 'regular' && group.message) {
|
} else if (group.type === 'regular' && group.message) {
|
||||||
|
// Encontrar índice do match atual para esta mensagem
|
||||||
|
let messageMatchIndex: number | undefined = undefined;
|
||||||
|
|
||||||
|
if (searchQuery && searchQuery.trim().length >= 2 && allMatches.length > 0 && group.index !== undefined) {
|
||||||
|
// Encontrar matches desta mensagem
|
||||||
|
const messageMatches = allMatches.filter(m => m.messageIndex === group.index);
|
||||||
|
|
||||||
|
if (messageMatches.length > 0) {
|
||||||
|
// Verificar se o match atual está nesta mensagem
|
||||||
|
const currentMatch = allMatches[searchMatchIndex];
|
||||||
|
if (currentMatch && currentMatch.messageIndex === group.index) {
|
||||||
|
messageMatchIndex = currentMatch.matchIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.index || idx} className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div key={group.index || idx} className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
message={group.message}
|
message={group.message}
|
||||||
isStreaming={group.isStreaming}
|
isStreaming={group.isStreaming}
|
||||||
|
highlightTerm={searchQuery && searchQuery.trim().length >= 2 ? searchQuery.trim() : undefined}
|
||||||
|
highlightIndex={messageMatchIndex}
|
||||||
|
messageIndex={group.index}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { AppUpdaterProvider } from "@/components/app-updater-provider";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -35,6 +36,7 @@ export default function RootLayout({
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
<AppUpdaterProvider />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+90
-1
@@ -24,11 +24,13 @@ import { useSettingsStore } from '@/store/settings-store';
|
|||||||
import { DomainTagsInput } from '@/components/settings/domain-tags-input';
|
import { DomainTagsInput } from '@/components/settings/domain-tags-input';
|
||||||
import { HardwareDashboard } from '@/components/settings/HardwareDashboard';
|
import { HardwareDashboard } from '@/components/settings/HardwareDashboard';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { CheckCircle2, XCircle, Loader2, Download, Trash2, Copy, ExternalLink, Plus, X, BookOpen, GraduationCap, Newspaper, Code, DollarSign, Edit, RotateCcw, Terminal, Power, ArrowLeft } from 'lucide-react';
|
import { CheckCircle2, XCircle, Loader2, Download, Trash2, Copy, ExternalLink, Plus, X, BookOpen, GraduationCap, Newspaper, Code, DollarSign, Edit, RotateCcw, Terminal, Power, ArrowLeft, Sparkles, RefreshCw } from 'lucide-react';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { SourcesConfig, SourceCategory } from '@/lib/types';
|
import { SourcesConfig, SourceCategory } from '@/lib/types';
|
||||||
import { TitleBar } from '@/components/titlebar';
|
import { TitleBar } from '@/components/titlebar';
|
||||||
|
import { useAppUpdater } from '@/hooks/use-app-updater';
|
||||||
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
|
|
||||||
interface Model {
|
interface Model {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -52,12 +54,20 @@ export default function SettingsPage() {
|
|||||||
const [isSavingSources, setIsSavingSources] = useState(false);
|
const [isSavingSources, setIsSavingSources] = useState(false);
|
||||||
const [recentLogs, setRecentLogs] = useState<string[]>([]);
|
const [recentLogs, setRecentLogs] = useState<string[]>([]);
|
||||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||||
|
const [appVersion, setAppVersion] = useState<string>('');
|
||||||
|
|
||||||
|
const updater = useAppUpdater();
|
||||||
|
|
||||||
// Load models, storage path, and sources config on mount
|
// Load models, storage path, and sources config on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadModels();
|
loadModels();
|
||||||
loadStoragePath();
|
loadStoragePath();
|
||||||
settings.fetchSources();
|
settings.fetchSources();
|
||||||
|
|
||||||
|
// Load app version
|
||||||
|
getVersion()
|
||||||
|
.then(setAppVersion)
|
||||||
|
.catch(() => setAppVersion('0.1.0'));
|
||||||
}, [settings.ollamaUrl]);
|
}, [settings.ollamaUrl]);
|
||||||
|
|
||||||
// Load logs periodically
|
// Load logs periodically
|
||||||
@@ -1095,6 +1105,85 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Atualizações</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure verificação automática de atualizações
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="auto-check-updates">Verificar Atualizações Automaticamente</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
O app verificará novas versões periodicamente
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="auto-check-updates"
|
||||||
|
checked={settings.autoCheckUpdates}
|
||||||
|
onCheckedChange={settings.setAutoCheckUpdates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Versão Atual</span>
|
||||||
|
<span className="font-mono font-medium">
|
||||||
|
{appVersion || 'Carregando...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{updater.updateAvailable && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Nova Versão Disponível</span>
|
||||||
|
<span className="font-mono font-medium text-primary">
|
||||||
|
{updater.updateInfo?.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
await updater.checkUpdate();
|
||||||
|
if (updater.updateAvailable) {
|
||||||
|
toast({
|
||||||
|
title: 'Atualização disponível',
|
||||||
|
description: `Versão ${updater.updateInfo?.version} está disponível para download.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Você está atualizado',
|
||||||
|
description: 'Não há novas atualizações disponíveis.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={updater.isChecking}
|
||||||
|
>
|
||||||
|
{updater.isChecking ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Verificando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Verificar Atualizações
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{updater.error && (
|
||||||
|
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||||
|
<p className="text-sm text-destructive">{updater.error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { UpdateDialog } from '@/components/modals/update-dialog';
|
||||||
|
import { useAppUpdater } from '@/hooks/use-app-updater';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
|
||||||
|
export function AppUpdaterProvider() {
|
||||||
|
const { updateAvailable, checkUpdate } = useAppUpdater();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [hasChecked, setHasChecked] = useState(false);
|
||||||
|
const autoCheckEnabled = useSettingsStore((state) => state.autoCheckUpdates ?? true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoCheckEnabled || hasChecked) return;
|
||||||
|
|
||||||
|
// Verificar após um delay para não interferir no carregamento inicial
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
checkUpdate().then(() => {
|
||||||
|
setHasChecked(true);
|
||||||
|
});
|
||||||
|
}, 5000); // 5 segundos após o app carregar
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [autoCheckEnabled, hasChecked, checkUpdate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateAvailable && !isOpen) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [updateAvailable, isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UpdateDialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Message } from "@/hooks/use-chat";
|
import { Message } from "@/hooks/use-chat";
|
||||||
import { Bot, User } from "lucide-react";
|
import { Bot, User } from "lucide-react";
|
||||||
@@ -13,9 +13,19 @@ import type { ThinkingMessageMetadata } from "@/hooks/use-chat";
|
|||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
highlightTerm?: string;
|
||||||
|
highlightIndex?: number;
|
||||||
|
messageIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) {
|
/**
|
||||||
|
* Componente de mensagem do chat otimizado com React.memo
|
||||||
|
*
|
||||||
|
* Durante streaming, usa renderização simplificada (texto puro) para evitar
|
||||||
|
* recálculo custoso da árvore DOM e syntax highlight a cada token.
|
||||||
|
* Markdown completo é aplicado apenas quando a mensagem está finalizada.
|
||||||
|
*/
|
||||||
|
function ChatMessageComponent({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex }: ChatMessageProps) {
|
||||||
// Verificar se é mensagem de processo de pensamento
|
// Verificar se é mensagem de processo de pensamento
|
||||||
const isThinkingMessage = message.metadata &&
|
const isThinkingMessage = message.metadata &&
|
||||||
typeof message.metadata === 'object' &&
|
typeof message.metadata === 'object' &&
|
||||||
@@ -52,6 +62,57 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
|
|||||||
return content || message.content;
|
return content || message.content;
|
||||||
}, [message.content]);
|
}, [message.content]);
|
||||||
|
|
||||||
|
// Processar highlight separadamente (após limpeza de conteúdo)
|
||||||
|
// Desabilitar durante streaming para performance
|
||||||
|
const highlightedContent = useMemo(() => {
|
||||||
|
// Não processar highlight durante streaming
|
||||||
|
if (isStreaming) return null;
|
||||||
|
|
||||||
|
if (!highlightTerm || highlightTerm.trim().length < 2 || (message.role !== 'user' && message.role !== 'assistant')) {
|
||||||
|
return null; // Não há highlight
|
||||||
|
}
|
||||||
|
|
||||||
|
const term = highlightTerm.trim();
|
||||||
|
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
||||||
|
const content = processedContent;
|
||||||
|
|
||||||
|
// Encontrar todos os matches
|
||||||
|
const matches: Array<{ start: number; end: number; text: string }> = [];
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
matches.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length,
|
||||||
|
text: match[0]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
// Construir conteúdo com highlights
|
||||||
|
let highlighted = '';
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
matches.forEach((match, idx) => {
|
||||||
|
// Adicionar texto antes do match
|
||||||
|
highlighted += content.substring(lastIndex, match.start);
|
||||||
|
|
||||||
|
// Adicionar match com highlight (laranja para atual, amarelo para outros)
|
||||||
|
const isCurrent = highlightIndex !== undefined && idx === highlightIndex;
|
||||||
|
const className = isCurrent
|
||||||
|
? 'bg-orange-400/60 dark:bg-orange-500/60 px-0.5 rounded'
|
||||||
|
: 'bg-yellow-200/50 dark:bg-yellow-800/50 px-0.5 rounded';
|
||||||
|
|
||||||
|
highlighted += `<mark class="${className}">${match.text}</mark>`;
|
||||||
|
lastIndex = match.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adicionar texto restante
|
||||||
|
highlighted += content.substring(lastIndex);
|
||||||
|
return highlighted;
|
||||||
|
}, [processedContent, highlightTerm, highlightIndex, message.role, isStreaming]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBackground
|
<MessageBackground
|
||||||
role={isUser ? 'user' : 'assistant'}
|
role={isUser ? 'user' : 'assistant'}
|
||||||
@@ -75,11 +136,14 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden min-w-0 pt-1">
|
<div className="flex-1 overflow-hidden min-w-0 pt-1" data-message-index={messageIndex}>
|
||||||
{isUser ? (
|
{isUser ? (
|
||||||
// Estilo específico para mensagem do usuário (mais destaque, fonte maior)
|
// Estilo específico para mensagem do usuário (mais destaque, fonte maior)
|
||||||
<div className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap">
|
<div
|
||||||
{processedContent}
|
className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap"
|
||||||
|
dangerouslySetInnerHTML={highlightedContent ? { __html: highlightedContent } : undefined}
|
||||||
|
>
|
||||||
|
{!highlightedContent && processedContent}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Estilo Assistant
|
// Estilo Assistant
|
||||||
@@ -88,22 +152,37 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
|
|||||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
||||||
<span className="text-sm font-medium">Gerando resposta...</span>
|
<span className="text-sm font-medium">Gerando resposta...</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : isStreaming ? (
|
||||||
|
// Renderização LEVE durante streaming: texto simples com formatação básica
|
||||||
|
// Evita recálculo de ReactMarkdown e syntax highlight a cada token
|
||||||
|
<div className={cn(
|
||||||
|
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
|
||||||
|
"prose-headings:font-semibold prose-headings:tracking-tight",
|
||||||
|
"relative"
|
||||||
|
)}>
|
||||||
|
<div className="whitespace-pre-wrap font-sans">
|
||||||
|
{processedContent}
|
||||||
|
</div>
|
||||||
|
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
// Renderização COMPLETA quando mensagem finalizada: ReactMarkdown com syntax highlight
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
|
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
|
||||||
"prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
|
"prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
|
||||||
"prose-p:leading-7 prose-li:leading-7",
|
"prose-p:leading-7 prose-li:leading-7",
|
||||||
"prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl",
|
"prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl",
|
||||||
isStreaming && "relative"
|
"prose-mark:bg-yellow-200/50 prose-mark:dark:bg-yellow-800/50 prose-mark:px-0.5 prose-mark:rounded"
|
||||||
)}>
|
)}>
|
||||||
<ReactMarkdown
|
{highlightedContent ? (
|
||||||
remarkPlugins={[remarkGfm]}
|
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
|
||||||
components={markdownRenderers}
|
) : (
|
||||||
>
|
<ReactMarkdown
|
||||||
{processedContent}
|
remarkPlugins={[remarkGfm]}
|
||||||
</ReactMarkdown>
|
components={markdownRenderers}
|
||||||
{isStreaming && !isEmpty && (
|
>
|
||||||
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
|
{processedContent}
|
||||||
|
</ReactMarkdown>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -123,5 +202,34 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChatMessage com React.memo para evitar re-renders desnecessários
|
||||||
|
*
|
||||||
|
* Comparação customizada:
|
||||||
|
* - Compara conteúdo da mensagem
|
||||||
|
* - Compara estado de streaming
|
||||||
|
* - Compara termo de highlight e índice
|
||||||
|
*/
|
||||||
|
export const ChatMessage = React.memo(ChatMessageComponent, (prevProps, nextProps) => {
|
||||||
|
// Se streaming mudou, sempre re-renderizar
|
||||||
|
if (prevProps.isStreaming !== nextProps.isStreaming) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Durante streaming, só re-renderizar se conteúdo mudou
|
||||||
|
if (nextProps.isStreaming) {
|
||||||
|
return prevProps.message.content === nextProps.message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quando não está em streaming, comparar todas as props relevantes
|
||||||
|
return (
|
||||||
|
prevProps.message.content === nextProps.message.content &&
|
||||||
|
prevProps.message.role === nextProps.message.role &&
|
||||||
|
prevProps.highlightTerm === nextProps.highlightTerm &&
|
||||||
|
prevProps.highlightIndex === nextProps.highlightIndex &&
|
||||||
|
prevProps.messageIndex === nextProps.messageIndex
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adicionar displayName para debugging
|
||||||
|
ChatMessage.displayName = 'ChatMessage';
|
||||||
@@ -2,7 +2,7 @@ import { SessionSummary } from "@/hooks/use-chat-storage";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Trash2, Plus, MessageSquare, Search, X, Loader2 } from "lucide-react";
|
import { Trash2, Plus, MessageSquare, Search, X, Loader2, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ interface SidebarListProps {
|
|||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
isSearching?: boolean;
|
isSearching?: boolean;
|
||||||
|
searchQuery?: string;
|
||||||
|
onNavigateMatch?: (sessionId: string, direction: 'prev' | 'next') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string) {
|
function formatDate(dateString: string) {
|
||||||
@@ -58,9 +60,11 @@ export function SidebarList({
|
|||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onSearch,
|
onSearch,
|
||||||
isSearching = false
|
isSearching = false,
|
||||||
|
searchQuery = '',
|
||||||
|
onNavigateMatch
|
||||||
}: SidebarListProps) {
|
}: SidebarListProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [localSearchInput, setLocalSearchInput] = useState('');
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const DEBOUNCE_MS = 300;
|
const DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
@@ -71,7 +75,7 @@ export function SidebarList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Se query vazia, limpar busca imediatamente
|
// Se query vazia, limpar busca imediatamente
|
||||||
if (!searchQuery.trim()) {
|
if (!localSearchInput.trim()) {
|
||||||
if (onSearch) {
|
if (onSearch) {
|
||||||
onSearch('');
|
onSearch('');
|
||||||
}
|
}
|
||||||
@@ -80,8 +84,8 @@ export function SidebarList({
|
|||||||
|
|
||||||
// Debounce: aguardar antes de buscar
|
// Debounce: aguardar antes de buscar
|
||||||
searchTimeoutRef.current = setTimeout(() => {
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
if (onSearch && searchQuery.trim().length >= 2) {
|
if (onSearch && localSearchInput.trim().length >= 2) {
|
||||||
onSearch(searchQuery.trim());
|
onSearch(localSearchInput.trim());
|
||||||
}
|
}
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
@@ -91,10 +95,10 @@ export function SidebarList({
|
|||||||
clearTimeout(searchTimeoutRef.current);
|
clearTimeout(searchTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [searchQuery, onSearch]);
|
}, [localSearchInput, onSearch]);
|
||||||
|
|
||||||
const handleClearSearch = () => {
|
const handleClearSearch = () => {
|
||||||
setSearchQuery('');
|
setLocalSearchInput('');
|
||||||
if (onSearch) {
|
if (onSearch) {
|
||||||
onSearch('');
|
onSearch('');
|
||||||
}
|
}
|
||||||
@@ -111,14 +115,14 @@ export function SidebarList({
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar conversas..."
|
placeholder="Buscar conversas..."
|
||||||
value={searchQuery}
|
value={localSearchInput}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setLocalSearchInput(e.target.value)}
|
||||||
className="pl-8 pr-8 h-9 text-sm"
|
className="pl-8 pr-8 h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
{isSearching && (
|
{isSearching && (
|
||||||
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
|
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
|
||||||
)}
|
)}
|
||||||
{searchQuery && !isSearching && (
|
{localSearchInput && !isSearching && (
|
||||||
<button
|
<button
|
||||||
onClick={handleClearSearch}
|
onClick={handleClearSearch}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
@@ -190,16 +194,55 @@ export function SidebarList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Texto do card */}
|
{/* Texto do card */}
|
||||||
<div className="sidebar-chat-item-text">
|
<div className="sidebar-chat-item-text flex-1 min-w-0">
|
||||||
<h3 className="sidebar-chat-item-title text-sm font-medium text-card-foreground leading-tight mb-0.5">
|
<h3 className="sidebar-chat-item-title text-sm font-medium text-card-foreground leading-tight mb-0.5">
|
||||||
{session.title}
|
{session.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="sidebar-chat-item-date text-xs text-muted-foreground">
|
<div className="flex items-center gap-2">
|
||||||
{formatDate(session.updated_at)}
|
<p className="sidebar-chat-item-date text-xs text-muted-foreground">
|
||||||
</p>
|
{formatDate(session.updated_at)}
|
||||||
|
</p>
|
||||||
|
{searchQuery && session.match_count !== undefined && session.match_count > 0 && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||||
|
{session.match_count} {session.match_count === 1 ? 'ocorrência' : 'ocorrências'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Navegação de matches - aparece apenas quando selecionado e há busca ativa */}
|
||||||
|
{isSelected && searchQuery && session.match_count !== undefined && session.match_count > 0 && onNavigateMatch && (
|
||||||
|
<div className="flex flex-col gap-0.5 mr-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onNavigateMatch(session.id, 'prev');
|
||||||
|
}}
|
||||||
|
aria-label="Ocorrência anterior"
|
||||||
|
>
|
||||||
|
<ChevronUp className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onNavigateMatch(session.id, 'next');
|
||||||
|
}}
|
||||||
|
aria-label="Próxima ocorrência"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Botão de deletar - aparece no hover */}
|
{/* Botão de deletar - aparece no hover */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Download, Loader2, X, Sparkles } from 'lucide-react';
|
||||||
|
import { useAppUpdater } from '@/hooks/use-app-updater';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
|
||||||
|
interface UpdateDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateDialog({ open, onOpenChange }: UpdateDialogProps) {
|
||||||
|
const {
|
||||||
|
currentVersion,
|
||||||
|
updateAvailable,
|
||||||
|
updateInfo,
|
||||||
|
isChecking,
|
||||||
|
isDownloading,
|
||||||
|
downloadProgress,
|
||||||
|
error,
|
||||||
|
installUpdate,
|
||||||
|
} = useAppUpdater();
|
||||||
|
|
||||||
|
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||||
|
const setAutoCheckUpdates = useSettingsStore((state) => state.setAutoCheckUpdates);
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (dontShowAgain) {
|
||||||
|
setAutoCheckUpdates(false);
|
||||||
|
}
|
||||||
|
await installUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (dontShowAgain) {
|
||||||
|
setAutoCheckUpdates(false);
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!updateAvailable && !isChecking) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open && updateAvailable} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
Atualização Disponível
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Uma nova versão do OllaHub está disponível para download.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Versão Atual</span>
|
||||||
|
<span className="font-mono font-medium">{currentVersion}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Nova Versão</span>
|
||||||
|
<span className="font-mono font-medium text-primary">
|
||||||
|
{updateInfo?.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{updateInfo?.body && (
|
||||||
|
<div className="p-3 bg-muted rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||||
|
{updateInfo.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDownloading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Baixando atualização...</span>
|
||||||
|
<span className="font-medium">{downloadProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={downloadProgress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="dont-show-updates"
|
||||||
|
checked={dontShowAgain}
|
||||||
|
onCheckedChange={(checked) => setDontShowAgain(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="dont-show-updates"
|
||||||
|
className="text-sm font-normal cursor-pointer"
|
||||||
|
>
|
||||||
|
Não verificar atualizações automaticamente
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-2" />
|
||||||
|
Depois
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={isDownloading || isChecking}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Baixando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Atualizar Agora
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Configuração do Sistema de Atualização Automática
|
||||||
|
|
||||||
|
Este documento explica como configurar o sistema de atualização automática do OllaHub.
|
||||||
|
|
||||||
|
## Pré-requisitos
|
||||||
|
|
||||||
|
1. Repositório GitHub configurado
|
||||||
|
2. Rust e Cargo instalados
|
||||||
|
3. Tauri CLI instalado
|
||||||
|
|
||||||
|
## Passo 1: Gerar Chaves de Assinatura
|
||||||
|
|
||||||
|
Execute o script para gerar as chaves:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/generate-updater-keys.sh
|
||||||
|
./scripts/generate-updater-keys.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou manualmente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.tauri
|
||||||
|
cargo tauri signer generate -w ~/.tauri/ollahub.key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Passo 2: Configurar Chave Pública
|
||||||
|
|
||||||
|
1. Copie a chave pública gerada (está em `~/.tauri/ollahub.key.pub`)
|
||||||
|
2. Edite `src-tauri/tauri.conf.json`
|
||||||
|
3. Substitua `YOUR_PUBLIC_KEY_HERE` pela chave pública
|
||||||
|
4. Atualize o endpoint com seu repositório GitHub:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"updater": {
|
||||||
|
"active": true,
|
||||||
|
"endpoints": [
|
||||||
|
"https://api.github.com/repos/SEU_USUARIO/SEU_REPOSITORIO/releases/latest"
|
||||||
|
],
|
||||||
|
"dialog": true,
|
||||||
|
"pubkey": "SUA_CHAVE_PUBLICA_AQUI"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Passo 3: Configurar GitHub Secrets
|
||||||
|
|
||||||
|
1. Vá em **Settings** > **Secrets and variables** > **Actions**
|
||||||
|
2. Clique em **New repository secret**
|
||||||
|
3. Nome: `TAURI_UPDATER_PRIVATE_KEY`
|
||||||
|
4. Valor: Cole o conteúdo completo do arquivo `~/.tauri/ollahub.key`
|
||||||
|
|
||||||
|
## Passo 4: Atualizar Workflow (se necessário)
|
||||||
|
|
||||||
|
O workflow `.github/workflows/release.yml` já está configurado para:
|
||||||
|
- Buildar para Windows, macOS e Linux
|
||||||
|
- Assinar binários com a chave privada
|
||||||
|
- Criar release automaticamente quando há push em `main`/`master`
|
||||||
|
|
||||||
|
## Como Funciona
|
||||||
|
|
||||||
|
1. **Build Automático**: Quando você faz push para `main`/`master`, o GitHub Actions:
|
||||||
|
- Builda o app para todas as plataformas
|
||||||
|
- Assina os binários
|
||||||
|
- Cria uma release no GitHub
|
||||||
|
|
||||||
|
2. **Verificação de Atualizações**: O app verifica automaticamente:
|
||||||
|
- 5 segundos após iniciar (primeira verificação)
|
||||||
|
- A cada 6 horas (verificação periódica)
|
||||||
|
- Manualmente via botão nas configurações
|
||||||
|
|
||||||
|
3. **Instalação**: Quando há atualização disponível:
|
||||||
|
- Usuário recebe notificação
|
||||||
|
- Pode escolher atualizar agora ou depois
|
||||||
|
- Download e instalação são automáticos
|
||||||
|
- App reinicia automaticamente após instalação
|
||||||
|
|
||||||
|
## Bump de Versão
|
||||||
|
|
||||||
|
Para criar uma nova versão:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Patch (0.1.0 -> 0.1.1)
|
||||||
|
pnpm run bump-version patch --commit
|
||||||
|
|
||||||
|
# Minor (0.1.0 -> 0.2.0)
|
||||||
|
pnpm run bump-version minor --commit
|
||||||
|
|
||||||
|
# Major (0.1.0 -> 1.0.0)
|
||||||
|
pnpm run bump-version major --commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Depois, faça push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
O GitHub Actions criará automaticamente a release.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Erro: "Failed to check for updates"
|
||||||
|
- Verifique se o endpoint no `tauri.conf.json` está correto
|
||||||
|
- Verifique se há releases públicas no GitHub
|
||||||
|
- Verifique a conexão com a internet
|
||||||
|
|
||||||
|
### Erro: "Signature verification failed"
|
||||||
|
- Verifique se a chave pública no `tauri.conf.json` está correta
|
||||||
|
- Verifique se a chave privada no GitHub Secret está correta
|
||||||
|
- Regere as chaves se necessário
|
||||||
|
|
||||||
|
### Release não é criada automaticamente
|
||||||
|
- Verifique se o push foi feito para `main` ou `master`
|
||||||
|
- Verifique os logs do GitHub Actions
|
||||||
|
- Verifique se as permissões do workflow estão corretas
|
||||||
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
import { check as checkForUpdate } from '@tauri-apps/plugin-updater';
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
version: string;
|
||||||
|
date?: string;
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppUpdaterState {
|
||||||
|
currentVersion: string;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
updateInfo: UpdateInfo | null;
|
||||||
|
isChecking: boolean;
|
||||||
|
isDownloading: boolean;
|
||||||
|
downloadProgress: number;
|
||||||
|
error: string | null;
|
||||||
|
checkUpdate: () => Promise<void>;
|
||||||
|
installUpdate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppUpdater(): AppUpdaterState {
|
||||||
|
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||||
|
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||||
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const autoCheckEnabled = useSettingsStore((state) => state.autoCheckUpdates ?? true);
|
||||||
|
|
||||||
|
// Obter versão atual
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchVersion = async () => {
|
||||||
|
try {
|
||||||
|
const version = await getVersion();
|
||||||
|
setCurrentVersion(version);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get app version:', err);
|
||||||
|
setCurrentVersion('0.1.0');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchVersion();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Verificar atualizações
|
||||||
|
const checkUpdate = useCallback(async () => {
|
||||||
|
setIsChecking(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const update = await checkForUpdate();
|
||||||
|
|
||||||
|
if (update?.available) {
|
||||||
|
setUpdateAvailable(true);
|
||||||
|
setUpdateInfo({
|
||||||
|
version: update.version,
|
||||||
|
date: update.date,
|
||||||
|
body: update.body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUpdateAvailable(false);
|
||||||
|
setUpdateInfo(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check for updates:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Erro ao verificar atualizações');
|
||||||
|
setUpdateAvailable(false);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Instalar atualização
|
||||||
|
const installUpdate = useCallback(async () => {
|
||||||
|
if (!updateAvailable) return;
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
setError(null);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const update = await checkForUpdate();
|
||||||
|
|
||||||
|
if (update?.available) {
|
||||||
|
// Escutar progresso do download
|
||||||
|
const unlisten = await update.on('download-progress', (progress: any) => {
|
||||||
|
setDownloadProgress(progress.progress || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Baixar e instalar atualização
|
||||||
|
await update.downloadAndInstall();
|
||||||
|
|
||||||
|
unlisten();
|
||||||
|
|
||||||
|
// Reiniciar aplicativo após instalação
|
||||||
|
await update.installAndRestart();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to install update:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Erro ao instalar atualização');
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
}, [updateAvailable]);
|
||||||
|
|
||||||
|
// Verificação automática periódica (a cada 6 horas)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoCheckEnabled) return;
|
||||||
|
|
||||||
|
// Verificar imediatamente ao montar (com delay para não interferir no carregamento)
|
||||||
|
const initialTimer = setTimeout(() => {
|
||||||
|
checkUpdate();
|
||||||
|
}, 5000); // 5 segundos após o app carregar
|
||||||
|
|
||||||
|
// Verificar periodicamente
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
checkUpdate();
|
||||||
|
}, 6 * 60 * 60 * 1000); // 6 horas
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(initialTimer);
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [autoCheckEnabled, checkUpdate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentVersion,
|
||||||
|
updateAvailable,
|
||||||
|
updateInfo,
|
||||||
|
isChecking,
|
||||||
|
isDownloading,
|
||||||
|
downloadProgress,
|
||||||
|
error,
|
||||||
|
checkUpdate,
|
||||||
|
installUpdate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
+158
-8
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { Message } from './use-chat';
|
import { Message } from './use-chat';
|
||||||
import { removeMetadataNoise } from '@/lib/metadata';
|
import { removeMetadataNoise } from '@/lib/metadata';
|
||||||
@@ -10,8 +10,31 @@ export interface SessionSummary {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
preview: string;
|
preview: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
|
match_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resultado de carregamento paginado do backend */
|
||||||
|
interface PaginatedHistoryResult {
|
||||||
|
messages: Array<{
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
metadata?: unknown;
|
||||||
|
}>;
|
||||||
|
total_count: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estado de paginação para uma sessão */
|
||||||
|
export interface PaginationState {
|
||||||
|
totalCount: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
offset: number;
|
||||||
|
isLoadingMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Número de mensagens a carregar por página */
|
||||||
|
const MESSAGES_PER_PAGE = 30;
|
||||||
|
|
||||||
const TITLE_MODEL = "qwen2.5:0.5b";
|
const TITLE_MODEL = "qwen2.5:0.5b";
|
||||||
|
|
||||||
// Helper to generate fallback title from user's first message
|
// Helper to generate fallback title from user's first message
|
||||||
@@ -41,6 +64,17 @@ export function useChatStorage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
// Estado de paginação por sessão
|
||||||
|
const [paginationState, setPaginationState] = useState<PaginationState>({
|
||||||
|
totalCount: 0,
|
||||||
|
hasMore: false,
|
||||||
|
offset: 0,
|
||||||
|
isLoadingMore: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ref para evitar múltiplos loads simultâneos
|
||||||
|
const loadingMoreRef = useRef(false);
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await invoke<SessionSummary[]>('load_chat_sessions');
|
const list = await invoke<SessionSummary[]>('load_chat_sessions');
|
||||||
@@ -73,23 +107,138 @@ export function useChatStorage() {
|
|||||||
}
|
}
|
||||||
}, [loadSessions]);
|
}, [loadSessions]);
|
||||||
|
|
||||||
const loadSessionHistory = useCallback(async (id: string): Promise<Message[]> => {
|
/**
|
||||||
|
* Carrega histórico de uma sessão com paginação (lazy loading)
|
||||||
|
*
|
||||||
|
* Por padrão, carrega as últimas MESSAGES_PER_PAGE mensagens.
|
||||||
|
* Retorna as mensagens e atualiza o estado de paginação.
|
||||||
|
*/
|
||||||
|
const loadSessionHistory = useCallback(async (
|
||||||
|
id: string,
|
||||||
|
options?: { loadAll?: boolean }
|
||||||
|
): Promise<Message[]> => {
|
||||||
try {
|
try {
|
||||||
const rawMessages = await invoke<any[]>('load_chat_history', { id });
|
// Reset pagination state
|
||||||
// Convert role from String to type-safe union and ensure proper types
|
setPaginationState({
|
||||||
return rawMessages.map(msg => ({
|
totalCount: 0,
|
||||||
|
hasMore: false,
|
||||||
|
offset: 0,
|
||||||
|
isLoadingMore: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se loadAll = true, usa o comando antigo que carrega tudo
|
||||||
|
if (options?.loadAll) {
|
||||||
|
const rawMessages = await invoke<Array<{
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
metadata?: unknown;
|
||||||
|
}>>('load_chat_history', { id });
|
||||||
|
|
||||||
|
return rawMessages.map(msg => ({
|
||||||
|
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
||||||
|
? msg.role as 'user' | 'assistant' | 'system'
|
||||||
|
: 'user' as const,
|
||||||
|
content: msg.content || '',
|
||||||
|
metadata: msg.metadata || undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usar paginação: carregar últimas N mensagens
|
||||||
|
const result = await invoke<PaginatedHistoryResult>('load_chat_history_paginated', {
|
||||||
|
id,
|
||||||
|
limit: MESSAGES_PER_PAGE,
|
||||||
|
offset: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atualizar estado de paginação
|
||||||
|
setPaginationState({
|
||||||
|
totalCount: result.total_count,
|
||||||
|
hasMore: result.has_more,
|
||||||
|
offset: result.messages.length,
|
||||||
|
isLoadingMore: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Converter para tipo Message
|
||||||
|
return result.messages.map(msg => ({
|
||||||
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
||||||
? msg.role as 'user' | 'assistant' | 'system'
|
? msg.role as 'user' | 'assistant' | 'system'
|
||||||
: 'user' as const, // fallback
|
: 'user' as const,
|
||||||
content: msg.content || '',
|
content: msg.content || '',
|
||||||
metadata: msg.metadata || undefined
|
metadata: msg.metadata || undefined
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load history:", error);
|
console.error("Failed to load history:", error);
|
||||||
return [];
|
|
||||||
|
// Fallback: tentar carregar tudo com o comando antigo
|
||||||
|
try {
|
||||||
|
const rawMessages = await invoke<Array<{
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
metadata?: unknown;
|
||||||
|
}>>('load_chat_history', { id });
|
||||||
|
|
||||||
|
return rawMessages.map(msg => ({
|
||||||
|
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
||||||
|
? msg.role as 'user' | 'assistant' | 'system'
|
||||||
|
: 'user' as const,
|
||||||
|
content: msg.content || '',
|
||||||
|
metadata: msg.metadata || undefined
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carrega mais mensagens antigas (scroll infinito reverso)
|
||||||
|
*
|
||||||
|
* Chamado quando o usuário faz scroll para o topo do chat.
|
||||||
|
* Retorna as mensagens anteriores que devem ser prepend ao estado.
|
||||||
|
*/
|
||||||
|
const loadMoreMessages = useCallback(async (
|
||||||
|
sessionId: string
|
||||||
|
): Promise<Message[]> => {
|
||||||
|
// Evitar múltiplos loads simultâneos
|
||||||
|
if (loadingMoreRef.current || !paginationState.hasMore) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingMoreRef.current = true;
|
||||||
|
setPaginationState(prev => ({ ...prev, isLoadingMore: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<PaginatedHistoryResult>('load_chat_history_paginated', {
|
||||||
|
id: sessionId,
|
||||||
|
limit: MESSAGES_PER_PAGE,
|
||||||
|
offset: paginationState.offset
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atualizar estado de paginação
|
||||||
|
setPaginationState(prev => ({
|
||||||
|
...prev,
|
||||||
|
hasMore: result.has_more,
|
||||||
|
offset: prev.offset + result.messages.length,
|
||||||
|
isLoadingMore: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Converter para tipo Message
|
||||||
|
return result.messages.map(msg => ({
|
||||||
|
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
||||||
|
? msg.role as 'user' | 'assistant' | 'system'
|
||||||
|
: 'user' as const,
|
||||||
|
content: msg.content || '',
|
||||||
|
metadata: msg.metadata || undefined
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load more messages:", error);
|
||||||
|
setPaginationState(prev => ({ ...prev, isLoadingMore: false }));
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
loadingMoreRef.current = false;
|
||||||
|
}
|
||||||
|
}, [paginationState.hasMore, paginationState.offset]);
|
||||||
|
|
||||||
const deleteSession = useCallback(async (id: string) => {
|
const deleteSession = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await invoke('delete_chat_session', { id });
|
await invoke('delete_chat_session', { id });
|
||||||
@@ -396,6 +545,8 @@ export function useChatStorage() {
|
|||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
loadSessionHistory,
|
loadSessionHistory,
|
||||||
|
loadMoreMessages,
|
||||||
|
paginationState,
|
||||||
saveSession,
|
saveSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
isGeneratingTitle,
|
isGeneratingTitle,
|
||||||
@@ -407,4 +558,3 @@ export function useChatStorage() {
|
|||||||
isSearching
|
isSearching
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
+40
-26
@@ -240,6 +240,29 @@ export function useChat() {
|
|||||||
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
|
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
|
||||||
const currentSessionIdRef = useRef<string | null>(null);
|
const currentSessionIdRef = useRef<string | null>(null);
|
||||||
const lastTokenRef = useRef<string>(''); // Track last token to detect duplicates
|
const lastTokenRef = useRef<string>(''); // Track last token to detect duplicates
|
||||||
|
const renderScheduledRef = useRef<boolean>(false);
|
||||||
|
const rafIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Função para atualizar o estado visual com o conteúdo acumulado
|
||||||
|
const flushToState = () => {
|
||||||
|
renderScheduledRef.current = false;
|
||||||
|
const displayContent = stripMetadataTag(rawContentRef.current);
|
||||||
|
setMessages(prev => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.role === 'assistant') {
|
||||||
|
return [...prev.slice(0, -1), { ...last, content: displayContent }];
|
||||||
|
}
|
||||||
|
return [...prev, { role: 'assistant', content: displayContent }];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agendar flush com requestAnimationFrame (limita a ~60fps)
|
||||||
|
const scheduleFlush = () => {
|
||||||
|
if (!renderScheduledRef.current) {
|
||||||
|
renderScheduledRef.current = true;
|
||||||
|
rafIdRef.current = requestAnimationFrame(flushToState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Listener para eventos do Rust
|
// Listener para eventos do Rust
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -262,51 +285,38 @@ export function useChat() {
|
|||||||
const { content, done } = event.payload;
|
const { content, done } = event.payload;
|
||||||
|
|
||||||
if (content && content.length > 0) {
|
if (content && content.length > 0) {
|
||||||
// Verificar se este é o mesmo token do último processado (evitar duplicação)
|
// Detecção de duplicatas (MANTER LÓGICA ORIGINAL)
|
||||||
// Isso pode acontecer se o evento for emitido múltiplas vezes
|
|
||||||
if (content === lastTokenRef.current && rawContentRef.current.endsWith(content)) {
|
if (content === lastTokenRef.current && rawContentRef.current.endsWith(content)) {
|
||||||
// Token duplicado, ignorar
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.warn('Token duplicado detectado, ignorando:', content.substring(0, 30));
|
console.warn('Token duplicado detectado, ignorando:', content.substring(0, 30));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atualizar referência do último token
|
|
||||||
lastTokenRef.current = content;
|
lastTokenRef.current = content;
|
||||||
|
|
||||||
// Accumulate raw content (including metadata) in ref
|
|
||||||
rawContentRef.current += content;
|
rawContentRef.current += content;
|
||||||
|
|
||||||
setMessages(prev => {
|
// MUDANÇA: Agendar flush em vez de chamar setMessages diretamente
|
||||||
const last = prev[prev.length - 1];
|
scheduleFlush();
|
||||||
if (last && last.role === 'assistant') {
|
|
||||||
// For display: strip incomplete metadata tag if present
|
|
||||||
const displayContent = stripMetadataTag(rawContentRef.current);
|
|
||||||
return [
|
|
||||||
...prev.slice(0, -1),
|
|
||||||
{ ...last, content: displayContent }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
// Se não existe mensagem do assistente, criar uma
|
|
||||||
return [...prev, { role: 'assistant', content: stripMetadataTag(rawContentRef.current) }];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
// Stream terminou, processar metadata final
|
// Cancelar qualquer flush pendente
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
rafIdRef.current = null;
|
||||||
|
renderScheduledRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush IMEDIATO com processamento de metadata
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
if (last && last.role === 'assistant') {
|
if (last && last.role === 'assistant') {
|
||||||
const rawContent = rawContentRef.current;
|
const rawContent = rawContentRef.current;
|
||||||
const { content: finalContent, metadata } = parseMetadata(rawContent);
|
const { content: finalContent, metadata } = parseMetadata(rawContent);
|
||||||
rawContentRef.current = '';
|
rawContentRef.current = '';
|
||||||
lastTokenRef.current = ''; // Reset last token tracker
|
lastTokenRef.current = '';
|
||||||
|
return [...prev.slice(0, -1), { ...last, content: finalContent || last.content, metadata }];
|
||||||
return [
|
|
||||||
...prev.slice(0, -1),
|
|
||||||
{ ...last, content: finalContent || last.content, metadata }
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
@@ -331,6 +341,10 @@ export function useChat() {
|
|||||||
unlistenCreated?.();
|
unlistenCreated?.();
|
||||||
unlistenToken?.();
|
unlistenToken?.();
|
||||||
unlistenError?.();
|
unlistenError?.();
|
||||||
|
// Cancelar RAF pendente
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -6,7 +6,9 @@
|
|||||||
"dev": "next dev --webpack",
|
"dev": "next dev --webpack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"generate-changelog": "node scripts/generate-changelog.js",
|
||||||
|
"bump-version": "node scripts/bump-version.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tauri-apps/api": "^2.9.0",
|
"@tauri-apps/api": "^2.9.0",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
Generated
+10
@@ -50,6 +50,9 @@ importers:
|
|||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2.9.0
|
specifier: ^2.9.0
|
||||||
version: 2.9.0
|
version: 2.9.0
|
||||||
|
'@tauri-apps/plugin-updater':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.9.0
|
||||||
'@types/react-syntax-highlighter':
|
'@types/react-syntax-highlighter':
|
||||||
specifier: ^15.5.13
|
specifier: ^15.5.13
|
||||||
version: 15.5.13
|
version: 15.5.13
|
||||||
@@ -1153,6 +1156,9 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@tauri-apps/[email protected]':
|
||||||
|
resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==}
|
||||||
|
|
||||||
'@tybys/[email protected]':
|
'@tybys/[email protected]':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@@ -4228,6 +4234,10 @@ snapshots:
|
|||||||
'@tauri-apps/cli-win32-ia32-msvc': 2.9.4
|
'@tauri-apps/cli-win32-ia32-msvc': 2.9.4
|
||||||
'@tauri-apps/cli-win32-x64-msvc': 2.9.4
|
'@tauri-apps/cli-win32-x64-msvc': 2.9.4
|
||||||
|
|
||||||
|
'@tauri-apps/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.9.0
|
||||||
|
|
||||||
'@tybys/[email protected]':
|
'@tybys/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
const versionType = process.argv[2] || 'patch'; // patch, minor, major
|
||||||
|
const shouldCommit = process.argv.includes('--commit');
|
||||||
|
|
||||||
|
// Validar tipo de versão
|
||||||
|
if (!['patch', 'minor', 'major'].includes(versionType)) {
|
||||||
|
console.error('❌ Tipo de versão inválido. Use: patch, minor ou major');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ler package.json
|
||||||
|
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||||
|
|
||||||
|
// Bump version
|
||||||
|
const [major, minor, patch] = packageJson.version.split('.').map(Number);
|
||||||
|
let newVersion;
|
||||||
|
|
||||||
|
switch (versionType) {
|
||||||
|
case 'major':
|
||||||
|
newVersion = `${major + 1}.0.0`;
|
||||||
|
break;
|
||||||
|
case 'minor':
|
||||||
|
newVersion = `${major}.${minor + 1}.0`;
|
||||||
|
break;
|
||||||
|
case 'patch':
|
||||||
|
default:
|
||||||
|
newVersion = `${major}.${minor}.${patch + 1}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📦 Atualizando versão de ${packageJson.version} para ${newVersion}...`);
|
||||||
|
|
||||||
|
// Atualizar package.json
|
||||||
|
packageJson.version = newVersion;
|
||||||
|
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
|
||||||
|
console.log('✅ package.json atualizado');
|
||||||
|
|
||||||
|
// Atualizar tauri.conf.json
|
||||||
|
const tauriConfPath = path.join(__dirname, '..', 'src-tauri', 'tauri.conf.json');
|
||||||
|
const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, 'utf-8'));
|
||||||
|
tauriConf.version = newVersion;
|
||||||
|
fs.writeFileSync(tauriConfPath, JSON.stringify(tauriConf, null, 2) + '\n');
|
||||||
|
console.log('✅ tauri.conf.json atualizado');
|
||||||
|
|
||||||
|
// Atualizar Cargo.toml
|
||||||
|
const cargoTomlPath = path.join(__dirname, '..', 'src-tauri', 'Cargo.toml');
|
||||||
|
let cargoToml = fs.readFileSync(cargoTomlPath, 'utf-8');
|
||||||
|
cargoToml = cargoToml.replace(/^version = ".*"$/m, `version = "${newVersion}"`);
|
||||||
|
fs.writeFileSync(cargoTomlPath, cargoToml);
|
||||||
|
console.log('✅ Cargo.toml atualizado');
|
||||||
|
|
||||||
|
// Criar commit e tag (opcional)
|
||||||
|
if (shouldCommit) {
|
||||||
|
try {
|
||||||
|
execSync(`git add package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml`, { stdio: 'inherit' });
|
||||||
|
execSync(`git commit -m "chore: bump version to ${newVersion}"`, { stdio: 'inherit' });
|
||||||
|
execSync(`git tag v${newVersion}`, { stdio: 'inherit' });
|
||||||
|
console.log(`✅ Commit e tag v${newVersion} criados`);
|
||||||
|
console.log(`\n📝 Próximo passo: git push && git push --tags`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erro ao criar commit/tag:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Versão atualizada para ${newVersion}`);
|
||||||
|
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
const { execSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Ler versão do package.json
|
||||||
|
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||||
|
const currentVersion = packageJson.version;
|
||||||
|
|
||||||
|
// Verificar se estamos em um repositório Git
|
||||||
|
let isGitRepo = false;
|
||||||
|
try {
|
||||||
|
execSync('git rev-parse --git-dir', { stdio: 'ignore', cwd: path.join(__dirname, '..') });
|
||||||
|
isGitRepo = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Não é um repositório Git. Gerando changelog vazio.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para sanitizar mensagens de commit
|
||||||
|
function sanitizeMessage(message) {
|
||||||
|
return message
|
||||||
|
.replace(/\n/g, ' ')
|
||||||
|
.replace(/\r/g, '')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para extrair tipo e mensagem do commit
|
||||||
|
function parseCommitMessage(fullMessage) {
|
||||||
|
const featMatch = fullMessage.match(/^feat(\(.+?\))?:\s*(.+)$/i);
|
||||||
|
const fixMatch = fullMessage.match(/^fix(\(.+?\))?:\s*(.+)$/i);
|
||||||
|
|
||||||
|
if (featMatch) {
|
||||||
|
return { type: 'feat', message: featMatch[2] || featMatch[0] };
|
||||||
|
}
|
||||||
|
if (fixMatch) {
|
||||||
|
return { type: 'fix', message: fixMatch[2] || fixMatch[0] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para obter tags de versão
|
||||||
|
function getVersionTags() {
|
||||||
|
try {
|
||||||
|
const tags = execSync('git tag --sort=-version:refname', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
})
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter(tag => /^v?\d+\.\d+\.\d+/.test(tag));
|
||||||
|
return tags;
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para obter commits entre duas tags ou desde o início
|
||||||
|
function getCommitsBetweenTags(fromTag, toTag) {
|
||||||
|
try {
|
||||||
|
let range = '';
|
||||||
|
if (fromTag && toTag) {
|
||||||
|
range = `${fromTag}..${toTag}`;
|
||||||
|
} else if (toTag) {
|
||||||
|
range = toTag;
|
||||||
|
} else {
|
||||||
|
range = 'HEAD';
|
||||||
|
}
|
||||||
|
|
||||||
|
const logFormat = '%H|%an|%ad|%s';
|
||||||
|
const dateFormat = '--date=iso-strict';
|
||||||
|
|
||||||
|
const output = execSync(
|
||||||
|
`git log ${range} --pretty=format:"${logFormat}" --date=${dateFormat}`,
|
||||||
|
{
|
||||||
|
encoding: 'utf-8',
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return output
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(line => {
|
||||||
|
const [hash, author, date, message] = line.split('|');
|
||||||
|
const parsed = parseCommitMessage(message);
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: hash.substring(0, 7),
|
||||||
|
type: parsed.type,
|
||||||
|
message: sanitizeMessage(parsed.message),
|
||||||
|
author: author.trim(),
|
||||||
|
date: date.trim(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função principal para gerar changelog
|
||||||
|
function generateChangelog() {
|
||||||
|
const changelogPath = path.join(__dirname, '..', 'data', 'changelog.json');
|
||||||
|
const dataDir = path.dirname(changelogPath);
|
||||||
|
|
||||||
|
// Criar diretório data se não existir
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGitRepo) {
|
||||||
|
// Gerar changelog vazio se não for repositório Git
|
||||||
|
const emptyChangelog = {
|
||||||
|
version: currentVersion,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
releases: [],
|
||||||
|
};
|
||||||
|
fs.writeFileSync(changelogPath, JSON.stringify(emptyChangelog, null, 2), 'utf-8');
|
||||||
|
console.log('✅ Changelog vazio gerado (não é repositório Git)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releases = [];
|
||||||
|
const versionTags = getVersionTags();
|
||||||
|
|
||||||
|
if (versionTags.length > 0) {
|
||||||
|
// Agrupar por tags de versão
|
||||||
|
for (let i = 0; i < versionTags.length; i++) {
|
||||||
|
const currentTag = versionTags[i];
|
||||||
|
const nextTag = i < versionTags.length - 1 ? versionTags[i + 1] : null;
|
||||||
|
|
||||||
|
const commits = getCommitsBetweenTags(nextTag, currentTag);
|
||||||
|
|
||||||
|
if (commits.length > 0) {
|
||||||
|
// Extrair versão da tag (remover 'v' prefix se existir)
|
||||||
|
const version = currentTag.replace(/^v/, '');
|
||||||
|
const releaseDate = commits[0]?.date || new Date().toISOString();
|
||||||
|
|
||||||
|
releases.push({
|
||||||
|
version,
|
||||||
|
date: releaseDate.split('T')[0],
|
||||||
|
commits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar commits não versionados (após a última tag)
|
||||||
|
const unreleasedCommits = getCommitsBetweenTags(versionTags[0], 'HEAD');
|
||||||
|
if (unreleasedCommits.length > 0) {
|
||||||
|
releases.unshift({
|
||||||
|
version: 'unreleased',
|
||||||
|
date: unreleasedCommits[0]?.date.split('T')[0] || new Date().toISOString().split('T')[0],
|
||||||
|
commits: unreleasedCommits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Sem tags, agrupar por data (últimos 30 dias ou todos se menos)
|
||||||
|
const allCommits = getCommitsBetweenTags(null, 'HEAD');
|
||||||
|
|
||||||
|
if (allCommits.length > 0) {
|
||||||
|
// Agrupar por data
|
||||||
|
const commitsByDate = {};
|
||||||
|
allCommits.forEach(commit => {
|
||||||
|
const date = commit.date.split('T')[0];
|
||||||
|
if (!commitsByDate[date]) {
|
||||||
|
commitsByDate[date] = [];
|
||||||
|
}
|
||||||
|
commitsByDate[date].push(commit);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar releases por data (ordenado por data decrescente)
|
||||||
|
Object.keys(commitsByDate)
|
||||||
|
.sort((a, b) => b.localeCompare(a))
|
||||||
|
.forEach(date => {
|
||||||
|
releases.push({
|
||||||
|
version: 'unreleased',
|
||||||
|
date,
|
||||||
|
commits: commitsByDate[date],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changelog = {
|
||||||
|
version: currentVersion,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
releases,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(changelogPath, JSON.stringify(changelog, null, 2), 'utf-8');
|
||||||
|
console.log(`✅ Changelog gerado: ${releases.length} release(s) encontrada(s)`);
|
||||||
|
console.log(` Total de commits: ${releases.reduce((sum, r) => sum + r.commits.length, 0)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executar
|
||||||
|
try {
|
||||||
|
generateChangelog();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erro ao gerar changelog:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
Generated
+679
-15
File diff suppressed because it is too large.
Load diff
@@ -44,6 +44,14 @@ futures-util = "0.3"
|
|||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
walkdir = "2.5"
|
walkdir = "2.5"
|
||||||
tauri-plugin-autostart = "2"
|
tauri-plugin-autostart = "2"
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
rfd = "0.14"
|
rfd = "0.14"
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
|
||||||
|
# ONNX Runtime para embeddings locais (ranking de relevância)
|
||||||
|
# Usando load-dynamic para evitar conflito de RuntimeLibrary (MD vs MT) no Windows
|
||||||
|
# entre ort (dynamic) e tokenizers/esaxx-rs (static)
|
||||||
|
ort = { version = "2.0.0-rc.10", features = ["load-dynamic"] }
|
||||||
|
ndarray = "0.16"
|
||||||
|
tokenizers = "0.21"
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"core:window:allow-maximize",
|
"core:window:allow-maximize",
|
||||||
"core:window:allow-unmaximize",
|
"core:window:allow-unmaximize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-is-maximized"
|
"core:window:allow-is-maximized",
|
||||||
|
"updater:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+257
-60
@@ -22,6 +22,13 @@ pub struct ChatMessage {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resultado de busca de sessões com contagem de matches
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SearchSessionResult {
|
||||||
|
pub session: ChatSession,
|
||||||
|
pub match_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
}
|
}
|
||||||
@@ -49,6 +56,19 @@ impl Database {
|
|||||||
let db_path = app_data_dir.join("ollahub.db");
|
let db_path = app_data_dir.join("ollahub.db");
|
||||||
let conn = Connection::open(&db_path)?;
|
let conn = Connection::open(&db_path)?;
|
||||||
|
|
||||||
|
// Otimizações de performance do SQLite
|
||||||
|
// WAL mode permite leituras e escritas simultâneas (evita bloqueio da UI)
|
||||||
|
// synchronous=NORMAL reduz fsync sem perder segurança
|
||||||
|
// cache_size maior acelera operações frequentes
|
||||||
|
// temp_store=MEMORY usa RAM para tabelas temporárias
|
||||||
|
conn.execute_batch(
|
||||||
|
"PRAGMA journal_mode=WAL;
|
||||||
|
PRAGMA synchronous=NORMAL;
|
||||||
|
PRAGMA cache_size=10000;
|
||||||
|
PRAGMA temp_store=MEMORY;
|
||||||
|
PRAGMA foreign_keys=ON;"
|
||||||
|
)?;
|
||||||
|
|
||||||
let db = Self { conn };
|
let db = Self { conn };
|
||||||
db.init_schema()?;
|
db.init_schema()?;
|
||||||
|
|
||||||
@@ -370,6 +390,54 @@ impl Database {
|
|||||||
Ok(self.conn.last_insert_rowid())
|
Ok(self.conn.last_insert_rowid())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Salva múltiplas mensagens de uma sessão em uma transação
|
||||||
|
///
|
||||||
|
/// Remove mensagens existentes da sessão antes de inserir as novas
|
||||||
|
/// para garantir que não haja duplicatas.
|
||||||
|
pub fn save_messages_batch(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
) -> SqliteResult<()> {
|
||||||
|
// Usar execute_batch para executar múltiplas operações atomicamente
|
||||||
|
// WAL mode permite isso de forma segura mesmo sem transação explícita
|
||||||
|
|
||||||
|
// Remover mensagens existentes da sessão (para evitar duplicatas)
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM messages WHERE session_id = ?1",
|
||||||
|
params![session_id],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Inserir todas as mensagens
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"INSERT INTO messages (session_id, role, content, metadata, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for message in messages {
|
||||||
|
stmt.execute(params![
|
||||||
|
message.session_id,
|
||||||
|
message.role,
|
||||||
|
message.content,
|
||||||
|
message.metadata,
|
||||||
|
message.created_at.to_rfc3339()
|
||||||
|
])?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar updated_at da sessão com a data da última mensagem
|
||||||
|
if let Some(last_message) = messages.last() {
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
|
||||||
|
params![
|
||||||
|
last_message.created_at.to_rfc3339(),
|
||||||
|
session_id
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Busca todas as mensagens de uma sessão
|
/// Busca todas as mensagens de uma sessão
|
||||||
pub fn get_messages(&self, session_id: &str) -> SqliteResult<Vec<ChatMessage>> {
|
pub fn get_messages(&self, session_id: &str) -> SqliteResult<Vec<ChatMessage>> {
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
@@ -399,6 +467,88 @@ impl Database {
|
|||||||
Ok(messages)
|
Ok(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Busca mensagens de uma sessão com paginação (lazy loading)
|
||||||
|
///
|
||||||
|
/// Retorna as últimas `limit` mensagens a partir do `offset`.
|
||||||
|
/// O offset conta do final (0 = últimas mensagens, 20 = 20 mensagens antes das últimas).
|
||||||
|
///
|
||||||
|
/// Parâmetros:
|
||||||
|
/// - session_id: ID da sessão
|
||||||
|
/// - limit: número máximo de mensagens a retornar
|
||||||
|
/// - offset: número de mensagens a pular do final (0 = começar das últimas)
|
||||||
|
///
|
||||||
|
/// Retorna: (mensagens em ordem ASC, total_count, has_more)
|
||||||
|
pub fn get_messages_paginated(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
offset: usize,
|
||||||
|
) -> SqliteResult<(Vec<ChatMessage>, usize, bool)> {
|
||||||
|
// Primeiro, obter o total de mensagens
|
||||||
|
let total_count: usize = self.conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM messages WHERE session_id = ?1",
|
||||||
|
params![session_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Se não há mensagens, retornar vazio
|
||||||
|
if total_count == 0 {
|
||||||
|
return Ok((Vec::new(), 0, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estratégia: usar subquery para pegar as últimas N mensagens ordenadas DESC,
|
||||||
|
// depois ordenar ASC para manter ordem cronológica
|
||||||
|
//
|
||||||
|
// Se offset=0 e limit=30: queremos as últimas 30 mensagens
|
||||||
|
// Se offset=30 e limit=30: queremos as 30 mensagens antes das últimas 30
|
||||||
|
let real_limit = std::cmp::min(limit, total_count.saturating_sub(offset));
|
||||||
|
|
||||||
|
if real_limit == 0 {
|
||||||
|
return Ok((Vec::new(), total_count, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query: pegar as últimas (offset + limit) mensagens ordenadas DESC,
|
||||||
|
// depois ordenar ASC e pegar as primeiras 'limit' (que são as mais antigas do conjunto)
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT id, session_id, role, content, metadata, created_at
|
||||||
|
FROM (
|
||||||
|
SELECT id, session_id, role, content, metadata, created_at
|
||||||
|
FROM messages
|
||||||
|
WHERE session_id = ?1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?2
|
||||||
|
) AS recent_messages
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT ?3"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Precisamos pegar (offset + limit) mensagens do final para depois pegar as primeiras 'limit'
|
||||||
|
let fetch_limit = offset + limit;
|
||||||
|
|
||||||
|
let rows = stmt.query_map(params![session_id, fetch_limit, real_limit], |row| {
|
||||||
|
Ok(ChatMessage {
|
||||||
|
id: Some(row.get(0)?),
|
||||||
|
session_id: row.get(1)?,
|
||||||
|
role: row.get(2)?,
|
||||||
|
content: row.get(3)?,
|
||||||
|
metadata: row.get(4)?,
|
||||||
|
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(5, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
messages.push(row?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// has_more = ainda há mensagens mais antigas para carregar
|
||||||
|
let has_more = offset + messages.len() < total_count;
|
||||||
|
|
||||||
|
Ok((messages, total_count, has_more))
|
||||||
|
}
|
||||||
|
|
||||||
/// Salva um documento RAG
|
/// Salva um documento RAG
|
||||||
pub fn save_rag_document(
|
pub fn save_rag_document(
|
||||||
&self,
|
&self,
|
||||||
@@ -446,89 +596,136 @@ impl Database {
|
|||||||
|
|
||||||
/// Busca sessões por query (título ou conteúdo de mensagens)
|
/// Busca sessões por query (título ou conteúdo de mensagens)
|
||||||
/// Retorna resultados ordenados por relevância (match no título > match no conteúdo)
|
/// Retorna resultados ordenados por relevância (match no título > match no conteúdo)
|
||||||
pub fn search_sessions(&self, query: &str, limit: usize) -> SqliteResult<Vec<ChatSession>> {
|
/// Inclui contagem de matches para navegação
|
||||||
|
pub fn search_sessions(&self, query: &str, limit: usize) -> SqliteResult<Vec<SearchSessionResult>> {
|
||||||
if query.trim().is_empty() {
|
if query.trim().is_empty() {
|
||||||
// Se query vazia, retornar todas as sessões ordenadas por updated_at
|
// Se query vazia, retornar todas as sessões ordenadas por updated_at com match_count = 0
|
||||||
return self.list_sessions();
|
let sessions = self.list_sessions()?;
|
||||||
|
return Ok(sessions.into_iter().map(|session| SearchSessionResult {
|
||||||
|
session,
|
||||||
|
match_count: 0,
|
||||||
|
}).collect());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escapar caracteres especiais para FTS5
|
// Escapar caracteres especiais para FTS5
|
||||||
let escaped_query = query.replace('"', "\"\"");
|
let escaped_query = query.replace('"', "\"\"");
|
||||||
let fts_query = format!("\"{}\"", escaped_query);
|
let fts_query = format!("\"{}\"", escaped_query);
|
||||||
|
|
||||||
// Busca combinada: título (peso 1.0) + conteúdo (peso 0.5)
|
// Busca simplificada: primeiro buscar por título, depois por conteúdo
|
||||||
let mut stmt = self.conn.prepare(
|
// Usando abordagem em duas etapas para evitar problemas com bm25 em CTEs
|
||||||
"WITH title_matches AS (
|
|
||||||
SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at,
|
|
||||||
bm25(sessions_fts) as rank
|
|
||||||
FROM sessions s
|
|
||||||
JOIN sessions_fts ON s.rowid = sessions_fts.rowid
|
|
||||||
WHERE sessions_fts MATCH ?1
|
|
||||||
ORDER BY rank
|
|
||||||
LIMIT ?2
|
|
||||||
),
|
|
||||||
content_matches AS (
|
|
||||||
SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at,
|
|
||||||
bm25(messages_fts) * 0.5 as rank
|
|
||||||
FROM sessions s
|
|
||||||
JOIN messages m ON s.id = m.session_id
|
|
||||||
JOIN messages_fts ON m.rowid = messages_fts.rowid
|
|
||||||
WHERE messages_fts MATCH ?1
|
|
||||||
ORDER BY rank
|
|
||||||
LIMIT ?2
|
|
||||||
)
|
|
||||||
SELECT id, title, emoji, created_at, updated_at
|
|
||||||
FROM (
|
|
||||||
SELECT * FROM title_matches
|
|
||||||
UNION
|
|
||||||
SELECT * FROM content_matches
|
|
||||||
)
|
|
||||||
ORDER BY rank DESC, updated_at DESC
|
|
||||||
LIMIT ?2"
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let rows = stmt.query_map(params![fts_query, limit], |row| {
|
// Etapa 1: Buscar sessões por título (FTS5)
|
||||||
Ok(ChatSession {
|
let mut title_sessions: Vec<SearchSessionResult> = Vec::new();
|
||||||
id: row.get(0)?,
|
{
|
||||||
title: row.get(1)?,
|
let mut stmt = self.conn.prepare(
|
||||||
emoji: row.get(2)?,
|
"SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at
|
||||||
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
|
FROM sessions s
|
||||||
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
JOIN sessions_fts ON s.rowid = sessions_fts.rowid
|
||||||
.with_timezone(&Utc),
|
WHERE sessions_fts MATCH ?1
|
||||||
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
|
ORDER BY s.updated_at DESC
|
||||||
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
LIMIT ?2"
|
||||||
.with_timezone(&Utc),
|
)?;
|
||||||
})
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut sessions = Vec::new();
|
let rows = stmt.query_map(params![&fts_query, limit], |row| {
|
||||||
for row in rows {
|
Ok(SearchSessionResult {
|
||||||
sessions.push(row?);
|
session: ChatSession {
|
||||||
|
id: row.get(0)?,
|
||||||
|
title: row.get(1)?,
|
||||||
|
emoji: row.get(2)?,
|
||||||
|
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
},
|
||||||
|
match_count: 1, // Match no título conta como 1
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
title_sessions.push(row?);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Etapa 2: Buscar sessões por conteúdo de mensagens (FTS5)
|
||||||
|
let mut content_sessions: Vec<(String, i64)> = Vec::new(); // (session_id, match_count)
|
||||||
|
{
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT m.session_id, COUNT(*) as match_count
|
||||||
|
FROM messages m
|
||||||
|
JOIN messages_fts ON m.rowid = messages_fts.rowid
|
||||||
|
WHERE messages_fts MATCH ?1
|
||||||
|
GROUP BY m.session_id
|
||||||
|
ORDER BY match_count DESC
|
||||||
|
LIMIT ?2"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let rows = stmt.query_map(params![&fts_query, limit], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
content_sessions.push(row?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combinar resultados: priorizar matches por título, adicionar matches por conteúdo
|
||||||
|
let mut session_map: std::collections::HashMap<String, SearchSessionResult> = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
// Adicionar sessões encontradas por título
|
||||||
|
for session in title_sessions {
|
||||||
|
session_map.insert(session.session.id.clone(), session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar ou atualizar com sessões encontradas por conteúdo
|
||||||
|
for (session_id, match_count) in content_sessions {
|
||||||
|
if let Some(existing) = session_map.get_mut(&session_id) {
|
||||||
|
// Atualizar match_count se já existe
|
||||||
|
existing.match_count = match_count;
|
||||||
|
} else {
|
||||||
|
// Buscar dados da sessão
|
||||||
|
if let Ok(Some(session)) = self.get_session(&session_id) {
|
||||||
|
session_map.insert(session_id, SearchSessionResult {
|
||||||
|
session,
|
||||||
|
match_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converter para vetor e ordenar por updated_at
|
||||||
|
let mut sessions: Vec<SearchSessionResult> = session_map.into_values().collect();
|
||||||
|
sessions.sort_by(|a, b| b.session.updated_at.cmp(&a.session.updated_at));
|
||||||
|
|
||||||
// Se não encontrou resultados com FTS, tentar busca simples com LIKE (fallback)
|
// Se não encontrou resultados com FTS, tentar busca simples com LIKE (fallback)
|
||||||
if sessions.is_empty() {
|
if sessions.is_empty() {
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
"SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at
|
"SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at,
|
||||||
|
COUNT(CASE WHEN m.content LIKE ?1 THEN 1 END) as match_count
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
LEFT JOIN messages m ON s.id = m.session_id
|
LEFT JOIN messages m ON s.id = m.session_id
|
||||||
WHERE s.title LIKE ?1 OR m.content LIKE ?1
|
WHERE s.title LIKE ?1 OR m.content LIKE ?1
|
||||||
|
GROUP BY s.id, s.title, s.emoji, s.created_at, s.updated_at
|
||||||
ORDER BY s.updated_at DESC
|
ORDER BY s.updated_at DESC
|
||||||
LIMIT ?2"
|
LIMIT ?2"
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let like_query = format!("%{}%", query);
|
let like_query = format!("%{}%", query);
|
||||||
let rows = stmt.query_map(params![like_query, limit], |row| {
|
let rows = stmt.query_map(params![like_query, limit], |row| {
|
||||||
Ok(ChatSession {
|
Ok(SearchSessionResult {
|
||||||
id: row.get(0)?,
|
session: ChatSession {
|
||||||
title: row.get(1)?,
|
id: row.get(0)?,
|
||||||
emoji: row.get(2)?,
|
title: row.get(1)?,
|
||||||
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
|
emoji: row.get(2)?,
|
||||||
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
|
||||||
.with_timezone(&Utc),
|
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
|
.with_timezone(&Utc),
|
||||||
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
|
||||||
.with_timezone(&Utc),
|
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
},
|
||||||
|
match_count: row.get(5)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
//! Módulo de Embeddings ONNX para ranking de relevância
|
||||||
|
//!
|
||||||
|
//! Usa o modelo all-MiniLM-L6-v2 para gerar embeddings de texto
|
||||||
|
//! e calcular similaridade de cosseno para ranking de resultados de busca.
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use ndarray::Array2;
|
||||||
|
use ort::session::{Session, builder::GraphOptimizationLevel};
|
||||||
|
use ort::value::Value;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use tokenizers::Tokenizer;
|
||||||
|
|
||||||
|
/// Flag para controlar se o ort já foi inicializado
|
||||||
|
static ORT_INITIALIZED: OnceLock<Result<(), String>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Dimensão dos embeddings do modelo all-MiniLM-L6-v2
|
||||||
|
pub const EMBEDDING_DIM: usize = 384;
|
||||||
|
|
||||||
|
/// Tamanho máximo de tokens para o modelo
|
||||||
|
const MAX_SEQ_LENGTH: usize = 256;
|
||||||
|
|
||||||
|
/// Global lazy-initialized embedding model (com Mutex para permitir mutabilidade)
|
||||||
|
static EMBEDDING_MODEL: OnceLock<Result<Arc<Mutex<EmbeddingModel>>, String>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Modelo de embeddings para cálculo de similaridade semântica
|
||||||
|
pub struct EmbeddingModel {
|
||||||
|
session: Session,
|
||||||
|
tokenizer: Tokenizer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmbeddingModel {
|
||||||
|
/// Carrega o modelo ONNX e tokenizer
|
||||||
|
pub fn new(model_path: &str, tokenizer_path: &str) -> Result<Self> {
|
||||||
|
// Verificar se arquivos existem
|
||||||
|
if !Path::new(model_path).exists() {
|
||||||
|
return Err(anyhow!("Model file not found: {}", model_path));
|
||||||
|
}
|
||||||
|
if !Path::new(tokenizer_path).exists() {
|
||||||
|
return Err(anyhow!("Tokenizer file not found: {}", tokenizer_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[Embeddings] Loading ONNX model from: {}", model_path);
|
||||||
|
|
||||||
|
// Criar sessão ONNX
|
||||||
|
let session = Session::builder()?
|
||||||
|
.with_optimization_level(GraphOptimizationLevel::Level3)?
|
||||||
|
.commit_from_file(model_path)?;
|
||||||
|
|
||||||
|
// Carregar tokenizer
|
||||||
|
let tokenizer = Tokenizer::from_file(tokenizer_path)
|
||||||
|
.map_err(|e| anyhow!("Failed to load tokenizer: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("[Embeddings] Model loaded successfully");
|
||||||
|
|
||||||
|
Ok(Self { session, tokenizer })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gera embedding para um texto
|
||||||
|
pub fn embed(&mut self, text: &str) -> Result<Vec<f32>> {
|
||||||
|
// Tokenizar texto
|
||||||
|
let encoding = self.tokenizer
|
||||||
|
.encode(text, true)
|
||||||
|
.map_err(|e| anyhow!("Tokenization failed: {}", e))?;
|
||||||
|
|
||||||
|
let mut input_ids: Vec<i64> = encoding.get_ids()
|
||||||
|
.iter()
|
||||||
|
.map(|&id| id as i64)
|
||||||
|
.collect();
|
||||||
|
let mut attention_mask: Vec<i64> = encoding.get_attention_mask()
|
||||||
|
.iter()
|
||||||
|
.map(|&m| m as i64)
|
||||||
|
.collect();
|
||||||
|
let mut token_type_ids: Vec<i64> = encoding.get_type_ids()
|
||||||
|
.iter()
|
||||||
|
.map(|&t| t as i64)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Truncar/pad para MAX_SEQ_LENGTH
|
||||||
|
input_ids.truncate(MAX_SEQ_LENGTH);
|
||||||
|
attention_mask.truncate(MAX_SEQ_LENGTH);
|
||||||
|
token_type_ids.truncate(MAX_SEQ_LENGTH);
|
||||||
|
|
||||||
|
while input_ids.len() < MAX_SEQ_LENGTH {
|
||||||
|
input_ids.push(0);
|
||||||
|
attention_mask.push(0);
|
||||||
|
token_type_ids.push(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar arrays para inferência (batch size = 1)
|
||||||
|
let input_ids_array = Array2::from_shape_vec((1, MAX_SEQ_LENGTH), input_ids)?;
|
||||||
|
let attention_mask_array = Array2::from_shape_vec((1, MAX_SEQ_LENGTH), attention_mask)?;
|
||||||
|
let token_type_ids_array = Array2::from_shape_vec((1, MAX_SEQ_LENGTH), token_type_ids)?;
|
||||||
|
|
||||||
|
// Criar inputs ONNX
|
||||||
|
let input_ids_value = Value::from_array(input_ids_array)?;
|
||||||
|
let attention_mask_value = Value::from_array(attention_mask_array)?;
|
||||||
|
let token_type_ids_value = Value::from_array(token_type_ids_array)?;
|
||||||
|
|
||||||
|
// Executar inferência usando vetor de inputs
|
||||||
|
let inputs: Vec<(std::borrow::Cow<str>, ort::session::SessionInputValue)> = vec![
|
||||||
|
("input_ids".into(), input_ids_value.into()),
|
||||||
|
("attention_mask".into(), attention_mask_value.into()),
|
||||||
|
("token_type_ids".into(), token_type_ids_value.into()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let outputs = self.session.run(inputs)?;
|
||||||
|
|
||||||
|
// Extrair output (last_hidden_state ou sentence_embedding dependendo do modelo)
|
||||||
|
// Para all-MiniLM-L6-v2, fazemos mean pooling do last_hidden_state
|
||||||
|
let output = outputs.get("last_hidden_state")
|
||||||
|
.or_else(|| outputs.get("sentence_embedding"))
|
||||||
|
.ok_or_else(|| anyhow!("Output tensor not found"))?;
|
||||||
|
|
||||||
|
let (shape, raw_data) = output.try_extract_tensor::<f32>()?;
|
||||||
|
// Shape implementa Deref para [i64], então podemos usar diretamente
|
||||||
|
let dims: &[i64] = &*shape;
|
||||||
|
let data: &[f32] = raw_data;
|
||||||
|
|
||||||
|
// Mean pooling: média ao longo da dimensão de sequência
|
||||||
|
let embedding = if dims.len() == 3 {
|
||||||
|
// Shape: (batch, seq_len, hidden_dim)
|
||||||
|
let seq_len = dims[1] as usize;
|
||||||
|
let hidden_dim = dims[2] as usize;
|
||||||
|
|
||||||
|
let mut pooled = vec![0.0f32; hidden_dim];
|
||||||
|
for i in 0..seq_len {
|
||||||
|
for j in 0..hidden_dim {
|
||||||
|
let idx = i * hidden_dim + j;
|
||||||
|
pooled[j] += data[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for v in &mut pooled {
|
||||||
|
*v /= seq_len as f32;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalizar L2
|
||||||
|
let norm: f32 = pooled.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
if norm > 0.0 {
|
||||||
|
for v in &mut pooled {
|
||||||
|
*v /= norm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pooled
|
||||||
|
} else {
|
||||||
|
// Shape: (batch, hidden_dim) - já pooled
|
||||||
|
let hidden_dim = dims[1] as usize;
|
||||||
|
let mut embedding = Vec::with_capacity(hidden_dim);
|
||||||
|
for j in 0..hidden_dim {
|
||||||
|
embedding.push(data[j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalizar L2
|
||||||
|
let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
if norm > 0.0 {
|
||||||
|
for v in &mut embedding {
|
||||||
|
*v /= norm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
embedding
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(embedding)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcula embeddings em batch (mais eficiente para múltiplos textos)
|
||||||
|
pub fn embed_batch(&mut self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
|
||||||
|
// Para simplificar, processa um por um (pode ser otimizado para batch real)
|
||||||
|
let mut results = Vec::with_capacity(texts.len());
|
||||||
|
for text in texts {
|
||||||
|
results.push(self.embed(text)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcula similaridade de cosseno entre dois vetores
|
||||||
|
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||||
|
if a.len() != b.len() || a.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||||
|
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
|
||||||
|
if norm_a == 0.0 || norm_b == 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dot / (norm_a * norm_b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URLs para download do modelo (Hugging Face)
|
||||||
|
const MODEL_URL: &str = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx";
|
||||||
|
const TOKENIZER_URL: &str = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json";
|
||||||
|
|
||||||
|
/// URL para download da biblioteca ONNX Runtime (Windows x64)
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const ORT_DLL_URL: &str = "https://github.com/microsoft/onnxruntime/releases/download/v1.20.1/onnxruntime-win-x64-1.20.1.zip";
|
||||||
|
|
||||||
|
/// Inicializa o ONNX Runtime com a biblioteca dinâmica
|
||||||
|
fn init_ort_runtime(app_data_dir: &Path) -> Result<()> {
|
||||||
|
let result = ORT_INITIALIZED.get_or_init(|| {
|
||||||
|
let ort_dir = app_data_dir.join("ort");
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let dll_path = ort_dir.join("onnxruntime.dll");
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let dll_path = ort_dir.join("libonnxruntime.dylib");
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let dll_path = ort_dir.join("libonnxruntime.so");
|
||||||
|
|
||||||
|
if dll_path.exists() {
|
||||||
|
// Definir variável de ambiente para o ort encontrar a DLL
|
||||||
|
std::env::set_var("ORT_DYLIB_PATH", &dll_path);
|
||||||
|
log::info!("[Embeddings] ORT_DYLIB_PATH set to: {:?}", dll_path);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
// Tentar usar a biblioteca do sistema
|
||||||
|
log::warn!("[Embeddings] ONNX Runtime library not found at {:?}, will try system path", dll_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) => Err(anyhow!("{}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baixa um arquivo de uma URL para o caminho especificado
|
||||||
|
async fn download_file(url: &str, path: &Path) -> Result<()> {
|
||||||
|
log::info!("[Embeddings] Downloading: {} -> {:?}", url, path);
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(300)) // 5 min timeout
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let response = client.get(url).send().await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow!("Download failed with status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
|
||||||
|
// Criar diretório pai se não existir
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::write(path, bytes)?;
|
||||||
|
log::info!("[Embeddings] Downloaded successfully: {:?}", path);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baixa e extrai a biblioteca ONNX Runtime
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
async fn ensure_ort_library(app_data_dir: &Path) -> Result<()> {
|
||||||
|
let ort_dir = app_data_dir.join("ort");
|
||||||
|
let dll_path = ort_dir.join("onnxruntime.dll");
|
||||||
|
|
||||||
|
if dll_path.exists() {
|
||||||
|
log::info!("[Embeddings] ONNX Runtime library already exists");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[Embeddings] Downloading ONNX Runtime library...");
|
||||||
|
|
||||||
|
// Criar diretório
|
||||||
|
std::fs::create_dir_all(&ort_dir)?;
|
||||||
|
|
||||||
|
// Baixar arquivo zip
|
||||||
|
let zip_path = ort_dir.join("onnxruntime.zip");
|
||||||
|
download_file(ORT_DLL_URL, &zip_path).await?;
|
||||||
|
|
||||||
|
// Extrair DLL do zip
|
||||||
|
log::info!("[Embeddings] Extracting ONNX Runtime library...");
|
||||||
|
|
||||||
|
let file = std::fs::File::open(&zip_path)?;
|
||||||
|
let mut archive = zip::ZipArchive::new(file)?;
|
||||||
|
|
||||||
|
// Procurar pela DLL dentro do zip
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let mut entry = archive.by_index(i)?;
|
||||||
|
let name = entry.name().to_string();
|
||||||
|
|
||||||
|
if name.ends_with("onnxruntime.dll") {
|
||||||
|
let mut outfile = std::fs::File::create(&dll_path)?;
|
||||||
|
std::io::copy(&mut entry, &mut outfile)?;
|
||||||
|
log::info!("[Embeddings] Extracted onnxruntime.dll");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remover arquivo zip
|
||||||
|
let _ = std::fs::remove_file(&zip_path);
|
||||||
|
|
||||||
|
if !dll_path.exists() {
|
||||||
|
return Err(anyhow!("Failed to extract onnxruntime.dll from archive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
async fn ensure_ort_library(_app_data_dir: &Path) -> Result<()> {
|
||||||
|
// Em outros sistemas, assumimos que a biblioteca está no sistema
|
||||||
|
log::info!("[Embeddings] Using system ONNX Runtime library");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Garante que os arquivos do modelo existem, baixando se necessário
|
||||||
|
pub async fn ensure_model_files(app_data_dir: &Path) -> Result<(std::path::PathBuf, std::path::PathBuf)> {
|
||||||
|
let models_dir = app_data_dir.join("models");
|
||||||
|
let model_path = models_dir.join("all-MiniLM-L6-v2.onnx");
|
||||||
|
let tokenizer_path = models_dir.join("tokenizer.json");
|
||||||
|
|
||||||
|
// Criar diretório de modelos
|
||||||
|
std::fs::create_dir_all(&models_dir)?;
|
||||||
|
|
||||||
|
// Baixar biblioteca ONNX Runtime se necessário (apenas Windows)
|
||||||
|
ensure_ort_library(app_data_dir).await?;
|
||||||
|
|
||||||
|
// Inicializar ort com o caminho da DLL
|
||||||
|
init_ort_runtime(app_data_dir)?;
|
||||||
|
|
||||||
|
// Baixar modelo se não existir
|
||||||
|
if !model_path.exists() {
|
||||||
|
log::info!("[Embeddings] Model not found, downloading...");
|
||||||
|
download_file(MODEL_URL, &model_path).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baixar tokenizer se não existir
|
||||||
|
if !tokenizer_path.exists() {
|
||||||
|
log::info!("[Embeddings] Tokenizer not found, downloading...");
|
||||||
|
download_file(TOKENIZER_URL, &tokenizer_path).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((model_path, tokenizer_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifica se o modelo está disponível
|
||||||
|
pub fn is_model_available(app_data_dir: &Path) -> bool {
|
||||||
|
let models_dir = app_data_dir.join("models");
|
||||||
|
let model_path = models_dir.join("all-MiniLM-L6-v2.onnx");
|
||||||
|
let tokenizer_path = models_dir.join("tokenizer.json");
|
||||||
|
|
||||||
|
model_path.exists() && tokenizer_path.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtém ou inicializa o modelo global de embeddings
|
||||||
|
pub fn get_or_init_model(app_data_dir: &Path) -> Result<Arc<Mutex<EmbeddingModel>>> {
|
||||||
|
// Inicializar ort com o caminho da DLL antes de criar o modelo
|
||||||
|
init_ort_runtime(app_data_dir)?;
|
||||||
|
|
||||||
|
let result = EMBEDDING_MODEL.get_or_init(|| {
|
||||||
|
let model_path = app_data_dir.join("models").join("all-MiniLM-L6-v2.onnx");
|
||||||
|
let tokenizer_path = app_data_dir.join("models").join("tokenizer.json");
|
||||||
|
|
||||||
|
match EmbeddingModel::new(
|
||||||
|
model_path.to_str().unwrap_or(""),
|
||||||
|
tokenizer_path.to_str().unwrap_or("")
|
||||||
|
) {
|
||||||
|
Ok(model) => Ok(Arc::new(Mutex::new(model))),
|
||||||
|
Err(e) => Err(format!("Failed to load embedding model: {}", e))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(model) => Ok(model.clone()),
|
||||||
|
Err(e) => Err(anyhow!("{}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcula scores de relevância para múltiplos textos em relação a uma query
|
||||||
|
pub fn rank_by_relevance(
|
||||||
|
model: &mut EmbeddingModel,
|
||||||
|
query: &str,
|
||||||
|
texts: &[&str],
|
||||||
|
) -> Result<Vec<(usize, f32)>> {
|
||||||
|
let query_embedding = model.embed(query)?;
|
||||||
|
|
||||||
|
let mut scores: Vec<(usize, f32)> = Vec::with_capacity(texts.len());
|
||||||
|
for (idx, text) in texts.iter().enumerate() {
|
||||||
|
let text_embedding = model.embed(text).unwrap_or_else(|_| vec![0.0; EMBEDDING_DIM]);
|
||||||
|
let score = cosine_similarity(&query_embedding, &text_embedding);
|
||||||
|
scores.push((idx, score));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por score decrescente
|
||||||
|
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
|
Ok(scores)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poda o contexto mantendo apenas os parágrafos mais relevantes
|
||||||
|
///
|
||||||
|
/// Argumentos:
|
||||||
|
/// - model: Modelo de embeddings
|
||||||
|
/// - query: Query do usuário
|
||||||
|
/// - context: Contexto completo (texto separado por parágrafos)
|
||||||
|
/// - max_tokens: Número máximo de tokens (aproximado por palavras)
|
||||||
|
/// - min_score: Score mínimo de relevância (0.0 a 1.0)
|
||||||
|
///
|
||||||
|
/// Retorna: Contexto podado com os parágrafos mais relevantes
|
||||||
|
pub fn prune_context(
|
||||||
|
model: &mut EmbeddingModel,
|
||||||
|
query: &str,
|
||||||
|
context: &str,
|
||||||
|
max_tokens: usize,
|
||||||
|
min_score: f32,
|
||||||
|
) -> Result<String> {
|
||||||
|
// Dividir contexto em parágrafos
|
||||||
|
let paragraphs: Vec<&str> = context
|
||||||
|
.split("\n\n")
|
||||||
|
.map(|p| p.trim())
|
||||||
|
.filter(|p| !p.is_empty() && p.len() > 20) // Filtrar parágrafos muito curtos
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if paragraphs.is_empty() {
|
||||||
|
return Ok(context.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular embedding da query
|
||||||
|
let query_embedding = model.embed(query)?;
|
||||||
|
|
||||||
|
// Calcular scores para cada parágrafo
|
||||||
|
let mut scored_paragraphs: Vec<(f32, &str, usize)> = Vec::with_capacity(paragraphs.len());
|
||||||
|
for (idx, &p) in paragraphs.iter().enumerate() {
|
||||||
|
let embedding = model.embed(p).unwrap_or_else(|_| vec![0.0; EMBEDDING_DIM]);
|
||||||
|
let score = cosine_similarity(&query_embedding, &embedding);
|
||||||
|
scored_paragraphs.push((score, p, idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por score decrescente
|
||||||
|
scored_paragraphs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
|
// Filtrar por score mínimo
|
||||||
|
scored_paragraphs.retain(|(score, _, _)| *score >= min_score);
|
||||||
|
|
||||||
|
// Selecionar parágrafos até atingir max_tokens
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut total_tokens = 0;
|
||||||
|
|
||||||
|
for (score, paragraph, original_idx) in scored_paragraphs {
|
||||||
|
let paragraph_tokens = paragraph.split_whitespace().count();
|
||||||
|
|
||||||
|
if total_tokens + paragraph_tokens > max_tokens {
|
||||||
|
// Se ainda não temos nenhum parágrafo, incluir o primeiro mesmo que exceda
|
||||||
|
if result.is_empty() {
|
||||||
|
result.push((original_idx, paragraph));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push((original_idx, paragraph));
|
||||||
|
total_tokens += paragraph_tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar pelo índice original para manter a ordem do contexto
|
||||||
|
result.sort_by_key(|(idx, _)| *idx);
|
||||||
|
|
||||||
|
// Reconstruir contexto
|
||||||
|
let pruned: Vec<&str> = result.into_iter().map(|(_, p)| p).collect();
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[Embeddings] Context pruned: {} paragraphs -> {} paragraphs, {} tokens",
|
||||||
|
paragraphs.len(),
|
||||||
|
pruned.len(),
|
||||||
|
total_tokens
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(pruned.join("\n\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Versão simplificada de poda usando apenas BM25-like (sem embeddings)
|
||||||
|
/// Útil quando o modelo não está disponível
|
||||||
|
pub fn prune_context_bm25(
|
||||||
|
query: &str,
|
||||||
|
context: &str,
|
||||||
|
max_tokens: usize,
|
||||||
|
) -> String {
|
||||||
|
let paragraphs: Vec<&str> = context
|
||||||
|
.split("\n\n")
|
||||||
|
.map(|p| p.trim())
|
||||||
|
.filter(|p| !p.is_empty() && p.len() > 20)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if paragraphs.is_empty() {
|
||||||
|
return context.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let query_lower = query.to_lowercase();
|
||||||
|
let query_terms: Vec<&str> = query_lower
|
||||||
|
.split_whitespace()
|
||||||
|
.filter(|w| w.len() > 2)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Calcular score BM25-like simples
|
||||||
|
let mut scored: Vec<(f32, &str, usize)> = paragraphs.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, &p)| {
|
||||||
|
let p_lower = p.to_lowercase();
|
||||||
|
let mut score = 0.0f32;
|
||||||
|
|
||||||
|
for term in &query_terms {
|
||||||
|
let count = p_lower.matches(term).count();
|
||||||
|
if count > 0 {
|
||||||
|
// TF com saturação logarítmica
|
||||||
|
score += (1.0 + (count as f32).ln()) * (1.0 / (1.0 + query_terms.len() as f32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(score, p, idx)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut total_tokens = 0;
|
||||||
|
|
||||||
|
for (_, paragraph, original_idx) in scored {
|
||||||
|
let paragraph_tokens = paragraph.split_whitespace().count();
|
||||||
|
|
||||||
|
if total_tokens + paragraph_tokens > max_tokens {
|
||||||
|
if result.is_empty() {
|
||||||
|
result.push((original_idx, paragraph));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push((original_idx, paragraph));
|
||||||
|
total_tokens += paragraph_tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sort_by_key(|(idx, _)| *idx);
|
||||||
|
|
||||||
|
let pruned: Vec<&str> = result.into_iter().map(|(_, p)| p).collect();
|
||||||
|
pruned.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cosine_similarity() {
|
||||||
|
let a = vec![1.0, 0.0, 0.0];
|
||||||
|
let b = vec![1.0, 0.0, 0.0];
|
||||||
|
assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
|
||||||
|
|
||||||
|
let c = vec![0.0, 1.0, 0.0];
|
||||||
|
assert!((cosine_similarity(&a, &c) - 0.0).abs() < 0.001);
|
||||||
|
|
||||||
|
let d = vec![-1.0, 0.0, 0.0];
|
||||||
|
assert!((cosine_similarity(&a, &d) - (-1.0)).abs() < 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+502
-15
@@ -21,6 +21,7 @@ mod sources_config;
|
|||||||
mod system_monitor;
|
mod system_monitor;
|
||||||
mod intent_classifier;
|
mod intent_classifier;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod embeddings;
|
||||||
|
|
||||||
use web_scraper::{
|
use web_scraper::{
|
||||||
ScrapedContent,
|
ScrapedContent,
|
||||||
@@ -114,6 +115,8 @@ struct SessionSummary {
|
|||||||
updated_at: DateTime<Utc>,
|
updated_at: DateTime<Utc>,
|
||||||
preview: String,
|
preview: String,
|
||||||
platform: String,
|
platform: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
match_count: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
@@ -395,6 +398,57 @@ fn save_chat_session(
|
|||||||
fs::rename(&temp_path, &file_path)
|
fs::rename(&temp_path, &file_path)
|
||||||
.map_err(|e| format!("Failed to rename temp file to session file: {}", e))?;
|
.map_err(|e| format!("Failed to rename temp file to session file: {}", e))?;
|
||||||
|
|
||||||
|
// Também salvar no SQLite (sistema novo) para melhor performance e paginação
|
||||||
|
// Se falhar, apenas logar erro mas não falhar a operação (compatibilidade)
|
||||||
|
use db::Database;
|
||||||
|
match Database::new(&app_handle) {
|
||||||
|
Ok(db) => {
|
||||||
|
// Criar/atualizar sessão no SQLite
|
||||||
|
let db_session = db::ChatSession {
|
||||||
|
id: session.id.clone(),
|
||||||
|
title: session.title.clone(),
|
||||||
|
emoji: "💬".to_string(), // Emoji padrão
|
||||||
|
created_at: session.created_at,
|
||||||
|
updated_at: session.updated_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = db.save_session(&db_session) {
|
||||||
|
log::warn!("Failed to save session to SQLite (continuing with JSON only): {}", e);
|
||||||
|
} else {
|
||||||
|
// Converter Message para ChatMessage e salvar no SQLite
|
||||||
|
// Preservar ordem usando timestamps incrementais baseados no índice
|
||||||
|
let chat_messages: Vec<db::ChatMessage> = session.messages.iter().enumerate().map(|(idx, msg)| {
|
||||||
|
let metadata_str = msg.metadata.as_ref()
|
||||||
|
.and_then(|m| serde_json::to_string(m).ok());
|
||||||
|
|
||||||
|
// Criar timestamp incremental para preservar ordem das mensagens
|
||||||
|
// Usar created_at da sessão como base e adicionar segundos baseados no índice
|
||||||
|
// Isso garante que a ordem seja mantida quando ordenado por created_at ASC
|
||||||
|
let base_time = session.created_at;
|
||||||
|
let msg_created_at = base_time + chrono::Duration::seconds(idx as i64);
|
||||||
|
|
||||||
|
db::ChatMessage {
|
||||||
|
id: None,
|
||||||
|
session_id: session.id.clone(),
|
||||||
|
role: msg.role.clone(),
|
||||||
|
content: msg.content.clone(),
|
||||||
|
metadata: metadata_str,
|
||||||
|
created_at: msg_created_at,
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
if let Err(e) = db.save_messages_batch(&session.id, &chat_messages) {
|
||||||
|
log::warn!("Failed to save messages to SQLite (continuing with JSON only): {}", e);
|
||||||
|
} else {
|
||||||
|
log::debug!("Successfully saved {} messages to SQLite for session {}", chat_messages.len(), session.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Failed to open database for saving (JSON saved successfully): {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lock é liberado automaticamente quando _guard sai de escopo
|
// Lock é liberado automaticamente quando _guard sai de escopo
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -407,7 +461,7 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
|
|||||||
.map_err(|e| format!("Failed to open database: {}", e))?;
|
.map_err(|e| format!("Failed to open database: {}", e))?;
|
||||||
|
|
||||||
let search_limit = limit.unwrap_or(50);
|
let search_limit = limit.unwrap_or(50);
|
||||||
let sessions = db.search_sessions(&query, search_limit)
|
let search_results = db.search_sessions(&query, search_limit)
|
||||||
.map_err(|e| format!("Search failed: {}", e))?;
|
.map_err(|e| format!("Search failed: {}", e))?;
|
||||||
|
|
||||||
// Validar existência de cada sessão antes de retornar
|
// Validar existência de cada sessão antes de retornar
|
||||||
@@ -415,7 +469,9 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
|
|||||||
let mut summaries = Vec::new();
|
let mut summaries = Vec::new();
|
||||||
let mut orphan_count = 0;
|
let mut orphan_count = 0;
|
||||||
|
|
||||||
for session in sessions {
|
for search_result in search_results {
|
||||||
|
let session = search_result.session;
|
||||||
|
let match_count = search_result.match_count;
|
||||||
// Verificar se sessão existe no SQLite (já temos)
|
// Verificar se sessão existe no SQLite (já temos)
|
||||||
let exists_in_sqlite = db.get_session(&session.id)
|
let exists_in_sqlite = db.get_session(&session.id)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -465,6 +521,7 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
|
|||||||
updated_at: session.updated_at, // Já é DateTime<Utc>
|
updated_at: session.updated_at, // Já é DateTime<Utc>
|
||||||
preview,
|
preview,
|
||||||
platform: String::new(), // Platform não está no SQLite ainda
|
platform: String::new(), // Platform não está no SQLite ainda
|
||||||
|
match_count: Some(match_count as usize),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +571,7 @@ fn load_chat_sessions(app_handle: AppHandle) -> Result<Vec<SessionSummary>, Stri
|
|||||||
updated_at: session.updated_at,
|
updated_at: session.updated_at,
|
||||||
preview: last_msg,
|
preview: last_msg,
|
||||||
platform: session.platform,
|
platform: session.platform,
|
||||||
|
match_count: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,6 +658,299 @@ fn load_chat_history(app_handle: AppHandle, id: String) -> Result<Vec<Message>,
|
|||||||
Ok(session.messages)
|
Ok(session.messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resultado de carregamento paginado de histórico
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct PaginatedHistory {
|
||||||
|
messages: Vec<Message>,
|
||||||
|
total_count: usize,
|
||||||
|
has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carrega histórico de chat com paginação (lazy loading)
|
||||||
|
///
|
||||||
|
/// Parâmetros:
|
||||||
|
/// - id: ID da sessão
|
||||||
|
/// - limit: número máximo de mensagens a retornar (default: 20)
|
||||||
|
/// - offset: número de mensagens a pular do final (default: 0)
|
||||||
|
#[command]
|
||||||
|
fn load_chat_history_paginated(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
id: String,
|
||||||
|
limit: Option<usize>,
|
||||||
|
offset: Option<usize>,
|
||||||
|
) -> Result<PaginatedHistory, String> {
|
||||||
|
use db::Database;
|
||||||
|
|
||||||
|
let limit = limit.unwrap_or(20);
|
||||||
|
let offset = offset.unwrap_or(0);
|
||||||
|
|
||||||
|
match Database::new(&app_handle) {
|
||||||
|
Ok(db) => {
|
||||||
|
match db.get_messages_paginated(&id, limit, offset) {
|
||||||
|
Ok((messages, total_count, has_more)) => {
|
||||||
|
// Se SQLite retornou 0 mensagens, tentar fallback para JSON (sistema legado)
|
||||||
|
if total_count == 0 {
|
||||||
|
log::debug!("No messages in SQLite for session {}, trying JSON fallback", id);
|
||||||
|
|
||||||
|
// Tentar carregar do JSON
|
||||||
|
let chats_dir = match get_chats_dir(&app_handle) {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Failed to get chats dir for JSON fallback: {}", e);
|
||||||
|
// Retornar vazio se não conseguir acessar diretório
|
||||||
|
return Ok(PaginatedHistory {
|
||||||
|
messages: Vec::new(),
|
||||||
|
total_count: 0,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_path = chats_dir.join(format!("{}.json", id));
|
||||||
|
if file_path.exists() {
|
||||||
|
match fs::read_to_string(&file_path) {
|
||||||
|
Ok(content) => {
|
||||||
|
match serde_json::from_str::<ChatSession>(&content) {
|
||||||
|
Ok(session) => {
|
||||||
|
let all_messages = session.messages;
|
||||||
|
let total = all_messages.len();
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
return Ok(PaginatedHistory {
|
||||||
|
messages: Vec::new(),
|
||||||
|
total_count: 0,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplicar paginação em memória (simular comportamento do SQLite)
|
||||||
|
// offset=0 significa últimas N mensagens
|
||||||
|
// offset>0 significa mensagens antes das últimas N
|
||||||
|
let start_idx = if offset + limit <= total {
|
||||||
|
total - offset - limit
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let end_idx = std::cmp::min(start_idx + limit, total);
|
||||||
|
let paginated_messages: Vec<Message> = all_messages
|
||||||
|
.into_iter()
|
||||||
|
.skip(start_idx)
|
||||||
|
.take(end_idx - start_idx)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let has_more = offset + paginated_messages.len() < total;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback for session {}",
|
||||||
|
paginated_messages.len(), offset, total, has_more, id
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(PaginatedHistory {
|
||||||
|
messages: paginated_messages,
|
||||||
|
total_count: total,
|
||||||
|
has_more,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Failed to parse JSON session: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Failed to read JSON file: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se chegou aqui, não encontrou mensagens nem no SQLite nem no JSON
|
||||||
|
return Ok(PaginatedHistory {
|
||||||
|
messages: Vec::new(),
|
||||||
|
total_count: 0,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converter ChatMessage para Message
|
||||||
|
let result: Vec<Message> = messages.into_iter().map(|msg| {
|
||||||
|
let role = if msg.role == "user" {
|
||||||
|
"user"
|
||||||
|
} else if msg.role == "assistant" {
|
||||||
|
"assistant"
|
||||||
|
} else {
|
||||||
|
"system"
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata = msg.metadata.and_then(|m| {
|
||||||
|
serde_json::from_str::<serde_json::Value>(&m).ok()
|
||||||
|
});
|
||||||
|
|
||||||
|
let metadata_value = metadata
|
||||||
|
.and_then(|m| {
|
||||||
|
if m.is_object() && !m.as_object().unwrap().is_empty() {
|
||||||
|
Some(m)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Message {
|
||||||
|
role: role.to_string(),
|
||||||
|
content: msg.content,
|
||||||
|
metadata: metadata_value,
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from SQLite for session {}",
|
||||||
|
result.len(), offset, total_count, has_more, id
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(PaginatedHistory {
|
||||||
|
messages: result,
|
||||||
|
total_count,
|
||||||
|
has_more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("SQLite query failed for session {}: {}, trying JSON fallback", id, e);
|
||||||
|
|
||||||
|
// Fallback para JSON em caso de erro também
|
||||||
|
let chats_dir = match get_chats_dir(&app_handle) {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(format!("Failed to load paginated history: {}", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_path = chats_dir.join(format!("{}.json", id));
|
||||||
|
if file_path.exists() {
|
||||||
|
match fs::read_to_string(&file_path) {
|
||||||
|
Ok(content) => {
|
||||||
|
match serde_json::from_str::<ChatSession>(&content) {
|
||||||
|
Ok(session) => {
|
||||||
|
let all_messages = session.messages;
|
||||||
|
let total = all_messages.len();
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
return Ok(PaginatedHistory {
|
||||||
|
messages: Vec::new(),
|
||||||
|
total_count: 0,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let start_idx = if offset + limit <= total {
|
||||||
|
total - offset - limit
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let end_idx = std::cmp::min(start_idx + limit, total);
|
||||||
|
let paginated_messages: Vec<Message> = all_messages
|
||||||
|
.into_iter()
|
||||||
|
.skip(start_idx)
|
||||||
|
.take(end_idx - start_idx)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let has_more = offset + paginated_messages.len() < total;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback (error case) for session {}",
|
||||||
|
paginated_messages.len(), offset, total, has_more, id
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(PaginatedHistory {
|
||||||
|
messages: paginated_messages,
|
||||||
|
total_count: total,
|
||||||
|
has_more,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e2) => {
|
||||||
|
return Err(format!("Failed to load paginated history: {} (JSON parse error: {})", e, e2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e2) => {
|
||||||
|
return Err(format!("Failed to load paginated history: {} (JSON read error: {})", e, e2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("Failed to load paginated history: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Failed to open database: {}, trying JSON fallback", e);
|
||||||
|
|
||||||
|
// Fallback para JSON se não conseguir abrir banco
|
||||||
|
let chats_dir = match get_chats_dir(&app_handle) {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(e2) => {
|
||||||
|
return Err(format!("Failed to open database: {} (chats dir error: {})", e, e2));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_path = chats_dir.join(format!("{}.json", id));
|
||||||
|
if file_path.exists() {
|
||||||
|
match fs::read_to_string(&file_path) {
|
||||||
|
Ok(content) => {
|
||||||
|
match serde_json::from_str::<ChatSession>(&content) {
|
||||||
|
Ok(session) => {
|
||||||
|
let all_messages = session.messages;
|
||||||
|
let total = all_messages.len();
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
return Ok(PaginatedHistory {
|
||||||
|
messages: Vec::new(),
|
||||||
|
total_count: 0,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let start_idx = if offset + limit <= total {
|
||||||
|
total - offset - limit
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let end_idx = std::cmp::min(start_idx + limit, total);
|
||||||
|
let paginated_messages: Vec<Message> = all_messages
|
||||||
|
.into_iter()
|
||||||
|
.skip(start_idx)
|
||||||
|
.take(end_idx - start_idx)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let has_more = offset + paginated_messages.len() < total;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback (db open error) for session {}",
|
||||||
|
paginated_messages.len(), offset, total, has_more, id
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(PaginatedHistory {
|
||||||
|
messages: paginated_messages,
|
||||||
|
total_count: total,
|
||||||
|
has_more,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e2) => {
|
||||||
|
return Err(format!("Failed to open database: {} (JSON parse error: {})", e, e2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e2) => {
|
||||||
|
return Err(format!("Failed to open database: {} (JSON read error: {})", e, e2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("Failed to open database: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
fn delete_chat_session(app_handle: AppHandle, id: String) -> Result<(), String> {
|
fn delete_chat_session(app_handle: AppHandle, id: String) -> Result<(), String> {
|
||||||
use db::Database;
|
use db::Database;
|
||||||
@@ -2978,12 +3329,18 @@ async fn chat_stream(
|
|||||||
return Err(error_msg);
|
return Err(error_msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Processar stream e emitir tokens
|
// 5. Processar stream e emitir tokens COM BUFFERING
|
||||||
// IMPORTANTE: O Ollama envia tokens INCREMENTAIS (cada chunk contém apenas o novo conteúdo)
|
// OTIMIZAÇÃO: Acumular tokens e emitir em batches para reduzir overhead da bridge
|
||||||
let mut stream = response.bytes_stream();
|
let mut stream = response.bytes_stream();
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let mut full_content = String::new();
|
let mut full_content = String::new();
|
||||||
|
|
||||||
|
// Buffer de tokens para reduzir eventos na bridge
|
||||||
|
let mut token_buffer = String::new();
|
||||||
|
let mut last_emit = std::time::Instant::now();
|
||||||
|
const EMIT_INTERVAL_MS: u64 = 16; // ~60fps para sincronizar com RAF do frontend
|
||||||
|
const MAX_BUFFER_CHARS: usize = 50; // Emitir quando buffer tiver ~50 chars
|
||||||
|
|
||||||
while let Some(chunk_result) = stream.next().await {
|
while let Some(chunk_result) = stream.next().await {
|
||||||
let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?;
|
let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?;
|
||||||
let chunk_str = String::from_utf8_lossy(&chunk);
|
let chunk_str = String::from_utf8_lossy(&chunk);
|
||||||
@@ -3007,20 +3364,23 @@ async fn chat_stream(
|
|||||||
// Extrair conteúdo do chunk (Ollama envia tokens incrementais)
|
// Extrair conteúdo do chunk (Ollama envia tokens incrementais)
|
||||||
if let Some(message) = json.get("message") {
|
if let Some(message) = json.get("message") {
|
||||||
if let Some(content) = message.get("content").and_then(|c| c.as_str()) {
|
if let Some(content) = message.get("content").and_then(|c| c.as_str()) {
|
||||||
// O Ollama envia apenas o NOVO conteúdo em cada chunk, não o acumulado
|
|
||||||
// Então podemos emitir diretamente
|
|
||||||
if !content.is_empty() {
|
if !content.is_empty() {
|
||||||
full_content.push_str(content);
|
full_content.push_str(content);
|
||||||
|
token_buffer.push_str(content);
|
||||||
|
|
||||||
// Emitir token para frontend
|
// Emitir buffer quando: tempo >= 16ms OU buffer >= 50 chars
|
||||||
let token_event = ChatTokenEvent {
|
let elapsed = last_emit.elapsed().as_millis() as u64;
|
||||||
session_id: session_id.clone(),
|
if elapsed >= EMIT_INTERVAL_MS || token_buffer.len() >= MAX_BUFFER_CHARS {
|
||||||
content: content.to_string(),
|
let token_event = ChatTokenEvent {
|
||||||
done: false,
|
session_id: session_id.clone(),
|
||||||
};
|
content: std::mem::take(&mut token_buffer),
|
||||||
|
done: false,
|
||||||
|
};
|
||||||
|
|
||||||
if let Err(e) = window.emit("chat-token", &token_event) {
|
if let Err(e) = window.emit("chat-token", &token_event) {
|
||||||
log::warn!("Erro ao emitir token: {}", e);
|
log::warn!("Erro ao emitir token: {}", e);
|
||||||
|
}
|
||||||
|
last_emit = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3028,6 +3388,16 @@ async fn chat_stream(
|
|||||||
|
|
||||||
// Verificar se stream terminou
|
// Verificar se stream terminou
|
||||||
if is_done {
|
if is_done {
|
||||||
|
// Flush do buffer residual antes de finalizar
|
||||||
|
if !token_buffer.is_empty() {
|
||||||
|
let flush_event = ChatTokenEvent {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
content: std::mem::take(&mut token_buffer),
|
||||||
|
done: false,
|
||||||
|
};
|
||||||
|
let _ = window.emit("chat-token", &flush_event);
|
||||||
|
}
|
||||||
|
|
||||||
// Emitir evento final
|
// Emitir evento final
|
||||||
let final_event = ChatTokenEvent {
|
let final_event = ChatTokenEvent {
|
||||||
session_id: session_id.clone(),
|
session_id: session_id.clone(),
|
||||||
@@ -3121,6 +3491,113 @@ async fn chat_stream(
|
|||||||
Ok(session_id)
|
Ok(session_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============== COMANDOS DE EMBEDDINGS ==============
|
||||||
|
|
||||||
|
/// Baixa o modelo de embeddings se não existir
|
||||||
|
#[command]
|
||||||
|
async fn download_embedding_model(app_handle: AppHandle) -> Result<bool, String> {
|
||||||
|
let app_data_dir = app_handle.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
|
||||||
|
|
||||||
|
match embeddings::ensure_model_files(&app_data_dir).await {
|
||||||
|
Ok(_) => {
|
||||||
|
log::info!("[Embeddings] Model files ready");
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[Embeddings] Failed to ensure model files: {}", e);
|
||||||
|
Err(format!("Failed to download model: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifica se o modelo de embeddings está disponível
|
||||||
|
#[command]
|
||||||
|
fn is_embedding_model_available(app_handle: AppHandle) -> Result<bool, String> {
|
||||||
|
let app_data_dir = app_handle.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
|
||||||
|
|
||||||
|
Ok(embeddings::is_model_available(&app_data_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcula scores de relevância para textos em relação a uma query
|
||||||
|
#[command]
|
||||||
|
fn calculate_relevance_scores(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
query: String,
|
||||||
|
texts: Vec<String>,
|
||||||
|
) -> Result<Vec<(usize, f32)>, String> {
|
||||||
|
let app_data_dir = app_handle.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
|
||||||
|
|
||||||
|
let model_arc = embeddings::get_or_init_model(&app_data_dir)
|
||||||
|
.map_err(|e| format!("Failed to load model: {}", e))?;
|
||||||
|
|
||||||
|
let mut model = model_arc.lock()
|
||||||
|
.map_err(|e| format!("Failed to lock model: {}", e))?;
|
||||||
|
|
||||||
|
let text_refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
|
embeddings::rank_by_relevance(&mut model, &query, &text_refs)
|
||||||
|
.map_err(|e| format!("Failed to calculate relevance: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gera embedding para um texto
|
||||||
|
#[command]
|
||||||
|
fn generate_embedding(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
text: String,
|
||||||
|
) -> Result<Vec<f32>, String> {
|
||||||
|
let app_data_dir = app_handle.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
|
||||||
|
|
||||||
|
let model_arc = embeddings::get_or_init_model(&app_data_dir)
|
||||||
|
.map_err(|e| format!("Failed to load model: {}", e))?;
|
||||||
|
|
||||||
|
let mut model = model_arc.lock()
|
||||||
|
.map_err(|e| format!("Failed to lock model: {}", e))?;
|
||||||
|
|
||||||
|
model.embed(&text)
|
||||||
|
.map_err(|e| format!("Failed to generate embedding: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poda o contexto mantendo apenas os parágrafos mais relevantes
|
||||||
|
#[command]
|
||||||
|
fn prune_context(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
query: String,
|
||||||
|
context: String,
|
||||||
|
max_tokens: Option<usize>,
|
||||||
|
min_score: Option<f32>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let max_tokens = max_tokens.unwrap_or(2000);
|
||||||
|
let min_score = min_score.unwrap_or(0.3);
|
||||||
|
|
||||||
|
let app_data_dir = app_handle.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
|
||||||
|
|
||||||
|
// Tentar usar embeddings se modelo disponível
|
||||||
|
if embeddings::is_model_available(&app_data_dir) {
|
||||||
|
let model_arc = embeddings::get_or_init_model(&app_data_dir)
|
||||||
|
.map_err(|e| format!("Failed to load model: {}", e))?;
|
||||||
|
|
||||||
|
let mut model = model_arc.lock()
|
||||||
|
.map_err(|e| format!("Failed to lock model: {}", e))?;
|
||||||
|
|
||||||
|
embeddings::prune_context(&mut model, &query, &context, max_tokens, min_score)
|
||||||
|
.map_err(|e| format!("Failed to prune context: {}", e))
|
||||||
|
} else {
|
||||||
|
// Fallback para BM25-like
|
||||||
|
log::info!("[PruneContext] Using BM25 fallback (embedding model not available)");
|
||||||
|
Ok(embeddings::prune_context_bm25(&query, &context, max_tokens))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -3136,6 +3613,9 @@ pub fn run() {
|
|||||||
// Plugin de notificações
|
// Plugin de notificações
|
||||||
app.handle().plugin(tauri_plugin_notification::init())?;
|
app.handle().plugin(tauri_plugin_notification::init())?;
|
||||||
|
|
||||||
|
// Plugin de atualização automática
|
||||||
|
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||||
|
|
||||||
// Modificar comportamento de fechar janela (ocultar ao invés de fechar)
|
// Modificar comportamento de fechar janela (ocultar ao invés de fechar)
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let window_clone = window.clone();
|
let window_clone = window.clone();
|
||||||
@@ -3219,6 +3699,7 @@ pub fn run() {
|
|||||||
load_chat_sessions,
|
load_chat_sessions,
|
||||||
search_chat_sessions,
|
search_chat_sessions,
|
||||||
load_chat_history,
|
load_chat_history,
|
||||||
|
load_chat_history_paginated,
|
||||||
delete_chat_session,
|
delete_chat_session,
|
||||||
cleanup_orphan_sessions,
|
cleanup_orphan_sessions,
|
||||||
load_mcp_config,
|
load_mcp_config,
|
||||||
@@ -3261,7 +3742,13 @@ pub fn run() {
|
|||||||
get_downloaded_installer_path,
|
get_downloaded_installer_path,
|
||||||
check_ollama_full,
|
check_ollama_full,
|
||||||
auto_start_ollama,
|
auto_start_ollama,
|
||||||
classify_intent
|
classify_intent,
|
||||||
|
// Embeddings commands
|
||||||
|
download_embedding_model,
|
||||||
|
is_embedding_model_available,
|
||||||
|
calculate_relevance_scores,
|
||||||
|
generate_embedding,
|
||||||
|
prune_context
|
||||||
])
|
])
|
||||||
.manage(Arc::new(Mutex::new(HashMap::<String, McpProcessHandle>::new())) as McpProcessMap)
|
.manage(Arc::new(Mutex::new(HashMap::<String, McpProcessHandle>::new())) as McpProcessMap)
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@@ -85,14 +85,13 @@ pub async fn reload_scheduled_tasks(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obter browser - precisa acessar via app_handle
|
// Obter browser - usando lazy initialization global
|
||||||
let browser_arc = {
|
let browser_arc = {
|
||||||
// Criar browser diretamente se necessário
|
use crate::web_scraper::get_or_create_browser;
|
||||||
use crate::web_scraper::create_browser;
|
match get_or_create_browser() {
|
||||||
match create_browser() {
|
Ok(b) => b,
|
||||||
Ok(b) => Arc::new(b),
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Erro ao criar browser para task {}: {}", task_id, e);
|
log::error!("Erro ao obter browser para task {}: {}", task_id, e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use anyhow::Result;
|
|||||||
use headless_chrome::{Browser, LaunchOptions, Tab};
|
use headless_chrome::{Browser, LaunchOptions, Tab};
|
||||||
use reqwest::header::USER_AGENT;
|
use reqwest::header::USER_AGENT;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
@@ -10,6 +10,37 @@ use tokio::sync::Semaphore;
|
|||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// Lazy-initialized global browser instance
|
||||||
|
/// Evita criar o browser no startup, economizando ~500MB de RAM até ser necessário
|
||||||
|
static LAZY_BROWSER: OnceLock<Mutex<Option<Arc<Browser>>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Obtém ou cria a instância global do browser (lazy initialization)
|
||||||
|
pub fn get_or_create_browser() -> Result<Arc<Browser>> {
|
||||||
|
let mutex = LAZY_BROWSER.get_or_init(|| Mutex::new(None));
|
||||||
|
let mut guard = mutex.lock().map_err(|e| anyhow::anyhow!("Browser mutex poisoned: {}", e))?;
|
||||||
|
|
||||||
|
if guard.is_none() {
|
||||||
|
log::info!("[LazyBrowser] Initializing headless browser on first use...");
|
||||||
|
let browser = create_browser()?;
|
||||||
|
*guard = Some(Arc::new(browser));
|
||||||
|
log::info!("[LazyBrowser] Browser initialized successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(guard.as_ref().unwrap().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Limpa a instância do browser (para liberar memória quando não em uso)
|
||||||
|
pub fn clear_browser() {
|
||||||
|
if let Some(mutex) = LAZY_BROWSER.get() {
|
||||||
|
if let Ok(mut guard) = mutex.lock() {
|
||||||
|
if guard.is_some() {
|
||||||
|
log::info!("[LazyBrowser] Clearing browser instance to free memory");
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resultado da extração de conteúdo de uma URL
|
/// Resultado da extração de conteúdo de uma URL
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
|
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
|
||||||
pub struct ScrapedContent {
|
pub struct ScrapedContent {
|
||||||
@@ -1302,7 +1333,7 @@ pub async fn search_and_scrape_with_config(
|
|||||||
if connection_closed && !failed_urls.is_empty() {
|
if connection_closed && !failed_urls.is_empty() {
|
||||||
let retry_concurrency = std::cmp::min(3, config.max_concurrent_tabs.max(1));
|
let retry_concurrency = std::cmp::min(3, config.max_concurrent_tabs.max(1));
|
||||||
let semaphore = Arc::new(Semaphore::new(retry_concurrency));
|
let semaphore = Arc::new(Semaphore::new(retry_concurrency));
|
||||||
let browser_new = Arc::new(create_browser()?);
|
let browser_new = get_or_create_browser()?;
|
||||||
let mut retry_handles = Vec::new();
|
let mut retry_handles = Vec::new();
|
||||||
for url in failed_urls.clone() {
|
for url in failed_urls.clone() {
|
||||||
let browser_clone = browser_new.clone();
|
let browser_clone = browser_new.clone();
|
||||||
@@ -1341,11 +1372,79 @@ pub async fn search_and_scrape_with_config(
|
|||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Busca e extrai conteúdo de uma única URL
|
/// Scraping estático usando apenas reqwest (sem headless browser)
|
||||||
|
/// Muito mais rápido (~100ms vs ~3s) e consome menos RAM
|
||||||
|
/// Retorna None se o conteúdo for insuficiente (SPA/JavaScript-heavy)
|
||||||
|
pub async fn scrape_url_static(url: &str) -> Result<Option<ScrapedContent>> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(8))
|
||||||
|
.redirect(reqwest::redirect::Policy::limited(5))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let user_agent = get_random_user_agent();
|
||||||
|
let start_time = Instant::now();
|
||||||
|
|
||||||
|
log::debug!("[StaticScrape] Fetching: {}", url);
|
||||||
|
|
||||||
|
let response = match client
|
||||||
|
.get(url)
|
||||||
|
.header(USER_AGENT, user_agent)
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
.header("Accept-Language", "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("[StaticScrape] Request failed for {}: {}", url, e);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
log::debug!("[StaticScrape] HTTP {} for {}", response.status(), url);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = match response.text().await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("[StaticScrape] Failed to read body for {}: {}", url, e);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let duration = start_time.elapsed().as_millis();
|
||||||
|
|
||||||
|
// Usar extract_paragraph_fallback existente
|
||||||
|
let result = extract_paragraph_fallback(url, &html);
|
||||||
|
|
||||||
|
if let Some(ref content) = result {
|
||||||
|
log::info!("[StaticScrape] Success for {} ({} chars, {}ms)",
|
||||||
|
url, content.content.len(), duration);
|
||||||
|
} else {
|
||||||
|
log::debug!("[StaticScrape] Insufficient content for {} ({}ms)", url, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Busca e extrai conteúdo de uma única URL (híbrido: tenta estático primeiro)
|
||||||
pub async fn scrape_url(
|
pub async fn scrape_url(
|
||||||
url: &str,
|
url: &str,
|
||||||
browser: Arc<Browser>,
|
browser: Arc<Browser>,
|
||||||
) -> Result<ScrapedContent> {
|
) -> Result<ScrapedContent> {
|
||||||
|
// OTIMIZAÇÃO: Tentar scraping estático primeiro (muito mais rápido)
|
||||||
|
if let Ok(Some(content)) = scrape_url_static(url).await {
|
||||||
|
// Se conseguiu conteúdo suficiente (>500 chars), usar resultado estático
|
||||||
|
if content.content.len() > 500 {
|
||||||
|
log::info!("[ScrapeHybrid] Using static result for {} ({} chars)", url, content.content.len());
|
||||||
|
return Ok(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: usar headless browser para SPAs/JS-heavy pages
|
||||||
|
log::info!("[ScrapeHybrid] Falling back to headless for {}", url);
|
||||||
let browser_clone = browser.clone();
|
let browser_clone = browser.clone();
|
||||||
let url_str = url.to_string();
|
let url_str = url.to_string();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"frontendDist": "../out",
|
"frontendDist": "../out",
|
||||||
"devUrl": "http://localhost:3000",
|
"devUrl": "http://localhost:3000",
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
"beforeBuildCommand": "pnpm build"
|
"beforeBuildCommand": "pnpm generate-changelog && pnpm build"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
@@ -35,5 +35,15 @@
|
|||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"active": true,
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/evandrodevbr/OllaHub/releases/latest/download/latest.json"
|
||||||
|
],
|
||||||
|
"dialog": true,
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxMjY3NThDMUQyREQ5QUYKUldTdjJTMGRqSFVtZ1k1ZWFHSEcxNExDZzgrNVh6M0Z6VXIyaTU5dDAvZnpQb3p0UVpuRW1Od3YK"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+22
-1
@@ -56,6 +56,9 @@ export interface SettingsState {
|
|||||||
keywords: string[];
|
keywords: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Updates
|
||||||
|
autoCheckUpdates: boolean;
|
||||||
|
|
||||||
// Query Preprocessing
|
// Query Preprocessing
|
||||||
queryPreprocessing: {
|
queryPreprocessing: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -96,6 +99,9 @@ export interface SettingsState {
|
|||||||
addNotificationKeyword: (keyword: string) => void;
|
addNotificationKeyword: (keyword: string) => void;
|
||||||
removeNotificationKeyword: (keyword: string) => void;
|
removeNotificationKeyword: (keyword: string) => void;
|
||||||
|
|
||||||
|
// Updates Actions
|
||||||
|
setAutoCheckUpdates: (enabled: boolean) => void;
|
||||||
|
|
||||||
// Query Preprocessing Actions
|
// Query Preprocessing Actions
|
||||||
setQueryPreprocessingEnabled: (enabled: boolean) => void;
|
setQueryPreprocessingEnabled: (enabled: boolean) => void;
|
||||||
setQueryPreprocessingMinLength: (min: number) => void;
|
setQueryPreprocessingMinLength: (min: number) => void;
|
||||||
@@ -221,6 +227,7 @@ const initialState = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
keywords: [],
|
keywords: [],
|
||||||
},
|
},
|
||||||
|
autoCheckUpdates: true,
|
||||||
queryPreprocessing: {
|
queryPreprocessing: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
minLength: 3,
|
minLength: 3,
|
||||||
@@ -407,6 +414,10 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
// Updates Actions
|
||||||
|
setAutoCheckUpdates: (enabled) =>
|
||||||
|
set({ autoCheckUpdates: enabled }),
|
||||||
|
|
||||||
// Query Preprocessing Actions
|
// Query Preprocessing Actions
|
||||||
setQueryPreprocessingEnabled: (enabled) =>
|
setQueryPreprocessingEnabled: (enabled) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -479,7 +490,17 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ollahub-settings',
|
name: 'ollahub-settings',
|
||||||
version: 1,
|
version: 3,
|
||||||
|
migrate: (persistedState: any, version: number) => {
|
||||||
|
// Migração da versão 1 para 3
|
||||||
|
if (version < 3) {
|
||||||
|
return {
|
||||||
|
...persistedState,
|
||||||
|
autoCheckUpdates: persistedState.autoCheckUpdates ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return persistedState;
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxMjY3NThDMUQyREQ5QUYKUldTdjJTMGRqSFVtZ1k1ZWFHSEcxNExDZzgrNVh6M0Z6VXIyaTU5dDAvZnpQb3p0UVpuRW1Od3YK
|
||||||
Reference in new issue
Block a user