initial commit
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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" ]
|
||||||
@@ -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.
|
||||||
+38
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"> </span></h1>
|
||||||
|
<ChatInput @submit="handleChatSubmit" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -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! });
|
||||||
@@ -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()
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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",
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -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 |
@@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { auth } from "~~/lib/auth";
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
return auth.handler(toWebRequest(event));
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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">;
|
||||||
@@ -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)];
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig, presetMini } from 'unocss'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
presets: [
|
||||||
|
presetMini(),
|
||||||
|
],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user