initial commit

This commit is contained in:
Zoe
2026-01-11 05:04:29 -06:00
commit 0877cc10bd
65 changed files with 5009 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
VERIDIAN_DB_NAME=veridian
POSTGRES_PASSWORD=password
NUXT_AUTH_SECRET="YOUR-SUPER-SECURE-SECRET" # openssl rand -base64 32
DATABASE_URL=postgresql://postgres:password@localhost:5432/veridian
+244
View File
@@ -0,0 +1,244 @@
# AGENTS.md
This file contains guidelines and commands for agentic coding agents working in
the Veridian repository.
## Project Overview
Veridian is a Nuxt 4 application built with TypeScript, using PostgreSQL with
Drizzle ORM, better-auth for authentication, and UnoCSS for styling. The app is
an agnetic chat platform that is meant to provide a well rounded experience for
interacting with LLMs as well as providing a way to manage agents and provide
helpful tools like RAG (retrieval augmented generation) and web scraping/search.
You should be connected to the Nuxt docs MCP server, if you need to reference
the documentation or are unsure on how to implement something, consult the
docs.
## Core Commands
As an agentic agent, never run the development server yourself.
Instead, once you have completed your task, end generation and inform me that
you are done and request a review of your changes. You are allowed to use
database commands.
### Database Commands
- `tsx db/migrate.ts` - Run database migrations
- `bun x @better-auth/cli@latest generate --output db/auth/auth.schema.ts` -
Generate authentication schema based on `lib/auth.ts`
- `bun x drizzle-kit generate` - Generate migration files
- `bun x drizzle-kit push` - Push schema changes to database
- `bun x drizzle-kit studio` - Open Drizzle Studio for database inspection
## Tech Stack & Dependencies
- **Framework**: Nuxt 4 (SSR enabled)
- **Language**: TypeScript with strict configuration
- **Database**: PostgreSQL with Drizzle ORM
- **Auth**: better-auth with email/password and social providers
- **Styling**: UnoCSS with presetMini
- **Icons**: @nuxt/icon with Iconify, use Myna UI Icons
## Code Style Guidelines
**Follow the LLVM golden rule: If you are extending, enhancing, or bug fixing
already implemented code, use the style that is already being used so that
the source is uniform and easy to follow.**
- **Always end lines with a semicolon regardless of surrounding code**
- Use 4 spaces for indentation
- Use consistent naming conventions for variables, functions, and constants
- Always use strict equality checks (`===` and `!==`) instead of loose equality
checks (`==` and `!=`)
- Prefer using explicity equality checks over truthy/falsy checks
e.g., prefer `if (value !== null)` instead of `if (value)`
- Always check existing code patterns before implementing new features
- Follow existing component and composable structures
- Use proper TypeScript types for all function parameters and returns
- Implement proper cleanup in composables (e.g., useClickOutside)
- Prefer reactive state management over direct DOM manipulation
- Keep components focused and single-purpose
- Use semantic HTML elements where appropriate
### General Structure
- Use Nuxt's app directory structure (`app/`, `server/`, `lib/`)
- Auth configuration in `lib/auth.ts`
- Server API routes go in `server/api/`
- Composables go in `app/composables/`
- Database schema in `db/schema.ts`
- Database migrations in `db/migrations/`
### TypeScript Guidelines
- Use strict TypeScript with no implicit any
- Export types and interfaces explicitly
- Use `computed()` and `ref()` from Vue 3 reactivity system
- Type API responses and database models
### Vue Components
- **Never use self-closing tags, excluding images, br, hr, and input**
- Use `<script setup lang="ts">` syntax
- Strongly prefer composition API over options API
- Prefer composables for shared state management
- Maintain consistent 4-space indentation
- Use PascalCase for component file names
### CSS/Styling
- Use UnoCSS utility classes exclusively
- Never use margin for spacing, always prefer flexbox or grid
- Prefer inline utility classes over custom CSS
- Use semantic color variables: `--color-base`, `--color-neutral`, `--color-accent`, `--color-subtle`
If you need to add new color variables, they are located in `app/assets/css/base.css`,
along with a general CSS reset.
- Apply consistent spacing and layout patterns
- Use responsive prefixes only when necessary
### Imports
- Order imports: Vue/Nuxt imports first, then local files, then dependencies
- `~/` is an alias for `app/` and `~~/` is an alias for the root of the project
- Avoid wildcard imports
- Keep imports sorted alphabetically within groups
### Naming Conventions
- **Page**: camelCase
- **Composables**: camelCase
- **Components**: PascalCase when referenced in templates
- **Functions/Variables**: camelCase
- **Constants**: UPPER_SNAKE_CASE for environment variables only
- **Database tables**: snake_case
- **API routes**: kebab-case path segments
### Database Patterns
- Use Drizzle ORM with PostgreSQL
- Export all schemas from `db/schema.ts`
- Use environment variables for database credentials
- `db/auth/auth.schema.ts` is a generated file, do not touch it. If you need to
change the authentication schema, edit `lib/auth.ts` and run the generation
script.
### Error Handling
- Use proper TypeScript error types
- Implement try-catch blocks for database operations
- Provide user-friendly error messages in API responses
- Log errors appropriately without exposing sensitive data
### Performance Guidelines
- Leverage Nuxt's auto-imports and code splitting
- Use `useState()` for shared state across components
- Implement proper loading states with async operations
- Optimize database queries and use indexes where needed
## Authentication Implementation
The app uses better-auth with:
- Email/password authentication (configurable via env vars)
- Drizzle adapter for PostgreSQL
- Session management through cookies
- Auth plugins in `app/plugins/` for client/server initialization
- Global auth middleware in `app/middleware/auth.global.ts`
All users must be signed in to view pages aside from authentication pages.
(located in `app/pages/auth/`), you do not need to check if session or user
objects are null or undefined, use the non-null assertion operator (`!`) where
necessary. In API routes, you can use `protectRoute` to guarantee that the
user must be authenticated.
## File Organization
```
|-- app/ # Application code
│ |-- components/ # Vue components
│ |-- composables/ # Reuseable composition functions
│ |-- layouts/ # Layout components
│ |-- middleware/ # Route middleware
│ |-- pages/ # File-based routing
│ \-- plugins/ # Vue/Nuxt plugins
|-- server/ # Server-side code
│ \-- api/ # API routes
|-- db/ # Database related files
│ |-- schema.ts # Database schema
│ |-- migrate.ts # Migration runner
│ \-- migrations/ # Migration files
\-- lib/ # Shared utilities
```
## Testing
Currently no test framework is configured, and tests are not currently a
requirement.
## Environment Variables
Key environment variables:
- `DATABASE_URL` - PostgreSQL connection string
- `BETTER_AUTH_SECRET` - Authentication secret
- `DISABLE_LOCAL_AUTH` - Disable email/password auth
- `DISABLE_SIGNUP` - Disable user registration
## Common Patterns
### Composables Pattern
```typescript
export const useFeature = () => {
const state = useState<Type>('feature:state', () => defaultValue)
const computed = computed(() => /* logic */)
const actions = {
// methods
}
return { state, computed, ...actions }
}
```
### API Route Pattern
API routes are defined in `server/api/` and are exported as a single object.
File names contain information about the route, e.g. `user.get.ts` specifies a
GET route to `/user`. This applies for all HTTP methods.
```typescript
// an example GET route to /user
export default defineEventHandler(async (event) => {
try {
// implementation
return { success: true, data: result };
} catch (error) {
throw createError({
statusCode: 500,
statusMessage: "Error description",
});
}
});
```
```typescript
import { users } from "~~/db/schema";
// an example POST route to /user
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const { email, name, password } = body;
const [user] = await db
.insert(users)
.values({ email, name, password })
.returning();
return user;
});
```
+25
View File
@@ -0,0 +1,25 @@
# use the official Bun image
# see all versions at https://hub.docker.com/r/oven/bun/tags
FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock* ./
# use ignore-scripts to avoid building node modules like better-sqlite3
RUN bun install --frozen-lockfile --ignore-scripts
# Copy the entire project
COPY . .
RUN bun --bun run build
# copy production dependencies and source code into final image
FROM oven/bun:1 AS production
WORKDIR /app
# Only `.output` folder is needed from the build stage
COPY --from=build /app/.output /app
# run the app
EXPOSE 3000/tcp
ENTRYPOINT [ "bun", "--bun", "run", "/app/server/index.mjs" ]
+75
View File
@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+3
View File
@@ -0,0 +1,3 @@
# Todo
- [ ] Standardize on one icon set rather than 4 (lmao)
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import '~/assets/css/base.css';
const { accent, neutral, hinting } = useTheme()
// by default, disable hinting
if (Number.isNaN(Number(hinting.value))) hinting.value = '0';
useHead({
htmlAttrs: {
style: `--color-accent: var(--accent-${accent.value}, var(--accent-violet)); --color-accent-hover: var(--accent-${accent.value}-hover, var(--accent-violet-hover)); --neutral-dark: var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark)); --neutral-light: var(--neutral-${neutral.value}-light, var(--neutral-zinc-light)); --accent-hinting: ${hinting.value}%; --base-hinting: calc(100% - var(--accent-hinting));`
}
})
if (import.meta.client) {
watchEffect(() => {
document.documentElement.style.setProperty('--color-accent', `var(--accent-${accent.value}, var(--accent-violet))`)
document.documentElement.style.setProperty('--color-accent-hover', `var(--accent-${accent.value}-hover, var(--accent-violet-hover))`)
document.documentElement.style.setProperty('--neutral-dark', `var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark))`)
document.documentElement.style.setProperty('--neutral-light', `var(--neutral-${neutral.value}-light, var(--neutral-zinc-light))`)
document.documentElement.style.setProperty('--accent-hinting', `${hinting.value}%`)
})
}
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<NuxtRouteAnnouncer />
</template>
<style>
#__nuxt {
height: 100%;
display: flex;
}
</style>
+243
View File
@@ -0,0 +1,243 @@
@layer reset, base, components, utilities;
@layer reset {
/*
Josh's Custom CSS Reset slightly Modified
https://www.joshwcomeau.com/css/custom-css-reset/
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
border: 0 solid;
line-height: calc(1em + 0.5rem);
margin: 0;
padding: 0;
}
body {
-webkit-font-smoothing: antialiased;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
color: inherit;
font: inherit;
}
input:focus {
outline: none;
}
button {
cursor: pointer;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
word-break: break-word;
}
p {
text-wrap: pretty;
hyphens: auto;
}
h1,
h2,
h3,
h4,
h5,
h6 {
text-wrap: balance;
}
a {
color: #fff;
}
a:hover {
text-decoration: none;
}
}
@layer base {
:root {
--sidebar-width: 400px;
--spacing: 0.25rem;
--color-accent-text: #000;
/* Accent Color Options */
/* violet (current default) */
--accent-violet: #A010FF;
--accent-violet-hover: #8D00E8;
/* volcanic heat - orange-red */
--accent-volcano: #ef4410;
--accent-volcano-hover: #dc2626;
/* neon lime */
--accent-lime: #77fb6b;
--accent-lime-hover: #5ee04f;
/* electric sky */
--accent-sky: #38d3fa;
--accent-sky-hover: #2bc7ee;
/* crushed coral - warm pink-orange */
--accent-coral: #FF6B6B;
--accent-coral-hover: #E55555;
/* deep emerald - rich green */
--accent-emerald: #10B981;
--accent-emerald-hover: #059669;
/* golden hour - warm amber */
--accent-amber: #F59E0B;
--accent-amber-hover: #D97706;
/* rose quartz - soft pink-red */
--accent-rose: #F43F5E;
--accent-rose-hover: #E11D48;
/* arctic cyan - crisp blue */
--accent-cyan: #06B6D4;
--accent-cyan-hover: #0891B2;
/* midnight indigo - deep blue-purple */
--accent-indigo: #6366F1;
--accent-indigo-hover: #4F46E5;
/* sunset magenta - vibrant pink-purple */
--accent-magenta: #EC4899;
--accent-magenta-hover: #DB2777;
/* Neutral Base Colors - different gray variations */
/* zinc - deep gray-blue */
--neutral-zinc-dark: #171619;
--neutral-zinc-light: #fbfafd;
/* charcoal - deep black-gray */
--neutral-charcoal-dark: #0a0a0a;
--neutral-charcoal-light: #fefdff;
}
:root.dark {
--color-base: color-mix(in srgb, var(--base-hinting) #040305, var(--accent-hinting) var(--color-accent));
--color-highlight-low: color-mix(in srgb, var(--base-hinting) rgba(255, 255, 255, 0.05), var(--accent-hinting) var(--color-accent));
--color-highlight: color-mix(in srgb, var(--base-hinting) rgba(255, 255, 255, 0.10), var(--accent-hinting) var(--color-accent));
--color-highlight-high: color-mix(in srgb, var(--base-hinting) rgba(255, 255, 255, 0.18), var(--accent-hinting) var(--color-accent));
--color-text: color-mix(in srgb, var(--base-hinting) #fafafa, var(--accent-hinting) var(--color-accent));
--color-subtle: color-mix(in srgb, var(--base-hinting) #a1a1aa, var(--accent-hinting) var(--color-accent));
--color-input: color-mix(in srgb, var(--base-hinting) #222124, var(--accent-hinting) var(--color-accent));
--color-neutral: color-mix(in srgb, var(--base-hinting) var(--neutral-dark), var(--accent-hinting) var(--color-accent));
}
:root.light {
--color-base: color-mix(in srgb, var(--base-hinting) #f2f0f0, var(--accent-hinting) var(--color-accent));
--color-highlight-low: color-mix(in srgb, var(--base-hinting) rgba(0, 0, 0, 0.06), var(--accent-hinting) var(--color-accent));
--color-highlight: color-mix(in srgb, var(--base-hinting) rgba(0, 0, 0, 0.08), var(--accent-hinting) var(--color-accent));
--color-highlight-high: color-mix(in srgb, var(--base-hinting) rgba(0, 0, 0, 0.12), var(--accent-hinting) var(--color-accent));
--color-text: color-mix(in srgb, var(--base-hinting) #1a1a1a, var(--accent-hinting) var(--color-accent));
--color-subtle: color-mix(in srgb, var(--base-hinting) #6b7280, var(--accent-hinting) var(--color-accent));
--color-input: color-mix(in srgb, var(--base-hinting) #ffffff, var(--accent-hinting) var(--color-accent));
--color-neutral: color-mix(in srgb, var(--base-hinting) var(--neutral-light), var(--accent-hinting) var(--color-accent));
}
html,
body {
padding: 0;
margin: 0;
font-family: system-ui, sans-serif;
background-color: var(--color-base);
color: var(--color-text);
}
html {
height: 100vh;
overflow: hidden;
}
body {
padding: 0.5rem;
height: 100%;
}
button {
color: inherit;
display: flex;
}
button.accent {
display: flex;
justify-content: center;
cursor: pointer;
background-color: var(--color-accent);
color: var(--color-accent-text);
border-radius: calc(var(--spacing) * 1.5);
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 1);
transition: background-color 150ms cubic-bezier(0.45, 0, 0.55, 1);
}
button.accent:hover {
background-color: var(--color-accent-hover);
}
}
@layer utilities {
.object-cover {
object-fit: cover;
}
.backdrop-blur-md {
backdrop-filter: blur(12px);
}
}
@layer components {
.cursor {
display: inline-block;
width: 1rem;
height: 0.9em;
background-color: currentColor;
animation: blink 1s step-end infinite;
vertical-align: middle;
margin-left: 0.125rem;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
}
+82
View File
@@ -0,0 +1,82 @@
<script setup lang="ts">
const inputRef = ref<HTMLTextAreaElement | null>(null);
const inputValue = ref('');
const isFocused = ref(false);
const emit = defineEmits<{
submit: [value: string];
}>();
const props = defineProps({
loading: {
type: Boolean,
default: false
}
})
const handleSubmit = () => {
if (inputValue.value.trim()) {
emit('submit', inputValue.value);
inputValue.value = '';
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
if (event.shiftKey) return;
if (event.ctrlKey || event.metaKey) {
if (inputRef.value === null) return;
// inset new line
let cursorPosition = inputRef.value.selectionStart;
if (cursorPosition === undefined) return;
if (cursorPosition !== inputRef.value.selectionEnd) return;
inputValue.value = inputValue.value.slice(0, cursorPosition) + "\n" + inputValue.value.slice(cursorPosition);
// inputRef.value.selectionStart = cursorPosition + 1;
return;
}
event.preventDefault();
handleSubmit();
}
};
let hasCommandKey = false;
if (import.meta.server) {
let headers = useRequestHeaders();
hasCommandKey = headers['user-agent']?.includes('Mac OS') ?? false;
} else {
hasCommandKey = navigator.userAgent.includes('Mac OS');
}
</script>
<template>
<div class="w-full max-w-full mx-auto">
<div class="relative flex flex-col gap-3 p-3 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--color-input)]
border-[var(--color-highlight)] focus-within:border-[var(--color-highlight-high)]">
<!-- Text Input -->
<div class="flex-1 min-w-0">
<textarea v-model="inputValue" ref="inputRef"
:placeholder="`Start something great. Press ${hasCommandKey ? '⌘ + Enter' : 'ctrl + Enter'} to insert a new line.`"
@focus="isFocused = true" @blur="isFocused = false" @keydown="handleKeyDown"
class="w-full bg-transparent text-[var(--color-text)] placeholder-white/50 resize-none outline-none text-[15px] leading-6 min-h-[24px] max-h-32 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
rows="2"></textarea>
</div>
<!-- Toolbar -->
<div class="flex">
<div class="flex-1"></div>
<!-- Send Button -->
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() || loading"
:class="[
'p-2 rounded-xl transition-all duration-200 flex items-center justify-center',
inputValue.trim()
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)]'
: 'bg-[var(--color-highlight)] text-[var(--color-highlight-high)] cursor-not-allowed'
]">
<Icon v-if="loading" name="svg-spinners:ring-resize" class="w-4 h-4" />
<Icon v-else name="mynaui:send-solid" class="w-4 h-4" />
</button>
</div>
</div>
</div>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
const { hasTasks } = useTasks()
const { open } = useSettings()
</script>
<template>
<ClientOnly>
<Teleport :to="open ? '#settings-loader-target' : '#primary-loader-target'">
<Icon name="svg-spinners:ring-resize"
:class="['text-[var(--color-accent)] text-4', hasTasks ? 'opacity-100' : 'opacity-0']" />
</Teleport>
</ClientOnly>
</template>
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
const { addTask, completeTask } = useTasks()
const { open, currentPage, setPage, close } = useSettings()
const pages = ['page1', 'page2', 'page3']
const simulateTask = () => {
const handle = addTask()
setTimeout(() => {
completeTask(handle)
}, 2000)
}
</script>
<template>
<div v-if="open" class="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/80"
@click.self="close">
<div class="w-[70vw] max-w-6xl h-[70vh] bg-[var(--color-base)] rounded-xl shadow-2xl border border-[var(--color-highlight)]
overflow-hidden flex max-h-[90vh] p-2">
<!-- Sidebar Nav -->
<nav class="w-64 flex flex-col gap-2">
<div class="flex items-center justify-between pb-4">
<div class="flex items-center gap-2 px-2">
<Icon name="mynaui:cog-four" class="w-7 h-7 text-[var(--color-subtle)] mt-1" />
<h1 class="font-semibold text-center">Settings</h1>
</div>
</div>
<div v-for="page in pages" :key="page" :class="[
'select-none cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors w-full text-left',
currentPage === page ? 'bg-[var(--color-highlight)]' : 'hover:bg-[var(--color-highlight)]/10'
]" @click="setPage(page)">
{{ page.charAt(0).toUpperCase() + page.slice(1) }}
</div>
</nav>
<!-- Content -->
<main class="flex-1 flex flex-col overflow-hidden">
<header class="flex items-center justify-between pl-2 pb-2">
<h2 class="text-lg font-semibold">{{ currentPage.charAt(0).toUpperCase() + currentPage.slice(1) }}
</h2>
<div class="flex items-center gap-3">
<div id="settings-loader-target"></div>
<button @click="close"
class="p-1.5 rounded-lg text-[var(--color-text)] bg-transparent hover:bg-[var(--color-highlight)]/10 transition-colors">
<Icon name="mynaui:x-solid" class="w-5 h-5" />
</button>
</div>
</header>
<div
class="flex-1 p-6 ml-1 mt-1 bg-[var(--color-neutral)] overflow-y-auto border rounded-lg border-[var(--color-highlight)]">
<div v-if="currentPage === 'page1'">
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 1 content. Try adding a task to
the queue
below.
</p>
<div class="flex gap-2">
<button class="accent" @click="simulateTask">
Simulate Task (2s)
</button>
</div>
</div>
<div v-else-if="currentPage === 'page2'">
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 2 content with some details.
</p>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="p-4 bg-[var(--color-highlight-low)] rounded-lg text-sm">Item 1</div>
<div class="p-4 bg-[var(--color-highlight-low)] rounded-lg text-sm">Item 2</div>
</div>
</div>
<div v-else-if="currentPage === 'page3'">
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 3 content.</p>
<button class="accent" @click="simulateTask">Run Task</button>
</div>
</div>
</main>
</div>
</div>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
const { user, signOut } = useAuth()
const { toggle: toggleSettings } = useSettings()
const profileRef = ref<HTMLElement | null>(null)
const profileOpen = ref(false)
const hovering = defineModel<boolean>({ required: true })
const toggleProfile = () => {
profileOpen.value = !profileOpen.value
}
const handleLogout = async () => {
await signOut()
profileOpen.value = false
}
useClickOutside(profileRef, () => {
profileOpen.value = false
})
</script>
<template>
<header class="flex items-center justify-between overflow-hidden">
<div role="button" aria-label="open user dropdown" ref="profileRef"
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-highlight)] cursor-pointer transition-colors max-w-full"
@click="toggleProfile">
<div
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-highlight-high)]']">
<img v-if="user?.image" :src="user.image" class="w-full h-full object-cover" />
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--color-subtle)]" />
</div>
<span
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">{{
user!.name
}}</span>
<div :class="['flex-shrink-0 w-4 h-4 text-[var(--color-subtle)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left',
hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-x-0 scale-y-90'
]">
<Icon class="text-4" name="mynaui:chevron-down" />
</div>
</div>
<!-- Profile Dropdown -->
<div v-show="profileOpen"
class="absolute top-full left-0 right-0 z-50 mt-1.5 bg-[var(--color-neutral)] border border-[var(--color-highlight)] rounded-xl p-2 w-full gap-1 flex flex-col text-[var(--color-text)]">
<SidenavItem @click="toggleSettings(); profileOpen = false" name="Settings" icon="mynaui:cog-four" />
<SidenavItem @click="handleLogout" name="Log out" icon="mynaui:logout" />
</div>
</header>
</template>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
const { user } = useAuth()
const { agents, activeAgent } = await useAgents()
const homeButtonRef = ref<HTMLElement | null>(null)
const agentDropdownRef = ref<HTMLElement | null>(null)
const agentDropdownOpen = ref(false)
const hovering = defineModel<boolean>({ required: true })
const initialized = ref(false)
onMounted(() => {
if (hovering.value) {
const width = homeButtonRef.value!.scrollWidth
homeButtonRef.value!.style.width = `calc(${width}px + 0.5rem)`
}
watch(hovering, (value) => {
if (!initialized.value) {
initialized.value = true
}
if (value) {
const width = homeButtonRef.value!.scrollWidth
homeButtonRef.value!.style.width = `calc(${width}px + 0.5rem)`
} else {
homeButtonRef.value!.style.width = '0'
}
})
});
useClickOutside(agentDropdownRef, () => {
agentDropdownOpen.value = false
})
</script>
<template>
<header class="flex items-center rounded-lg overflow-hidden">
<div ref="homeButtonRef" style="width: 0;"
:class="['flex flex-shrink-0 items-center overflow-hidden', initialized ? 'transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]' : '', hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']">
<NuxtLink to="/"
class="flex hover:bg-[var(--color-highlight)] rounded-lg decoration-none transition-inherit text-[var(--color-subtle)] p-1.5">
<Icon name="mynaui:chevron-left" class="w-4.5 h-4.5" />
</NuxtLink>
</div>
<div class="flex overflow-hidden gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-highlight)] rounded-lg"
ref="agentDropdownRef" @click="agentDropdownOpen = !agentDropdownOpen">
<div
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-highlight-high)]']">
<img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl" class="w-full h-full object-cover" />
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
</div>
<span class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">
{{ activeAgent?.name }}
</span>
<div class="w-4 h-4 text-[var(--color-subtle)]">
<Icon
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
name="mynaui:chevron-up-down" />
</div>
</div>
<!-- Agent Dropdown -->
<div v-show="agentDropdownOpen" class="absolute top-full left-1/10 z-50 mt-1.5 bg-[var(--color-neutral)] border border-[var(--color-highlight)]
rounded-xl p-2 w-8/10">
<div class="flex flex-col gap-1.5 max-h-[calc(2.25rem*4+0.375rem*3)] overflow-y-auto">
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id" :class="['decoration-none whitespace-nowrap', activeAgent?.id === agent.id ? 'text-[var(--color-text)]' :
'text-[var(--color-subtle)]']" @click="agentDropdownOpen = false">
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon"
:active="activeAgent?.id === agent.id" />
</NuxtLink>
</div>
</div>
</header>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
const props = defineProps<{
name: string,
icon: string,
active?: boolean
}>()
</script>
<template>
<div role="button"
:class="['flex items-center gap-2 px-1 rounded-lg hover:bg-[var(--color-highlight)] transition-colors cursor-pointer h-9 overflow-hidden', props.active ? 'bg-[var(--color-highlight)]' : '']">
<div class="h-7 w-7 flex items-center justify-center">
<Icon class="text-4.5" :name="props.icon" />
</div>
<span class="text-sm font-medium overflow-hidden text-ellipsis">{{ props.name }}</span>
</div>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
const route = useRoute()
const routeParts = computed(() => {
return route.path.replace('/agent/', '').split('/')
});
if (routeParts.value.length < 1) navigateTo('/')
if (!routeParts.value[0]!.match(/^agents_[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/)) navigateTo('/')
const pageInfo = computed(() => {
// `/agent/agent_[uuid]`
if (routeParts.value.length === 1) {
return 'new-conversation'
}
// `/agent/agent_[uuid]/profile`
if (routeParts.value.length === 2 && routeParts.value[1]! === 'profile') {
return 'agent-profile'
}
})
</script>
<template>
<nav class="flex flex-col gap-1">
<div class="mt-2">
<NuxtLink :to="`/agent/${routeParts[0]}/profile`" class="decoration-none text-[var(--color-subtle)]">
<SidenavItem name="Agent Info" icon="mynaui:info-square" :active="pageInfo === 'agent-profile'" />
</NuxtLink>
</div>
</nav>
</template>
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
const { agents, createAgent } = await useAgents()
const route = useRoute()
const agentsListRef = ref<HTMLElement | null>(null)
const agentsOpen = ref(true)
const agentsListHeight = ref('auto')
const agentsListOpacity = ref(1)
const agentsListScale = ref(1)
const creatingAgent = ref(false)
function easeInOutQuad(x: number): number {
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
}
const toggleAgentsList = () => {
if (!agentsListRef.value) return;
let animationLength = 200;
let animationStart: number | null = null;
let startHeight: number;
let startOpacity = agentsListOpacity.value;
let startScale = agentsListScale.value;
if (agentsListHeight.value === 'auto') {
startHeight = agentsListRef.value.clientHeight;
} else {
startHeight = Number(agentsListHeight.value.replace('px', ''));
}
let targetHeight = agentsOpen.value ? 0 : agentsListRef.value.scrollHeight;
let targetOpacity = agentsOpen.value ? 0 : 1;
let targetScale = agentsOpen.value ? 0.95 : 1;
agentsOpen.value = !agentsOpen.value;
const animate = (timestamp: number) => {
if (!animationStart) animationStart = timestamp;
const elapsed = timestamp - animationStart;
const progress = Math.min(elapsed / animationLength, 1);
const currentHeight = startHeight + (targetHeight - startHeight) * easeInOutQuad(progress);
const currentOpacity = startOpacity + (targetOpacity - startOpacity) * easeInOutQuad(progress);
const currentScale = startScale + (targetScale - startScale) * easeInOutQuad(progress);
agentsListOpacity.value = currentOpacity;
agentsListScale.value = currentScale;
agentsListHeight.value = `${currentHeight}px`;
if (progress < 1) {
requestAnimationFrame(animate);
} else {
if (agentsOpen.value) {
agentsListHeight.value = 'auto';
}
}
};
requestAnimationFrame(animate);
}
const newAgent = async () => {
creatingAgent.value = true;
const agent = await createAgent();
creatingAgent.value = false;
navigateTo(`/agent/${agent!.id}`);
}
</script>
<template>
<nav class="flex flex-col gap-1">
<SidenavItem name="Search" icon="mynaui:search" />
<NuxtLink to="/" class="decoration-none text-[var(--color-text)]">
<SidenavItem class="bg-[var(--color-highlight)]" name="Home" icon="mynaui:home" />
</NuxtLink>
<!-- Agents Section -->
<div class="relative group">
<div class="flex items-center justify-between px-2 h-9 rounded-lg hover:bg-[var(--color-highlight)] transition-colors cursor-pointer"
@click="toggleAgentsList()">
<div class="flex items-center gap-0.5">
<span class="text-sm">Agents</span>
<Icon name="mynaui:chevron-right-solid"
:class="['transition-transform duration-200 ease-in-out transform-origin-center', agentsOpen ? 'rotate-90' : '']" />
</div>
<button aria-label="create new agent"
class="opacity-0 group-hover:opacity-100 p-1 rounded-md transition-all bg-transparent hover:bg-[var(--color-highlight)] text-[var(--color-subtle)] active:text-[var(--color-text)]"
@click.stop="newAgent">
<Icon v-if="!creatingAgent" name="mynaui:plus" class="w-4 h-4" />
<Icon v-else name="svg-spinners:ring-resize" class="w-4 h-4" />
</button>
</div>
<div ref="agentsListRef"
:style="{ height: agentsListHeight, opacity: agentsListOpacity, transform: `scale(${agentsListScale})` }"
class="mt-1 gap-1 flex flex-col overflow-hidden transform-origin-center-top">
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id"
class="decoration-none text-[var(--color-subtle)]">
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon" />
</NuxtLink>
</div>
</div>
</nav>
</template>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar()
const route = useRoute()
const isResizing = ref(false)
const startX = ref(0)
const initialWidth = ref(0)
const closeSidenavRef = ref<HTMLElement | null>(null)
const onResizeStart = (event: MouseEvent) => {
isResizing.value = true
startX.value = event.clientX
initialWidth.value = sidebarWidth.value
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}
const onResizeMove = (event: MouseEvent) => {
if (!isResizing.value) return
if (resizeAnimationFrame) return;
resizeAnimationFrame = requestAnimationFrame(() => {
const deltaX = event.clientX - startX.value
const newWidth = initialWidth.value + deltaX
resize(newWidth)
resizeAnimationFrame = null
})
}
const onResizeEnd = () => {
if (!isResizing.value) return
isResizing.value = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
saveWidth()
}
let resizeAnimationFrame: number | null = null
onMounted(() => {
document.addEventListener('mousemove', onResizeMove)
document.addEventListener('mouseup', onResizeEnd)
watch(hovering, (value) => {
if (value) {
const width = closeSidenavRef.value!.scrollWidth
closeSidenavRef.value!.style.width = `${width}px`
} else {
closeSidenavRef.value!.style.width = '0'
}
})
})
onUnmounted(() => {
document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', onResizeEnd)
})
const hovering = ref(false)
const navKind = computed(() => {
if (route.path === '/') return 'home'
if (route.path.startsWith('/agent/')) return 'agent'
return null
})
</script>
<template>
<div class="relative">
<aside :class="[
'h-full max-w-fit bg-[var(--color-base)] overflow-hidden will-change-width text-[var(--color-subtle)] select-none',
open ? 'w-full mr-2' : 'w-0 mr-0',
isResizing ? '' : 'transition-[width,margin] duration-250 ease-[cubic-bezier(0,0.55,0.45,1)]'
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="hovering = true"
@mouseleave="hovering = false">
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
<div class="flex flex-col">
<!-- Header -->
<div class="relative flex flex-row gap-2 justify-between items-center pb-1.5">
<SidenavHeader v-if="navKind === 'home'" v-model="hovering" />
<SidenavHeaderAgent v-else-if="navKind === 'agent'" v-model="hovering" />
<div class="flex items-center justify-end text-[var(--color-subtle)] gap-0.5">
<div ref="closeSidenavRef" style="width: 0;"
:class="['flex-shrink-0 overflow-hidden rounded-lg transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center-right']">
<button aria-label="close sidebar" @click="closeSidebar" :class="[
'text-5 p-1.5 hover:bg-[var(--color-highlight)] bg-transparent transition-inherit',
]">
<Icon name="mynaui:panel-left-close"
:class="['transition-inherit', hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" />
</button>
</div>
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
<NuxtLink aria-label="Start a new topic" :to="`/agent/${route.params.id}`" :class="[
'flex text-5 p-1.5 hover:bg-[var(--color-highlight)] bg-transparent text-inherit',
]">
<Icon name="mynaui:book-plus" />
</NuxtLink>
</div>
</div>
</div>
<!-- Main Menu -->
<div class="max-h-full overflow-auto">
<SidenavNavHome v-if="navKind === 'home'" />
<SidenavNavAgent v-else-if="navKind === 'agent'" />
</div>
</div>
<!-- Theme Switcher -->
<div class="flex justify-end">
<ThemeSwitcher />
</div>
</div>
</aside>
<!-- resize handle -->
<div @mousedown="onResizeStart" class="absolute top-0 right-0 bottom-0 p-1 cursor-ew-resize">
</div>
</div>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
type Theme = 'light' | 'dark' | 'system'
const colorMode = useColorMode()
const isOpen = ref(false)
const buttonRef = ref<HTMLElement | null>(null)
const themeOptions: { value: Theme; label: string; icon: string }[] = [
{ value: 'light', label: 'Light', icon: 'mynaui:sun' },
{ value: 'dark', label: 'Dark', icon: 'mynaui:moon' },
{ value: 'system', label: 'System', icon: 'mynaui:desktop' }
]
const currentOption = computed(() =>
themeOptions.find(option => option.value === colorMode.preference) || themeOptions[2]
)
const selectTheme = (newTheme: Theme) => {
colorMode.preference = newTheme
isOpen.value = false
}
const toggleDropdown = () => {
isOpen.value = !isOpen.value
}
useClickOutside(buttonRef, () => {
isOpen.value = false
})
</script>
<template>
<div ref="buttonRef" class="relative">
<button aria-label="Open theme switcher" @click="toggleDropdown"
class="p-2 flex items-center justify-center rounded-lg hover:bg-[var(--color-highlight)] transition-colors group"
:class="isOpen ? 'bg-[var(--color-highlight)]' : 'bg-transparent'">
<Icon :name="currentOption!.icon"
class="text-5 text-[var(--color-subtle)] group-hover:text-[var(--color-text)] transition-colors" />
</button>
<div v-if="isOpen"
class="flex flex-col gap-1 absolute bottom-full mb-2 right-0 bg-[var(--color-neutral)] border border-[var(--color-highlight)] rounded-lg p-1 min-w-[120px] shadow-lg">
<button v-for="option in themeOptions" :key="option.value" @click="selectTheme(option.value)"
class="w-full flex items-center justify-left gap-2 px-3 py-2 rounded-md hover:bg-[var(--color-highlight)] transition-colors"
:class="colorMode.preference === option.value ? 'bg-[var(--color-highlight)] text-[var(--color-text)]' :
'bg-transparent text-[var(--color-subtle)]'">
<Icon :name="option.icon" class="w-4 h-4" />
<span class="text-sm">{{ option.label }}</span>
</button>
</div>
</div>
</template>
+68
View File
@@ -0,0 +1,68 @@
import { createAuthClient } from 'better-auth/client'
import type {
InferSessionFromClient,
InferUserFromClient,
BetterAuthClientOptions,
} from 'better-auth/client'
import type { RouteLocationRaw } from 'vue-router'
export function useAuth() {
const url = useRequestURL()
const headers = import.meta.server ? useRequestHeaders() : undefined
const authClient = createAuthClient({
baseURL: url.origin,
fetchOptions: {
headers,
},
})
const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null)
const user = useState<InferUserFromClient<BetterAuthClientOptions> | null>('auth:user', () => null)
const pending = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false)
const fetchSession = async () => {
if (pending.value) {
console.log('already fetching session')
return
}
pending.value = true
const { data } = await authClient.getSession({
fetchOptions: {
headers,
},
})
session.value = data?.session || null
user.value = data?.user || null
pending.value = false
return data
}
if (import.meta.client) {
authClient.$store.listen('$sessionSignal', async (signal) => {
if (!signal) return
await fetchSession()
})
}
return {
session,
user,
pending,
loggedIn: computed(() => !!session.value),
signIn: authClient.signIn,
signUp: authClient.signUp,
async signOut() {
const res = await authClient.signOut()
session.value = null
user.value = null
await navigateTo('/auth/login')
return res
},
fetchSession,
authClient,
}
}
+109
View File
@@ -0,0 +1,109 @@
import { ref } from 'vue'
import type { Agent } from '~~/types'
export const useAgents = async () => {
const { addTask, completeTask } = useTasks()
const fetchingAgents = ref(false);
const agents: Ref<Agent[] | null> = useState('agents', () => null);
const activeAgent = computed(() => {
if (agents.value === null) return;
const routeId = useRoute().params.id;
if (routeId === undefined) return;
const agent = agents.value.find(agent => agent.id === routeId);
if (agent === undefined) return;
return agent;
});
const refreshAgents = async () => {
if (fetchingAgents.value) return;
fetchingAgents.value = true;
const { data, error } = await useFetch('/api/agents');
if (error.value) throw error;
agents.value = data.value!;
fetchingAgents.value = false;
}
if (agents.value === null) await refreshAgents();
const createAgent = async () => {
const taskHandle = addTask()
try {
const agent = await $fetch('/api/agents', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'New Agent',
systemPrompt: 'You are a helpful assistant.'
})
});
if (agent === undefined) throw new Error('Failed to create agent');
if (agents.value === null) agents.value = [];
agents.value.push(agent);
completeTask(taskHandle)
return agent;
} catch (error) {
console.error(error);
completeTask(taskHandle)
return;
}
}
let debounceTimeout: NodeJS.Timeout | null = null;
const updateAgent = async (id: string, data: Partial<Agent>) => {
if (agents.value === null) agents.value = [];
// update the local state always
agents.value = agents.value.map(agent => {
if (agent.id === id) return { ...agent, ...data };
return agent;
});
// falling edge debounce (when the user stops typing)
if (debounceTimeout !== null) clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(async () => {
const taskHandle = addTask()
try {
const agent = await $fetch(`/api/agents/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (agent === undefined) throw new Error('Failed to update agent');
const index = agents.value!.findIndex(agent => agent.id === id);
if (index === -1) throw new Error('Agent not found');
if (agents.value![index] === null) throw new Error('Agent not found');
agents.value![index] = agent;
completeTask(taskHandle)
return agent;
} catch (error) {
console.error(error);
completeTask(taskHandle)
return;
}
}, 500);
}
return { agents, createAgent, activeAgent, updateAgent };
}
+18
View File
@@ -0,0 +1,18 @@
import { onMounted, onUnmounted } from 'vue'
import type { Ref } from 'vue'
export const useClickOutside = (target: Ref<HTMLElement | null>, callback: () => void) => {
const onClick = (event: MouseEvent) => {
if (target.value && !target.value.contains(event.target as Node)) {
callback()
}
}
onMounted(() => {
document.addEventListener('click', onClick)
})
onUnmounted(() => {
document.removeEventListener('click', onClick)
})
}
+23
View File
@@ -0,0 +1,23 @@
export const useKeyboardShortcuts = () => {
const { toggle: toggleSidebar } = useSidebar();
const handleKeyDown = (event: KeyboardEvent) => {
// Ctrl+[ to collapse sidebar
if (event.ctrlKey && event.key === '[') {
event.preventDefault();
toggleSidebar();
}
};
onMounted(() => {
document.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown);
});
return {
handleKeyDown
};
};
+13
View File
@@ -0,0 +1,13 @@
export const useSettings = () => {
const open = useState<boolean>('settings:open', () => false)
const currentPage = useState<string>('settings:currentPage', () => 'page1')
const toggle = () => { open.value = !open.value }
const setPage = (page: string) => { currentPage.value = page }
const close = () => {
open.value = false
currentPage.value = 'page1'
}
return { open, currentPage, toggle, setPage, close }
}
+31
View File
@@ -0,0 +1,31 @@
export const useSidebar = () => {
const open = useState<boolean>('sidebar:open', () => true)
const sidebarWidth = useState<number>('sidebar:width', () => {
return Number(useCookie('sidebar:width', { default: () => "226", maxAge: 60 * 60 * 24 * 30 }).value)
})
// I still want the state to update when the cookie change, like it does for the theme cookies
// but I dont want to use the cookie value as the state value because then when we change the
// cookie value, we thrash the hell out of the cookie and gobble CPU cycles
watch(useCookie('sidebar:width'), (value) => {
console.log(value)
sidebarWidth.value = Number(value)
})
const toggle = () => { open.value = !open.value }
const close = () => { open.value = false }
const openSidebar = () => { open.value = true }
const resize = (width: number) => {
const minWidth = 200
const maxWidth = 400
const clampedWidth = Math.max(minWidth, Math.min(maxWidth, width))
sidebarWidth.value = clampedWidth
}
const saveWidth = () => {
useCookie('sidebar:width').value = sidebarWidth.value.toString()
}
return { open, toggle, close, openSidebar, sidebarWidth: readonly(sidebarWidth), resize, saveWidth }
}
+21
View File
@@ -0,0 +1,21 @@
type TaskHandle = number
export const useTasks = () => {
const taskQueue = useState<Set<number>>('spinner:taskQueue', () => new Set())
const taskId = useState<number>('spinner:taskId', () => 1)
const hasTasks = computed(() => taskQueue.value.size > 0)
const addTask = (): TaskHandle => {
const handle = taskId.value++
taskQueue.value = new Set(taskQueue.value).add(handle)
return handle
}
const completeTask = (handle: TaskHandle) => {
const newSet = new Set(taskQueue.value)
newSet.delete(handle)
taskQueue.value = newSet
}
return { hasTasks, addTask, completeTask }
}
+12
View File
@@ -0,0 +1,12 @@
export const useTheme = () => {
const accent = useCookie('accent', { default: () => 'violet', maxAge: 60 * 60 * 24 * 365 })
const neutral = useCookie('neutral', { default: () => 'zinc', maxAge: 60 * 60 * 24 * 365 })
// disable hinting by default
const hinting = useCookie('hinting', { default: () => '0', maxAge: 60 * 60 * 24 * 365 })
return {
accent,
neutral,
hinting,
}
}
+49
View File
@@ -0,0 +1,49 @@
import type { Topic } from "~~/types";
export const useTopics = async () => {
const fetchingTopics = ref(false);
const topics: Ref<Topic[] | null> = useState('topics', () => null);
const activeTopic = computed(() => {
if (topics.value === null) return;
const routeId = useRoute().query.topicId;
if (routeId === undefined) return;
const topic = topics.value.find(topic => topic.id === routeId);
if (topic === undefined) return;
return topic;
});
const refreshTopics = async () => {
if (fetchingTopics.value) return;
fetchingTopics.value = true;
const { data, error } = await useFetch('/api/topics');
if (error.value) throw error;
topics.value = data.value!;
fetchingTopics.value = false;
}
if (topics.value === null) await refreshTopics();
const createTopic = async (name: string, agentId: string) => {
const res = await fetch('/api/topics', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name, agentId })
})
if (!res.ok) {
throw new Error('Failed to create topic')
}
return res.json()
}
return { createTopic, activeTopic, topics }
}
+31
View File
@@ -0,0 +1,31 @@
<template>
<div class="h-full w-full grid place-items-center">
<div
class="max-w-xs p-4 bg-[var(--color-neutral)] border border-solid border-[var(--color-highlight)] w-full rounded-lg">
<slot />
</div>
</div>
</template>
<style>
input {
color: var(--color-text);
padding-inline: calc(var(--spacing) * 4);
padding-block: calc(var(--spacing) * 2);
border-radius: calc(var(--spacing) * 1.5);
width: 100%;
background-color: var(--color-input);
transition-property: color, border, background-color;
transition-duration: 300ms;
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
overflow: hidden;
&:focus-visible {
outline: none;
}
}
</style>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
const { open: sidebarOpen, openSidebar } = useSidebar()
useKeyboardShortcuts()
</script>
<template>
<Sidenav />
<main
class="bg-[var(--color-neutral)] flex flex-col h-full w-full border border-solid border-[var(--color-highlight)] rounded-lg overflow-hidden">
<div class="h-14 flex items-center justify-between px-4">
<div class="flex items-center gap-2">
<button v-if="!sidebarOpen" @click="openSidebar"
class="p-2 rounded-lg text-[var(--color-text)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors">
<Icon class="text-5" name="mynaui:panel-left-open" />
</button>
</div>
<div class="flex items-center gap-2">
<div id="primary-loader-target"></div>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<slot />
</div>
</main>
<SettingsDialog />
<LoadingSpinner />
</template>
+13
View File
@@ -0,0 +1,13 @@
export default defineNuxtRouteMiddleware(async (to) => {
const { loggedIn } = useAuth()
// if authenticated, and on a signin/signup page, redirect to home page
if (to.path.toLowerCase().includes('/auth/') && loggedIn.value) {
return navigateTo('/')
}
// If not authenticated, and not on a signin/signup page, redirect to login page
if (!loggedIn.value && !to.path.toLowerCase().includes('/auth/')) {
return navigateTo('/auth/login')
}
})
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
import type { Message } from '~~/types'
const { createTopic, activeTopic } = await useTopics()
const { activeAgent } = await useAgents()
const loading = ref(false)
const messages = useState<Message[]>('messages', () => [])
const generatingMessage = ref('')
if (activeTopic.value !== undefined) {
const topicData = await useFetch(`/api/topics/${activeTopic.value.id}`)
if (topicData.error.value) throw topicData.error
messages.value = topicData.data.value!.messages
console.log(messages.value)
}
const handleSubmit = async (message: string) => {
loading.value = true
let topic;
if (activeTopic.value) {
topic = activeTopic.value
} else {
topic = await createTopic('New Topic', activeAgent.value!.id)
}
console.log(topic.id)
const generation = await $fetch(`/api/chat/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
topicId: topic.id,
messages: [{
type: 'user',
message
}]
})
})
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
method: 'get',
responseType: 'stream',
})
// Create a new ReadableStream from the response with TextDecoderStream to get the data as text
const reader = response.pipeThrough(new TextDecoderStream()).getReader()
generatingMessage.value = ''
// Read the data from the stream and update the UI
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
const { type, data } = JSON.parse(value)
if (type === 'token') {
generatingMessage.value += data
continue
}
if (type === 'complete') {
if (generatingMessage.value !== data) {
generatingMessage.value = data
}
break
}
}
loading.value = false
}
</script>
<template>
<div class="flex h-full w-full pb-4 justify-center">
<div class="max-w-4xl h-full w-full flex flex-col">
<!-- chat pane -->
<div class="flex h-full flex-col gap-6">
<p v-if="activeTopic !== undefined" v-for="message in messages" :key="message.id">
{{ message.content }}
</p>
<p v-else>No messages yet</p>
<p v-if="generatingMessage" class="text-sm text-gray-500">{{ generatingMessage }}</p>
</div>
<ChatInput @submit="handleSubmit" :loading="loading" />
</div>
</div>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
const { activeAgent: agent, updateAgent } = await useAgents();
const route = useRoute()
if (agent.value === undefined) navigateTo('/');
const handleInput = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.value.length === 0) {
return;
}
updateAgent(agent.value!.id, { name: target.value });
};
</script>
<template>
<div class="flex flex-col gap-4 px-14">
<div class="flex items-center gap-4">
<div>
<img v-if="agent?.imageUrl" :src="agent.imageUrl" class="w-16 h-16 rounded-full object-cover" />
<Icon v-else name="mynaui:check-hexagon" class="text-16" />
</div>
<input @input="handleInput" placeholder="Agent Name..."
class="placeholder:text-[var(--color-highlight)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-highlight-high)] text-12 p-0"
type="text" :value="agent?.name" />
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--color-subtle)]">Agent ID:</span>
<span class="text-sm font-semibold">{{ route.params.id }}</span>
</div>
</div>
</template>
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
definePageMeta({
layout: 'auth',
})
const { signIn, session, fetchSession, authClient } = useAuth();
if (session.value !== null) {
navigateTo("/");
}
const form = reactive({
name: "",
email: "",
password: "",
confirmPassword: "",
});
const loading = ref(false);
let emailInputEl = ref<HTMLInputElement | null>(null);
let passwordInputEl = ref<HTMLInputElement | null>(null);
onMounted(() => {
emailInputEl.value!.addEventListener("input", () => {
emailInputEl.value!.setCustomValidity("");
});
passwordInputEl.value!.addEventListener("input", () => {
passwordInputEl.value!.setCustomValidity("");
});
});
const submit = async () => {
const inputs = [emailInputEl, passwordInputEl];
for (const input of inputs) {
if (input.value?.validity.valid === false) {
input.value!.reportValidity();
return;
}
}
loading.value = true;
await signIn.email({
email: form.email,
password: form.password,
}, {
onSuccess: async () => {
await fetchSession();
navigateTo("/")
},
onError: (ctx) => {
const error = ctx.error.code as keyof typeof authClient.$ERROR_CODES;
// TODO: i18n
// ref https://www.better-auth.com/docs/concepts/client#error-codes
switch (error) {
case "INVALID_PASSWORD":
passwordInputEl.value!.setCustomValidity(ctx.error.message);
passwordInputEl.value!.reportValidity();
break;
case "ACCOUNT_NOT_FOUND":
case "USER_NOT_FOUND":
case "USER_EMAIL_NOT_FOUND":
emailInputEl.value!.setCustomValidity(ctx.error.message);
emailInputEl.value!.reportValidity();
break;
default:
console.log(ctx.error);
alert(`Something went wrong. ${ctx.error.message}`);
break;
}
}
});
loading.value = false;
}
</script>
<template>
<h1 class="font-bold text-center mb-4">Login</h1>
<form class="flex flex-col [&>input]:mb-2" @submit.prevent="submit">
<label for="email">Email</label>
<input required pattern=".{1,}@.{1,}\..{2,3}" ref="emailInputEl" type="email" autocomplete="email" id="email"
v-model="form.email" />
<label for="password">Password</label>
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
autocomplete="current-password" id="password" v-model="form.password" />
<button class="accent" type="submit">
<iconify-icons v-if="loading" width="24" icon="svg-spinners:90-ring-with-bg" />
<span v-else>Login</span>
</button>
</form>
<p class="text-center">Dont have an account? <a href="/auth/register">Register</a></p>
</template>
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
definePageMeta({
layout: 'auth',
})
const { signUp, session, fetchSession, authClient } = useAuth();
if (session.value !== null) {
navigateTo("/");
}
if (import.meta.server) {
if (process.env.DISABLE_SIGNUP?.toLowerCase() === "true" || process.env.DISABLE_SIGNUP === "1") {
navigateTo("/auth/login")
}
}
const form = reactive({
name: "",
email: "",
password: "",
confirmPassword: "",
});
const loading = ref(false);
let nameInputEl = ref<HTMLInputElement | null>(null);
let emailInputEl = ref<HTMLInputElement | null>(null);
let passwordInputEl = ref<HTMLInputElement | null>(null);
let confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
onMounted(() => {
nameInputEl.value!.addEventListener("input", () => {
nameInputEl.value!.setCustomValidity("");
});
emailInputEl.value!.addEventListener("input", () => {
emailInputEl.value!.setCustomValidity("");
});
passwordInputEl.value!.addEventListener("input", () => {
passwordInputEl.value!.setCustomValidity("");
});
confirmPasswordInputEl.value!.addEventListener("input", () => {
if (passwordInputEl.value!.value !== confirmPasswordInputEl.value!.value) {
confirmPasswordInputEl.value!.setCustomValidity("Passwords do not match");
confirmPasswordInputEl.value!.reportValidity();
} else {
confirmPasswordInputEl.value!.setCustomValidity("");
}
});
});
const submit = async () => {
const inputs = [nameInputEl, emailInputEl, passwordInputEl, confirmPasswordInputEl];
for (const input of inputs) {
if (input.value?.validity.valid === false) {
input.value!.reportValidity();
return;
}
}
if (form.password !== form.confirmPassword) {
alert("Passwords do not match")
return;
}
loading.value = true;
await signUp.email({
name: form.name,
email: form.email,
password: form.password,
}, {
onSuccess: async () => {
await fetchSession();
navigateTo("/")
},
onError: (ctx) => {
const error = ctx.error.code as keyof typeof authClient.$ERROR_CODES;
// TODO: i18n
// ref https://www.better-auth.com/docs/concepts/client#error-codes
switch (error) {
case "USER_ALREADY_EXISTS":
case "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL":
emailInputEl.value!.setCustomValidity("Account with this email already exists");
emailInputEl.value!.reportValidity();
break;
case "INVALID_EMAIL":
emailInputEl.value!.setCustomValidity(ctx.error.message);
emailInputEl.value!.reportValidity();
break;
case "INVALID_PASSWORD":
passwordInputEl.value!.setCustomValidity(ctx.error.message);
passwordInputEl.value!.reportValidity();
break;
default:
console.log(ctx.error);
alert("Something went wrong")
break;
}
}
});
loading.value = false;
}
</script>
<template>
<h1 class="font-bold text-center mb-4">Register</h1>
<form class="flex flex-col [&>input]:mb-2" @submit.prevent="submit">
<label for="name">Name</label>
<input required ref="nameInputEl" type="text" id="name" v-model="form.name" />
<label for="email">Email</label>
<input required pattern=".{1,}@.{1,}\..{2,3}" ref="emailInputEl" type="email" autocomplete="email" id="email"
v-model="form.email" />
<label for="password">Password</label>
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password" autocomplete="new-password"
id="password" v-model="form.password" />
<label for="confirmPassword">Confirm Password</label>
<input required minlength="8" maxlength="128" ref="confirmPasswordInputEl" type="password"
autocomplete="new-password" id="confirmPassword" v-model="form.confirmPassword" />
<button class="accent" type="submit">
<Icon v-if="loading" class="text-6" name="svg-spinners:90-ring-with-bg" />
<span v-else>Register</span>
</button>
</form>
<p class="text-center">Already have an account? <a href="/auth/login">Login</a></p>
</template>
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
const { user } = useAuth();
if (user.value === null) {
navigateTo("/auth/login");
}
const taglines = {
morning: [
"crush your goals before breakfast",
"turn your productivity to 11",
"start your day like a boss",
"make it happen before noon",
"rise and grind, repeat",
"morning MVP, all day",
"fuel your fire early",
"own the first half of your day"
],
afternoon: [
"afternoon focus session",
"making afternoon moves",
"steady progress continues",
"keeping the flow going",
"afternoon productivity boost",
"momentum is building",
"making it happen today",
"afternoon hustle mode"
],
evening: [
"finish strong today",
"end on a high note",
"wrap it up like a pro",
"leave it all on the field",
"tomorrow's success starts tonight",
"last call for wins",
"close it out like a champion",
"seal the deal before bed"
]
}
const animatedText = ref("");
const currentTaglineIndex = ref(0);
const isDeleting = ref(false);
const typeWriter = (time: 'morning' | 'afternoon' | 'evening') => {
const currentTaglines = taglines[time];
const currentText = currentTaglines[currentTaglineIndex.value]!;
if (!isDeleting.value) {
animatedText.value = currentText.substring(0, animatedText.value.length + 1);
if (animatedText.value === currentText) {
isDeleting.value = true;
setTimeout(() => typeWriter(time), 2000);
return;
}
} else {
animatedText.value = currentText.substring(0, animatedText.value.length - 1);
if (animatedText.value === "") {
isDeleting.value = false;
currentTaglineIndex.value = (currentTaglineIndex.value + 1) % currentTaglines.length;
}
}
const baseDeleteSpeed = 80;
const speed = isDeleting.value
? baseDeleteSpeed * (0.5 + 0.25 * (animatedText.value.length / currentText.length))
: 100;
setTimeout(() => typeWriter(time), speed);
};
const handleChatSubmit = (message: string) => {
console.log('Message submitted:', message);
// TODO: Implement chat functionality
};
onMounted(() => {
let time: 'morning' | 'afternoon' | 'evening' = "morning";
const now = new Date();
const hours = now.getHours();
if (hours < 12) {
time = "morning";
} else if (hours < 22) {
time = "afternoon";
} else {
time = "evening";
}
// randomly select a tagline
currentTaglineIndex.value = Math.floor(Math.random() * taglines[time].length);
typeWriter(time);
});
</script>
<template>
<div class="flex flex-col items-center pt-12 px-4 h-full gap-12">
<h1 class="text-center">{{ animatedText }}<span class="cursor">&nbsp;</span></h1>
<ChatInput @submit="handleChatSubmit" />
</div>
</template>
+10
View File
@@ -0,0 +1,10 @@
export default defineNuxtPlugin(async (nuxtApp) => {
if (!nuxtApp.payload.serverRendered) {
await useAuth().fetchSession()
} else if (Boolean(nuxtApp.payload.prerenderedAt) || Boolean(nuxtApp.payload.isCached)) {
// To avoid hydration mismatch
nuxtApp.hook('app:mounted', async () => {
await useAuth().fetchSession()
})
}
})
+11
View File
@@ -0,0 +1,11 @@
export default defineNuxtPlugin({
name: 'better-auth-fetch-plugin',
enforce: 'pre',
async setup(nuxtApp) {
// Flag if request is cached
nuxtApp.payload.isCached = Boolean(useRequestEvent()?.context.cache)
if (nuxtApp.payload.serverRendered && !nuxtApp.payload.prerenderedAt && !nuxtApp.payload.isCached) {
await useAuth().fetchSession()
}
}
})
+1988
View File
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
+7
View File
@@ -0,0 +1,7 @@
import { migrate } from "drizzle-orm/node-postgres/migrator";
import config from "~~/config/drizzle.config";
import { useDrizzle } from "~~/server/utils/drizzle";
const db = useDrizzle();
await migrate(db, { migrationsFolder: config.out! });
+43
View File
@@ -0,0 +1,43 @@
import { integer, pgTable, text, boolean, timestamp } from "drizzle-orm/pg-core";
import { uuidv7 } from "uuidv7";
import { user } from "./auth/auth.schema";
export * from "./auth/auth.schema";
export const agents = pgTable("agents", {
id: text("id").primaryKey().$defaultFn(() => 'agents_' + uuidv7()),
userId: text("user_id").references(() => user.id).notNull(),
name: text("name").notNull(),
systemPrompt: text("system_prompt").notNull(),
imageUrl: text("image_url")
});
export const topics = pgTable("topics", {
id: text("id").primaryKey().$defaultFn(() => 'topics_' + uuidv7()),
userId: text("user_id").references(() => user.id).notNull(),
agentId: text("agent_id").references(() => agents.id).notNull(),
name: text("name").notNull()
});
export const generations = pgTable("generations", {
id: text("id").primaryKey(),
userId: text("user_id").references(() => user.id).notNull(),
topicId: text("topic_id").references(() => topics.id).notNull(),
// nullable, because we insert into generations when we start a new generation,
// and once the generation is complete we insert the complete generation into messages
// but its currently needed to be able to fetch an entire message from its generation
// if necessary
messageId: text("message_id").references(() => messages.id),
createdAt: timestamp("created_at").notNull().defaultNow()
});
export const messages = pgTable("messages", {
id: text("id").primaryKey().$defaultFn(() => 'messages_' + uuidv7()),
userId: text("user_id").references(() => user.id).notNull(),
topicId: text("topic_id").references(() => topics.id).notNull(),
isUser: boolean("is_user").notNull(),
content: text("content").notNull(),
model: text("model"),
tokensGenerated: integer("tokens_generated"),
tokensUsedThinking: integer("tokens_used_thinking"),
createdAt: timestamp("created_at").notNull().defaultNow()
});
+30
View File
@@ -0,0 +1,30 @@
name: veridian-development
services:
postgresql:
image: pgvector/pgvector:pg17
container_name: veridian-postgres
ports:
- "5432:5432"
volumes:
- "data:/var/lib/postgresql/data"
environment:
- "POSTGRES_DB=${VERIDIAN_DB_NAME}"
- "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
env_file:
- .env
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: always
networks:
- veridian-network
volumes:
data:
driver: local
networks:
veridian-network:
driver: bridge
+11
View File
@@ -0,0 +1,11 @@
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './db/schema.ts',
out: './db/migrations',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
+19
View File
@@ -0,0 +1,19 @@
import { betterAuth } from "better-auth/minimal";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import * as schema from "~~/db/schema";
import { useDrizzle } from "~~/server/utils/drizzle";
export const auth = betterAuth({
database: drizzleAdapter(useDrizzle(), {
provider: "pg",
schema: {
...schema
}
}),
emailAndPassword: {
// if DISABLE_LOCAL_AUTH is set to "1" or "true" then local auth is disabled
enabled: process.env.DISABLE_LOCAL_AUTH?.toLowerCase() !== "true" && process.env.DISABLE_LOCAL_AUTH !== "1",
disableSignUp: process.env.DISABLE_SIGNUP?.toLowerCase() === "true" || process.env.DISABLE_SIGNUP === "1",
},
})
+40
View File
@@ -0,0 +1,40 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
ssr: true,
app: {
head: {
title: 'Veridian',
htmlAttrs: {
lang: 'en'
},
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: 'A chat-based AI assistant for your LLMs.' },
{ name: 'keywords', content: 'LLM, AI, Chat, Assistant, Agent, LLMs, OpenAI, GPT, GPT-3, GPT-4, Claude, ChatGPT, Whisper, Bard, Bing, Anthropic, DeepAI, Dolly, StableLM, Vicuna, Llama, Alpaca, ChatGLM, MOSS, MPT, Codex, Codex2, Codex 3, Codex 4, Falcon, Flan-T5, T5, Llama2, LLaMA, LLaMA2, StableLM, OpenAssistant, OpenChat' }
]
}
},
vite: {
server: {
allowedHosts: true,
}
},
modules: ['@nuxt/hints', '@nuxt/icon', '@unocss/nuxt', '@nuxtjs/color-mode'],
features: {
inlineStyles: true,
},
colorMode: {
preference: 'system',
fallback: 'dark',
storage: 'cookie',
},
devtools: { enabled: true },
compatibilityDate: '2025-07-15',
})
+33
View File
@@ -0,0 +1,33 @@
{
"name": "veridian",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@iconify-json/mynaui": "^1.2.17",
"@nuxt/hints": "1.0.0-alpha.5",
"@nuxt/icon": "2.2.0",
"@nuxtjs/color-mode": "4.0.0",
"better-auth": "^1.4.10",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"nuxt": "^4.2.2",
"pg": "^8.16.3",
"uuidv7": "^1.1.0",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/pg": "^8.16.0",
"@unocss/nuxt": "^66.5.12",
"drizzle-kit": "^0.31.8",
"tsx": "^4.21.0",
"unocss": "^66.5.12"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
+29
View File
@@ -0,0 +1,29 @@
import { eq } from "drizzle-orm";
import { agents } from "~~/db/schema";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const db = useDrizzle();
const { id } = event.context.params!;
const [row] = await db.select().from(agents).where(eq(agents.id, id));
if (row === undefined || row.userId !== event.context.user.id) {
throw createError({ statusCode: 404, statusMessage: 'Agent not found' });
}
const { name, systemPrompt, imageUrl } = await readBody(event);
const updateObject: Partial<typeof row> = {};
if (name !== undefined) updateObject.name = name;
if (systemPrompt !== undefined) updateObject.systemPrompt = systemPrompt;
if (imageUrl !== undefined) updateObject.imageUrl = imageUrl;
if (Object.keys(updateObject).length === 0) {
throw createError({ statusCode: 400, statusMessage: 'No update data provided' });
}
const [agent] = await db.update(agents).set(updateObject).where(eq(agents.id, id)).returning();
return agent;
});
+11
View File
@@ -0,0 +1,11 @@
import { agents } from "~~/db/schema";
import { protectRoute } from "~~/server/utils/auth";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const db = useDrizzle();
const rows = await db.select().from(agents);
return rows;
});
+17
View File
@@ -0,0 +1,17 @@
import { agents } from "~~/db/schema";
import { protectRoute } from "~~/server/utils/auth";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const db = useDrizzle();
const { name, systemPrompt, imageUrl } = await readBody(event);
const userId = event.context.user.id;
if (!name || !systemPrompt) {
throw createError({ statusCode: 400, statusMessage: 'Missing required fields' });
}
const [inserted] = await db.insert(agents).values({ name, userId, systemPrompt, imageUrl }).returning();
return inserted;
});
+5
View File
@@ -0,0 +1,5 @@
import { auth } from "~~/lib/auth";
export default defineEventHandler((event) => {
return auth.handler(toWebRequest(event));
});
+33
View File
@@ -0,0 +1,33 @@
import { protectRoute } from '~~/server/utils/auth';
import { registerPendingGeneration } from '~~/server/utils/generation';
import type { GenerateRequestBody } from '~~/server/types/chat';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const body = await readBody(event) as GenerateRequestBody;
const { topicId, messages } = body;
if (!topicId || !messages) {
throw createError({
statusCode: 400,
statusMessage: 'Missing required fields: topicId and messages'
});
}
if (messages.length === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Messages array cannot be empty'
});
}
const generationId = `gen_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
registerPendingGeneration(event.context.user.id, generationId, topicId, messages);
return {
generationId,
status: 'pending'
};
});
+52
View File
@@ -0,0 +1,52 @@
import { protectRoute } from '~~/server/utils/auth';
import { getPendingGeneration, getActiveGeneration, isGenerationActive } from '~~/server/utils/generation';
import type { GenerationStatus } from '~~/server/types/chat';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const generationId = getRouterParam(event, 'id');
if (!generationId) {
throw createError({
statusCode: 400,
statusMessage: 'Missing generation ID'
});
}
const pendingGeneration = getPendingGeneration(generationId);
const isActive = isGenerationActive(generationId);
const activeGeneration = getActiveGeneration(generationId);
if (!pendingGeneration && !activeGeneration) {
throw createError({
statusCode: 404,
statusMessage: 'Generation not found'
});
}
if (pendingGeneration) {
const status: GenerationStatus = {
generationId,
status: 'pending',
topicId: pendingGeneration.topicId
};
return status;
}
if (isActive && activeGeneration) {
const status: GenerationStatus = {
generationId,
status: 'active',
content: activeGeneration.content,
topicId: activeGeneration.topicId
};
return status;
}
const status: GenerationStatus = {
generationId,
status: 'completed'
};
return status;
});
+67
View File
@@ -0,0 +1,67 @@
import { protectRoute } from '~~/server/utils/auth';
import { getPendingGeneration, startGeneration, addClientToGeneration, removeClientFromGeneration, sendToClient } from '~~/server/utils/generation';
import { eventHandler, setHeader, setResponseStatus } from 'h3';
import { generations, messages } from '~~/db/schema';
import { eq } from 'drizzle-orm';
export default eventHandler(async (event) => {
await protectRoute(event);
const generationId = getRouterParam(event, 'id');
if (!generationId) {
throw createError({
statusCode: 400,
statusMessage: 'Missing generation ID'
});
}
const pendingGeneration = getPendingGeneration(generationId);
if (pendingGeneration && pendingGeneration.expired) {
throw createError({
statusCode: 410,
statusMessage: 'Generation expired - no client connected within 60 seconds'
});
}
setHeader(event, 'Content-Type', 'text/event-stream');
setHeader(event, 'Cache-Control', 'no-cache');
setHeader(event, 'Connection', 'keep-alive');
setHeader(event, 'X-Accel-Buffering', 'no');
setResponseStatus(event, 200);
const shouldStartGeneration = pendingGeneration;
try {
const stream = new ReadableStream({
async start(controller) {
addClientToGeneration(generationId, controller);
if (shouldStartGeneration) {
startGeneration(generationId, controller);
} else {
const generation = await useDrizzle().select().from(generations).where(eq(generations.id, getRouterParam(event, 'id')!))
if (!generation) throw createError({ statusCode: 404, statusMessage: 'Generation not found' });
const message = await useDrizzle().select().from(messages).where(eq(messages.id, generation[0].messageId!))
if (!message) throw createError({ statusCode: 404, statusMessage: 'Message not found' });
sendToClient(controller, {
type: 'complete',
data: message[0].content
});
controller.close();
}
event.node.req.on('close', () => {
removeClientFromGeneration(generationId, controller);
});
}
});
return sendStream(event, stream);
} catch (error) {
// assume it failed because the generation is complete so try to send the message
}
});
+23
View File
@@ -0,0 +1,23 @@
import { and, desc, eq } from "drizzle-orm";
import { messages, topics } from "~~/db/schema";
import type { Message, Topic } from '~~/types'
export default defineEventHandler(async (event) => {
await protectRoute(event);
const db = useDrizzle();
const rows = await db.select().from(topics).where(and(eq(topics.userId, event.context.user.id), eq(topics.id, getRouterParam(event, 'id')!)));
if (rows.length === 0) {
throw createError({
statusCode: 404,
statusMessage: 'Topic not found'
});
}
const topic = rows[0] as Topic & { messages: Message[] };
topic.messages = await db.select().from(messages).where(eq(messages.topicId, topic.id)).orderBy(desc(messages.createdAt));
return topic;
});
+11
View File
@@ -0,0 +1,11 @@
import { topics } from "~~/db/schema";
import { protectRoute } from "~~/server/utils/auth";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const db = useDrizzle();
const rows = await db.select().from(topics);
return rows;
});
+16
View File
@@ -0,0 +1,16 @@
import { topics } from "~~/db/schema";
import { protectRoute } from "~~/server/utils/auth";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const db = useDrizzle();
const body = await readBody(event);
const { agentId, name } = body;
if (!agentId || !name) {
throw createError({ statusCode: 400, statusMessage: 'Missing required fields' });
}
const [inserted] = await db.insert(topics).values({ userId: event.context.user.id, agentId, name }).returning();
return inserted;
});
+14
View File
@@ -0,0 +1,14 @@
import { consola } from 'consola';
export default defineNitroPlugin(async () => {
consola.info('Connecting to database...');
try {
const db = useDrizzle();
await db.execute('SELECT 1');
consola.success('Connected to database!');
} catch (e) {
consola.error('Connection to database failed!');
process.kill(process.pid);
}
});
+26
View File
@@ -0,0 +1,26 @@
export type MessageType = 'system' | 'agent' | 'user';
export interface ChatMessage {
type: MessageType;
message: string;
}
export interface GenerateRequestBody {
topicId: string;
messages: ChatMessage[];
}
export interface GenerationStreamEvent {
type: 'token' | 'complete' | 'error';
data: string | object | null;
}
export interface GenerationStatus {
generationId: string;
status: 'pending' | 'active' | 'completed' | 'error';
content?: string;
topicId?: string;
model?: string;
tokensGenerated?: number;
tokensUsedThinking?: number;
}
+15
View File
@@ -0,0 +1,15 @@
import { H3Event } from 'h3';
import { auth } from '~~/lib/auth';
export const protectRoute = async (event: H3Event) => {
const sessionData = await auth.api.getSession(event);
if (sessionData === null) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
event.context.user = sessionData.user;
};
+11
View File
@@ -0,0 +1,11 @@
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "~~/db/schema";
export const useDrizzle = () => {
return drizzle(process.env.DATABASE_URL!)
}
export const tables = schema;
export const UserInsert = schema.user.$inferInsert;
export type UserRegisterType = Omit<typeof UserInsert, "createdAt" | "updatedAt" | "id" | "emailVerified">;
+230
View File
@@ -0,0 +1,230 @@
import { useDrizzle } from '~~/server/utils/drizzle';
import { generations, messages as messages_drizzle } from '~~/db/schema';
import { type GenerationStreamEvent, type ChatMessage, type MessageType } from '~~/server/types/chat';
import { eq } from 'drizzle-orm';
interface ActiveGeneration {
userId: string;
topicId: string;
messages: ChatMessage[];
content: string;
clients: Set<ReadableStreamDefaultController<Uint8Array>>;
complete: boolean;
}
interface PendingGeneration {
generationId: string;
userId: string;
topicId: string;
messages: ChatMessage[];
timeout: NodeJS.Timeout;
expired: boolean;
}
const activeGenerations = new Map<string, ActiveGeneration>();
const pendingGenerations = new Map<string, PendingGeneration>();
export const getActiveGeneration = (generationId: string): ActiveGeneration | undefined => {
return activeGenerations.get(generationId);
};
export const getPendingGeneration = (generationId: string): PendingGeneration | undefined => {
return pendingGenerations.get(generationId);
};
export const isGenerationActive = (generationId: string): boolean => {
return activeGenerations.has(generationId);
};
export const isGenerationPending = (generationId: string): boolean => {
return pendingGenerations.has(generationId);
};
export const addClientToGeneration = (generationId: string, controller: ReadableStreamDefaultController<Uint8Array>): boolean => {
const generation = activeGenerations.get(generationId);
if (!generation) {
return false;
}
generation.clients.add(controller);
return true;
};
export const removeClientFromGeneration = (generationId: string, controller: ReadableStreamDefaultController<Uint8Array>): void => {
const generation = activeGenerations.get(generationId);
if (generation) {
generation.clients.delete(controller);
if (generation.clients.size === 0 && generation.complete) {
activeGenerations.delete(generationId);
}
}
};
const sendToClients = (generation: ActiveGeneration, event: GenerationStreamEvent): void => {
for (const client of generation.clients) {
sendToClient(client, event);
}
};
export const sendToClient = (client: ReadableStreamDefaultController<Uint8Array>, event: GenerationStreamEvent): void => {
const data = JSON.stringify(event);
const encoder = new TextEncoder();
try {
client.enqueue(encoder.encode(`${data}\n`));
} catch (error) {
console.error('Failed to send to client:', error);
}
}
const buildPrompt = (messages: ChatMessage[]): string => {
return messages
.map((msg: ChatMessage) => {
const roleMap: Record<MessageType, string> = {
system: 'System',
user: 'User',
agent: 'Assistant'
};
return `${roleMap[msg.type]}: ${msg.message}`;
})
.join('\n\n');
};
const db = useDrizzle();
export const startGeneration = async (generationId: string, controller: ReadableStreamDefaultController<Uint8Array>): Promise<void> => {
const pending = pendingGenerations.get(generationId);
if (!pending) {
console.error(`Generation ${generationId} not found in pending generations`);
return;
}
clearTimeout(pending.timeout);
pendingGenerations.delete(generationId);
const { userId, topicId, messages } = pending;
const prompt = buildPrompt(messages);
const generation: ActiveGeneration = {
userId,
topicId,
messages,
content: '',
clients: new Set([controller]),
complete: false
};
await db.insert(generations).values({
id: generationId,
userId,
topicId,
messageId: null
});
activeGenerations.set(generationId, generation);
try {
const dummyResponse = generateDummyResponse(prompt, messages);
const tokens = dummyResponse.split(' ');
for (const token of tokens) {
await new Promise(resolve => setTimeout(resolve, 50));
generation.content += token + ' ';
sendToClients(generation, {
type: 'token',
data: token + ' '
});
}
const [message] = await db.insert(messages_drizzle).values({
topicId: generation.topicId,
userId: generation.userId,
isUser: false,
content: generation.content.trim(),
model: 'dummy-model-v1',
tokensGenerated: tokens.length,
tokensUsedThinking: 0
}).returning();
generation.complete = true;
sendToClients(generation, {
type: 'complete',
data: message
});
if (generation.clients.size === 0) {
activeGenerations.delete(generationId);
} else {
generation.clients.forEach((client) => {
try {
client.close();
} catch {
}
});
activeGenerations.delete(generationId);
}
await db.update(generations).set({
messageId: message.id
}).where(eq(generations.id, generationId));
} catch (error) {
console.error('Generation failed:', error);
sendToClients(generation, {
type: 'error',
data: error instanceof Error ? error.message : 'Unknown error'
});
await db.delete(generations).where(eq(generations.id, generationId));
activeGenerations.delete(generationId);
}
};
export const registerPendingGeneration = (userId: string, generationId: string, topicId: string, messages: ChatMessage[]): void => {
const timeout = setTimeout(() => {
const gen = pendingGenerations.get(generationId)
if (gen) gen.expired = true;
console.log(`Generation ${generationId} expired - no client connected within 60 seconds`);
}, 60000);
pendingGenerations.set(generationId, {
generationId,
userId,
topicId,
messages,
timeout,
expired: false
});
console.log(`Registered pending generation ${generationId}, waiting for client connection...`);
};
const generateDummyResponse = (prompt: string, messages: ChatMessage[]): string => {
const responses = [
"This is a simulated response to your prompt. In a real implementation, this would be generated by an AI model like GPT-4 or Claude."
+ " I'm processing your message about: " + prompt.substring(0, 50) + "... "
+ "This dummy generation demonstrates the streaming and background save functionality.",
"I understand your query. This is a placeholder response that simulates AI-generated content."
+ " The system will continue generating this response even if you close the tab, and it will"
+ " automatically save to the database when complete.",
"Here's a simulated AI response. This demonstrates two key features:"
+ " 1) The generation continues in the background even if you disconnect,"
+ " 2) The complete response is automatically saved to the database without requiring"
+ " a separate update request from the client."
];
const lastUserMessage = messages[messages.length - 1]?.message.toLowerCase() || '';
if (lastUserMessage.includes('hello') || lastUserMessage.includes('hi')) {
return "Hello! I'm a dummy AI assistant. This is a simulated response to your greeting."
+ " In production, this would be replaced with actual AI-generated content from an LLM provider.";
}
return responses[Math.floor(Math.random() * responses.length)];
};
+18
View File
@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}
+7
View File
@@ -0,0 +1,7 @@
import type { agents } from '~~/db/schema';
import type { messages } from '~~/db/schema';
import type { topics } from '~~/db/schema';
export type Agent = typeof agents.$inferSelect;
export type Message = typeof messages.$inferSelect;
export type Topic = typeof topics.$inferSelect;
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig, presetMini } from 'unocss'
export default defineConfig({
presets: [
presetMini(),
],
})