feat(web): conversation management — search, rename, delete, archive (#121)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Add search/filter input to sidebar that filters conversations by title - Add rename via double-click or context menu (right-click), confirmed with Enter or blur - Add delete with inline confirmation dialog in context menu - Add archive/unarchive via context menu with collapsible archived section - Add auto-title: generates conversation title from first message content - Show relative timestamps (e.g. "2h ago", "Yesterday") instead of raw dates - Extend Conversation type with `archived` boolean field - Add `archived` column + index to conversations DB schema (migration 0001) - Extend UpdateConversationDto with optional `archived` field Fixes #121 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,12 @@
|
|||||||
import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateConversationDto {
|
export class CreateConversationDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -20,6 +28,10 @@ export class UpdateConversationDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
archived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SendMessageDto {
|
export class SendMessageDto {
|
||||||
|
|||||||
@@ -141,19 +141,72 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleRename = useCallback(async (id: string, title: string) => {
|
||||||
|
const updated = await api<Conversation>(`/api/conversations/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { title },
|
||||||
|
});
|
||||||
|
setConversations((prev) => prev.map((c) => (c.id === id ? updated : c)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
await api<void>(`/api/conversations/${id}`, { method: 'DELETE' });
|
||||||
|
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
if (activeId === id) {
|
||||||
|
setActiveId(null);
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleArchive = useCallback(
|
||||||
|
async (id: string, archived: boolean) => {
|
||||||
|
const updated = await api<Conversation>(`/api/conversations/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { archived },
|
||||||
|
});
|
||||||
|
setConversations((prev) => prev.map((c) => (c.id === id ? updated : c)));
|
||||||
|
// If archiving the active conversation, deselect it
|
||||||
|
if (archived && activeId === id) {
|
||||||
|
setActiveId(null);
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeId],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
async (content: string) => {
|
async (content: string) => {
|
||||||
let convId = activeId;
|
let convId = activeId;
|
||||||
|
|
||||||
// Auto-create conversation if none selected
|
// Auto-create conversation if none selected
|
||||||
if (!convId) {
|
if (!convId) {
|
||||||
|
const autoTitle = content.slice(0, 60);
|
||||||
const conv = await api<Conversation>('/api/conversations', {
|
const conv = await api<Conversation>('/api/conversations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { title: content.slice(0, 50) },
|
body: { title: autoTitle },
|
||||||
});
|
});
|
||||||
setConversations((prev) => [conv, ...prev]);
|
setConversations((prev) => [conv, ...prev]);
|
||||||
setActiveId(conv.id);
|
setActiveId(conv.id);
|
||||||
convId = conv.id;
|
convId = conv.id;
|
||||||
|
} else {
|
||||||
|
// Auto-title: if the active conversation still has the default "New
|
||||||
|
// conversation" title and this is the first message, update the title
|
||||||
|
// from the message content.
|
||||||
|
const activeConv = conversations.find((c) => c.id === convId);
|
||||||
|
if (activeConv?.title === 'New conversation' && messages.length === 0) {
|
||||||
|
const autoTitle = content.slice(0, 60);
|
||||||
|
api<Conversation>(`/api/conversations/${convId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { title: autoTitle },
|
||||||
|
})
|
||||||
|
.then((updated) => {
|
||||||
|
setConversations((prev) => prev.map((c) => (c.id === convId ? updated : c)));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimistic user message in local UI state
|
// Optimistic user message in local UI state
|
||||||
@@ -186,7 +239,7 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
socket.emit('message', { conversationId: convId, content });
|
socket.emit('message', { conversationId: convId, content });
|
||||||
},
|
},
|
||||||
[activeId],
|
[activeId, conversations, messages],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -196,6 +249,9 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
onSelect={setActiveId}
|
onSelect={setActiveId}
|
||||||
onNew={handleNewConversation}
|
onNew={handleNewConversation}
|
||||||
|
onRename={handleRename}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onArchive={handleArchive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import type { Conversation } from '@/lib/types';
|
import type { Conversation } from '@/lib/types';
|
||||||
|
|
||||||
@@ -8,6 +9,32 @@ interface ConversationListProps {
|
|||||||
activeId: string | null;
|
activeId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
|
onRename: (id: string, title: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onArchive: (id: string, archived: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
conversationId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a date as relative time (e.g. "2h ago", "Yesterday"). */
|
||||||
|
function formatRelativeTime(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||||
|
|
||||||
|
if (diffMinutes < 1) return 'Just now';
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays === 1) return 'Yesterday';
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationList({
|
export function ConversationList({
|
||||||
@@ -15,9 +42,151 @@ export function ConversationList({
|
|||||||
activeId,
|
activeId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onNew,
|
onNew,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
onArchive,
|
||||||
}: ConversationListProps): React.ReactElement {
|
}: ConversationListProps): React.ReactElement {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const activeConversations = conversations.filter((c) => !c.archived);
|
||||||
|
const archivedConversations = conversations.filter((c) => c.archived);
|
||||||
|
|
||||||
|
const filteredActive = searchQuery
|
||||||
|
? activeConversations.filter((c) =>
|
||||||
|
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
: activeConversations;
|
||||||
|
|
||||||
|
const filteredArchived = searchQuery
|
||||||
|
? archivedConversations.filter((c) =>
|
||||||
|
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
: archivedConversations;
|
||||||
|
|
||||||
|
const handleContextMenu = useCallback((e: React.MouseEvent, conversationId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setContextMenu({ conversationId, x: e.clientX, y: e.clientY });
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeContextMenu = useCallback(() => {
|
||||||
|
setContextMenu(null);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startRename = useCallback(
|
||||||
|
(id: string, currentTitle: string | null) => {
|
||||||
|
setRenamingId(id);
|
||||||
|
setRenameValue(currentTitle ?? '');
|
||||||
|
closeContextMenu();
|
||||||
|
setTimeout(() => renameInputRef.current?.focus(), 0);
|
||||||
|
},
|
||||||
|
[closeContextMenu],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitRename = useCallback(() => {
|
||||||
|
if (renamingId) {
|
||||||
|
const trimmed = renameValue.trim();
|
||||||
|
onRename(renamingId, trimmed || 'Untitled');
|
||||||
|
}
|
||||||
|
setRenamingId(null);
|
||||||
|
setRenameValue('');
|
||||||
|
}, [renamingId, renameValue, onRename]);
|
||||||
|
|
||||||
|
const cancelRename = useCallback(() => {
|
||||||
|
setRenamingId(null);
|
||||||
|
setRenameValue('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRenameKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') commitRename();
|
||||||
|
if (e.key === 'Escape') cancelRename();
|
||||||
|
},
|
||||||
|
[commitRename, cancelRename],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteClick = useCallback((id: string) => {
|
||||||
|
setDeleteConfirmId(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmDelete = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
onDelete(id);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
closeContextMenu();
|
||||||
|
},
|
||||||
|
[onDelete, closeContextMenu],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleArchiveToggle = useCallback(
|
||||||
|
(id: string, archived: boolean) => {
|
||||||
|
onArchive(id, archived);
|
||||||
|
closeContextMenu();
|
||||||
|
},
|
||||||
|
[onArchive, closeContextMenu],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextConv = contextMenu
|
||||||
|
? conversations.find((c) => c.id === contextMenu.conversationId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function renderConversationItem(conv: Conversation): React.ReactElement {
|
||||||
|
const isActive = activeId === conv.id;
|
||||||
|
const isRenaming = renamingId === conv.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div key={conv.id} className="group relative">
|
||||||
|
{isRenaming ? (
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
ref={renameInputRef}
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onBlur={commitRename}
|
||||||
|
onKeyDown={handleRenameKeyDown}
|
||||||
|
className="w-full rounded border border-blue-500 bg-surface-elevated px-2 py-0.5 text-sm text-text-primary outline-none"
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(conv.id)}
|
||||||
|
onDoubleClick={() => startRename(conv.id, conv.title)}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, conv.id)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 text-left text-sm transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600/20 text-blue-400'
|
||||||
|
: 'text-text-secondary hover:bg-surface-elevated',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block truncate">{conv.title ?? 'Untitled'}</span>
|
||||||
|
<span className="block text-xs text-text-muted">
|
||||||
|
{formatRelativeTime(conv.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop to close context menu */}
|
||||||
|
{contextMenu && (
|
||||||
|
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex h-full w-64 flex-col border-r border-surface-border bg-surface-card">
|
<div className="flex h-full w-64 flex-col border-r border-surface-border bg-surface-card">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-3">
|
<div className="flex items-center justify-between p-3">
|
||||||
<h2 className="text-sm font-medium text-text-secondary">Conversations</h2>
|
<h2 className="text-sm font-medium text-text-secondary">Conversations</h2>
|
||||||
<button
|
<button
|
||||||
@@ -29,29 +198,109 @@ export function ConversationList({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="px-3 pb-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search conversations\u2026"
|
||||||
|
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-xs text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversation list */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{conversations.length === 0 && (
|
{filteredActive.length === 0 && !searchQuery && (
|
||||||
<p className="px-3 py-2 text-xs text-text-muted">No conversations yet</p>
|
<p className="px-3 py-2 text-xs text-text-muted">No conversations yet</p>
|
||||||
)}
|
)}
|
||||||
{conversations.map((conv) => (
|
{filteredActive.length === 0 && searchQuery && (
|
||||||
|
<p className="px-3 py-2 text-xs text-text-muted">
|
||||||
|
No results for “{searchQuery}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{filteredActive.map((conv) => renderConversationItem(conv))}
|
||||||
|
|
||||||
|
{/* Archived section */}
|
||||||
|
{archivedConversations.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
key={conv.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(conv.id)}
|
onClick={() => setShowArchived((v) => !v)}
|
||||||
|
className="flex w-full items-center gap-1 px-3 py-1 text-xs text-text-muted transition-colors hover:text-text-secondary"
|
||||||
|
>
|
||||||
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full px-3 py-2 text-left text-sm transition-colors',
|
'inline-block transition-transform',
|
||||||
activeId === conv.id
|
showArchived ? 'rotate-90' : '',
|
||||||
? 'bg-blue-600/20 text-blue-400'
|
|
||||||
: 'text-text-secondary hover:bg-surface-elevated',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="block truncate">{conv.title ?? 'Untitled'}</span>
|
►
|
||||||
<span className="block text-xs text-text-muted">
|
|
||||||
{new Date(conv.updatedAt).toLocaleDateString()}
|
|
||||||
</span>
|
</span>
|
||||||
|
Archived ({archivedConversations.length})
|
||||||
</button>
|
</button>
|
||||||
))}
|
{showArchived && (
|
||||||
|
<div className="opacity-60">
|
||||||
|
{filteredArchived.map((conv) => renderConversationItem(conv))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Context menu */}
|
||||||
|
{contextMenu && contextConv && (
|
||||||
|
<div
|
||||||
|
className="fixed z-20 min-w-36 rounded-md border border-surface-border bg-surface-card py-1 shadow-lg"
|
||||||
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
|
||||||
|
onClick={() => startRename(contextConv.id, contextConv.title)}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
|
||||||
|
onClick={() => handleArchiveToggle(contextConv.id, !contextConv.archived)}
|
||||||
|
>
|
||||||
|
{contextConv.archived ? 'Unarchive' : 'Archive'}
|
||||||
|
</button>
|
||||||
|
<hr className="my-1 border-surface-border" />
|
||||||
|
{deleteConfirmId === contextConv.id ? (
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<p className="mb-1.5 text-xs text-red-400">Delete this conversation?</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded bg-red-600 px-2 py-0.5 text-xs text-white hover:bg-red-700"
|
||||||
|
onClick={() => confirmDelete(contextConv.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded px-2 py-0.5 text-xs text-text-muted hover:bg-surface-elevated"
|
||||||
|
onClick={() => setDeleteConfirmId(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-surface-elevated"
|
||||||
|
onClick={() => handleDeleteClick(contextConv.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface Conversation {
|
|||||||
userId: string;
|
userId: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
|
archived: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
81
packages/db/drizzle/0001_cynical_ultimatum.sql
Normal file
81
packages/db/drizzle/0001_cynical_ultimatum.sql
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
CREATE TABLE "agent_logs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"session_id" text NOT NULL,
|
||||||
|
"user_id" text,
|
||||||
|
"level" text DEFAULT 'info' NOT NULL,
|
||||||
|
"category" text DEFAULT 'general' NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"metadata" jsonb,
|
||||||
|
"tier" text DEFAULT 'hot' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"summarized_at" timestamp with time zone,
|
||||||
|
"archived_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "insights" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"embedding" vector(1536),
|
||||||
|
"source" text DEFAULT 'agent' NOT NULL,
|
||||||
|
"category" text DEFAULT 'general' NOT NULL,
|
||||||
|
"relevance_score" real DEFAULT 1 NOT NULL,
|
||||||
|
"metadata" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"decayed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "preferences" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"value" jsonb NOT NULL,
|
||||||
|
"category" text DEFAULT 'general' NOT NULL,
|
||||||
|
"source" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "skills" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"version" text,
|
||||||
|
"source" text DEFAULT 'custom' NOT NULL,
|
||||||
|
"config" jsonb,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"installed_by" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "skills_name_unique" UNIQUE("name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "summarization_jobs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"logs_processed" integer DEFAULT 0 NOT NULL,
|
||||||
|
"insights_created" integer DEFAULT 0 NOT NULL,
|
||||||
|
"error_message" text,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"completed_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "conversations" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "agent_logs" ADD CONSTRAINT "agent_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "insights" ADD CONSTRAINT "insights_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "preferences" ADD CONSTRAINT "preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "skills" ADD CONSTRAINT "skills_installed_by_users_id_fk" FOREIGN KEY ("installed_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "agent_logs_session_id_idx" ON "agent_logs" USING btree ("session_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "agent_logs_user_id_idx" ON "agent_logs" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "agent_logs_tier_idx" ON "agent_logs" USING btree ("tier");--> statement-breakpoint
|
||||||
|
CREATE INDEX "agent_logs_created_at_idx" ON "agent_logs" USING btree ("created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "insights_user_id_idx" ON "insights" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "insights_category_idx" ON "insights" USING btree ("category");--> statement-breakpoint
|
||||||
|
CREATE INDEX "insights_relevance_idx" ON "insights" USING btree ("relevance_score");--> statement-breakpoint
|
||||||
|
CREATE INDEX "preferences_user_id_idx" ON "preferences" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "preferences_user_key_idx" ON "preferences" USING btree ("user_id","key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "skills_enabled_idx" ON "skills" USING btree ("enabled");--> statement-breakpoint
|
||||||
|
CREATE INDEX "summarization_jobs_status_idx" ON "summarization_jobs" USING btree ("status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "conversations_archived_idx" ON "conversations" USING btree ("archived");
|
||||||
1802
packages/db/drizzle/meta/0001_snapshot.json
Normal file
1802
packages/db/drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -199,12 +199,14 @@ export const conversations = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
||||||
|
archived: boolean('archived').notNull().default(false),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
index('conversations_user_id_idx').on(t.userId),
|
index('conversations_user_id_idx').on(t.userId),
|
||||||
index('conversations_project_id_idx').on(t.projectId),
|
index('conversations_project_id_idx').on(t.projectId),
|
||||||
|
index('conversations_archived_idx').on(t.archived),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user