mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 06:26:42 +02:00
feat(brain): add Companion Chat module with LLM tool calling
Phase 5 of the Companion Brain. Introduces the Companion Chat that ties together all previous phases into a conversational interface. Module (modules/companion/): - types.ts: LocalConversation + LocalMessage with tool call/result fields - collections.ts: companionConversations + companionMessages tables - stores/chat.svelte.ts: conversation + message CRUD - queries.ts: reactive useConversations() + useMessages() - engine.ts: chat orchestration — builds system prompt from Context Document, sends to local LLM (Gemma via @mana/local-llm), handles tool calls via JSON extraction + executeTool(), supports multi-round tool calling (max 3 rounds) UI: - CompanionChat.svelte: message list, streaming output, tool result display, keyboard submit (Enter) - /companion route: sidebar with conversation list + chat area Also updates the architecture plan with Phase 1-4 completion status. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1e992d3c92
commit
46db527f8c
9 changed files with 1100 additions and 113 deletions
|
|
@ -0,0 +1,5 @@
|
|||
import { db } from '$lib/data/database';
|
||||
import type { LocalConversation, LocalMessage } from './types';
|
||||
|
||||
export const conversationTable = db.table<LocalConversation>('companionConversations');
|
||||
export const messageTable = db.table<LocalMessage>('companionMessages');
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
<!--
|
||||
CompanionChat — Chat interface for the Mana Companion Brain.
|
||||
|
||||
Displays conversation messages, handles user input, streams LLM
|
||||
responses, and shows tool execution results inline.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { PaperPlaneRight, Robot, User, Lightning, CircleNotch } from '@mana/shared-icons';
|
||||
import { chatStore } from '../stores/chat.svelte';
|
||||
import { runCompanionChat, isCompanionAvailable } from '../engine';
|
||||
import { useMessages } from '../queries';
|
||||
import { useDaySnapshot } from '$lib/data/projections/day-snapshot';
|
||||
import { useStreaks } from '$lib/data/projections/streaks';
|
||||
import type { LocalConversation, LocalMessage } from '../types';
|
||||
|
||||
interface Props {
|
||||
conversation: LocalConversation;
|
||||
}
|
||||
|
||||
let { conversation }: Props = $props();
|
||||
|
||||
const messages = useMessages(conversation.id);
|
||||
const day = useDaySnapshot();
|
||||
const streaks = useStreaks();
|
||||
|
||||
let inputText = $state('');
|
||||
let sending = $state(false);
|
||||
let streamingText = $state('');
|
||||
let messagesEndEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
async function scrollToBottom() {
|
||||
await tick();
|
||||
messagesEndEl?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (messages.value.length > 0) scrollToBottom();
|
||||
});
|
||||
|
||||
async function handleSend() {
|
||||
const text = inputText.trim();
|
||||
if (!text || sending) return;
|
||||
|
||||
inputText = '';
|
||||
sending = true;
|
||||
streamingText = '';
|
||||
|
||||
// Add user message
|
||||
await chatStore.addMessage(conversation.id, 'user', text);
|
||||
await scrollToBottom();
|
||||
|
||||
try {
|
||||
const result = await runCompanionChat(
|
||||
text,
|
||||
messages.value,
|
||||
day.value,
|
||||
streaks.value,
|
||||
(token) => {
|
||||
streamingText += token;
|
||||
}
|
||||
);
|
||||
|
||||
// Add tool results as separate messages
|
||||
for (const tc of result.toolCalls) {
|
||||
await chatStore.addMessage(conversation.id, 'assistant', '', {
|
||||
toolCall: { name: tc.name, params: tc.params },
|
||||
});
|
||||
await chatStore.addMessage(conversation.id, 'tool_result', tc.result.message, {
|
||||
toolResult: tc.result,
|
||||
});
|
||||
}
|
||||
|
||||
// Add final assistant message
|
||||
if (result.content) {
|
||||
await chatStore.addMessage(conversation.id, 'assistant', result.content);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Fehler bei der Verarbeitung';
|
||||
await chatStore.addMessage(conversation.id, 'assistant', `Fehler: ${msg}`);
|
||||
} finally {
|
||||
sending = false;
|
||||
streamingText = '';
|
||||
await scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="companion-chat">
|
||||
<div class="messages">
|
||||
{#each messages.value as msg (msg.id)}
|
||||
<div
|
||||
class="message"
|
||||
class:user={msg.role === 'user'}
|
||||
class:assistant={msg.role === 'assistant'}
|
||||
class:tool={msg.role === 'tool_result'}
|
||||
>
|
||||
<div class="message-icon">
|
||||
{#if msg.role === 'user'}
|
||||
<User size={16} weight="bold" />
|
||||
{:else if msg.role === 'tool_result'}
|
||||
<Lightning size={16} weight="bold" />
|
||||
{:else}
|
||||
<Robot size={16} weight="bold" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="message-content">
|
||||
{#if msg.toolCall}
|
||||
<span class="tool-badge">{msg.toolCall.name}</span>
|
||||
{/if}
|
||||
{#if msg.toolResult}
|
||||
<span
|
||||
class="tool-result"
|
||||
class:success={msg.toolResult.success}
|
||||
class:error={!msg.toolResult.success}
|
||||
>
|
||||
{msg.content}
|
||||
</span>
|
||||
{:else}
|
||||
{msg.content}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if sending && streamingText}
|
||||
<div class="message assistant">
|
||||
<div class="message-icon">
|
||||
<Robot size={16} weight="bold" />
|
||||
</div>
|
||||
<div class="message-content streaming">{streamingText}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div bind:this={messagesEndEl}></div>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<textarea
|
||||
class="chat-input"
|
||||
bind:value={inputText}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Nachricht an Companion..."
|
||||
disabled={sending}
|
||||
rows={1}
|
||||
></textarea>
|
||||
<button class="send-btn" onclick={handleSend} disabled={sending || !inputText.trim()}>
|
||||
{#if sending}
|
||||
<CircleNotch size={18} weight="bold" />
|
||||
{:else}
|
||||
<PaperPlaneRight size={18} weight="bold" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.companion-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-height: calc(100dvh - var(--bottom-chrome-height, 80px) - 6rem);
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message.tool {
|
||||
align-self: flex-start;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.message-icon {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
}
|
||||
|
||||
.message.user .message-icon {
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.message.tool .message-icon {
|
||||
background: hsl(var(--color-warning, 45 93% 47%) / 0.15);
|
||||
color: hsl(var(--color-warning, 45 93% 47%));
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.message.assistant .message-content {
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
color: hsl(var(--color-foreground));
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.message.tool .message-content {
|
||||
background: hsl(var(--color-muted) / 0.15);
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
}
|
||||
|
||||
.streaming {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tool-result.success {
|
||||
color: hsl(var(--color-success, 142 71% 45%));
|
||||
}
|
||||
|
||||
.tool-result.error {
|
||||
color: hsl(var(--color-error, 0 84% 60%));
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 1rem;
|
||||
border: 1.5px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-background));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.chat-input:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
176
apps/mana/apps/web/src/lib/modules/companion/engine.ts
Normal file
176
apps/mana/apps/web/src/lib/modules/companion/engine.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Companion Chat Engine — Orchestrates LLM + Context Document + Tool Calling.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Build system prompt from Context Document (projections + streaks)
|
||||
* 2. Collect conversation history
|
||||
* 3. Send to LLM with tool schemas
|
||||
* 4. If LLM returns tool_use → execute tool → feed result back → repeat
|
||||
* 5. Return final assistant message
|
||||
*
|
||||
* Currently uses @mana/local-llm directly (Gemma, browser-local).
|
||||
* Tool calling is simulated via JSON extraction since Gemma doesn't
|
||||
* natively support function calling — the system prompt instructs the
|
||||
* model to output JSON when it wants to call a tool.
|
||||
*/
|
||||
|
||||
import { generate, getLocalLlmStatus, loadLocalLlm } from '@mana/local-llm';
|
||||
import { generateContextDocument } from '$lib/data/projections/context-document';
|
||||
import { getToolsForLlm, executeTool } from '$lib/data/tools';
|
||||
import type { DaySnapshot, StreakInfo } from '$lib/data/projections/types';
|
||||
import type { LocalMessage } from './types';
|
||||
import type { ToolResult } from '$lib/data/tools/types';
|
||||
|
||||
const MAX_TOOL_ROUNDS = 3;
|
||||
|
||||
interface EngineResult {
|
||||
content: string;
|
||||
toolCalls: { name: string; params: Record<string, unknown>; result: ToolResult }[];
|
||||
}
|
||||
|
||||
function buildSystemPrompt(day: DaySnapshot, streaks: StreakInfo[]): string {
|
||||
const context = generateContextDocument(day, streaks);
|
||||
const toolSchemas = getToolsForLlm();
|
||||
const toolList = toolSchemas.map((t) => `- ${t.name}: ${t.description}`).join('\n');
|
||||
|
||||
return `Du bist der Mana Companion — ein hilfreicher persoenlicher Assistent.
|
||||
Du hast Zugriff auf die Daten und Aktionen des Nutzers ueber verschiedene Module.
|
||||
|
||||
${context}
|
||||
|
||||
## Verfuegbare Aktionen
|
||||
|
||||
${toolList}
|
||||
|
||||
## Tool-Aufruf Format
|
||||
|
||||
Wenn du eine Aktion ausfuehren willst, antworte mit einem JSON-Block:
|
||||
\`\`\`tool
|
||||
{"name": "tool_name", "params": {"key": "value"}}
|
||||
\`\`\`
|
||||
|
||||
Du kannst pro Antwort EINEN Tool-Aufruf machen. Nach dem Ergebnis kannst du weiter antworten.
|
||||
Wenn du keine Aktion ausfuehren willst, antworte einfach mit Text.
|
||||
|
||||
## Verhalten
|
||||
|
||||
- Antworte auf Deutsch
|
||||
- Sei kurz und hilfreich
|
||||
- Nutze die Kontext-Daten um relevante Vorschlaege zu machen
|
||||
- Wenn der Nutzer etwas loggen will, nutze das passende Tool
|
||||
- Ermutige den Nutzer bei Fortschritt und Streaks`;
|
||||
}
|
||||
|
||||
function extractToolCall(
|
||||
text: string
|
||||
): { name: string; params: Record<string, unknown>; before: string; after: string } | null {
|
||||
const toolBlockRegex = /```tool\s*\n?([\s\S]*?)\n?```/;
|
||||
const match = text.match(toolBlockRegex);
|
||||
if (!match) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]) as { name: string; params: Record<string, unknown> };
|
||||
if (!parsed.name) return null;
|
||||
const before = text.slice(0, match.index).trim();
|
||||
const after = text.slice((match.index ?? 0) + match[0].length).trim();
|
||||
return { name: parsed.name, params: parsed.params ?? {}, before, after };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function messagesToLlm(
|
||||
messages: LocalMessage[]
|
||||
): { role: 'user' | 'assistant' | 'system'; content: string }[] {
|
||||
return messages
|
||||
.filter((m) => m.role !== 'tool_result')
|
||||
.map((m) => ({
|
||||
role:
|
||||
m.role === 'tool_result' ? ('user' as const) : (m.role as 'user' | 'assistant' | 'system'),
|
||||
content: m.content,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the Companion and get a response.
|
||||
*
|
||||
* @param userMessage - The user's input text
|
||||
* @param history - Previous messages in this conversation
|
||||
* @param day - Current DaySnapshot projection
|
||||
* @param streaks - Current streak info
|
||||
* @param onToken - Streaming callback for progressive UI updates
|
||||
*/
|
||||
export async function runCompanionChat(
|
||||
userMessage: string,
|
||||
history: LocalMessage[],
|
||||
day: DaySnapshot,
|
||||
streaks: StreakInfo[],
|
||||
onToken?: (token: string) => void
|
||||
): Promise<EngineResult> {
|
||||
// Ensure local LLM is loaded
|
||||
const status = getLocalLlmStatus();
|
||||
if (status.current.state !== 'ready') {
|
||||
await loadLocalLlm();
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(day, streaks);
|
||||
const toolCalls: EngineResult['toolCalls'] = [];
|
||||
|
||||
// Build message chain
|
||||
const llmMessages: { role: 'user' | 'assistant' | 'system'; content: string }[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messagesToLlm(history),
|
||||
{ role: 'user', content: userMessage },
|
||||
];
|
||||
|
||||
let finalContent = '';
|
||||
|
||||
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
|
||||
const result = await generate({
|
||||
messages: llmMessages,
|
||||
temperature: 0.7,
|
||||
maxTokens: 1024,
|
||||
onToken: round === 0 ? onToken : undefined, // Only stream first round
|
||||
});
|
||||
|
||||
const text = result.content;
|
||||
const toolCall = extractToolCall(text);
|
||||
|
||||
if (!toolCall) {
|
||||
finalContent = text;
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
const toolResult = await executeTool(toolCall.name, toolCall.params);
|
||||
toolCalls.push({ name: toolCall.name, params: toolCall.params, result: toolResult });
|
||||
|
||||
// Build response text from before/after the tool block
|
||||
const parts = [toolCall.before, toolCall.after].filter(Boolean);
|
||||
|
||||
// Feed tool result back into conversation
|
||||
llmMessages.push({
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
});
|
||||
llmMessages.push({
|
||||
role: 'user',
|
||||
content: `Tool-Ergebnis fuer ${toolCall.name}: ${toolResult.message}${toolResult.data ? `\nDaten: ${JSON.stringify(toolResult.data)}` : ''}`,
|
||||
});
|
||||
|
||||
// If this was the last round, use what we have
|
||||
if (round === MAX_TOOL_ROUNDS) {
|
||||
finalContent = parts.join('\n\n') || `Aktion ausgefuehrt: ${toolResult.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return { content: finalContent, toolCalls };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Companion Chat is available (LLM loaded or loadable).
|
||||
*/
|
||||
export function isCompanionAvailable(): boolean {
|
||||
const status = getLocalLlmStatus();
|
||||
return status.current.state === 'ready' || status.current.state === 'idle';
|
||||
}
|
||||
4
apps/mana/apps/web/src/lib/modules/companion/index.ts
Normal file
4
apps/mana/apps/web/src/lib/modules/companion/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { chatStore } from './stores/chat.svelte';
|
||||
export { runCompanionChat, isCompanionAvailable } from './engine';
|
||||
export { useConversations, useMessages } from './queries';
|
||||
export type { LocalConversation, LocalMessage } from './types';
|
||||
25
apps/mana/apps/web/src/lib/modules/companion/queries.ts
Normal file
25
apps/mana/apps/web/src/lib/modules/companion/queries.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Companion Queries — Reactive reads for conversations and messages.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { conversationTable, messageTable } from './collections';
|
||||
import type { LocalConversation, LocalMessage } from './types';
|
||||
|
||||
export function useConversations() {
|
||||
return useLiveQueryWithDefault<LocalConversation[]>(async () => {
|
||||
const all = await conversationTable.toArray();
|
||||
return all.filter((c) => !c.deletedAt).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useMessages(conversationId: string) {
|
||||
return useLiveQueryWithDefault<LocalMessage[]>(async () => {
|
||||
if (!conversationId) return [];
|
||||
const msgs = await messageTable
|
||||
.where('conversationId')
|
||||
.equals(conversationId)
|
||||
.sortBy('createdAt');
|
||||
return msgs;
|
||||
}, []);
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Companion Chat Store — Manages conversations, messages, and LLM interaction.
|
||||
*
|
||||
* Uses the Context Document as system prompt and the Tool Layer for
|
||||
* function calling. Currently wired to @mana/local-llm (Gemma, browser-local).
|
||||
* Can be upgraded to the LLM orchestrator for multi-tier support.
|
||||
*/
|
||||
|
||||
import { conversationTable, messageTable } from '../collections';
|
||||
import type { LocalConversation, LocalMessage } from '../types';
|
||||
|
||||
// ── Conversation CRUD ───────────────────────────────
|
||||
|
||||
export const chatStore = {
|
||||
async createConversation(title?: string): Promise<LocalConversation> {
|
||||
const now = new Date().toISOString();
|
||||
const conv: LocalConversation = {
|
||||
id: crypto.randomUUID(),
|
||||
title: title ?? 'Neues Gespraech',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await conversationTable.add(conv);
|
||||
return conv;
|
||||
},
|
||||
|
||||
async renameConversation(id: string, title: string): Promise<void> {
|
||||
await conversationTable.update(id, {
|
||||
title,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteConversation(id: string): Promise<void> {
|
||||
await conversationTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
// ── Messages ──────────────────────────────────────
|
||||
|
||||
async addMessage(
|
||||
conversationId: string,
|
||||
role: LocalMessage['role'],
|
||||
content: string,
|
||||
extra?: {
|
||||
toolCall?: LocalMessage['toolCall'];
|
||||
toolResult?: LocalMessage['toolResult'];
|
||||
}
|
||||
): Promise<LocalMessage> {
|
||||
const msg: LocalMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
conversationId,
|
||||
role,
|
||||
content,
|
||||
toolCall: extra?.toolCall,
|
||||
toolResult: extra?.toolResult,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await messageTable.add(msg);
|
||||
|
||||
// Touch conversation updatedAt
|
||||
await conversationTable.update(conversationId, {
|
||||
updatedAt: msg.createdAt,
|
||||
});
|
||||
|
||||
return msg;
|
||||
},
|
||||
|
||||
async updateMessageContent(id: string, content: string): Promise<void> {
|
||||
await messageTable.update(id, { content });
|
||||
},
|
||||
|
||||
async getMessages(conversationId: string): Promise<LocalMessage[]> {
|
||||
return messageTable.where('conversationId').equals(conversationId).sortBy('createdAt');
|
||||
},
|
||||
};
|
||||
30
apps/mana/apps/web/src/lib/modules/companion/types.ts
Normal file
30
apps/mana/apps/web/src/lib/modules/companion/types.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Companion Chat types.
|
||||
*/
|
||||
|
||||
export interface LocalConversation {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
export interface LocalMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool_result';
|
||||
content: string;
|
||||
/** Tool call info (for assistant messages that invoke a tool) */
|
||||
toolCall?: {
|
||||
name: string;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
/** Tool result (for tool_result messages) */
|
||||
toolResult?: {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
createdAt: string;
|
||||
}
|
||||
284
apps/mana/apps/web/src/routes/(app)/companion/+page.svelte
Normal file
284
apps/mana/apps/web/src/routes/(app)/companion/+page.svelte
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Robot, Plus, Trash } from '@mana/shared-icons';
|
||||
import CompanionChat from '$lib/modules/companion/components/CompanionChat.svelte';
|
||||
import { chatStore } from '$lib/modules/companion/stores/chat.svelte';
|
||||
import { useConversations } from '$lib/modules/companion/queries';
|
||||
import type { LocalConversation } from '$lib/modules/companion/types';
|
||||
|
||||
const conversations = useConversations();
|
||||
|
||||
let activeConversation = $state<LocalConversation | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
// Auto-create or resume last conversation
|
||||
if (conversations.value.length > 0) {
|
||||
activeConversation = conversations.value[0];
|
||||
}
|
||||
});
|
||||
|
||||
// When conversations load, select the first one
|
||||
$effect(() => {
|
||||
if (!activeConversation && conversations.value.length > 0) {
|
||||
activeConversation = conversations.value[0];
|
||||
}
|
||||
});
|
||||
|
||||
async function handleNewConversation() {
|
||||
const conv = await chatStore.createConversation();
|
||||
activeConversation = conv;
|
||||
}
|
||||
|
||||
async function handleDeleteConversation(id: string) {
|
||||
await chatStore.deleteConversation(id);
|
||||
if (activeConversation?.id === id) {
|
||||
activeConversation = conversations.value.find((c) => c.id !== id) ?? null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Companion - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="companion-page">
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-title">
|
||||
<Robot size={20} weight="bold" />
|
||||
<span>Companion</span>
|
||||
</div>
|
||||
<button class="new-btn" onclick={handleNewConversation} title="Neues Gespraech">
|
||||
<Plus size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="conversation-list">
|
||||
{#each conversations.value as conv (conv.id)}
|
||||
<button
|
||||
class="conversation-item"
|
||||
class:active={activeConversation?.id === conv.id}
|
||||
onclick={() => (activeConversation = conv)}
|
||||
>
|
||||
<span class="conv-title">{conv.title}</span>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="conv-delete"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteConversation(conv.id);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
handleDeleteConversation(conv.id);
|
||||
}
|
||||
}}
|
||||
title="Loeschen"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if conversations.value.length === 0}
|
||||
<p class="empty-hint">Noch keine Gespraeche. Starte mit dem + Button.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<div class="chat-area">
|
||||
{#if activeConversation}
|
||||
{#key activeConversation.id}
|
||||
<CompanionChat conversation={activeConversation} />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<Robot size={48} weight="thin" />
|
||||
<h2>Mana Companion</h2>
|
||||
<p>
|
||||
Dein persoenlicher Assistent. Frag nach deinem Tag, lass Tasks erstellen oder Getraenke
|
||||
loggen.
|
||||
</p>
|
||||
<button class="start-btn" onclick={handleNewConversation}> Gespraech starten </button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.companion-page {
|
||||
display: flex;
|
||||
height: calc(100dvh - var(--bottom-chrome-height, 80px) - 4rem);
|
||||
gap: 1px;
|
||||
background: hsl(var(--color-border));
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: hsl(var(--color-card));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.new-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.new-btn:hover {
|
||||
background: hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: hsl(var(--color-surface-hover));
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-delete {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
padding: 0.125rem;
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.conversation-item:hover .conv-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.conv-delete:hover {
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
background: hsl(var(--color-background));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
max-width: 320px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue