mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
feat(chat): add workbench detail overlay with streaming AI
Register a detail view for the chat module so clicking a conversation in the workbench opens an inline overlay with the full message thread and input area. Reuses the shared sendAndStream() completion service. - ListView: decrypt conversations + messages, add "Neuer Chat" button, click opens detail overlay with sibling navigation, context menu - DetailView: message bubbles, streaming indicator, auto-scroll, Enter to send / Shift+Enter for newline - App registry: add detail view loader + paramKey 'conversationId' Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6cc40242e9
commit
fa099145eb
3 changed files with 458 additions and 27 deletions
|
|
@ -449,7 +449,10 @@ registerApp({
|
|||
icon: ChatCircle,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/chat/ListView.svelte') },
|
||||
detail: { load: () => import('$lib/modules/chat/views/DetailView.svelte') },
|
||||
},
|
||||
collection: 'conversations',
|
||||
paramKey: 'conversationId',
|
||||
});
|
||||
|
||||
registerApp({
|
||||
|
|
|
|||
|
|
@ -1,22 +1,34 @@
|
|||
<!--
|
||||
Chat — Workbench ListView
|
||||
Recent conversations list.
|
||||
Recent conversations with click-to-open detail overlay + new chat button.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { BaseListView } from '@mana/shared-ui';
|
||||
import type { LocalConversation, LocalMessage } from './types';
|
||||
import { Plus, PushPin, Trash } from '@mana/shared-icons';
|
||||
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
||||
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
|
||||
import { conversationsStore } from './stores/conversations.svelte';
|
||||
import type { LocalConversation, LocalMessage, Conversation } from './types';
|
||||
import { toConversation } from './queries';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
|
||||
let { navigate }: ViewProps = $props();
|
||||
|
||||
const conversationsQuery = useLiveQueryWithDefault(async () => {
|
||||
const convs = await db.table<LocalConversation>('conversations').toArray();
|
||||
return convs.filter((c) => !c.deletedAt && !c.isArchived);
|
||||
}, [] as LocalConversation[]);
|
||||
const visible = convs.filter((c) => !c.deletedAt && !c.isArchived);
|
||||
const decrypted = await decryptRecords('conversations', visible);
|
||||
return decrypted.map(toConversation);
|
||||
}, [] as Conversation[]);
|
||||
|
||||
const lastMessagesQuery = useLiveQueryWithDefault(async () => {
|
||||
const msgs = await db.table<LocalMessage>('messages').toArray();
|
||||
const decrypted = await decryptRecords('messages', msgs);
|
||||
const map = new Map<string, LocalMessage>();
|
||||
for (const msg of msgs) {
|
||||
for (const msg of decrypted) {
|
||||
if (msg.deletedAt) continue;
|
||||
const existing = map.get(msg.conversationId);
|
||||
if (!existing || (msg.createdAt ?? '') > (existing.createdAt ?? '')) {
|
||||
|
|
@ -36,29 +48,175 @@
|
|||
function truncate(text: string, max = 60): string {
|
||||
return text.length > max ? text.slice(0, max) + '...' : text;
|
||||
}
|
||||
|
||||
function openConversation(id: string) {
|
||||
navigate('detail', {
|
||||
conversationId: id,
|
||||
_siblingIds: sorted.map((c) => c.id),
|
||||
_siblingKey: 'conversationId',
|
||||
});
|
||||
}
|
||||
|
||||
async function handleNewChat() {
|
||||
const conv = await conversationsStore.create({});
|
||||
openConversation(conv.id);
|
||||
}
|
||||
|
||||
const ctxMenu = useItemContextMenu<Conversation>();
|
||||
|
||||
let ctxMenuItems = $derived<ContextMenuItem[]>(
|
||||
ctxMenu.state.target
|
||||
? [
|
||||
{
|
||||
id: 'pin',
|
||||
label: ctxMenu.state.target.isPinned ? 'Loslösen' : 'Anpinnen',
|
||||
icon: PushPin,
|
||||
action: () => {
|
||||
const t = ctxMenu.state.target;
|
||||
if (t) t.isPinned ? conversationsStore.unpin(t.id) : conversationsStore.pin(t.id);
|
||||
},
|
||||
},
|
||||
{ id: 'div', label: '', type: 'divider' as const },
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Löschen',
|
||||
icon: Trash,
|
||||
variant: 'danger' as const,
|
||||
action: () => {
|
||||
const t = ctxMenu.state.target;
|
||||
if (t) conversationsStore.delete(t.id);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
</script>
|
||||
|
||||
<BaseListView items={sorted} getKey={(c) => c.id} emptyTitle="Keine Unterhaltungen">
|
||||
{#snippet header()}
|
||||
<span>{conversations.length} Unterhaltungen</span>
|
||||
{/snippet}
|
||||
<div class="chat-list-view">
|
||||
<button class="new-chat-btn" onclick={handleNewChat}>
|
||||
<Plus size={14} />
|
||||
<span>Neuer Chat</span>
|
||||
</button>
|
||||
|
||||
{#snippet item(conv)}
|
||||
{@const lastMsg = lastMessages.get(conv.id)}
|
||||
<div class="mb-1 min-h-[44px] rounded-md px-3 py-3 transition-colors hover:bg-white/5">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="truncate text-sm font-medium text-white/80">
|
||||
{conv.title || 'Neue Unterhaltung'}
|
||||
</p>
|
||||
{#if conv.isPinned}
|
||||
<span class="text-[10px] text-white/30">📌</span>
|
||||
<BaseListView items={sorted} getKey={(c) => c.id} emptyTitle="Keine Unterhaltungen">
|
||||
{#snippet header()}
|
||||
<span>{conversations.length} Unterhaltungen</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet item(conv)}
|
||||
{@const lastMsg = lastMessages.get(conv.id)}
|
||||
<button
|
||||
class="conv-item"
|
||||
onclick={() => openConversation(conv.id)}
|
||||
oncontextmenu={(e) => ctxMenu.open(e, conv)}
|
||||
>
|
||||
<div class="conv-top">
|
||||
<span class="conv-title">
|
||||
{conv.title || 'Neue Unterhaltung'}
|
||||
</span>
|
||||
{#if conv.isPinned}
|
||||
<span class="pin-badge">📌</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if lastMsg}
|
||||
<p class="conv-preview">
|
||||
{lastMsg.sender === 'user' ? 'Du: ' : ''}{truncate(lastMsg.messageText)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if lastMsg}
|
||||
<p class="mt-0.5 truncate text-xs text-white/40">
|
||||
{lastMsg.sender === 'user' ? 'Du: ' : ''}{truncate(lastMsg.messageText)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</BaseListView>
|
||||
</button>
|
||||
{/snippet}
|
||||
</BaseListView>
|
||||
|
||||
<ContextMenu
|
||||
visible={ctxMenu.state.visible}
|
||||
x={ctxMenu.state.x}
|
||||
y={ctxMenu.state.y}
|
||||
items={ctxMenuItems}
|
||||
onClose={ctxMenu.close}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat-list-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
margin: 0.5rem 0.5rem 0;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.new-chat-btn:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
min-height: 44px;
|
||||
}
|
||||
.conv-item:hover {
|
||||
background: hsl(var(--color-surface-hover));
|
||||
}
|
||||
|
||||
.conv-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pin-badge {
|
||||
font-size: 0.625rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conv-preview {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.conv-item {
|
||||
padding: 0.625rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
270
apps/mana/apps/web/src/lib/modules/chat/views/DetailView.svelte
Normal file
270
apps/mana/apps/web/src/lib/modules/chat/views/DetailView.svelte
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
<!--
|
||||
Chat — Workbench Detail View (overlay)
|
||||
Conversation thread with streaming AI responses.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useConversationMessages, useAllConversations } from '../queries';
|
||||
import { conversationsStore } from '../stores/conversations.svelte';
|
||||
import { sendAndStream } from '../services/completion';
|
||||
import { PaperPlaneRight } from '@mana/shared-icons';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
|
||||
const conversationId = $derived((params.conversationId as string) ?? '');
|
||||
|
||||
const allConversations = useAllConversations();
|
||||
const conversation = $derived(allConversations.value.find((c) => c.id === conversationId));
|
||||
|
||||
const messagesQuery = $derived(useConversationMessages(conversationId));
|
||||
let messages = $derived(messagesQuery.value);
|
||||
|
||||
let inputText = $state('');
|
||||
let isSending = $state(false);
|
||||
let streamingText = $state('');
|
||||
|
||||
let messagesEl: HTMLDivElement;
|
||||
|
||||
function scrollToBottom() {
|
||||
if (messagesEl) {
|
||||
requestAnimationFrame(() => {
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll when messages change or streaming updates
|
||||
$effect(() => {
|
||||
// Touch reactive dependencies
|
||||
void messages.length;
|
||||
void streamingText;
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
async function handleSend() {
|
||||
const text = inputText.trim();
|
||||
if (!text || isSending) return;
|
||||
|
||||
isSending = true;
|
||||
streamingText = '';
|
||||
inputText = '';
|
||||
|
||||
try {
|
||||
await sendAndStream(
|
||||
{
|
||||
conversationId,
|
||||
text,
|
||||
history: messages,
|
||||
currentTitle: conversation?.title,
|
||||
},
|
||||
(accumulated) => {
|
||||
streamingText = accumulated;
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Chat send failed:', e);
|
||||
} finally {
|
||||
isSending = false;
|
||||
streamingText = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-detail">
|
||||
<!-- Messages -->
|
||||
<div class="messages" bind:this={messagesEl}>
|
||||
{#if messages.length === 0 && !isSending}
|
||||
<div class="empty">Schreibe eine Nachricht</div>
|
||||
{/if}
|
||||
|
||||
{#each messages as msg (msg.id)}
|
||||
<div class="msg" class:user={msg.sender === 'user'}>
|
||||
<div class="bubble" class:user-bubble={msg.sender === 'user'}>
|
||||
<p class="msg-text">{msg.messageText}</p>
|
||||
<span class="msg-time">
|
||||
{new Date(msg.createdAt).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if isSending && streamingText}
|
||||
<div class="msg">
|
||||
<div class="bubble">
|
||||
<p class="msg-text">{streamingText}</p>
|
||||
<span class="msg-time">...</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isSending}
|
||||
<div class="msg">
|
||||
<div class="bubble">
|
||||
<span class="thinking-dots">●●●</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="input-bar">
|
||||
<textarea
|
||||
bind:value={inputText}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Nachricht..."
|
||||
rows="1"
|
||||
class="input-area"
|
||||
></textarea>
|
||||
<button class="send-btn" onclick={handleSend} disabled={!inputText.trim() || isSending}>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Messages ──────────────────────────────── */
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.msg.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 85%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.user-bubble {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
display: block;
|
||||
margin-top: 0.125rem;
|
||||
font-size: 0.5625rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.thinking-dots {
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
animation: dots-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Input ─────────────────────────────────── */
|
||||
.input-bar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.input-area {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
outline: none;
|
||||
}
|
||||
.input-area:focus {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.input-area::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.send-btn:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.send-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes dots-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.messages {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.input-bar {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue