mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
feat(chat): add auto title generation, inline renaming, and styled delete modal
- Fix missing conversationsStore import for auto title generation - Make model ID dynamic in generateTitle() with error handling and fallback - Add inline editing for manual conversation renaming in sidebar - Add updateConversationTitle API endpoint and store method - Replace browser confirm() with styled ConfirmationModal for delete - Update Modal and ConfirmationModal with glassmorphism styling - Add DEV_BYPASS_AUTH and GOOGLE_GENAI_API_KEY to env generation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a32c4f0a16
commit
05fe8ca5b6
14 changed files with 447 additions and 71 deletions
|
|
@ -165,6 +165,34 @@ export class ConversationController {
|
|||
return result.value;
|
||||
}
|
||||
|
||||
@Patch(':id/pin')
|
||||
async pinConversation(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<Conversation> {
|
||||
const result = await this.conversationService.pinConversation(id, user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Patch(':id/unpin')
|
||||
async unpinConversation(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<Conversation> {
|
||||
const result = await this.conversationService.unpinConversation(id, user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteConversation(
|
||||
@Param('id') id: string,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export class ConversationService {
|
|||
.select()
|
||||
.from(conversations)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(conversations.updatedAt));
|
||||
.orderBy(desc(conversations.isPinned), desc(conversations.updatedAt));
|
||||
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
|
|
@ -267,4 +267,46 @@ export class ConversationService {
|
|||
return err(DatabaseError.queryFailed('Failed to get message count'));
|
||||
}
|
||||
}
|
||||
|
||||
async pinConversation(conversationId: string, userId: string): AsyncResult<Conversation> {
|
||||
try {
|
||||
// First verify the conversation belongs to the user
|
||||
const convResult = await this.getConversation(conversationId, userId);
|
||||
if (!convResult.ok) {
|
||||
return err(convResult.error);
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.update(conversations)
|
||||
.set({ isPinned: true, updatedAt: new Date() })
|
||||
.where(eq(conversations.id, conversationId))
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error pinning conversation', error);
|
||||
return err(DatabaseError.queryFailed('Failed to pin conversation'));
|
||||
}
|
||||
}
|
||||
|
||||
async unpinConversation(conversationId: string, userId: string): AsyncResult<Conversation> {
|
||||
try {
|
||||
// First verify the conversation belongs to the user
|
||||
const convResult = await this.getConversation(conversationId, userId);
|
||||
if (!convResult.ok) {
|
||||
return err(convResult.error);
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.update(conversations)
|
||||
.set({ isPinned: false, updatedAt: new Date() })
|
||||
.where(eq(conversations.id, conversationId))
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error unpinning conversation', error);
|
||||
return err(DatabaseError.queryFailed('Failed to unpin conversation'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const conversations = pgTable('conversations', {
|
|||
conversationMode: conversationModeEnum('conversation_mode').default('free').notNull(),
|
||||
documentMode: boolean('document_mode').default(false).notNull(),
|
||||
isArchived: boolean('is_archived').default(false).notNull(),
|
||||
isPinned: boolean('is_pinned').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { isSidebarMode, isNavCollapsed } from '$lib/stores/navigation';
|
||||
import { MagnifyingGlass, X, Plus, ChatCircle, Archive, Trash } from '@manacore/shared-icons';
|
||||
import { MagnifyingGlass, X, Plus, ChatCircle, Archive, Trash, PushPin } from '@manacore/shared-icons';
|
||||
import { ConfirmationModal } from '@manacore/shared-ui';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
|
|
@ -14,6 +15,11 @@
|
|||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// Delete confirmation modal state
|
||||
let showDeleteModal = $state(false);
|
||||
let deleteTargetId = $state<string | null>(null);
|
||||
let isDeleting = $state(false);
|
||||
|
||||
// Resizer state
|
||||
let leftColumnWidth = $state(320);
|
||||
let isResizing = $state(false);
|
||||
|
|
@ -106,17 +112,48 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Delete conversation
|
||||
async function handleDelete(e: MouseEvent, convId: string) {
|
||||
// Pin/unpin conversation
|
||||
async function handleTogglePin(e: MouseEvent, convId: string, isPinned: boolean) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (confirm('Möchtest du diese Konversation wirklich löschen?')) {
|
||||
const success = await conversationsStore.deleteConversation(convId);
|
||||
if (success && isActive(convId)) {
|
||||
if (isPinned) {
|
||||
await conversationsStore.unpinConversation(convId);
|
||||
} else {
|
||||
await conversationsStore.pinConversation(convId);
|
||||
}
|
||||
}
|
||||
|
||||
// Open delete confirmation modal
|
||||
function handleDelete(e: MouseEvent, convId: string) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
deleteTargetId = convId;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
// Confirm delete action
|
||||
async function confirmDelete() {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
isDeleting = true;
|
||||
try {
|
||||
const wasActive = isActive(deleteTargetId);
|
||||
const success = await conversationsStore.deleteConversation(deleteTargetId);
|
||||
if (success && wasActive) {
|
||||
goto('/chat');
|
||||
}
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
showDeleteModal = false;
|
||||
deleteTargetId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Close delete modal
|
||||
function closeDeleteModal() {
|
||||
showDeleteModal = false;
|
||||
deleteTargetId = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
|
@ -213,13 +250,21 @@
|
|||
>
|
||||
<!-- Title Row -->
|
||||
<div class="mb-1.5 flex items-center gap-2">
|
||||
<ChatCircle
|
||||
size={16}
|
||||
weight={isActive(conv.id) ? 'fill' : 'regular'}
|
||||
class="flex-shrink-0 {isActive(conv.id)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'}"
|
||||
/>
|
||||
{#if conv.isPinned}
|
||||
<PushPin
|
||||
size={16}
|
||||
weight="fill"
|
||||
class="flex-shrink-0 text-primary"
|
||||
/>
|
||||
{:else}
|
||||
<ChatCircle
|
||||
size={16}
|
||||
weight={isActive(conv.id) ? 'fill' : 'regular'}
|
||||
class="flex-shrink-0 {isActive(conv.id)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'}"
|
||||
/>
|
||||
{/if}
|
||||
<h3 class="text-sm font-semibold line-clamp-1 text-foreground flex-1">
|
||||
{conv.title || 'Neue Konversation'}
|
||||
</h3>
|
||||
|
|
@ -245,6 +290,13 @@
|
|||
{/if}
|
||||
<!-- Action Buttons (visible on hover) -->
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onclick={(e) => handleTogglePin(e, conv.id, conv.isPinned)}
|
||||
class="p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors {conv.isPinned ? 'text-primary' : ''}"
|
||||
title={conv.isPinned ? 'Nicht mehr anpinnen' : 'Anpinnen'}
|
||||
>
|
||||
<PushPin size={14} weight={conv.isPinned ? 'fill' : 'bold'} />
|
||||
</button>
|
||||
<button
|
||||
onclick={(e) => handleArchive(e, conv.id)}
|
||||
class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
|
||||
|
|
@ -284,6 +336,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<ConfirmationModal
|
||||
visible={showDeleteModal}
|
||||
onClose={closeDeleteModal}
|
||||
onConfirm={confirmDelete}
|
||||
variant="danger"
|
||||
title="Konversation löschen?"
|
||||
message="Diese Aktion kann nicht rückgängig gemacht werden. Alle Nachrichten werden dauerhaft gelöscht."
|
||||
confirmLabel="Löschen"
|
||||
cancelLabel="Abbrechen"
|
||||
loading={isDeleting}
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* Hide scrollbar completely */
|
||||
.scrollbar-hide {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { page } from '$app/stores';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import type { Conversation } from '@chat/types';
|
||||
import { Plus } from '@manacore/shared-icons';
|
||||
import { Plus, PencilSimple, Check, X } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
conversations: Conversation[];
|
||||
|
|
@ -11,6 +11,11 @@
|
|||
|
||||
let { conversations, isLoading = false }: Props = $props();
|
||||
|
||||
// Edit state
|
||||
let editingId = $state<string | null>(null);
|
||||
let editTitle = $state('');
|
||||
let isSaving = $state(false);
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
|
|
@ -32,6 +37,40 @@
|
|||
if (title.length <= maxLength) return title;
|
||||
return title.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
function startEdit(conv: Conversation, event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
editingId = conv.id;
|
||||
editTitle = conv.title || '';
|
||||
}
|
||||
|
||||
async function saveTitle(conversationId: string) {
|
||||
if (!editTitle.trim() || isSaving) return;
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
await conversationsStore.updateConversationTitle(conversationId, editTitle.trim());
|
||||
} finally {
|
||||
isSaving = false;
|
||||
editingId = null;
|
||||
editTitle = '';
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
editTitle = '';
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent, conversationId: string) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
saveTitle(conversationId);
|
||||
} else if (event.key === 'Escape') {
|
||||
cancelEdit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
|
|
@ -65,22 +104,65 @@
|
|||
<div class="py-2">
|
||||
{#each conversations as conv (conv.id)}
|
||||
{@const isActive = $page.params.id === conv.id}
|
||||
<a
|
||||
href="/chat/{conv.id}"
|
||||
class="block px-3 py-2 mx-2 rounded-lg transition-colors
|
||||
{isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-muted text-foreground'}"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium truncate">
|
||||
{truncateTitle(conv.title || 'Neue Konversation')}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatDate(conv.updatedAt || conv.createdAt)}
|
||||
</span>
|
||||
{#if editingId === conv.id}
|
||||
<!-- Edit Mode -->
|
||||
<div class="flex items-center gap-1 px-3 py-2 mx-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editTitle}
|
||||
onkeydown={(e) => handleKeydown(e, conv.id)}
|
||||
class="flex-1 px-2 py-1 text-sm bg-background border border-border rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
onclick={() => saveTitle(conv.id)}
|
||||
disabled={isSaving || !editTitle.trim()}
|
||||
class="p-1.5 text-primary hover:bg-primary/10 rounded-md transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Speichern"
|
||||
>
|
||||
<Check size={16} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
onclick={cancelEdit}
|
||||
class="p-1.5 text-muted-foreground hover:bg-muted rounded-md transition-colors"
|
||||
title="Abbrechen"
|
||||
>
|
||||
<X size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<!-- View Mode -->
|
||||
<div class="group relative">
|
||||
<a
|
||||
href="/chat/{conv.id}"
|
||||
class="block px-3 py-2 mx-2 rounded-lg transition-colors
|
||||
{isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-muted text-foreground'}"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium truncate pr-6">
|
||||
{truncateTitle(conv.title || 'Neue Konversation')}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatDate(conv.updatedAt || conv.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<!-- Edit Button (visible on hover) -->
|
||||
<button
|
||||
onclick={(e) => startEdit(conv, e)}
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 p-1 rounded-md
|
||||
text-muted-foreground hover:text-foreground hover:bg-muted
|
||||
opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Umbenennen"
|
||||
>
|
||||
<PencilSimple size={14} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export type Conversation = {
|
|||
conversationMode: 'free' | 'guided' | 'template';
|
||||
documentMode: boolean;
|
||||
isArchived: boolean;
|
||||
isPinned: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
|
@ -203,6 +204,28 @@ export const conversationApi = {
|
|||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
async pinConversation(conversationId: string): Promise<boolean> {
|
||||
const { error } = await fetchApi<Conversation>(`/conversations/${conversationId}/pin`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (error) {
|
||||
console.error('Error pinning conversation:', error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
async unpinConversation(conversationId: string): Promise<boolean> {
|
||||
const { error } = await fetchApi<Conversation>(`/conversations/${conversationId}/unpin`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (error) {
|
||||
console.error('Error unpinning conversation:', error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
// ============ Template API ============
|
||||
|
|
|
|||
|
|
@ -97,6 +97,20 @@ export const conversationService = {
|
|||
return conversationApi.deleteConversation(conversationId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Pin a conversation
|
||||
*/
|
||||
async pinConversation(conversationId: string): Promise<boolean> {
|
||||
return conversationApi.pinConversation(conversationId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Unpin a conversation
|
||||
*/
|
||||
async unpinConversation(conversationId: string): Promise<boolean> {
|
||||
return conversationApi.unpinConversation(conversationId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a message and get AI response
|
||||
*/
|
||||
|
|
@ -142,7 +156,7 @@ export const conversationService = {
|
|||
// Generate title if this is a new conversation (first or second message)
|
||||
let title: string | undefined;
|
||||
if (messages.length <= 2) {
|
||||
title = await this.generateTitle(userMessage);
|
||||
title = await this.generateTitle(userMessage, modelId);
|
||||
if (title) {
|
||||
await this.updateTitle(conversationId, title);
|
||||
}
|
||||
|
|
@ -157,32 +171,50 @@ export const conversationService = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Generate a conversation title based on user message
|
||||
* Generate a conversation title based on user message using AI
|
||||
*/
|
||||
async generateTitle(userMessage: string): Promise<string> {
|
||||
const titlePrompt = `Schreibe eine kurze, prägnante Überschrift (maximal 5 Wörter) für diesen Chat: "${userMessage}"`;
|
||||
async generateTitle(userMessage: string, modelId: string): Promise<string> {
|
||||
try {
|
||||
const titlePrompt = `Schreibe eine kurze, prägnante Überschrift (maximal 5 Wörter) für diesen Chat: "${userMessage}"`;
|
||||
|
||||
const response = await chatApi.createCompletion({
|
||||
messages: [{ role: 'user', content: titlePrompt }],
|
||||
modelId: '550e8400-e29b-41d4-a716-446655440101', // Gemini 2.5 Flash (default)
|
||||
temperature: 0.3,
|
||||
maxTokens: 50,
|
||||
});
|
||||
const response = await chatApi.createCompletion({
|
||||
messages: [{ role: 'user', content: titlePrompt }],
|
||||
modelId,
|
||||
temperature: 0.3,
|
||||
maxTokens: 50,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return 'Neue Konversation';
|
||||
if (!response) {
|
||||
console.warn('Title generation returned no response, using fallback');
|
||||
return this.createFallbackTitle(userMessage);
|
||||
}
|
||||
|
||||
// Clean up title
|
||||
let title = response.content
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\.$/g, '');
|
||||
|
||||
if (title.length > 100) {
|
||||
title = title.substring(0, 97) + '...';
|
||||
}
|
||||
|
||||
return title || this.createFallbackTitle(userMessage);
|
||||
} catch (error) {
|
||||
console.error('Error generating title:', error);
|
||||
return this.createFallbackTitle(userMessage);
|
||||
}
|
||||
},
|
||||
|
||||
// Clean up title
|
||||
let title = response.content
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\.$/g, '');
|
||||
|
||||
if (title.length > 100) {
|
||||
title = title.substring(0, 97) + '...';
|
||||
/**
|
||||
* Create a fallback title from the first words of the message
|
||||
*/
|
||||
createFallbackTitle(message: string): string {
|
||||
const words = message.trim().split(/\s+/).slice(0, 5);
|
||||
let title = words.join(' ');
|
||||
if (message.trim().split(/\s+/).length > 5) {
|
||||
title += '...';
|
||||
}
|
||||
|
||||
return title;
|
||||
return title || 'Neue Konversation';
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -74,6 +74,17 @@ export const conversationsStore = {
|
|||
conversations = conversations.map((c) => (c.id === conversationId ? { ...c, ...updates } : c));
|
||||
},
|
||||
|
||||
/**
|
||||
* Update conversation title via API and update local state
|
||||
*/
|
||||
async updateConversationTitle(conversationId: string, title: string): Promise<boolean> {
|
||||
const success = await conversationService.updateTitle(conversationId, title);
|
||||
if (success) {
|
||||
this.updateConversation(conversationId, { title });
|
||||
}
|
||||
return success;
|
||||
},
|
||||
|
||||
/**
|
||||
* Archive a conversation
|
||||
*/
|
||||
|
|
@ -122,6 +133,48 @@ export const conversationsStore = {
|
|||
return success;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pin a conversation (moves it to top of list)
|
||||
*/
|
||||
async pinConversation(conversationId: string) {
|
||||
const success = await conversationService.pinConversation(conversationId);
|
||||
|
||||
if (success) {
|
||||
conversations = conversations.map((c) =>
|
||||
c.id === conversationId ? { ...c, isPinned: true } : c
|
||||
);
|
||||
// Re-sort: pinned first, then by updatedAt
|
||||
conversations = [...conversations].sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
return success;
|
||||
},
|
||||
|
||||
/**
|
||||
* Unpin a conversation
|
||||
*/
|
||||
async unpinConversation(conversationId: string) {
|
||||
const success = await conversationService.unpinConversation(conversationId);
|
||||
|
||||
if (success) {
|
||||
conversations = conversations.map((c) =>
|
||||
c.id === conversationId ? { ...c, isPinned: false } : c
|
||||
);
|
||||
// Re-sort: pinned first, then by updatedAt
|
||||
conversations = [...conversations].sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
return success;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all data
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { chatService } from '$lib/services/chat';
|
||||
import { documentService } from '$lib/services/document';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import MessageList from '$lib/components/chat/MessageList.svelte';
|
||||
import ChatInput from '$lib/components/chat/ChatInput.svelte';
|
||||
import ChatLayout from '$lib/components/chat/ChatLayout.svelte';
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ export default defineConfig({
|
|||
'@manacore/shared-branding',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-theme-ui',
|
||||
'@manacore/shared-feedback-types',
|
||||
'@manacore/shared-feedback-service',
|
||||
'@manacore/shared-feedback-ui',
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
|
|
@ -24,6 +27,9 @@ export default defineConfig({
|
|||
'@manacore/shared-branding',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-theme-ui',
|
||||
'@manacore/shared-feedback-types',
|
||||
'@manacore/shared-feedback-service',
|
||||
'@manacore/shared-feedback-ui',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface Conversation {
|
|||
documentMode: boolean;
|
||||
title?: string;
|
||||
isArchived: boolean;
|
||||
isPinned: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,9 +31,9 @@
|
|||
* ```
|
||||
*/
|
||||
|
||||
import { Warning, WarningCircle, Info } from '@manacore/shared-icons';
|
||||
import { Warning, WarningCircle, Info, X, Trash, Check } from '@manacore/shared-icons';
|
||||
import Modal from './Modal.svelte';
|
||||
import { Text, Button } from '../atoms';
|
||||
import { Text } from '../atoms';
|
||||
|
||||
type ConfirmationVariant = 'danger' | 'warning' | 'info';
|
||||
|
||||
|
|
@ -72,19 +72,25 @@
|
|||
|
||||
const variantConfig: Record<
|
||||
ConfirmationVariant,
|
||||
{ iconColor: string; buttonVariant: 'danger' | 'primary' }
|
||||
{ iconColor: string; iconBg: string; buttonColor: string; buttonHover: string }
|
||||
> = {
|
||||
danger: {
|
||||
iconColor: 'text-red-500',
|
||||
buttonVariant: 'danger',
|
||||
iconBg: 'bg-red-500/10',
|
||||
buttonColor: 'bg-red-500 text-white',
|
||||
buttonHover: 'hover:bg-red-600 hover:shadow-lg hover:shadow-red-500/25',
|
||||
},
|
||||
warning: {
|
||||
iconColor: 'text-yellow-500',
|
||||
buttonVariant: 'primary',
|
||||
iconBg: 'bg-yellow-500/10',
|
||||
buttonColor: 'bg-yellow-500 text-white',
|
||||
buttonHover: 'hover:bg-yellow-600 hover:shadow-lg hover:shadow-yellow-500/25',
|
||||
},
|
||||
info: {
|
||||
iconColor: 'text-blue-500',
|
||||
buttonVariant: 'primary',
|
||||
iconBg: 'bg-blue-500/10',
|
||||
buttonColor: 'bg-blue-500 text-white',
|
||||
buttonHover: 'hover:bg-blue-600 hover:shadow-lg hover:shadow-blue-500/25',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -97,7 +103,7 @@
|
|||
|
||||
<Modal {visible} {onClose} {title} maxWidth="sm">
|
||||
{#snippet icon()}
|
||||
<div class="p-2 rounded-full bg-menu-hover {config.iconColor}">
|
||||
<div class="p-2.5 rounded-xl {config.iconBg} {config.iconColor}">
|
||||
{#if variant === 'danger'}
|
||||
<Warning size={20} weight="bold" />
|
||||
{:else if variant === 'warning'}
|
||||
|
|
@ -117,13 +123,46 @@
|
|||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button variant="ghost" onclick={onClose} disabled={loading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={config.buttonVariant} onclick={handleConfirm} {loading}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Confirm Button -->
|
||||
<button
|
||||
onclick={handleConfirm}
|
||||
disabled={loading}
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm
|
||||
{config.buttonColor} {config.buttonHover}
|
||||
transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-none"
|
||||
>
|
||||
{#if loading}
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if variant === 'danger'}
|
||||
<Trash size={18} weight="bold" />
|
||||
{:else}
|
||||
<Check size={18} weight="bold" />
|
||||
{/if}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<!-- Cancel Button -->
|
||||
<button
|
||||
onclick={onClose}
|
||||
disabled={loading}
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm
|
||||
bg-black/5 dark:bg-white/10 text-foreground
|
||||
hover:bg-black/10 dark:hover:bg-white/20 hover:shadow-md
|
||||
transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0"
|
||||
>
|
||||
<X size={18} weight="bold" />
|
||||
{cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -65,14 +65,14 @@
|
|||
<div
|
||||
class="relative flex max-h-[90vh] w-full {maxWidthClasses[
|
||||
maxWidth
|
||||
]} flex-col rounded-xl border border-theme bg-menu shadow-xl"
|
||||
]} flex-col rounded-2xl border border-black/10 dark:border-white/20 bg-white/80 dark:bg-white/10 backdrop-blur-xl shadow-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#if showHeader}
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-theme">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<div class="flex items-center justify-between p-6 border-b border-black/10 dark:border-white/10">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
{#if icon}
|
||||
{@render icon()}
|
||||
{/if}
|
||||
|
|
@ -84,10 +84,10 @@
|
|||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-2 rounded-full hover:bg-menu-hover transition-colors"
|
||||
class="p-2 rounded-xl bg-black/5 dark:bg-white/10 hover:bg-black/10 dark:hover:bg-white/20 transition-all duration-200 hover:scale-105"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={20} weight="bold" class="text-theme-muted" />
|
||||
<X size={18} weight="bold" class="text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
|
||||
<!-- Footer (optional) -->
|
||||
{#if footer}
|
||||
<div class="border-t border-theme p-6">
|
||||
<div class="border-t border-black/10 dark:border-white/10 p-6">
|
||||
{@render footer()}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ const APP_CONFIGS = [
|
|||
CREDITS_DAILY_FREE: (env) => env.CREDITS_DAILY_FREE,
|
||||
RATE_LIMIT_TTL: (env) => env.RATE_LIMIT_TTL,
|
||||
RATE_LIMIT_MAX: (env) => env.RATE_LIMIT_MAX,
|
||||
GOOGLE_GENAI_API_KEY: (env) => env.GOOGLE_GENAI_API_KEY,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -86,9 +87,11 @@ const APP_CONFIGS = [
|
|||
vars: {
|
||||
NODE_ENV: () => 'development',
|
||||
PORT: (env) => env.CHAT_BACKEND_PORT || '3002',
|
||||
DEV_BYPASS_AUTH: () => 'true',
|
||||
AZURE_OPENAI_ENDPOINT: (env) => env.AZURE_OPENAI_ENDPOINT,
|
||||
AZURE_OPENAI_API_KEY: (env) => env.AZURE_OPENAI_API_KEY,
|
||||
AZURE_OPENAI_API_VERSION: (env) => env.AZURE_OPENAI_API_VERSION,
|
||||
GOOGLE_GENAI_API_KEY: (env) => env.GOOGLE_GENAI_API_KEY,
|
||||
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
DATABASE_URL: (env) => env.CHAT_DATABASE_URL,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue