Files
voice-to-notes/src/lib/components/AIChatPanel.svelte
Claude 61caa07e4c Add project save/load and improve AI chat formatting
Project persistence:
- save_project_transcript command: persists segments, speakers, words to SQLite
- load_project_transcript command: loads full transcript with nested words
- delete_project command: soft-delete projects
- Auto-save after pipeline completes (named from filename)
- Project dropdown in header to switch between saved transcripts
- Projects load audio, segments, and speakers from database

AI chat improvements:
- Markdown rendering in assistant messages (headers, lists, bold, italic, code)
- Better message spacing and visual distinction (border-left accents)
- Styled markdown elements matching dark theme
- Improved empty state and quick action button sizing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:29 -07:00

417 lines
11 KiB
Svelte

<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { segments, speakers } from '$lib/stores/transcript';
import { settings } from '$lib/stores/settings';
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
let messages = $state<ChatMessage[]>([]);
let inputText = $state('');
let isLoading = $state(false);
let chatContainer: HTMLDivElement;
function getTranscriptContext(): string {
const segs = $segments;
const spks = $speakers;
if (segs.length === 0) return '';
return segs.map(seg => {
const speaker = spks.find(s => s.id === seg.speaker_id);
const name = speaker?.display_name || speaker?.label || 'Unknown';
return `[${name}]: ${seg.text}`;
}).join('\n');
}
async function sendMessage() {
const text = inputText.trim();
if (!text || isLoading) return;
messages = [...messages, { role: 'user', content: text }];
inputText = '';
isLoading = true;
// Auto-scroll to bottom
requestAnimationFrame(() => {
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
});
try {
const chatMessages = messages.map(m => ({
role: m.role,
content: m.content,
}));
// Ensure the provider is configured with current credentials before chatting
const s = $settings;
const configMap: Record<string, Record<string, string>> = {
openai: { api_key: s.openai_api_key, model: s.openai_model },
anthropic: { api_key: s.anthropic_api_key, model: s.anthropic_model },
litellm: { api_key: s.litellm_api_key, api_base: s.litellm_api_base, model: s.litellm_model },
local: { model: s.local_model_path, base_url: 'http://localhost:8080' },
};
const config = configMap[s.ai_provider];
if (config) {
await invoke('ai_configure', { provider: s.ai_provider, config });
}
const result = await invoke<{ response: string }>('ai_chat', {
messages: chatMessages,
transcriptContext: getTranscriptContext(),
provider: s.ai_provider,
});
messages = [...messages, { role: 'assistant', content: result.response }];
} catch (err) {
messages = [...messages, {
role: 'assistant',
content: `Error: ${err}`,
}];
} finally {
isLoading = false;
requestAnimationFrame(() => {
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
});
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function clearChat() {
messages = [];
}
function formatMarkdown(text: string): string {
// Split into lines for block-level processing
const lines = text.split('\n');
const result: string[] = [];
let inList = false;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Headers
if (line.startsWith('### ')) {
if (inList) { result.push('</ul>'); inList = false; }
const content = applyInlineFormatting(line.slice(4));
result.push(`<h4>${content}</h4>`);
continue;
}
if (line.startsWith('## ')) {
if (inList) { result.push('</ul>'); inList = false; }
const content = applyInlineFormatting(line.slice(3));
result.push(`<h3>${content}</h3>`);
continue;
}
if (line.startsWith('# ')) {
if (inList) { result.push('</ul>'); inList = false; }
const content = applyInlineFormatting(line.slice(2));
result.push(`<h2>${content}</h2>`);
continue;
}
// List items (- or *)
if (/^[\-\*] /.test(line)) {
if (!inList) { result.push('<ul>'); inList = true; }
const content = applyInlineFormatting(line.slice(2));
result.push(`<li>${content}</li>`);
continue;
}
// Numbered list items
if (/^\d+\.\s/.test(line)) {
if (!inList) { result.push('<ol>'); inList = true; }
const content = applyInlineFormatting(line.replace(/^\d+\.\s/, ''));
result.push(`<li>${content}</li>`);
continue;
}
// Non-list line: close any open list
if (inList) {
// Check if previous list was ordered or unordered
const lastOpen = result.findLast(r => r === '<ul>' || r === '<ol>');
result.push(lastOpen === '<ol>' ? '</ol>' : '</ul>');
inList = false;
}
// Empty line = paragraph break
if (line.trim() === '') {
result.push('<br>');
continue;
}
// Regular text line
result.push(applyInlineFormatting(line));
}
// Close any trailing open list
if (inList) {
const lastOpen = result.findLast(r => r === '<ul>' || r === '<ol>');
result.push(lastOpen === '<ol>' ? '</ol>' : '</ul>');
}
return result.join('\n');
}
function applyInlineFormatting(text: string): string {
// Code blocks (backtick) — process first to avoid conflicts
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold (**text**)
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic (*text*) — only single asterisks not already consumed by bold
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
return text;
}
// Quick action buttons
async function summarize() {
inputText = 'Please summarize this transcript in bullet points.';
await sendMessage();
}
async function extractActions() {
inputText = 'What action items or follow-ups were discussed?';
await sendMessage();
}
</script>
<div class="ai-chat-panel">
<div class="panel-header">
<h3>AI Chat</h3>
{#if messages.length > 0}
<button class="clear-btn" onclick={clearChat} title="Clear chat">Clear</button>
{/if}
</div>
<div class="chat-messages" bind:this={chatContainer}>
{#if messages.length === 0}
<div class="empty-state">
<p>Ask questions about the transcript</p>
{#if $segments.length > 0}
<div class="quick-actions">
<button class="quick-btn" onclick={summarize}>Summarize</button>
<button class="quick-btn" onclick={extractActions}>Action Items</button>
</div>
{/if}
</div>
{:else}
{#each messages as msg}
<div class="message {msg.role}">
{#if msg.role === 'assistant'}
<div class="message-content">{@html formatMarkdown(msg.content)}</div>
{:else}
<div class="message-content">{msg.content}</div>
{/if}
</div>
{/each}
{#if isLoading}
<div class="message assistant loading">
<div class="message-content">Thinking...</div>
</div>
{/if}
{/if}
</div>
<div class="chat-input">
<textarea
class="input-textarea"
placeholder="Ask about the transcript..."
bind:value={inputText}
onkeydown={handleKeydown}
disabled={isLoading}
></textarea>
<button
class="send-btn"
onclick={sendMessage}
disabled={isLoading || !inputText.trim()}
>
Send
</button>
</div>
</div>
<style>
.ai-chat-panel {
flex: 1;
display: flex;
flex-direction: column;
background: #16213e;
border-radius: 8px;
color: #e0e0e0;
min-height: 0;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem 0.5rem;
}
.panel-header h3 {
margin: 0;
font-size: 0.95rem;
}
.clear-btn {
background: none;
border: 1px solid #4a5568;
color: #999;
padding: 0.15rem 0.5rem;
border-radius: 3px;
cursor: pointer;
font-size: 0.7rem;
}
.clear-btn:hover {
color: #e0e0e0;
border-color: #e94560;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0 0.75rem;
min-height: 0;
}
.empty-state {
text-align: center;
color: #888;
font-size: 0.85rem;
padding: 2rem 1rem;
}
.empty-state p {
margin-bottom: 1rem;
}
.quick-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-top: 1rem;
}
.quick-btn {
background: rgba(233, 69, 96, 0.15);
border: 1px solid rgba(233, 69, 96, 0.3);
color: #e94560;
padding: 0.45rem 0.85rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
transition: background 0.15s;
}
.quick-btn:hover {
background: rgba(233, 69, 96, 0.25);
}
.message {
margin-bottom: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.8rem;
line-height: 1.55;
}
.message.user {
background: rgba(233, 69, 96, 0.15);
border-left: 3px solid rgba(233, 69, 96, 0.4);
}
.message.assistant {
background: rgba(255, 255, 255, 0.05);
border-left: 3px solid rgba(255, 255, 255, 0.1);
}
.message.loading {
opacity: 0.6;
font-style: italic;
}
/* Markdown styles inside assistant messages */
.message.assistant :global(h2) {
font-size: 1rem;
font-weight: 600;
margin: 0.6rem 0 0.3rem;
color: #f0f0f0;
}
.message.assistant :global(h3) {
font-size: 0.9rem;
font-weight: 600;
margin: 0.5rem 0 0.25rem;
color: #e8e8e8;
}
.message.assistant :global(h4) {
font-size: 0.85rem;
font-weight: 600;
margin: 0.4rem 0 0.2rem;
color: #e0e0e0;
}
.message.assistant :global(strong) {
color: #f0f0f0;
font-weight: 600;
}
.message.assistant :global(em) {
color: #ccc;
font-style: italic;
}
.message.assistant :global(code) {
background: rgba(0, 0, 0, 0.3);
color: #e94560;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-size: 0.75rem;
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
}
.message.assistant :global(ul),
.message.assistant :global(ol) {
margin: 0.35rem 0;
padding-left: 1.3rem;
}
.message.assistant :global(li) {
margin-bottom: 0.25rem;
line-height: 1.5;
}
.message.assistant :global(br) {
display: block;
content: '';
margin-top: 0.35rem;
}
.chat-input {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.75rem 0.75rem;
}
.input-textarea {
flex: 1;
background: #1a1a2e;
color: #e0e0e0;
border: 1px solid #4a5568;
border-radius: 4px;
padding: 0.4rem 0.5rem;
font-family: inherit;
font-size: 0.8rem;
resize: none;
min-height: 2rem;
max-height: 4rem;
}
.input-textarea:focus {
outline: none;
border-color: #e94560;
}
.send-btn {
background: #e94560;
border: none;
color: white;
padding: 0.4rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
align-self: flex-end;
}
.send-btn:hover:not(:disabled) {
background: #d63851;
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>