streaming, markdown, model selecting, and lots more
This commit is contained in:
+6
-5
@@ -1,5 +1,6 @@
|
||||
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
|
||||
TRIPLIT_SERVICE_TOKEN="your-triplit-service-token" # given to you on startup, or by running `tsx generate-tokens.ts`
|
||||
NUXT_TRIPLIT_ANON_TOKEN="your-anonymous-triplit-token" # also given to you on startup, or by running `tsx generate-tokens.ts`
|
||||
BETTER_AUTH_SECRET="super-secret" # openssl rand -base64 32
|
||||
TRIPLIT_JWT_SECRET="super-secret"
|
||||
EXTERNAL_JWT_SECRET=${BETTER_AUTH_SECRET} # no need to change this
|
||||
NUXT_PUBLIC_TRIPLIT_URL="http://localhost:6543" # your triplit server url
|
||||
|
||||
@@ -18,6 +18,9 @@ logs
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# tunneling config
|
||||
frpc.toml
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -5,11 +5,11 @@ 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.
|
||||
Veridian is a Nuxt 4 application built with TypeScript, using Triplit with
|
||||
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
|
||||
@@ -24,18 +24,14 @@ 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
|
||||
- `bunx triplit schema push` - Push schema changes to database
|
||||
|
||||
## Tech Stack & Dependencies
|
||||
|
||||
- **Framework**: Nuxt 4 (SSR enabled)
|
||||
- **Language**: TypeScript with strict configuration
|
||||
- **Database**: PostgreSQL with Drizzle ORM
|
||||
- **Database**: Triplit (next generation fullstack syncing database, sort of
|
||||
like convex)
|
||||
- **Auth**: better-auth with email/password and social providers
|
||||
- **Styling**: UnoCSS with presetMini
|
||||
- **Icons**: @nuxt/icon with Iconify, use Myna UI Icons
|
||||
@@ -116,12 +112,10 @@ the source is uniform and easy to follow.**
|
||||
|
||||
### Database Patterns
|
||||
|
||||
- Use Drizzle ORM with PostgreSQL
|
||||
- Export all schemas from `db/schema.ts`
|
||||
- Use Triplit for database operations
|
||||
- Export all schemas from `triplit/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.
|
||||
- Triplit schemas are defined in `triplit/schema.ts` and pushed using `bunx triplit schema push`
|
||||
|
||||
### Error Handling
|
||||
|
||||
@@ -142,7 +136,7 @@ the source is uniform and easy to follow.**
|
||||
The app uses better-auth with:
|
||||
|
||||
- Email/password authentication (configurable via env vars)
|
||||
- Drizzle adapter for PostgreSQL
|
||||
- Triplit adapter for authentication
|
||||
- Session management through cookies
|
||||
- Auth plugins in `app/plugins/` for client/server initialization
|
||||
- Global auth middleware in `app/middleware/auth.global.ts`
|
||||
@@ -165,10 +159,10 @@ user must be authenticated.
|
||||
│ \-- plugins/ # Vue/Nuxt plugins
|
||||
|-- server/ # Server-side code
|
||||
│ \-- api/ # API routes
|
||||
|-- db/ # Database related files
|
||||
|-- triplit/ # Database related files
|
||||
│ |-- schema.ts # Database schema
|
||||
│ |-- migrate.ts # Migration runner
|
||||
│ \-- migrations/ # Migration files
|
||||
│ |-- client.ts # Interacts with the database on the client
|
||||
│ \-- server.ts # Interacts with the database on the server
|
||||
\-- lib/ # Shared utilities
|
||||
```
|
||||
|
||||
@@ -181,10 +175,12 @@ requirement.
|
||||
|
||||
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
|
||||
- `TRIPLIT_SERVICE_TOKEN` - Triplit service token (admin token. **SECRET**)
|
||||
- `NUXT_TRIPLIT_ANON_TOKEN` - Triplit anonymous token (for anonymous access)
|
||||
- `BETTER_AUTH_SECRET` - Authentication secret (for better-auth)
|
||||
- `EXTERNAL_JWT_SECRET` - Will always be BETTER_AUTH_SECRET (used for
|
||||
verifying JWT tokens from better-auth)
|
||||
- `NUXT_PUBLIC_TRIPLIT_URL` - Triplit server URL
|
||||
|
||||
## Common Patterns
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Known Bugs
|
||||
|
||||
1. [ ] Occasionally, once in maybe 10 runs, triplit will make TWO connections to the backend.
|
||||
I'm not entire sure why, I think its a race condition, but I'm not sure.
|
||||
I _do not_ think that triplit-nuxt is the culprit, but I'm not sure.
|
||||
|
||||
2. [ ] The logic to handle hovering over the sidebar is half-baked at best. On the
|
||||
agents route, the back arrow sometimes stays full sized
|
||||
|
||||
3. [ ] If you open the theme switcher on the sidenav then close it and re-open
|
||||
it _without_ moving your mouse off of the sidenav, the agent button/dropdown
|
||||
trigger will slowly crawl to the right (likely related to #2).
|
||||
|
||||
4. [x] When navigating to different topics, or making a new topic, the page sort
|
||||
of freezes but its not completely frozen? I think this was because of
|
||||
page transitions
|
||||
|
||||
5. [x] If you navigate from "/" to an agent, then navigate to topics, none of
|
||||
the topic data is loaded. **This was a bug in the triplit-nuxt plugin.**
|
||||
|
||||
6. [ ] Stop button showing when no generation
|
||||
|
||||
7. [x] Sidenavs keep the overflow even if the lists are collapsed
|
||||
|
||||
8. [ ] Sometimes the sidebar wont change views until you move your mouse off of the sidenav?
|
||||
|
||||
9. [ ] ~~Sometimes on first page load, the message send button is on the left?~~
|
||||
If input was typed into the chat input **before hydation** the message send button
|
||||
will be on the left.
|
||||
@@ -1,3 +1,5 @@
|
||||
# Todo
|
||||
|
||||
- [ ] Standardize on one icon set rather than 4 (lmao)
|
||||
- [X] Standardize on one icon set rather than 4 (lmao)
|
||||
- [ ] Make dropdowns a singleton component
|
||||
- [ ] Make the sidebar better :kekdoggo:. It's annoying to manage across routes
|
||||
|
||||
+32
-10
@@ -1,29 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import '~/assets/css/reset.css';
|
||||
import '~/assets/css/base.css';
|
||||
|
||||
const { accent, neutral, hinting } = useTheme()
|
||||
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));`
|
||||
}
|
||||
})
|
||||
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) {
|
||||
// force shiki into browser rendering only
|
||||
window.sessionStorage.setItem('mdc-shiki-highlighter', 'browser');
|
||||
|
||||
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}%`)
|
||||
})
|
||||
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>
|
||||
<NuxtLoadingIndicator />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
@@ -31,7 +48,12 @@ if (import.meta.client) {
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#__nuxt {
|
||||
padding: 0.5rem;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
+131
-220
@@ -1,243 +1,154 @@
|
||||
@layer reset, base, components, utilities;
|
||||
:root {
|
||||
--font-sans: system-ui, sans-serif;
|
||||
--sidebar-width: 400px;
|
||||
--spacing: 0.25rem;
|
||||
|
||||
@layer reset {
|
||||
/*
|
||||
Josh's Custom CSS Reset slightly Modified
|
||||
https://www.joshwcomeau.com/css/custom-css-reset/
|
||||
*/
|
||||
--color-accent-text: #000;
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
--reasoning-accent: #a62bcb;
|
||||
|
||||
* {
|
||||
border: 0 solid;
|
||||
line-height: calc(1em + 0.5rem);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
/* Accent Color Options */
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
/* violet (current default) */
|
||||
--accent-violet: #a010ff;
|
||||
--accent-violet-hover: #8d00e8;
|
||||
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
/* volcanic heat - orange-red */
|
||||
--accent-volcano: #ef4410;
|
||||
--accent-volcano-hover: #dc2626;
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
/* neon lime */
|
||||
--accent-lime: #77fb6b;
|
||||
--accent-lime-hover: #5ee04f;
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
/* electric sky */
|
||||
--accent-sky: #38d3fa;
|
||||
--accent-sky-hover: #2bc7ee;
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
/* crushed coral - warm pink-orange */
|
||||
--accent-coral: #ff6b6b;
|
||||
--accent-coral-hover: #e55555;
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
word-break: break-word;
|
||||
}
|
||||
/* deep emerald - rich green */
|
||||
--accent-emerald: #10b981;
|
||||
--accent-emerald-hover: #059669;
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
hyphens: auto;
|
||||
}
|
||||
/* golden hour - warm amber */
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-amber-hover: #d97706;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
/* rose quartz - soft pink-red */
|
||||
--accent-rose: #f43f5e;
|
||||
--accent-rose-hover: #e11d48;
|
||||
|
||||
a {
|
||||
color: #fff;
|
||||
}
|
||||
/* arctic cyan - crisp blue */
|
||||
--accent-cyan: #06b6d4;
|
||||
--accent-cyan-hover: #0891b2;
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
: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.1),
|
||||
var(--accent-hinting) var(--color-accent));
|
||||
--color-highlight-high: color-mix(in srgb,
|
||||
var(--base-hinting) rgba(255, 255, 255, 0.15),
|
||||
var(--accent-hinting) var(--color-accent));
|
||||
--color-text: color-mix(in srgb, var(--base-hinting) #fafafa, var(--accent-hinting) var(--color-accent));
|
||||
--color-muted: color-mix(in srgb, var(--base-hinting) #a1a1a0, var(--accent-hinting) var(--color-accent));
|
||||
--color-subtle: color-mix(in srgb, var(--base-hinting) #d1d1d0, 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));
|
||||
--color-reasoning: color-mix(in srgb, var(--base-hinting) #66666a, var(--accent-hinting) var(--color-accent));
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.object-cover {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.backdrop-blur-md {
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
: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.13),
|
||||
var(--accent-hinting) var(--color-accent));
|
||||
--color-text: color-mix(in srgb, var(--base-hinting) #1a1a1a, var(--accent-hinting) var(--color-accent));
|
||||
--color-muted: color-mix(in srgb, var(--base-hinting) #6b7270, var(--accent-hinting) var(--color-accent));
|
||||
--color-subtle: color-mix(in srgb, var(--base-hinting) #9ba2a0, 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));
|
||||
--color-reasoning: color-mix(in srgb, var(--base-hinting) #99979c, var(--accent-hinting) var(--color-accent));
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--color-base);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
html.light {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
html {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
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,
|
||||
textarea:focus,
|
||||
button:focus,
|
||||
a:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: transparent;
|
||||
}
|
||||
+141
-33
@@ -1,38 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch, computed } from 'vue';
|
||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||
import type { Entity } from '@triplit/client';
|
||||
import { schema } from '#triplit/schema';
|
||||
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||
let tempInput = '';
|
||||
const inputValue = ref('');
|
||||
const isFocused = ref(false);
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [value: string];
|
||||
submit: [value: string, model: ModelWithProvider | null];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
const props = defineProps<{
|
||||
loading?: boolean;
|
||||
agent?: Entity<typeof schema, 'agents'>;
|
||||
providers?: ProviderWithModels[];
|
||||
}>();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (inputValue.value.trim()) {
|
||||
emit('submit', inputValue.value);
|
||||
inputValue.value = '';
|
||||
// Model selection state
|
||||
const selectedModel = ref<ModelWithProvider | null>(null);
|
||||
|
||||
// Get all available models from all providers
|
||||
const allModels = computed(() => {
|
||||
if (!props.providers) return [];
|
||||
return props.providers.flatMap((provider) =>
|
||||
provider.models.map((model) => ({
|
||||
...model,
|
||||
provider,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
// Initialize model selection based on agent's defaultModelId or first available
|
||||
const initializeModel = () => {
|
||||
if (selectedModel.value) return;
|
||||
if (!props.providers || !props.agent) return;
|
||||
|
||||
// Try to use agent's default model
|
||||
if (props.agent.defaultModelId) {
|
||||
const agentModel = allModels.value.find((m) => m.id === props.agent!.defaultModelId);
|
||||
if (agentModel) {
|
||||
selectedModel.value = agentModel;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to first available model
|
||||
if (allModels.value.length > 0) {
|
||||
selectedModel.value = allModels.value[0]!;
|
||||
// Update agent's default model
|
||||
updateAgentDefaultModel(selectedModel.value.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Update agent's default model in Triplit
|
||||
const updateAgentDefaultModel = async (modelId: string) => {
|
||||
if (!props.agent) return;
|
||||
try {
|
||||
await triplit.update('agents', props.agent.id, (agent) => {
|
||||
agent.defaultModelId = modelId;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update agent default model:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for model changes and persist to agent
|
||||
watch(selectedModel, (newModel) => {
|
||||
if (newModel && props.agent && newModel.id !== props.agent.defaultModelId) {
|
||||
updateAgentDefaultModel(newModel.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize when providers change
|
||||
watch(() => props.providers, initializeModel, { immediate: true });
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (props.loading) {
|
||||
emit('cancel');
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue.value.trim()) {
|
||||
emit('submit', inputValue.value, selectedModel.value);
|
||||
inputValue.value = '';
|
||||
}
|
||||
// Reset height after sending
|
||||
if (inputRef.value) inputRef.value.style.height = 'auto';
|
||||
};
|
||||
|
||||
const handleKeyDown = async (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;
|
||||
const 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;
|
||||
inputValue.value =
|
||||
inputValue.value.slice(0, cursorPosition) + '\n' + inputValue.value.slice(cursorPosition);
|
||||
await nextTick();
|
||||
handleInput();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
@@ -40,41 +114,75 @@ const handleKeyDown = (event: KeyboardEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
const textarea = inputRef.value;
|
||||
if (!textarea) return;
|
||||
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
const lineHeight = 24;
|
||||
const maxLines = 10;
|
||||
const maxHeight = maxLines * lineHeight;
|
||||
|
||||
const newHeight = textarea.scrollHeight;
|
||||
|
||||
if (newHeight > maxHeight) {
|
||||
textarea.style.height = `${maxHeight}px`;
|
||||
} else {
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
let hasCommandKey = false;
|
||||
if (import.meta.server) {
|
||||
let headers = useRequestHeaders();
|
||||
const headers = useRequestHeaders();
|
||||
hasCommandKey = headers['user-agent']?.includes('Mac OS') ?? false;
|
||||
} else {
|
||||
hasCommandKey = navigator.userAgent.includes('Mac OS');
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
tempInput = (document.getElementById('chat') as HTMLInputElement)?.value ?? '';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
inputValue.value = tempInput;
|
||||
handleInput();
|
||||
});
|
||||
</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)]
|
||||
<div :class="['w-full flex max-h-full', $attrs.class]">
|
||||
<div class="relative w-full flex flex-shrink-1 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"
|
||||
<div class="flex-1 min-w-0 max-h-full">
|
||||
<!-- Grammarly literally breaks everything, go fuck yourself -->
|
||||
<textarea data-gramm="false" id="chat" 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>
|
||||
@keydown="handleKeyDown" @input="handleInput"
|
||||
class="[scrollbar-width:none] w-full bg-transparent text-[var(--color-text)] resize-none outline-none text-[15px] leading-6 min-h-0 overflow-y-auto">
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex">
|
||||
<div class="flex-1"></div>
|
||||
<!-- Send Button -->
|
||||
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() || loading"
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<ModelSelector v-if="providers && providers.length > 0" v-model="selectedModel"
|
||||
:providers="providers"></ModelSelector>
|
||||
</div>
|
||||
<!-- Send/Stop 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()
|
||||
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
|
||||
inputValue.trim() && !loading
|
||||
? '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'
|
||||
: 'text-[var(--color-highlight-high)]',
|
||||
loading && 'bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)]',
|
||||
]">
|
||||
<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" />
|
||||
<Icon v-if="loading" name="mynaui:stop-solid" class="text-6.5" />
|
||||
<Icon v-else name="mynaui:send-solid" class="text-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+30
-35
@@ -2,71 +2,66 @@
|
||||
import type { DropdownItem } from '~/types/dropdown';
|
||||
|
||||
interface Props {
|
||||
items: DropdownItem[];
|
||||
modelValue?: boolean;
|
||||
items?: DropdownItem[];
|
||||
placement?: 'right' | 'left' | 'center';
|
||||
verticality?: 'asscending' | 'descending';
|
||||
width?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
placement: 'right',
|
||||
verticality: 'descending',
|
||||
width: 'auto'
|
||||
})
|
||||
width: 'auto',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'select', item: DropdownItem): void;
|
||||
}>()
|
||||
const emit = defineEmits<(e: 'select', item: DropdownItem) => void>();
|
||||
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const isOpen = defineModel<boolean>({ required: true })
|
||||
const triggerRef = ref<HTMLElement | null>(null);
|
||||
const isOpen = defineModel<boolean>({ required: false });
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const select = (item: DropdownItem) => {
|
||||
if (item.disabled || item.divider) return
|
||||
emit('select', item)
|
||||
isOpen.value = false
|
||||
}
|
||||
if (item.disabled || item.divider) return;
|
||||
emit('select', item);
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const placementClasses = computed(() => {
|
||||
let classes = ''
|
||||
let classes = '';
|
||||
|
||||
switch (props.placement) {
|
||||
case 'right':
|
||||
classes += 'right-0 '
|
||||
break
|
||||
classes += 'right-0 ';
|
||||
break;
|
||||
case 'left':
|
||||
classes += 'left-0 '
|
||||
break
|
||||
classes += 'left-0 ';
|
||||
break;
|
||||
case 'center':
|
||||
classes += 'left-1/2 -translate-x-1/2 '
|
||||
break
|
||||
classes += 'left-1/2 -translate-x-1/2 ';
|
||||
break;
|
||||
default:
|
||||
classes += 'left-0 '
|
||||
break
|
||||
classes += 'left-0 ';
|
||||
break;
|
||||
}
|
||||
|
||||
switch (props.verticality) {
|
||||
case 'asscending':
|
||||
classes += 'bottom-full mb-1.5'
|
||||
break
|
||||
classes += 'bottom-full mb-1.5';
|
||||
break;
|
||||
case 'descending':
|
||||
classes += 'top-full mt-1.5'
|
||||
break
|
||||
classes += 'top-full mt-1.5';
|
||||
break;
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
return classes;
|
||||
});
|
||||
|
||||
useClickOutside(triggerRef, () => {
|
||||
isOpen.value = false
|
||||
})
|
||||
isOpen.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -88,7 +83,7 @@ useClickOutside(triggerRef, () => {
|
||||
:class="[
|
||||
item.disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-[var(--color-highlight)] cursor-pointer'
|
||||
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] cursor-pointer'
|
||||
]">
|
||||
<Icon v-if="item.icon" :name="item.icon" class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="text-sm whitespace-nowrap">{{ item.label }}</span>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<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,58 @@
|
||||
<script setup lang="ts">
|
||||
import { useFillIds } from '~/composables/useFillIds';
|
||||
|
||||
defineProps<{
|
||||
size?: string | number;
|
||||
color?: boolean;
|
||||
avatar?: boolean;
|
||||
}>();
|
||||
|
||||
const TITLE = 'Gemini';
|
||||
|
||||
const [a, b, c] = useFillIds(TITLE, 3);
|
||||
|
||||
const BACKGROUND_COLOR = "#fff";
|
||||
|
||||
const d = "M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:style="[`max-width: ${size}px; max-height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 9999px; padding: 0.25rem;` : '']">
|
||||
<svg v-if="color" class="w-full h-full" style="flex: none; line-height: 1;" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<title>{{ TITLE }}</title>
|
||||
|
||||
<!-- Base Layer -->
|
||||
<path :d="d" fill="#3186FF" />
|
||||
|
||||
<!-- Gradient Layers -->
|
||||
<path :d="d" :fill="a!.fill" />
|
||||
<path :d="d" :fill="b!.fill" />
|
||||
<path :d="d" :fill="c!.fill" />
|
||||
|
||||
<defs>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" :id="a!.id" x1="7" x2="11" y1="15.5" y2="12">
|
||||
<stop stop-color="#08B962" />
|
||||
<stop offset="1" stop-color="#08B962" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient gradientUnits="userSpaceOnUse" :id="b!.id" x1="8" x2="11.5" y1="5.5" y2="11">
|
||||
<stop stop-color="#F94543" />
|
||||
<stop offset="1" stop-color="#F94543" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient gradientUnits="userSpaceOnUse" :id="c!.id" x1="3.5" x2="17.5" y1="13.5" y2="12">
|
||||
<stop stop-color="#FABC12" />
|
||||
<stop offset=".46" stop-color="#FABC12" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg v-else fill="currentColor" fillRule="evenodd" :height="size" style="flex: none; line-height: 1;"
|
||||
viewBox="0 0 24 24" :width="size" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>{{ TITLE }}</title>
|
||||
<path
|
||||
d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
size?: string | number;
|
||||
color?: boolean;
|
||||
avatar?: boolean;
|
||||
}>();
|
||||
|
||||
const TITLE = 'Grok';
|
||||
|
||||
const BACKGROUND_COLOR = "#000";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:style="[`max-width: ${size}px; max-height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 9999px; padding: 0.25rem;` : '']">
|
||||
<svg class="w-full h-full" fill="currentColor" fillRule="evenodd" style="flex: none; line-height: 1;"
|
||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>{{ TITLE }}</title>
|
||||
<path
|
||||
d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { h, resolveComponent } from 'vue';
|
||||
|
||||
const props = defineProps<{ node: any }>();
|
||||
|
||||
const render = () => {
|
||||
const { node } = props;
|
||||
|
||||
if (node.type === 'text' || node.type === 'raw') return node.value;
|
||||
|
||||
if (node.type === 'element') {
|
||||
if (node.tagName === 'code') {
|
||||
const isBlock = node.position?.start.line !== node.position?.end.line;
|
||||
if (isBlock && node.children?.[0]?.type === 'text') {
|
||||
return h(resolveComponent('MarkdownShikiHighlight'), {
|
||||
code: node.children[0].value,
|
||||
lang: node.properties?.className?.[0]?.replace('language-', '') || 'text'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return h(
|
||||
node.tagName,
|
||||
node.properties,
|
||||
node.children?.map((child: any, index: number) =>
|
||||
h(resolveComponent('MarkdownAstNode'), {
|
||||
node: child,
|
||||
key: `${node.tagName}-${index}`
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="render" />
|
||||
</template>
|
||||
@@ -0,0 +1,289 @@
|
||||
<script setup lang="ts">
|
||||
import type { RootContent } from 'hast';
|
||||
|
||||
const props = defineProps<{
|
||||
content: string;
|
||||
finished: boolean;
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
const { $remark } = useNuxtApp();
|
||||
|
||||
function splitMarkdown(markdown: string): string[] {
|
||||
const paragraphs: string[] = [];
|
||||
let currentParagraph = "";
|
||||
let isInCodeBlock = false;
|
||||
|
||||
const lines = markdown.split("\n");
|
||||
|
||||
for (let line of lines) {
|
||||
if (line.trim().startsWith("```")) {
|
||||
isInCodeBlock = !isInCodeBlock;
|
||||
}
|
||||
|
||||
if (line.trim() === "" && !isInCodeBlock) {
|
||||
if (currentParagraph.trim() !== "") {
|
||||
paragraphs.push(currentParagraph.trim());
|
||||
currentParagraph = "";
|
||||
}
|
||||
} else {
|
||||
currentParagraph += (currentParagraph === "" ? "" : "\n") + line;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentParagraph.trim() !== "") {
|
||||
paragraphs.push(currentParagraph.trim());
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
const partseAst = async (content: string) => {
|
||||
const mdast = $remark.parse(content);
|
||||
const hast = $remark.run(mdast);
|
||||
return hast;
|
||||
}
|
||||
|
||||
// SSR Initial Load
|
||||
const { data: hastParts } = await useAsyncData(`md-${props.id}`, async () => {
|
||||
return (await partseAst(props.content)).children;
|
||||
});
|
||||
|
||||
let activeIdx = 0;
|
||||
let partIdx = [0];
|
||||
|
||||
if (import.meta.client && hastParts.value && hastParts.value.length > 0 && !props.finished) {
|
||||
const initialParts = splitMarkdown(props.content);
|
||||
|
||||
activeIdx = Math.max(0, initialParts.length - 1);
|
||||
|
||||
let currentOffset = 0;
|
||||
for (let i = 0; i < initialParts.length; i++) {
|
||||
partIdx[i] = currentOffset;
|
||||
|
||||
const tempAst = await partseAst(initialParts[i]!);
|
||||
currentOffset += tempAst.children.length;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const parts = computed(() => {
|
||||
return splitMarkdown(props.content);
|
||||
})
|
||||
|
||||
watch(parts, async (newParts) => {
|
||||
if (!hastParts.value) hastParts.value = [];
|
||||
|
||||
while (activeIdx < newParts.length - 1) {
|
||||
const finalHast = await partseAst(newParts[activeIdx]!);
|
||||
|
||||
const base: RootContent[] = hastParts.value!.slice(0, partIdx[activeIdx]);
|
||||
hastParts.value = base.concat(finalHast.children);
|
||||
|
||||
partIdx[activeIdx + 1] = hastParts.value!.length;
|
||||
activeIdx++;
|
||||
}
|
||||
|
||||
const currentString = newParts[activeIdx];
|
||||
if (currentString !== undefined) {
|
||||
const latestHast = await partseAst(currentString);
|
||||
|
||||
const stableBase = hastParts.value!.slice(0, partIdx[activeIdx]);
|
||||
hastParts.value = stableBase.concat(latestHast.children);
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose-wrapper">
|
||||
<article class="markdown-body">
|
||||
<MarkdownAstNode v-for="(node, index) in hastParts" :key="`${id}-${index}`" :node="node" />
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
article>* {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
article>*:first-child {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
article>*:last-child {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
article>*:only-child {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 1px solid var(--color-highlight-high);
|
||||
}
|
||||
|
||||
li {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin-left: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
ul>li {
|
||||
position: relative;
|
||||
padding-bottom: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
ul>li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.5rem;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background-color: var(--color-muted);
|
||||
border-radius: 50%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
ul>li::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 23px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: var(--color-highlight);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
ul>li:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: none;
|
||||
margin-left: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
counter-reset: ordered-list-counter var(--start-value, 0);
|
||||
}
|
||||
|
||||
ol[start] {
|
||||
--start-value: calc(attr(start type(<number>)) - 1);
|
||||
}
|
||||
|
||||
ol>li {
|
||||
position: relative;
|
||||
padding-bottom: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
counter-increment: ordered-list-counter;
|
||||
}
|
||||
|
||||
ol>li::before {
|
||||
content: counter(ordered-list-counter) ".";
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
color: var(--color-muted);
|
||||
font-weight: 500;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
html.dark .shiki,
|
||||
html.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
/* Optional, if you also want font styles */
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
|
||||
ol:only-child,
|
||||
ul:only-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
code:not(pre code) {
|
||||
background-color: var(--color-highlight);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
color: var(--color-muted);
|
||||
border-left: 4px solid var(--color-highlight-high);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* TODO: make these tables better, this is literally the first attempt from Gemini 3 flash */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: calc(var(--spacing) * 4) 0;
|
||||
font-size: 0.95rem;
|
||||
text-align: left;
|
||||
background-color: var(--color-base);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
table thead tr {
|
||||
background-color: var(--color-highlight-high);
|
||||
}
|
||||
|
||||
table th {
|
||||
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
table td {
|
||||
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
|
||||
}
|
||||
|
||||
table tbody tr {
|
||||
background-color: var(--color-highlight);
|
||||
transition: background-color 250ms cubic-bezier(0.5, 1, 0.89, 1);
|
||||
}
|
||||
|
||||
table tbody tr:nth-of-type(even) {
|
||||
background-color: var(--color-highlight-low);
|
||||
}
|
||||
|
||||
/* Hover effect */
|
||||
table tbody tr:hover {
|
||||
background-color: var(--color-highlight-high);
|
||||
}
|
||||
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
label>span {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: min-content;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts" setup>
|
||||
import { hashSync } from '~/utils/hash';
|
||||
const props = defineProps<{ code: string; lang: string }>();
|
||||
|
||||
const renderId = hashSync(props.code + props.lang);
|
||||
|
||||
const { data: html } = useAsyncData<string>(`shiki-${renderId}`, async () => parseCode());
|
||||
const lineNumberWidth = computed(() => {
|
||||
if (!html.value) return 1;
|
||||
// Count newlines in the generated HTML or the source code
|
||||
// Using props.code is safer and faster than parsing the HTML string
|
||||
return props.code.split('\n').length.toString().length;
|
||||
});
|
||||
|
||||
watch(() => props.code, async () => {
|
||||
html.value = await parseCode();
|
||||
});
|
||||
|
||||
async function parseCode() {
|
||||
const shiki = await getShikiHighlighter();
|
||||
let lang = props.lang.toLowerCase();
|
||||
try {
|
||||
shiki.getLanguage(lang);
|
||||
} catch {
|
||||
lang = 'text';
|
||||
}
|
||||
return shiki.codeToHtml(props.code.trim(), {
|
||||
lang,
|
||||
themes: { dark: 'vitesse-dark', light: 'vitesse-light' },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl overflow-hidden code-container" :style="`--line-number-width: ${lineNumberWidth}ch`"
|
||||
:id="`code-${renderId}`" v-html="html">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.code-container {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.code-container>pre {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
padding: 1rem;
|
||||
line-height: 1.625;
|
||||
counter-reset: lines;
|
||||
}
|
||||
|
||||
.code-container>pre>code .line::before {
|
||||
counter-increment: lines;
|
||||
content: counter(lines);
|
||||
width: var(--line-number-width);
|
||||
margin-right: 1.5rem;
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dark .code-container>pre>code .line::before {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.light .code-container>pre>code .line::before {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
</style>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Message } from '~~/types';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
regenerations?: Message[];
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
regenerations: () => [],
|
||||
isCurrent: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
regenerate: [messageId: string];
|
||||
select: [messageId: string];
|
||||
delete: [messageId: string];
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const content = computed(() => props.message.content)
|
||||
const hasAlternatives = computed(() => props.regenerations.length > 0)
|
||||
const showDropdown = computed(() => hasAlternatives.value || !props.isUser)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-3 relative group">
|
||||
<div
|
||||
class="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
:class="isUser ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-neutral)] border border-[var(--color-highlight)]'"
|
||||
>
|
||||
<Icon
|
||||
:name="isUser ? 'mynaui:user' : 'mynaui:check-hexagon'"
|
||||
class="w-4 h-4"
|
||||
:class="isUser ? 'text-[var(--color-accent-text)]' : 'text-[var(--color-accent)]'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<p class="text-sm font-medium" :class="isUser ? 'text-[var(--color-accent)]' : 'text-[var(--color-neutral)]'">
|
||||
{{ isUser ? 'You' : 'Agent' }}
|
||||
</p>
|
||||
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Dropdown v-if="showDropdown" v-model="isOpen" placement="bottom-right" width="140px">
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
@click="toggle"
|
||||
class="p-1 rounded hover:bg-[var(--color-highlight)]"
|
||||
aria-label="More options"
|
||||
>
|
||||
<Icon name="mynaui:dots-horizontal" class="w-4 h-4 text-[var(--color-subtle)]" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<button
|
||||
v-if="!isUser"
|
||||
@click="emit('regenerate', message.id)"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--color-highlight)] text-left"
|
||||
>
|
||||
<Icon name="mynaui:refresh" class="w-4 h-4" />
|
||||
<span class="text-sm">Regenerate</span>
|
||||
</button>
|
||||
<div v-if="!isUser && hasAlternatives" class="h-px bg-[var(--color-highlight)] my-1" />
|
||||
<template v-if="hasAlternatives">
|
||||
<button
|
||||
v-for="alt in regenerations"
|
||||
:key="alt.id"
|
||||
@click="emit('select', alt.id)"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--color-highlight)] text-left"
|
||||
:class="message.id === alt.id ? 'bg-[var(--color-highlight)]' : ''"
|
||||
>
|
||||
<Icon name="mynaui:clock" class="w-4 h-4 text-[var(--color-subtle)]" />
|
||||
<span class="text-sm text-[var(--color-subtle)]">{{ alt.id }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<div class="h-px bg-[var(--color-highlight)] my-1" />
|
||||
<button
|
||||
@click="emit('delete', message.id)"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--color-highlight)] text-left text-red-400"
|
||||
>
|
||||
<Icon name="mynaui:trash" class="w-4 h-4" />
|
||||
<span class="text-sm">Delete</span>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-[var(--color-text)] whitespace-pre-wrap break-words">{{ content }}</p>
|
||||
|
||||
<div v-if="editedAt" class="mt-1 text-xs text-[var(--color-subtle)] flex items-center gap-1">
|
||||
<Icon name="mynaui:pencil" class="w-3 h-3" />
|
||||
<span>Edited</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
|
||||
defineProps<{
|
||||
error_part: Entity<typeof schema, 'message_parts'>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
|
||||
const props = defineProps<{
|
||||
part: Readonly<Entity<typeof schema, 'message_parts'>>;
|
||||
}>();
|
||||
|
||||
const reasoningOpen = ref(!props.part.finished);
|
||||
|
||||
watch(
|
||||
() => props.part.finished,
|
||||
() => {
|
||||
reasoningOpen.value = !props.part.finished;
|
||||
},
|
||||
);
|
||||
|
||||
const containerRef: Ref<HTMLDivElement | null> = ref(null);
|
||||
const scrollState = ref('middle');
|
||||
|
||||
const { scrollToBottom } = useAutoScroll(containerRef);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
const container = containerRef.value;
|
||||
const scrollTop = container.scrollTop;
|
||||
const scrollHeight = container.scrollHeight;
|
||||
const clientHeight = container.clientHeight;
|
||||
|
||||
const topThreshold = 0.02 * clientHeight;
|
||||
|
||||
if (scrollTop <= topThreshold) {
|
||||
scrollState.value = 'top';
|
||||
} else if (scrollTop + 100 >= scrollHeight - clientHeight) {
|
||||
scrollState.value = 'bottom';
|
||||
} else {
|
||||
scrollState.value = 'middle';
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReasoning = async () => {
|
||||
if (!props.part.finished) return;
|
||||
|
||||
reasoningOpen.value = !reasoningOpen.value;
|
||||
if (!reasoningOpen.value) return;
|
||||
await nextTick();
|
||||
scrollToBottom('instant');
|
||||
handleScroll();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="toggleReasoning" :class="[
|
||||
'w-full hover:bg-[var(--color-highlight)] rounded-lg p-1 flex justify-between items-center text-[--color-reasoning] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
|
||||
part.finished ? '' : 'cursor-default'
|
||||
]">
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center">
|
||||
<Icon name="mynaui:atom" class="w-3 h-3 text-[var(--reasoning-accent)]" />
|
||||
</div>
|
||||
Deep Thinking
|
||||
</div>
|
||||
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', reasoningOpen ? '' : '-rotate-90']" />
|
||||
</button>
|
||||
<div v-if="reasoningOpen" ref="containerRef" @scroll="handleScroll"
|
||||
:class="['reasoning-contaizner p-2 text-[--color-reasoning] max-h-[min(40vh,320px)] overflow-y-auto', scrollState]">
|
||||
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.reasoning-contaizner.middle {
|
||||
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent)
|
||||
}
|
||||
|
||||
.reasoning-contaizner.top {
|
||||
mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent)
|
||||
}
|
||||
|
||||
.reasoning-contaizner.bottom {
|
||||
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000)
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
|
||||
const props = defineProps<{
|
||||
part: Readonly<Entity<typeof schema, 'message_parts'>>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" />
|
||||
</template>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
|
||||
const props = defineProps<{
|
||||
toolCall: Readonly<Entity<typeof schema, 'tool_calls'>>;
|
||||
}>();
|
||||
|
||||
const activeTab = ref('input');
|
||||
|
||||
const indicatorStyle = computed(() => {
|
||||
const tabs = ['input', 'output', 'trace'];
|
||||
const index = tabs.indexOf(activeTab.value);
|
||||
// Each tab button is ~48px (40px height + 8px gap)
|
||||
const offset = index * 48;
|
||||
return {
|
||||
transform: `translateY(${offset}px)`,
|
||||
top: '4px',
|
||||
};
|
||||
});
|
||||
|
||||
const shiki = await getShikiHighlighter();
|
||||
|
||||
const html = ref('');
|
||||
const lineNumberWidth = ref(1);
|
||||
|
||||
const input: ComputedRef<string> = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'input':
|
||||
if (props.toolCall.input === null) return '';
|
||||
if (props.toolCall.input!.type === 'json') {
|
||||
return JSON.stringify(JSON.parse(props.toolCall.input!.value), null, 2);
|
||||
}
|
||||
|
||||
return props.toolCall.input!.value;
|
||||
case 'output':
|
||||
if (props.toolCall.output === null) return '';
|
||||
if (props.toolCall.output!.type === 'json') {
|
||||
return JSON.stringify(JSON.parse(props.toolCall.output!.value), null, 2);
|
||||
}
|
||||
|
||||
return props.toolCall.output!.value;
|
||||
case 'trace': {
|
||||
const traceObj: any = { ...props.toolCall };
|
||||
if (traceObj === null) return '';
|
||||
// marshall the trace object and the input, output, and error into their correct types
|
||||
switch (traceObj.input?.type) {
|
||||
case 'text':
|
||||
traceObj.input = traceObj.input.value;
|
||||
break;
|
||||
case 'json':
|
||||
traceObj.input = JSON.parse(traceObj.input.value);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (traceObj.output?.type) {
|
||||
case 'text':
|
||||
traceObj.output = traceObj.output.value;
|
||||
break;
|
||||
case 'json':
|
||||
traceObj.output = JSON.parse(traceObj.output.value);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (traceObj.error?.type) {
|
||||
case 'text':
|
||||
traceObj.error = traceObj.error.value;
|
||||
break;
|
||||
case 'json':
|
||||
traceObj.error = JSON.parse(traceObj.error.value);
|
||||
break;
|
||||
}
|
||||
|
||||
return JSON.stringify(traceObj, null, 2);
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
input,
|
||||
(newCode) => {
|
||||
let lang = 'json';
|
||||
try {
|
||||
shiki.getLanguage(lang);
|
||||
} catch (e) {
|
||||
lang = 'text';
|
||||
}
|
||||
|
||||
html.value = shiki.codeToHtml(newCode, {
|
||||
lang,
|
||||
themes: {
|
||||
dark: 'vitesse-dark',
|
||||
light: 'vitesse-light',
|
||||
},
|
||||
});
|
||||
lineNumberWidth.value = html.value.split('\n').length.toString().length;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full border rounded-lg border-[var(--color-highlight)] flex flex-row h-80 overflow-hidden">
|
||||
<div class="flex items-center gap-2 flex-col border-r border-[var(--color-highlight)] p-1 relative shrink-0">
|
||||
<button @click="activeTab = 'input'" :class="[
|
||||
'hover:bg-[var(--color-highlight)] p-2 rounded-lg flex gap-1 items-center w-full transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
|
||||
activeTab === 'input' ? 'text-orange-6' : ''
|
||||
]">
|
||||
<div
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden flex items-center justify-center">
|
||||
<Icon name="mynaui:code" class="w-4.5 h-4.5" />
|
||||
</div>
|
||||
Input
|
||||
</button>
|
||||
<button @click="activeTab = 'output'" :class="[
|
||||
'hover:bg-[var(--color-highlight)] p-2 rounded-lg flex gap-1 items-center w-full transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
|
||||
activeTab === 'output' ? 'text-orange-6' : ''
|
||||
]">
|
||||
<div
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden flex items-center justify-center">
|
||||
<Icon name="mynaui:arrow-down-square" class="w-4.5 h-4.5" />
|
||||
</div>
|
||||
Output
|
||||
</button>
|
||||
<button @click="activeTab = 'trace'" :class="[
|
||||
'hover:bg-[var(--color-highlight)] p-2 rounded-lg flex gap-1 items-center w-full transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
|
||||
activeTab === 'trace' ? 'text-orange-6' : ''
|
||||
]">
|
||||
<div
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden flex items-center justify-center">
|
||||
<Icon name="mynaui:flask" class="w-4.5 h-4.5" />
|
||||
</div>
|
||||
Function call
|
||||
</button>
|
||||
|
||||
<div :style="indicatorStyle"
|
||||
class="absolute bg-orange-6 w-[3px] h-8 rounded-l mt-1 right-0 transition-transform duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow overflow-auto max-h-full max-w-full">
|
||||
<div class="overflow-hidden h-full" :style="`--line-number-width: ${lineNumberWidth}ch`"
|
||||
id="function-call-container" v-html="html">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#function-call-container>pre {
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
padding: 1rem;
|
||||
line-height: 1.625;
|
||||
counter-reset: lines;
|
||||
}
|
||||
|
||||
#function-call-container>pre>code .line::before {
|
||||
counter-increment: lines;
|
||||
content: counter(lines);
|
||||
width: var(--line-number-width);
|
||||
margin-right: 1.5rem;
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dark #function-call-container>pre>code .line::before {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.light #function-call-container>pre>code .line::before {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
import Debug from './Debug.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
toolCall: Readonly<Entity<typeof schema, 'tool_calls'>>;
|
||||
}>();
|
||||
|
||||
const deubgToolCallOpen = ref(false);
|
||||
|
||||
const toggleDebugToolCall = () => {
|
||||
deubgToolCallOpen.value = !deubgToolCallOpen.value;
|
||||
};
|
||||
|
||||
const iconName = ref('mynaui:tool');
|
||||
const iconColor = ref('var(--color-subtle)');
|
||||
|
||||
watch(
|
||||
() => props.toolCall.status,
|
||||
(status) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
iconName.value = 'svg-spinners:180-ring-with-bg';
|
||||
break;
|
||||
case 'completed':
|
||||
iconName.value = 'mynaui:tool';
|
||||
break;
|
||||
case 'failed':
|
||||
iconName.value = 'mynaui:x-solid';
|
||||
iconColor.value = '#ff3b3b';
|
||||
break;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
class="select-none w-full hover:bg-[var(--color-highlight)] group rounded-lg p-1 flex justify-between items-center text-[--color-reasoning] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center">
|
||||
<Icon :name="iconName" :style="{ color: iconColor }"
|
||||
class="w-3 h-3 text-[var(--color-subtle)]" />
|
||||
</div>
|
||||
{{ toolCall.toolName }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<button @click="toggleDebugToolCall"
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden hover:bg-[var(--color-highlight)] flex items-center justify-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon name="mynaui:search" class="w-3 h-3 text-[var(--color-subtle)]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Debug v-if="deubgToolCallOpen" :toolCall="toolCall" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
import ShikiHighlight from '~/components/Markdown/ShikiHighlight.vue';
|
||||
import Reasoning from './Reasoning.vue';
|
||||
import Text from './Text.vue';
|
||||
import Tool from './Tool/index.vue';
|
||||
|
||||
defineProps<{
|
||||
message: Readonly<
|
||||
Entity<typeof schema, 'messages'> & {
|
||||
parts: (Entity<typeof schema, 'message_parts'> & {
|
||||
toolCall: Entity<typeof schema, 'tool_calls'> | null;
|
||||
})[];
|
||||
} & { generation: Entity<typeof schema, 'generations'> | null }
|
||||
>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- <pre class="max-w-full overflow-x-auto">{{ JSON.stringify(message, null, 2) }}</pre> -->
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div v-for="part in message.parts" :key="part.id">
|
||||
<Reasoning v-if="part.type === 'reasoning'" :part="part" />
|
||||
<Text v-else-if="part.type === 'text'" :part="part" />
|
||||
<Tool v-else-if="part.type === 'tool-call'" :toolCall="part.toolCall!" />
|
||||
<div v-else>
|
||||
Unhandled part type: {{ part.type }} {{ part }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between text-zinc-400 dark:text-zinc-600 text-xs"
|
||||
v-if="message.generation && message.generation.status !== 'pending'">
|
||||
<span class="flex items-center gap-1">
|
||||
<ModelIcon :size="12" :model-id="message.generation.modelId" />
|
||||
{{ message.generation.modelId }}
|
||||
</span>
|
||||
<span class="flex gap-1 items-center" v-if="message.generation.tokens?.output">
|
||||
<Icon name="tabler:coins" />
|
||||
{{ message.generation?.tokens?.output }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="message.generation?.status === 'pending' && message.parts.length === 0">
|
||||
<span class="text-sm text-[var(--color-muted)] flex flex-row items-center">
|
||||
<Icon name="svg-spinners:pulse-2" class="text-4" />
|
||||
Preparing generating...
|
||||
</span>
|
||||
</span>
|
||||
<div v-else-if="message.generation?.status === 'failed'">
|
||||
<span class="text-sm text-[var(--color-error)]">Generation failed</span>
|
||||
<div class="text-sm">
|
||||
<ShikiHighlight :code="message.generation.error ?? 'An unknown error occurred'" lang="json" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
|
||||
defineProps<{
|
||||
message: Readonly<Entity<typeof schema, 'messages'>>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MarkdownRenderer :finished="true" class="max-w-full bg-[var(--color-highlight)] py-2 px-3 rounded-xl"
|
||||
:content="message.content!" :id="message.id" />
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
|
||||
defineProps<{
|
||||
message: Readonly<
|
||||
Entity<typeof schema, 'messages'> & {
|
||||
parts: (Entity<typeof schema, 'message_parts'> & {
|
||||
toolCall: Entity<typeof schema, 'tool_calls'> | null;
|
||||
})[];
|
||||
} & { generation: Entity<typeof schema, 'generations'> | null }
|
||||
>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['max-w-full mb-4', message.role === 'user' ? 'pl-9 flex justify-end' : '']">
|
||||
<MessageUser v-if="message.role === 'user'" :message="message" />
|
||||
<MessageAgent v-else :message="message" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { getModelConfig } from '~/utils/model-mapping';
|
||||
|
||||
const props = defineProps<{
|
||||
modelId: string;
|
||||
variant?: 'monochrome' | 'color';
|
||||
size?: string | number;
|
||||
avatar?: boolean;
|
||||
}>();
|
||||
|
||||
const config = computed(() => getModelConfig(props.modelId));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center">
|
||||
<component :is="config.icon" v-if="config.icon" :avatar="avatar" :size="size" :color="variant === 'color'" />
|
||||
<!-- Fallback if no logo matches -->
|
||||
<div v-else :style="{ width: `${props.size}px`, height: `${props.size}px` }" class="bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||
import { schema } from '#triplit/schema';
|
||||
import type { Entity } from '@triplit/client';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: ModelWithProvider | null;
|
||||
providers: ProviderWithModels[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [model: ModelWithProvider | null];
|
||||
}>();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const dropdownRef = ref<HTMLDivElement | null>(null);
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
const filteredProviders = computed(() => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
return props.providers;
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return props.providers
|
||||
.map((provider) => ({
|
||||
...provider,
|
||||
models: provider.models.filter((model) =>
|
||||
model.name.toLowerCase().includes(query)
|
||||
),
|
||||
}))
|
||||
.filter((provider) => provider.models.length > 0);
|
||||
});
|
||||
|
||||
const formatContextWindow = (window: number | null | undefined): string => {
|
||||
if (!window) return '';
|
||||
if (window >= 1000000) return `${(window / 1000000).toFixed(0)}M`;
|
||||
if (window >= 1000) return `${(window / 1000).toFixed(0)}K`;
|
||||
return window.toString();
|
||||
};
|
||||
|
||||
const hasCapability = (model: Entity<typeof schema, 'models'>, capability: string): boolean => {
|
||||
return model.attributes.capabilities.has(capability);
|
||||
};
|
||||
|
||||
const hasInputModality = (model: Entity<typeof schema, 'models'>, modality: string): boolean => {
|
||||
return model.attributes.inputModalities.has(modality);
|
||||
};
|
||||
|
||||
const selectModel = (model: Entity<typeof schema, 'models'>, provider: Entity<typeof schema, 'providers'>) => {
|
||||
selectedModel.value = { ...model, provider };
|
||||
isOpen.value = false;
|
||||
searchQuery.value = '';
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
isOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useClickOutside(dropdownRef, () => {
|
||||
isOpen.value = false;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="dropdownRef" class="relative">
|
||||
<button @click="isOpen = !isOpen"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors duration-200"
|
||||
:class="[
|
||||
isOpen
|
||||
? 'bg-[var(--color-highlight)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-subtle)] hover:text-[var(--color-text)] hover:bg-[var(--color-highlight-low)]',
|
||||
]">
|
||||
<ModelIcon v-if="selectedModel" :avatar="true" variant="color" :model-id="selectedModel.externalId"
|
||||
size="16" />
|
||||
<Icon v-else name="mynaui:warning-circle" class="text-4" />
|
||||
<span class="max-w-[150px] truncate">
|
||||
{{ selectedModel ? selectedModel.name : 'Select a model' }}
|
||||
</span>
|
||||
<Icon name="mynaui:chevron-down" class="text-3.5 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isOpen }" />
|
||||
</button>
|
||||
|
||||
<Transition enter-active-class="transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 translate-y-1">
|
||||
<div v-if="isOpen"
|
||||
class="transform-origin-bottom-center absolute bottom-full left-0 mb-2 max-w-[420px] w-full max-h-[460px] flex flex-col rounded-xl border border-[var(--color-highlight)] bg-[var(--color-neutral)] shadow-lg overflow-hidden z-50">
|
||||
<div>
|
||||
<div class="relative">
|
||||
<Icon name="mynaui:search"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--color-text-subtle)]" />
|
||||
<input ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search models..."
|
||||
class="w-full pl-9 pr-3 py-2 text-sm text-[var(--color-text)] bg-transparent placeholder-[var(--color-text-subtle)] outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-2 select-none">
|
||||
<div v-if="filteredProviders.length === 0"
|
||||
class="px-4 py-8 text-center text-sm text-[var(--color-muted)]">
|
||||
No models found
|
||||
</div>
|
||||
|
||||
<div v-for="provider in filteredProviders" :key="provider.id" class="mb-2">
|
||||
<div class="px-4 py-1.5 text-xs font-medium text-[var(--color-muted)] uppercase tracking-wider">
|
||||
{{ provider.name }}
|
||||
</div>
|
||||
|
||||
<button v-for="model in provider.models.filter(m => m.enabled)" :key="model.id"
|
||||
@click="selectModel(model, provider)"
|
||||
class="w-full px-4 py-2 flex items-center gap-3 hover:bg-[var(--color-highlight-low)] transition-colors duration-150"
|
||||
:class="{ 'bg-[var(--color-highlight-low)]': selectedModel?.id === model.id }">
|
||||
<ModelIcon :avatar="true" variant="color" :model-id="model.externalId" size="20" />
|
||||
|
||||
<span class="flex-1 text-sm text-left text-[var(--color-text)] truncate">
|
||||
{{ model.name }}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div v-if="hasInputModality(model, 'image')"
|
||||
class="w-4.5 h-4.5 bg-emerald/10 rounded flex items-center justify-center">
|
||||
<Icon name="mynaui:image" class="text-2.5 text-emerald" title="Vision" />
|
||||
</div>
|
||||
<div v-if="hasCapability(model, 'reasoning')"
|
||||
class="w-4.5 h-4.5 bg-[color-mix(in_srgb,_transparent_90%,_var(--reasoning-accent)_10%)] rounded flex items-center justify-center">
|
||||
<Icon name="mynaui:atom" class="text-2.5 text-[var(--reasoning-accent)]"
|
||||
title="Reasoning" />
|
||||
</div>
|
||||
<div v-if="hasCapability(model, 'tools')"
|
||||
class="w-4.5 h-4.5 bg-emerald/10 rounded flex items-center justify-center">
|
||||
<Icon name="mynaui:tool" class="text-2.5 text-sky" title="Tools" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span v-if="model.attributes.contextWindow"
|
||||
class="text-xs font-mono text-[var(--color-subtle)] px-1.5 py-0.5 rounded bg-[var(--color-highlight)]">
|
||||
{{ formatContextWindow(model.attributes.contextWindow) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-1 border-t border-[var(--color-highlight-low)]">
|
||||
<NuxtLink to="/settings/providers"
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm text-[var(--color-text-subtle)] hover:text-[var(--color-text)] hover:bg-[var(--color-highlight-low)] rounded-lg transition-colors duration-150"
|
||||
@click="isOpen = false">
|
||||
<Icon name="mynaui:cog-four" class="text-4" />
|
||||
<span>Manage Provider</span>
|
||||
<Icon name="mynaui:arrow-right" class="text-3.5 ml-auto" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
|
||||
import { providerBaseUrls } from '~/types/model';
|
||||
import { useSettings } from '~/composables/useSettings';
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const { pageParams } = useSettings();
|
||||
|
||||
const { providers } = await useModels();
|
||||
|
||||
const provider = computed(() => {
|
||||
if (pageParams.value.length === 0) return null;
|
||||
return providers.value!.find(p => p.id === pageParams.value[0]);
|
||||
});
|
||||
|
||||
watch(provider, async () => {
|
||||
if (!provider.value) return;
|
||||
await decryptApiKey();
|
||||
});
|
||||
|
||||
const apiKeyVisible = ref(false);
|
||||
|
||||
const apiKey = ref('');
|
||||
const apiProxyUrl = ref(provider.value!.config.apiProxyUrl ?? '');
|
||||
const modelSearch = ref('');
|
||||
|
||||
const providerApiUrl = computed(() => apiProxyUrl.value === '' ? providerBaseUrls[provider.value!.type] : apiProxyUrl.value);
|
||||
|
||||
const decryptApiKey = async () => {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
apiKey.value = await decrypt(key, base64ToUint8Array(provider.value!.config.apiKey));
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
await decryptApiKey();
|
||||
};
|
||||
|
||||
const toggleProvider = async () => {
|
||||
await triplit.update('providers', provider.value!.id, {
|
||||
enabled: !provider.value!.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
const updateApiKey = async (value: string) => {
|
||||
if (!provider.value) return;
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
const encypted = await encryptData(key, value);
|
||||
|
||||
await triplit.update('providers', provider.value.id, {
|
||||
config: {
|
||||
...provider.value.config,
|
||||
apiKey: uint8ArrayToBase64(encypted),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateProxyUrl = async (value: string) => {
|
||||
if (!provider.value) return;
|
||||
|
||||
await triplit.update('providers', provider.value.id, {
|
||||
config: {
|
||||
...provider.value.config,
|
||||
apiProxyUrl: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleModel = async (id: string) => {
|
||||
if (!provider.value) return;
|
||||
|
||||
await triplit.update('models', id, {
|
||||
enabled: !provider.value!.models.find(m => m.id === id)!.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchingModels = ref(false);
|
||||
|
||||
const fetchModels = async () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
fetchingModels.value = true;
|
||||
|
||||
try {
|
||||
const [providerResponse, devDataResponse] = await Promise.all([
|
||||
$fetch(`${providerApiUrl.value}/models`),
|
||||
$fetch('https://models.dev/api.json')
|
||||
]);
|
||||
|
||||
const providerType = provider.value!.type;
|
||||
const modelDetails = devDataResponse[providerType]?.models || {};
|
||||
|
||||
const existingModelsMap = new Map(
|
||||
(provider.value?.models || []).map((m: any) => [m.externalId, m])
|
||||
);
|
||||
|
||||
const toInsert: any[] = [];
|
||||
const toUpdate: { id: string, data: any }[] = [];
|
||||
|
||||
providerResponse.data.forEach((pModel: any) => {
|
||||
const slug = pModel.id.toLowerCase();
|
||||
const info = modelDetails[slug] || {};
|
||||
|
||||
console.log("INFO", info);
|
||||
|
||||
const capabilities = [];
|
||||
|
||||
if (info.reasoning) {
|
||||
capabilities.push('reasoning');
|
||||
}
|
||||
|
||||
if (info.tool_call) {
|
||||
capabilities.push('tools');
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
inputModalities: new Set(info.modalities?.input.filter(m => ['text', 'image'].includes(m)) || ['text']),
|
||||
outputModalities: new Set(info.modalities?.output.filter(m => ['text', 'image'].includes(m)) || ['text']),
|
||||
capabilities,
|
||||
contextWindow: pModel.context_length || info.limit?.context || null,
|
||||
supported_parameters: new Set(pModel.supported_parameters || ["temperature", "max_tokens"]),
|
||||
};
|
||||
|
||||
const existing = existingModelsMap.get(pModel.id);
|
||||
|
||||
if (existing) {
|
||||
// UPDATE logic: Remove 'id' from the payload as per Triplit requirements
|
||||
const { id, ...existingWithoutId } = existing;
|
||||
|
||||
toUpdate.push({
|
||||
id: existing.id,
|
||||
data: {
|
||||
...existingWithoutId,
|
||||
name: existing.name || info.name || pModel.name || pModel.id,
|
||||
attributes: attributes, // Update tech specs
|
||||
releasedAt: new Date(pModel.created * 1000),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// INSERT logic: This is a brand new model
|
||||
toInsert.push({
|
||||
userId: user.value?.id,
|
||||
providerId: provider.value!.id,
|
||||
externalId: pModel.id,
|
||||
name: info.name || pModel.name || pModel.id,
|
||||
isCustom: false,
|
||||
enabled: false,
|
||||
attributes: attributes,
|
||||
releasedAt: new Date(pModel.created * 1000),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
...toInsert.map(item => triplit.insert('models', item)),
|
||||
...toUpdate.map(item => triplit.update('models', item.id, (m) => {
|
||||
Object.assign(m, item.data);
|
||||
}))
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch models:', error);
|
||||
} finally {
|
||||
fetchingModels.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 mt-4">
|
||||
<div class="flex flex-row justify-between gap-16">
|
||||
<label class="whitespace-nowrap" for="provider-api-key">Enabled</label>
|
||||
<Slider :checked="provider!.enabled" @click.stop="toggleProvider()" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-between gap-16">
|
||||
<label class="whitespace-nowrap" for="provider-api-key">API Key</label>
|
||||
<div
|
||||
class="text-sm font-mono flex flex-row rounded-md bg-[var(--color-highlight)] items-center gap-1 w-7/10">
|
||||
<input class="w-full p-0 pl-2 py-1 bg-transparent" :type="apiKeyVisible ? 'text' : 'password'"
|
||||
id="provider-api-key" :value="apiKey"
|
||||
@input="updateApiKey(($event.target! as HTMLInputElement).value)" />
|
||||
<button @click="apiKeyVisible = !apiKeyVisible"
|
||||
class="text-sm p-2 text-[var(--color-muted)] hover:text-[var(--color-text)]">
|
||||
<Icon :name="apiKeyVisible ? 'mynaui:eye' : 'mynaui:eye-slash'" class="text-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-between gap-16">
|
||||
<label class="whitespace-nowrap" for="provider-api-key">API Proxy URL</label>
|
||||
<div
|
||||
class="text-sm font-mono flex flex-row rounded-md bg-[var(--color-highlight)] items-center gap-1 w-7/10">
|
||||
<input :placeholder="providerBaseUrls[provider!.type]" class="w-full px-2 py-1 bg-transparent"
|
||||
:type="apiKeyVisible ? 'text' : 'password'" id="provider-api-key" :value="apiProxyUrl"
|
||||
@input="updateProxyUrl(($event.target! as HTMLInputElement).value)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-center text-xs">
|
||||
<p class="text-[var(--color-muted)]">
|
||||
<Icon name="mynaui:lock" /> Your API key is encrypted using <a
|
||||
href="https://datatracker.ietf.org/doc/html/draft-ietf-avt-srtp-aes-gcm-01">AES-GCM</a> encryption.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="pt-5 justify-between w-full flex">
|
||||
<h4 class="whitespace-nowrap m-0">
|
||||
Model List
|
||||
<span class="text-sm text-[var(--color-muted)] font-normal text-xs">
|
||||
{{ provider?.models.length }} models available
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="modelSearch" type="text" class="px-2 py-1 bg-[var(--color-highlight)] text-xs"
|
||||
placeholder="Search models..." />
|
||||
|
||||
<button @click="fetchModels"
|
||||
class="whitespace-nowrap flex bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] text-sm rounded-md items-center px-2 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon :class="[fetchingModels ? 'animate-rotate' : '']" name="mynaui:refresh" />
|
||||
fetch models
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="provider?.models?.length === 0" class="flex flex-row items-center justify-center gap-2 mt-2">
|
||||
<Icon name="mynaui:info-circle" class="text-4" />
|
||||
<span class="text-sm text-[var(--color-muted)]">
|
||||
No models found
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-1 mt-2">
|
||||
<span class="text-sm text-[var(--color-muted)]">
|
||||
Enabled
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="p-3 flex items-center justify-between"
|
||||
v-for="model in provider?.models.filter(m => m.enabled === true).filter(m => !modelSearch || m.name.toLowerCase().includes(modelSearch.toLowerCase()))"
|
||||
:key="model.id">
|
||||
<div class="flex flex-row items-center">
|
||||
<div class="flex items-center">
|
||||
<ModelIcon :avatar="true" variant="color" :model-id="model.externalId" size="32" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 ml-2">
|
||||
<div
|
||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] flex items-center gap-1">
|
||||
{{ model.name }}
|
||||
<span
|
||||
class="text-xs text-[var(--color-muted)] px-1 py-0.5 rounded bg-[var(--color-highlight)]">
|
||||
{{ model.externalId }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-muted)]">
|
||||
Released on {{
|
||||
model.releasedAt?.toISOString().split('T')[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Slider :checked="model.enabled" @click="toggleModel(model.id)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="text-sm text-[var(--color-muted)]">
|
||||
Disabled
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="p-3 flex items-center justify-between"
|
||||
v-for="model in provider?.models.filter(m => m.enabled === false).filter(m => !modelSearch || m.name.toLowerCase().includes(modelSearch.toLowerCase()))"
|
||||
:key="model.id">
|
||||
<div class="flex flex-row items-center">
|
||||
<div class="flex items-center">
|
||||
<ModelIcon :avatar="true" variant="color" :model-id="model.externalId" size="32" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 ml-2">
|
||||
<div
|
||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] flex items-center gap-1">
|
||||
{{ model.name }}
|
||||
<span
|
||||
class="text-xs text-[var(--color-muted)] px-1 py-0.5 rounded bg-[var(--color-highlight)]">
|
||||
{{ model.externalId }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--color-muted)]">
|
||||
Released on {{ model.releasedAt?.toISOString().split('T')[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Slider :checked="model.enabled" @click="toggleModel(model.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.animate-rotate {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import GeneralSettings from './GeneralSettings.vue';
|
||||
import ProviderSettings from './ProviderSettings.vue';
|
||||
import ProviderSidebar from './ProviderSidebar.vue';
|
||||
import AIServiceProvider from './AIServiceProvider.vue';
|
||||
|
||||
const { providers } = await useModels();
|
||||
|
||||
const { currentPage, pageParams, open, setPage, close } = useSettings();
|
||||
|
||||
console.log(providers.value);
|
||||
|
||||
const PAGES_CONFIG = {
|
||||
general: {
|
||||
label: 'General',
|
||||
icon: 'mynaui:cog-four',
|
||||
component: GeneralSettings
|
||||
},
|
||||
providers: {
|
||||
label: 'AI Providers',
|
||||
icon: 'mynaui:api',
|
||||
component: ProviderSettings,
|
||||
sidebar: ProviderSidebar
|
||||
},
|
||||
} as const;
|
||||
|
||||
const runtimePage = computed(() => {
|
||||
// 1. Get the base config (e.g., 'providers' or 'general')
|
||||
const config = PAGES_CONFIG[currentPage.value as keyof typeof PAGES_CONFIG] || PAGES_CONFIG.general;
|
||||
|
||||
// 2. Determine the actual component to show
|
||||
let component = config.component;
|
||||
let label = config.label as string;
|
||||
|
||||
if (currentPage.value === 'providers' && pageParams.value.length > 0) {
|
||||
component = AIServiceProvider;
|
||||
const providerId = pageParams.value[0];
|
||||
console.log("PROVIDERS", providers.value);
|
||||
const provider = providers.value!.find(p => p.id === providerId);
|
||||
label = provider ? provider.name : 'Unknown Provider';
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
label,
|
||||
component,
|
||||
params: pageParams.value,
|
||||
} as {
|
||||
label: string;
|
||||
icon: string;
|
||||
component: Component;
|
||||
sidebar?: Component;
|
||||
params: string[];
|
||||
};
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
watch(open, (value) => {
|
||||
if (value) {
|
||||
document.body.addEventListener('keydown', handleKeyDown);
|
||||
} else {
|
||||
document.body.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (open.value) {
|
||||
document.body.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-if="open" class="fixed inset-0 z-45 bg-black/80 backdrop-blur-md" @click.self="close">
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-2" leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 -translate-y-2">
|
||||
<div v-if="open" class="z-50 fixed top-1/2 left-1/2 -translate-x-1/2 flex items-center justify-center">
|
||||
<div class="absolute w-[85vw] 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">
|
||||
<nav class="w-64 flex flex-col gap-1 mr-2">
|
||||
<!-- If the page has a custom sidebar (for nested lists), show it; otherwise show default nav -->
|
||||
<component v-if="runtimePage?.sidebar" :is="runtimePage.sidebar" @navigate="setPage" />
|
||||
|
||||
<button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="setPage(id)"
|
||||
:class="[currentPage === id ? 'bg-[var(--color-highlight)]' : 'hover:bg-[var(--color-highlight)]', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9']">
|
||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||
<Icon :name="config.icon" class="w-5 h-5" />
|
||||
{{ config.label }}
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- DYNAMIC CONTENT -->
|
||||
<main class="flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
class="flex-1 p-3 bg-[var(--color-neutral)] overflow-y-auto border rounded-lg border-[var(--color-highlight)]">
|
||||
<header class="flex items-center justify-between pl-2 pb-2 ">
|
||||
<h2 class="text-lg font-semibold m-0">{{ runtimePage.label }}</h2>
|
||||
<button
|
||||
class="hover:bg-[var(--color-highlight)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]"
|
||||
@click="close">
|
||||
<Icon name="mynaui:x-solid" />
|
||||
</button>
|
||||
</header>
|
||||
<!-- KeepAlive preserves state if the user clicks back/forth between tabs -->
|
||||
<KeepAlive>
|
||||
<component @navigate="setPage" :is="runtimePage.component" />
|
||||
</KeepAlive>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
const triplit = useTriplitClient();
|
||||
const { providers } = await useModels();
|
||||
|
||||
const toggleProvider = async (id: string) => {
|
||||
const provider = providers.value!.find(p => p.id === id);
|
||||
if (!provider) return;
|
||||
|
||||
await triplit.update('providers', provider.id, {
|
||||
enabled: !provider.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||
Enabled <span class="text-sm bg-[var(--color-highlight)] px-2 rounded-md py-0.5 text-[var(--color-muted)]">
|
||||
{{providers?.filter(p => p.enabled).length}}
|
||||
</span>
|
||||
</h2>
|
||||
<div
|
||||
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
||||
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => p.enabled)"
|
||||
:key="p.id"
|
||||
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-highlight)] hover:border-[var(--color-highlight-high)]">
|
||||
<div class="flex flex-col flex-grow">
|
||||
<h3 class="text-md font-semibold text-start">{{ p.name }}</h3>
|
||||
<hr class="border-t border-[var(--color-highlight)]" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<!-- <input type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-highlight)]" /> -->
|
||||
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||
Disabled <span class="text-sm bg-[var(--color-highlight)] px-2 rounded-md py-0.5 text-[var(--color-muted)]">
|
||||
{{providers?.filter(p => !p.enabled).length}}
|
||||
</span>
|
||||
</h2>
|
||||
<div
|
||||
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
||||
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => !p.enabled)"
|
||||
:key="p.id"
|
||||
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-highlight)] hover:border-[var(--color-highlight-high)]">
|
||||
<div class="flex flex-col flex-grow">
|
||||
<h3 class="text-md font-semibold text-start">{{ p.name }}</h3>
|
||||
<hr class="border-t border-[var(--color-highlight)]" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<!-- <input type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-highlight)]" /> -->
|
||||
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
const { pageParams } = useSettings();
|
||||
const { providers } = await useModels();
|
||||
|
||||
console.log("PROVIDERS", providers.value);
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button @click="$emit('navigate', 'general')"
|
||||
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon name="mynaui:chevron-left" class="text-4" /> Back to General
|
||||
</button>
|
||||
|
||||
<button @click="$emit('navigate', 'providers')"
|
||||
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Icon name="mynaui:envelope-open" class="text-4" /> All
|
||||
</button>
|
||||
|
||||
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Enabled Providers</div>
|
||||
|
||||
<button v-for="p in providers?.filter(p => p.enabled)" :key="p.id" @click="$emit('navigate', 'providers', p.id)"
|
||||
:class="['flex items-center justify-between p-2 hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === pageParams[0] ? 'bg-[var(--color-highlight)]' : '']">
|
||||
<span>{{ p.name }}</span>
|
||||
</button>
|
||||
|
||||
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Disabled Providers</div>
|
||||
|
||||
<button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
|
||||
@click="$emit('navigate', 'providers', p.id)"
|
||||
:class="['flex items-center justify-between p-2 hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === pageParams[0] ? 'bg-[var(--color-highlight)]' : '']">
|
||||
<span>{{ p.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,91 +0,0 @@
|
||||
<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>
|
||||
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-if="open" class="fixed inset-0 z-45 bg-black/80" @click.self="close">
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-2" leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 -translate-y-2">
|
||||
<div v-if="open" class="z-50 fixed top-1/2 left-1/2 -translate-x-1/2 flex items-center justify-center">
|
||||
<div class="absolute 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>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -1,45 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownItem } from '~/types/dropdown'
|
||||
import type { DropdownItem } from '~/types/dropdown';
|
||||
import { authClient } from '~~/lib/auth-client';
|
||||
|
||||
const { user, signOut } = useAuth()
|
||||
const { toggle: toggleSettings } = useSettings()
|
||||
const triplit = useTriplitClient();
|
||||
const { user } = await useAuth();
|
||||
|
||||
const hovering = defineModel<boolean>({ required: true })
|
||||
const { toggle: toggleSettings } = useSettings();
|
||||
|
||||
const profileOpen = ref(false)
|
||||
const hovering = defineModel<boolean>({ required: true });
|
||||
|
||||
const profileOpen = ref(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut()
|
||||
}
|
||||
await authClient.signOut();
|
||||
if ('endSession' in triplit) {
|
||||
await triplit.endSession();
|
||||
}
|
||||
|
||||
clearNuxtData();
|
||||
|
||||
await navigateTo('/auth/login');
|
||||
};
|
||||
|
||||
const profileItems: DropdownItem[] = [
|
||||
{ label: 'Settings', icon: 'mynaui:cog-four', onClick: toggleSettings },
|
||||
{ label: 'Log out', icon: 'mynaui:logout', divider: true, onClick: handleLogout },
|
||||
]
|
||||
{
|
||||
label: 'Log out',
|
||||
icon: 'mynaui:logout',
|
||||
divider: true,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex items-center justify-between overflow-hidden">
|
||||
<Dropdown v-model="profileOpen" :items="profileItems" placement="left" verticality="descending" width="100%">
|
||||
<Dropdown class="overflow-hidden" v-model="profileOpen" :items="profileItems" placement="left"
|
||||
verticality="descending" width="100%">
|
||||
<template #trigger="{ toggle }">
|
||||
<div role="button" aria-label="open user dropdown"
|
||||
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-highlight)] cursor-pointer transition-colors max-w-full"
|
||||
<button aria-label="open user dropdown"
|
||||
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] cursor-pointer transition-colors max-w-full"
|
||||
@click="toggle">
|
||||
<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)]" />
|
||||
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--color-muted)]" />
|
||||
</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',
|
||||
<div :class="['flex-shrink-0 w-4 h-4 text-[var(--color-muted)] 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>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</header>
|
||||
|
||||
@@ -1,36 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownItem } from '~/types/dropdown'
|
||||
import type { DropdownItem } from '~/types/dropdown';
|
||||
|
||||
const { user } = useAuth()
|
||||
const { agents, activeAgent } = await useAgents()
|
||||
const homeButtonRef = ref<HTMLElement | null>(null)
|
||||
const route = useRoute();
|
||||
const { agents, getAgent } = await useAgents();
|
||||
const homeButtonRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const hovering = defineModel<boolean>({ required: true })
|
||||
const initialized = ref(false)
|
||||
const activeAgent = computed(() => getAgent(route.params.id as string));
|
||||
|
||||
const hovering = defineModel<boolean>({ required: true });
|
||||
const initialized = ref(false);
|
||||
|
||||
let lastHovering: boolean | null = null;
|
||||
onMounted(() => {
|
||||
if (hovering.value) {
|
||||
const width = homeButtonRef.value!.scrollWidth
|
||||
homeButtonRef.value!.style.width = `calc(${width}px + 0.5rem)`
|
||||
console.log(hovering.value);
|
||||
|
||||
if (hovering.value && homeButtonRef.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 (lastHovering === value) {
|
||||
console.warn('Hovering value did not change, but watcher was triggered');
|
||||
}
|
||||
console.log(value, lastHovering);
|
||||
lastHovering = value;
|
||||
|
||||
if (!initialized.value) {
|
||||
initialized.value = true;
|
||||
}
|
||||
|
||||
if (!homeButtonRef.value) return;
|
||||
|
||||
if (value) {
|
||||
const width = homeButtonRef.value!.scrollWidth
|
||||
homeButtonRef.value!.style.width = `calc(${width}px + 0.5rem)`
|
||||
const width = homeButtonRef.value.scrollWidth;
|
||||
homeButtonRef.value.style.width = `calc(${width}px + 0.5rem)`;
|
||||
} else {
|
||||
homeButtonRef.value!.style.width = '0'
|
||||
homeButtonRef.value.style.width = '0';
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
const agentDropdownOpen = ref(false)
|
||||
onUnmounted(() => {
|
||||
console.log('unmounted');
|
||||
});
|
||||
|
||||
const agentItems: DropdownItem[] = []
|
||||
const agentDropdownOpen = ref(false);
|
||||
|
||||
const agentItems: DropdownItem[] = [];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -38,14 +55,16 @@ const agentItems: DropdownItem[] = []
|
||||
<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">
|
||||
class="flex hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg decoration-none transition-inherit text-[var(--color-muted)] p-1.5">
|
||||
<Icon name="mynaui:chevron-left" class="w-4.5 h-4.5" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<Dropdown v-model="agentDropdownOpen" :items="agentItems" placement="center" width="calc(80% - 1rem)">
|
||||
<Dropdown class="overflow-hidden" v-model="agentDropdownOpen" :items="agentItems" placement="center"
|
||||
width="calc(80% - 1rem)">
|
||||
<template #trigger="{ toggle }">
|
||||
<div class="flex overflow-hidden gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-highlight)] rounded-lg"
|
||||
<button
|
||||
class="flex max-w-full gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg"
|
||||
@click="toggle">
|
||||
<div
|
||||
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-highlight-high)]']">
|
||||
@@ -57,20 +76,18 @@ const agentItems: DropdownItem[] = []
|
||||
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)]">
|
||||
<div class="w-4 h-4 text-[var(--color-muted)]">
|
||||
<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>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<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>
|
||||
<SidenavItem draggable="false" v-for="agent in agents" :to="`/agent/${agent.id}`" :name="agent.name"
|
||||
class="whitespace-nowrap" icon="mynaui:check-hexagon" :key="agent.id"
|
||||
:active="activeAgent?.id === agent.id" />
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
|
||||
@@ -1,17 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
name: string,
|
||||
icon: string,
|
||||
active?: boolean
|
||||
}>()
|
||||
name: string;
|
||||
icon?: string;
|
||||
to?: 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" />
|
||||
<NuxtLink v-if="props.to" v-bind="$attrs" :to="props.to" :aria-label="props.name" :class="[
|
||||
'decoration-none text-[var(--color-muted)] flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
|
||||
props.icon ? 'px-1' : 'px-2',
|
||||
props.active
|
||||
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
|
||||
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
|
||||
]">
|
||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
|
||||
<Icon class="text-4.5" :name="props.icon" />
|
||||
</div>
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
|
||||
}}</span>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-medium overflow-hidden text-ellipsis">{{ props.name }}</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<button v-else v-bind="$attrs" :aria-label="props.name" :class="[
|
||||
'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9',
|
||||
props.icon ? 'px-1' : 'px-2',
|
||||
props.active
|
||||
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
|
||||
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
|
||||
]">
|
||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
|
||||
<Icon class="text-4.5" :name="props.icon" />
|
||||
</div>
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
|
||||
}}</span>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,5 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const topicsListRef = ref<HTMLElement | null>(null);
|
||||
const topicsListHeight = ref('auto');
|
||||
const topicsListOpacity = ref(1);
|
||||
const topicsListScale = ref(1);
|
||||
const { getAgent } = await useAgents();
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const activeAgent = computed(() => getAgent(route.params.id as string));
|
||||
|
||||
const topics = computed(() => {
|
||||
if (activeAgent.value === undefined) return [];
|
||||
// return the todos but sorted and in a new array do not add messages or anything to the object, JUST SORT IT
|
||||
return activeAgent.value.topics
|
||||
.map((topic) => topic)
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.reverse();
|
||||
});
|
||||
|
||||
const routeParts = computed(() => {
|
||||
return route.path.replace('/agent/', '').split('/');
|
||||
@@ -18,19 +36,122 @@ const pageInfo = computed(() => {
|
||||
return 'agent-profile';
|
||||
}
|
||||
});
|
||||
|
||||
const topicsOpen = ref(true);
|
||||
|
||||
function easeInOutQuad(x: number): number {
|
||||
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
|
||||
}
|
||||
|
||||
const toggleAgentsList = () => {
|
||||
if (!topicsListRef.value) return;
|
||||
const animationLength = 200;
|
||||
let animationStart: number | null = null;
|
||||
|
||||
let startHeight: number;
|
||||
const startOpacity = topicsListOpacity.value;
|
||||
const startScale = topicsListScale.value;
|
||||
if (topicsListHeight.value === 'auto') {
|
||||
startHeight = topicsListRef.value.clientHeight;
|
||||
} else {
|
||||
startHeight = Number(topicsListHeight.value.replace('px', ''));
|
||||
}
|
||||
|
||||
const targetHeight = topicsOpen.value ? 0 : topicsListRef.value.scrollHeight;
|
||||
const targetOpacity = topicsOpen.value ? 0 : 1;
|
||||
const targetScale = topicsOpen.value ? 0.95 : 1;
|
||||
topicsOpen.value = !topicsOpen.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);
|
||||
|
||||
topicsListOpacity.value = currentOpacity;
|
||||
topicsListScale.value = currentScale;
|
||||
topicsListHeight.value = `${currentHeight}px`;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
if (topicsOpen.value) {
|
||||
topicsListHeight.value = 'auto';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const renameTopic = (topicId: string) => {
|
||||
console.log('renameTopic', topicId);
|
||||
};
|
||||
|
||||
const deleteTopic = async (topicId: string) => {
|
||||
if (route.params.topicId === topicId) {
|
||||
if (route.params.id) {
|
||||
await navigateTo(`/agent/${route.params.id}`);
|
||||
} else {
|
||||
await navigateTo('/');
|
||||
}
|
||||
}
|
||||
await triplit.delete('topics', topicId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex flex-col gap-1">
|
||||
|
||||
<!-- Agent Info Link -->
|
||||
<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>
|
||||
<div class="mt-2 flex flex-col">
|
||||
<SidenavItem :to="`/agent/${routeParts[0]}/profile`" name="Agent Info" icon="mynaui:info-square"
|
||||
:active="pageInfo === 'agent-profile'" />
|
||||
|
||||
<!-- Topics Section -->
|
||||
<SidenavNavAgentTopics />
|
||||
<!-- Topics Section -->
|
||||
<button @click="toggleAgentsList"
|
||||
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors w-full text-left">
|
||||
<span class="text-sm font-medium">Topics</span>
|
||||
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', topicsOpen ? '' : '-rotate-90']" />
|
||||
</button>
|
||||
|
||||
<div ref="topicsListRef" :inert="!topicsOpen"
|
||||
:style="{ height: topicsListHeight, opacity: topicsListOpacity, transform: `scale(${topicsListScale})` }"
|
||||
class="mt-1 gap-1 flex flex-col transform-origin-center-top overflow-y-hidden">
|
||||
<SidenavItem draggable="false" class="[&>div>div>div>[dots]]:hover:opacity-100 relative"
|
||||
v-if="activeAgent?.topics !== undefined" v-for="topic in topics"
|
||||
:to="`/agent/${activeAgent.id}/topic/${topic.id}`" :active="topic.id === route.params.topicId"
|
||||
:name="topic.name" :key="topic.id">
|
||||
<Dropdown class="shrink-0" verticality="descending" placement="right">
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<div dots @click.prevent.stop="toggle"
|
||||
class="opacity-0 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
|
||||
viewBox="0 0 24 24"><!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ -->
|
||||
<path fill="currentColor"
|
||||
d="M7 12a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="shadow-lg rounded p-1 flex flex-col min-w-[120px] gap-1">
|
||||
<button @click.prevent="renameTopic(topic.id)"
|
||||
class="text-left px-3 py-1.5 text-sm hover:bg-[var(--color-highlight)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
Rename
|
||||
</button>
|
||||
<button @click.prevent="deleteTopic(topic.id)"
|
||||
class="text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-600/20 rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</SidenavItem>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const appState = useAppState();
|
||||
const topicsListRef = ref<HTMLElement | null>(null);
|
||||
const topicsListHeight = ref('auto');
|
||||
const topicsListOpacity = ref(1);
|
||||
const topicsListScale = ref(1);
|
||||
const { topicsForActiveAgent, createTopic } = await useTopics();
|
||||
const { activeAgent } = await useAgents();
|
||||
|
||||
const creatingTopic = ref(false);
|
||||
const topicsOpen = ref(true);
|
||||
|
||||
function easeInOutQuad(x: number): number {
|
||||
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
|
||||
}
|
||||
|
||||
const toggleAgentsList = () => {
|
||||
if (!topicsListRef.value) return;
|
||||
let animationLength = 200;
|
||||
let animationStart: number | null = null;
|
||||
|
||||
let startHeight: number;
|
||||
let startOpacity = topicsListOpacity.value;
|
||||
let startScale = topicsListScale.value;
|
||||
if (topicsListHeight.value === 'auto') {
|
||||
startHeight = topicsListRef.value.clientHeight;
|
||||
} else {
|
||||
startHeight = Number(topicsListHeight.value.replace('px', ''));
|
||||
}
|
||||
|
||||
let targetHeight = topicsOpen.value ? 0 : topicsListRef.value.scrollHeight;
|
||||
let targetOpacity = topicsOpen.value ? 0 : 1;
|
||||
let targetScale = topicsOpen.value ? 0.95 : 1;
|
||||
topicsOpen.value = !topicsOpen.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);
|
||||
|
||||
topicsListOpacity.value = currentOpacity;
|
||||
topicsListScale.value = currentScale;
|
||||
topicsListHeight.value = `${currentHeight}px`;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
if (topicsOpen.value) {
|
||||
topicsListHeight.value = 'auto';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
const newTopic = async () => {
|
||||
if (!activeAgent.value) return;
|
||||
|
||||
creatingTopic.value = true;
|
||||
try {
|
||||
const newTopic = await createTopic('New Topic', activeAgent.value.id);
|
||||
// Navigate to new topic
|
||||
await navigateTo(`/agent/${activeAgent.value.id}/topic/${newTopic.id}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to create topic:', error);
|
||||
} finally {
|
||||
creatingTopic.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Header -->
|
||||
<button @click="toggleAgentsList"
|
||||
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] transition-colors w-full text-left">
|
||||
<span class="text-sm font-medium">Topics</span>
|
||||
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', topicsOpen ? '' : '-rotate-90']" />
|
||||
</button>
|
||||
|
||||
<div ref="topicsListRef"
|
||||
:style="{ height: topicsListHeight, opacity: topicsListOpacity, transform: `scale(${topicsListScale})` }"
|
||||
class="mt-1 gap-1 flex flex-col overflow-hidden transform-origin-center-top">
|
||||
<button @click="newTopic" :disabled="creatingTopic"
|
||||
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--color-subtle)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors disabled:opacity-50 w-full">
|
||||
<div class="h-7 w-7 flex items-center justify-center">
|
||||
<Icon v-if="creatingTopic" class="text-4.5" name="svg-spinners:ring-resize" />
|
||||
<Icon v-else class="text-4.5" name="mynaui:plus" />
|
||||
</div>
|
||||
<span>{{ creatingTopic ? 'Creating...' : 'New Topic' }}</span>
|
||||
</button>
|
||||
|
||||
<NuxtLink v-for="topic in topicsForActiveAgent" :to="`/agent/${activeAgent?.id}/topic/${topic.id}`"
|
||||
class="decoration-none text-[var(--color-subtle)]">
|
||||
<SidenavItem :name="topic.name" icon="mynaui:check-hexagon" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,33 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
const { agents, createAgent } = await useAgents()
|
||||
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)
|
||||
const { agents } = await useAgents();
|
||||
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;
|
||||
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
|
||||
}
|
||||
|
||||
const toggleAgentsList = () => {
|
||||
if (!agentsListRef.value) return;
|
||||
let animationLength = 200;
|
||||
const animationLength = 200;
|
||||
let animationStart: number | null = null;
|
||||
|
||||
let startHeight: number;
|
||||
let startOpacity = agentsListOpacity.value;
|
||||
let startScale = agentsListScale.value;
|
||||
const startOpacity = agentsListOpacity.value;
|
||||
const 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;
|
||||
const targetHeight = agentsOpen.value ? 0 : agentsListRef.value.scrollHeight;
|
||||
const targetOpacity = agentsOpen.value ? 0 : 1;
|
||||
const targetScale = agentsOpen.value ? 0.95 : 1;
|
||||
agentsOpen.value = !agentsOpen.value;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
@@ -54,36 +54,59 @@ const toggleAgentsList = () => {
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
const newAgent = async () => {
|
||||
creatingAgent.value = true;
|
||||
const agent = await createAgent();
|
||||
creatingAgent.value = false;
|
||||
navigateTo(`/agent/${agent!.id}`);
|
||||
}
|
||||
const triplit = useTriplitClient();
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user.value) throw new Error('User not logged in');
|
||||
|
||||
const agent = await triplit.insert('agents', {
|
||||
name: 'New Agent',
|
||||
userId: user.value.id,
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
imageUrl: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
console.log(agents.value, agent);
|
||||
if (!agent) throw new Error('Failed to create agent');
|
||||
|
||||
let agentExists: () => void;
|
||||
const agentExistsPromise = new Promise<void>((resolve) => {
|
||||
agentExists = resolve;
|
||||
});
|
||||
|
||||
watch(agents, () => {
|
||||
agentExists();
|
||||
});
|
||||
|
||||
await agentExistsPromise;
|
||||
|
||||
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>
|
||||
<SidenavItem to="/" :active="true" name="Home" icon="mynaui:home" />
|
||||
|
||||
<!-- Agents Section -->
|
||||
<div class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] transition-colors w-full text-left"
|
||||
<button
|
||||
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors w-full text-left"
|
||||
@click="toggleAgentsList()">
|
||||
<span class="text-sm">Agents</span>
|
||||
<Icon name="mynaui:chevron-down"
|
||||
:class="['w-4 h-4 transition-transform duration-250 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center', agentsOpen ? '' : '-rotate-90']" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div ref="agentsListRef"
|
||||
<div ref="agentsListRef" :inert="!agentsOpen"
|
||||
:style="{ height: agentsListHeight, opacity: agentsListOpacity, transform: `scale(${agentsListScale})` }"
|
||||
class="mt-1 gap-1 flex flex-col overflow-hidden transform-origin-center-top">
|
||||
class="mt-1 gap-1 flex flex-col transform-origin-center-top overflow-y-hidden">
|
||||
<button @click="newAgent" :disabled="creatingAgent"
|
||||
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--color-subtle)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors disabled:opacity-50 w-full">
|
||||
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--color-muted)] bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors disabled:opacity-50 w-full">
|
||||
<div class="h-7 w-7 flex items-center justify-center">
|
||||
<Icon v-if="creatingAgent" class="text-4.5" name="svg-spinners:ring-resize" />
|
||||
<Icon v-else class="text-4.5" name="mynaui:plus" />
|
||||
@@ -91,10 +114,9 @@ const newAgent = async () => {
|
||||
<span>New Agent</span>
|
||||
</button>
|
||||
|
||||
<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>
|
||||
<SidenavItem
|
||||
v-for="agent in agents?.map((agent) => agent)?.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())"
|
||||
:to="`/agent/${agent.id}`" :key="agent.id" :name="agent.name" icon="mynaui:check-hexagon" />
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,93 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar()
|
||||
const route = useRoute()
|
||||
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar();
|
||||
const route = useRoute();
|
||||
|
||||
const isResizing = ref(false)
|
||||
const startX = ref(0)
|
||||
const initialWidth = ref(0)
|
||||
const isResizing = ref(false);
|
||||
const startX = ref(0);
|
||||
const initialWidth = ref(0);
|
||||
|
||||
const closeSidenavRef = ref<HTMLElement | null>(null)
|
||||
const sidenavRef = ref<HTMLElement | null>(null);
|
||||
const closeSidenavRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const { toggle: toggleSettings } = useSettings();
|
||||
|
||||
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'
|
||||
}
|
||||
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 (!isResizing.value) return;
|
||||
if (resizeAnimationFrame) return;
|
||||
|
||||
resizeAnimationFrame = requestAnimationFrame(() => {
|
||||
const deltaX = event.clientX - startX.value
|
||||
const newWidth = initialWidth.value + deltaX
|
||||
resize(newWidth)
|
||||
resizeAnimationFrame = null
|
||||
})
|
||||
}
|
||||
const deltaX = event.clientX - startX.value;
|
||||
const newWidth = initialWidth.value + deltaX;
|
||||
resize(newWidth);
|
||||
resizeAnimationFrame = null;
|
||||
});
|
||||
};
|
||||
|
||||
const onResizeEnd = () => {
|
||||
if (!isResizing.value) return
|
||||
if (!isResizing.value) return;
|
||||
|
||||
isResizing.value = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
saveWidth()
|
||||
}
|
||||
isResizing.value = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
saveWidth();
|
||||
};
|
||||
|
||||
let resizeAnimationFrame: number | null = null
|
||||
let resizeAnimationFrame: number | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', onResizeMove)
|
||||
document.addEventListener('mouseup', onResizeEnd)
|
||||
document.addEventListener('mousemove', onResizeMove);
|
||||
document.addEventListener('mouseup', onResizeEnd);
|
||||
|
||||
watch(hovering, (value) => {
|
||||
if (!closeSidenavRef.value) return;
|
||||
|
||||
if (value) {
|
||||
const width = closeSidenavRef.value!.scrollWidth
|
||||
closeSidenavRef.value!.style.width = `${width}px`
|
||||
const width = closeSidenavRef.value.scrollWidth;
|
||||
closeSidenavRef.value.style.width = `${width}px`;
|
||||
} else {
|
||||
closeSidenavRef.value!.style.width = '0'
|
||||
closeSidenavRef.value.style.width = '0';
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onResizeMove)
|
||||
document.removeEventListener('mouseup', onResizeEnd)
|
||||
})
|
||||
document.removeEventListener('mousemove', onResizeMove);
|
||||
document.removeEventListener('mouseup', onResizeEnd);
|
||||
});
|
||||
|
||||
const hovering = ref(false)
|
||||
const hovering = ref(false);
|
||||
|
||||
const navKind = computed(() => {
|
||||
if (route.path === '/') return 'home'
|
||||
if (route.path.startsWith('/agent/')) return 'agent'
|
||||
return null
|
||||
})
|
||||
if (route.path === '/') return 'home';
|
||||
if (route.path.startsWith('/agent/')) return 'agent';
|
||||
return null;
|
||||
});
|
||||
|
||||
const onFocusOut = (e: FocusEvent) => {
|
||||
const isMovingOutside = sidenavRef.value && !sidenavRef.value.contains(e.relatedTarget as Node);
|
||||
if (isMovingOutside) {
|
||||
hovering.value = false;
|
||||
}
|
||||
};
|
||||
</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',
|
||||
<aside ref="sidenavRef" :class="[
|
||||
'h-full max-w-fit bg-[var(--color-base)] overflow-hidden will-change-width text-[var(--color-muted)] 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">
|
||||
@mouseleave="hovering = false" @focusin="hovering = true" @focusout="onFocusOut">
|
||||
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col h-full max-h-full overflow-y-hidden">
|
||||
<!-- Header -->
|
||||
<div class="relative flex flex-row gap-2 justify-between items-center mb-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 class="flex items-center justify-end text-[var(--color-muted)] 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',
|
||||
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] scale-100 bg-transparent transition-inherit',
|
||||
]">
|
||||
<Icon name="mynaui:panel-left-close"
|
||||
:class="['transition-inherit', hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" />
|
||||
@@ -95,7 +107,7 @@ const navKind = computed(() => {
|
||||
</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',
|
||||
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] bg-transparent text-inherit',
|
||||
]">
|
||||
<Icon name="mynaui:book-plus" />
|
||||
</NuxtLink>
|
||||
@@ -104,15 +116,24 @@ const navKind = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Main Menu -->
|
||||
<div class="max-h-full overflow-auto">
|
||||
<div class="max-h-full h-full overflow-auto" style="scrollbar-width: thin;">
|
||||
<SidenavNavHome v-if="navKind === 'home'" />
|
||||
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Switcher -->
|
||||
<div class="flex justify-end">
|
||||
<ThemeSwitcher />
|
||||
<div class="flex justify-between pt-2">
|
||||
<div class="flex">
|
||||
<button @click="toggleSettings()"
|
||||
class="flex items-center justify-center h-7 w-7 cursor-pointer hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg transition-colors text-[var(--color-muted)] active:text-[var(--color-text)]">
|
||||
<Icon name="mynaui:cog-four" class="text-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Theme Switcher -->
|
||||
<div class="flex justify-end gap-1">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
checked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
id: {
|
||||
type: String
|
||||
},
|
||||
label: {
|
||||
type: String
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const active = ref(props.checked);
|
||||
|
||||
watch(() => props.checked, (newValue) => {
|
||||
active.value = newValue;
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button role="switch" class="vl-toggle-switch" :aria-disabled="(props.disabled === true) ? 'true' : 'false'"
|
||||
:aria-label="label" :aria-labelledby="id" :tabindex="(disabled) ? '-1' : '0'" @click="(e) => $emit('click', e)"
|
||||
:aria-checked="active" :data-state="(active) ? 'checked' : 'unchecked'">
|
||||
<div></div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vl-toggle-switch {
|
||||
font-size: inherit;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
width: 2.5em;
|
||||
height: 1.4em;
|
||||
background: var(--color-highlight);
|
||||
border-radius: 100px;
|
||||
padding: 0.125rem 0.25rem;
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.vl-toggle-switch[aria-disabled="true"] div {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vl-toggle-switch div {
|
||||
position: relative;
|
||||
left: 0;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
background: #f7f7f7;
|
||||
border-radius: 90px;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.vl-toggle-switch[data-state="checked"] {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.vl-toggle-switch[data-state="checked"] div {
|
||||
left: 100%;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.vl-toggle-switch:active div {
|
||||
width: 1.3em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownItem } from '~/types/dropdown'
|
||||
import type { DropdownItem } from '~/types/dropdown';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system'
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const colorMode = useColorMode();
|
||||
|
||||
const themeOptions: DropdownItem[] = [
|
||||
{ value: 'light', label: 'Light', icon: 'mynaui:sun' },
|
||||
{ value: 'dark', label: 'Dark', icon: 'mynaui:moon' },
|
||||
{ value: 'system', label: 'System', icon: 'mynaui:desktop' }
|
||||
]
|
||||
{ value: 'system', label: 'System', icon: 'mynaui:desktop' },
|
||||
];
|
||||
|
||||
const currentOption = computed(() =>
|
||||
themeOptions.find(option => option.value === colorMode.preference) || themeOptions[2]
|
||||
)
|
||||
const currentOption = computed(
|
||||
() => themeOptions.find((option) => option.value === colorMode.preference) || themeOptions[2],
|
||||
);
|
||||
|
||||
const isOpen = ref(false)
|
||||
const isOpen = ref(false);
|
||||
|
||||
const selectTheme = (item: DropdownItem) => {
|
||||
colorMode.preference = item.value as Theme
|
||||
}
|
||||
colorMode.preference = item.value as Theme;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown class="relative" v-model="isOpen" @select="selectTheme" :items="themeOptions" verticality="asscending"
|
||||
placement="right" width="140px">
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<button aria-label="Open theme switcher" @click="toggle"
|
||||
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 aria-label="Open theme switcher" @click="toggle" :class="[isOpen ? 'bg-[var(--color-highlight)] hover:text-[var(--color-subtle)] focus-visible:text-[var(--color-subtle)]' : 'bg-transparent text-[var(--color-muted)]',
|
||||
'h-7 w-7 flex items-center justify-center rounded-lg hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors active:text-[var(--color-text)]'
|
||||
]">
|
||||
<Icon :name="currentOption!.icon!" class="text-4" />
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
+10
-103
@@ -1,107 +1,14 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Agent } from '~~/types'
|
||||
|
||||
export const useAgents = async () => {
|
||||
const { addTask, completeTask } = useTasks()
|
||||
const appState = useAppState()
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const fetchingAgents = ref(false);
|
||||
const agents: Ref<Agent[] | null> = useState('agents', () => null);
|
||||
|
||||
const activeAgent = computed(() => {
|
||||
if (agents.value === null) return;
|
||||
if (!appState.activeAgentId.value) return;
|
||||
const { results: agents } = await useQuery('agents', triplit, triplit.query('agents').Include('topics'));
|
||||
|
||||
const agent = agents.value.find(agent => agent.id === appState.activeAgentId.value);
|
||||
return agent;
|
||||
});
|
||||
const getAgent = (id: string) => {
|
||||
return agents.value?.find((a) => a.id === id);
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
return {
|
||||
agents,
|
||||
getAgent,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { User, Session } from 'better-auth/types';
|
||||
|
||||
/**
|
||||
* Central application state composable
|
||||
* Manages navigation context and critical app-level state
|
||||
* This is the single source of truth for "what am I viewing"
|
||||
*/
|
||||
export const useAppState = () => {
|
||||
// Current navigation context
|
||||
const activeAgentId = useState<string | null>('appState:activeAgentId', () => null);
|
||||
const activeTopicId = useState<string | null>('appState:activeTopicId', () => null);
|
||||
|
||||
// User data
|
||||
const user = useState<User | null>('appState:user', () => null);
|
||||
const session = useState<Session | null>('appState:session', () => null);
|
||||
|
||||
// Loading states
|
||||
const isInitializing = useState<boolean>('appState:isInitializing', () => true);
|
||||
const generationInProgress = useState<{ generationId: string } | null>('appState:generationInProgress', () => null);
|
||||
|
||||
/**
|
||||
* Set the active agent and clear the topic
|
||||
*/
|
||||
const setActiveAgent = (agentId: string | null | undefined) => {
|
||||
activeAgentId.value = agentId || null;
|
||||
// Clear topic when switching agents
|
||||
activeTopicId.value = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the active topic
|
||||
*/
|
||||
const setActiveTopic = (topicId: string | null | undefined) => {
|
||||
activeTopicId.value = topicId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set user session data
|
||||
*/
|
||||
const setUser = (userData: User | null) => {
|
||||
user.value = userData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set session
|
||||
*/
|
||||
const setSession = (sessionData: Session | null) => {
|
||||
session.value = sessionData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark initialization complete
|
||||
*/
|
||||
const markInitialized = () => {
|
||||
isInitializing.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a generation
|
||||
*/
|
||||
const startGeneration = (generationId: string) => {
|
||||
generationInProgress.value = { generationId };
|
||||
};
|
||||
|
||||
/**
|
||||
* End current generation
|
||||
*/
|
||||
const endGeneration = () => {
|
||||
generationInProgress.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
activeAgentId,
|
||||
activeTopicId,
|
||||
user,
|
||||
session,
|
||||
isInitializing,
|
||||
generationInProgress,
|
||||
|
||||
// Actions
|
||||
setActiveAgent,
|
||||
setActiveTopic,
|
||||
setUser,
|
||||
setSession,
|
||||
markInitialized,
|
||||
startGeneration,
|
||||
endGeneration
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { BetterAuthClientOptions, InferSessionFromClient, InferUserFromClient } from 'better-auth/client';
|
||||
import { authClient } from '~~/lib/auth-client';
|
||||
|
||||
export const useAuth = () => {
|
||||
const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null);
|
||||
const user = useState<InferUserFromClient<BetterAuthClientOptions> | null>('auth:user', () => null);
|
||||
const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false);
|
||||
|
||||
const fetchSession = async () => {
|
||||
if (sessionFetching.value) {
|
||||
console.log('already fetching session');
|
||||
return;
|
||||
}
|
||||
sessionFetching.value = true;
|
||||
let data: {
|
||||
session: InferSessionFromClient<BetterAuthClientOptions>;
|
||||
user: InferUserFromClient<BetterAuthClientOptions>;
|
||||
} | null = null;
|
||||
if (import.meta.server) {
|
||||
data =
|
||||
(
|
||||
await useFetch<{
|
||||
session: InferSessionFromClient<BetterAuthClientOptions>;
|
||||
user: InferUserFromClient<BetterAuthClientOptions>;
|
||||
}>('/api/auth/get-session')
|
||||
).data.value ?? null;
|
||||
} else {
|
||||
data = (await authClient.getSession()).data;
|
||||
}
|
||||
session.value = data?.session || null;
|
||||
user.value = data?.user || null;
|
||||
sessionFetching.value = false;
|
||||
return data;
|
||||
};
|
||||
|
||||
if (import.meta.client) {
|
||||
authClient.$store.listen('$sessionSignal', async (signal) => {
|
||||
if (!signal) return;
|
||||
await fetchSession();
|
||||
|
||||
if (!session.value) return;
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
if ('updateOptions' in triplit) {
|
||||
triplit.updateOptions({
|
||||
token: session.value.token,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
loggedIn: computed(() => !!session.value),
|
||||
signIn: authClient.signIn,
|
||||
signUp: authClient.signUp,
|
||||
async signOut() {
|
||||
await authClient.signOut();
|
||||
session.value = null;
|
||||
user.value = null;
|
||||
return navigateTo('/auth/login');
|
||||
},
|
||||
fetchSession,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ref, watch, onUnmounted, type Ref } from 'vue';
|
||||
|
||||
export function useAutoScroll(elementRef: Ref<HTMLElement | null>) {
|
||||
const userIsScrollingUp = ref(false);
|
||||
const THRESHOLD = 50;
|
||||
|
||||
const isAtBottom = () => {
|
||||
const el = elementRef.value;
|
||||
if (!el) return false;
|
||||
const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
return distanceToBottom <= THRESHOLD;
|
||||
};
|
||||
|
||||
const scrollToBottom = (behavior: ScrollBehavior = 'auto') => {
|
||||
const el = elementRef.value;
|
||||
if (!el) return;
|
||||
el.scrollTo({
|
||||
top: el.scrollHeight,
|
||||
behavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = elementRef.value;
|
||||
if (!el) return;
|
||||
|
||||
userIsScrollingUp.value = !isAtBottom();
|
||||
};
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
|
||||
watch(elementRef, (newEl, oldEl) => {
|
||||
if (oldEl) {
|
||||
oldEl.removeEventListener('scroll', handleScroll);
|
||||
observer?.disconnect();
|
||||
}
|
||||
|
||||
if (newEl) {
|
||||
newEl.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
observer = new MutationObserver(() => {
|
||||
if (!userIsScrollingUp.value) {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(newEl, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
elementRef.value?.removeEventListener('scroll', handleScroll);
|
||||
observer?.disconnect();
|
||||
});
|
||||
|
||||
return {
|
||||
userIsScrollingUp,
|
||||
scrollToBottom,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import type schema from "#triplit/schema";
|
||||
import type { Entity } from "@triplit/client";
|
||||
import type { ModelMessage } from "ai";
|
||||
import { decrypt, base64ToUint8Array } from "~/utils/crypto";
|
||||
|
||||
export type Message = Entity<typeof schema, 'messages'> & { parts: (Entity<typeof schema, 'message_parts'> & { toolCall: Entity<typeof schema, 'tool_calls'> | null } | undefined)[] };
|
||||
|
||||
export const useChat = (agentId: string) => {
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const createTopic = async () => {
|
||||
const { user } = useAuth();
|
||||
if (!user.value) {
|
||||
console.error('No user');
|
||||
return;
|
||||
}
|
||||
|
||||
const newTopic = await triplit.insert('topics', {
|
||||
name: 'New Topic',
|
||||
userId: user.value.id,
|
||||
agentId,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if ('flush' in triplit) {
|
||||
await triplit.flush();
|
||||
}
|
||||
|
||||
return newTopic;
|
||||
};
|
||||
|
||||
const marshallMessages = (agent: Entity<typeof schema, 'agents'>, messages: Readonly<Message[]>) => {
|
||||
const marshalledMessages: ModelMessage[] = [];
|
||||
|
||||
if (agent && agent.systemPrompt) {
|
||||
marshalledMessages.push({
|
||||
role: 'system',
|
||||
content: agent.systemPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
messages.forEach((message) => {
|
||||
switch (message.role) {
|
||||
case 'user':
|
||||
marshalledMessages.push({
|
||||
role: 'user',
|
||||
// TODO: when we have images or files, this is where we need to handle them
|
||||
content: message.content,
|
||||
});
|
||||
break;
|
||||
case 'assistant':
|
||||
message.parts.forEach((part) => {
|
||||
if (!part) throw new Error('Part is undefined');
|
||||
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
case 'reasoning': {
|
||||
marshalledMessages.push({
|
||||
role: 'assistant',
|
||||
content: part.content,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'tool-call': {
|
||||
if (part.toolCall === null) throw new Error('Tool call is null');
|
||||
|
||||
if (part.toolCall.status === 'pending') {
|
||||
throw new Error(
|
||||
'Marshalling tool call that is still pending. This is likely a UI bug if this happens.',
|
||||
);
|
||||
}
|
||||
|
||||
let inputValue: string = '';
|
||||
|
||||
switch (typeof part.toolCall.input!.value) {
|
||||
case 'string':
|
||||
inputValue = part.toolCall.input!.value;
|
||||
break;
|
||||
case 'object':
|
||||
inputValue = JSON.stringify(part.toolCall.input!.value, null, 2);
|
||||
break;
|
||||
}
|
||||
|
||||
marshalledMessages.push({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: part.toolCall.id,
|
||||
toolName: part.toolCall.toolName,
|
||||
input: inputValue,
|
||||
},
|
||||
],
|
||||
providerOptions: part.providerOptions,
|
||||
});
|
||||
|
||||
if (part.toolCall.status === 'failed') {
|
||||
let failureType: 'error-text' | 'error-json';
|
||||
let failureValue: string;
|
||||
|
||||
if (part.toolCall.error === null || part.toolCall.error === undefined) {
|
||||
failureType = 'error-text';
|
||||
failureValue = 'An unknown error occurred';
|
||||
} else {
|
||||
switch (part.toolCall.error!.type) {
|
||||
case 'text':
|
||||
failureType = 'error-text';
|
||||
failureValue = part.toolCall.error!.value;
|
||||
break;
|
||||
case 'json':
|
||||
failureType = 'error-json';
|
||||
failureValue = JSON.stringify(part.toolCall.error!.value, null, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
marshalledMessages.push({
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: part.toolCall.id,
|
||||
toolName: part.toolCall.toolName,
|
||||
output: {
|
||||
type: failureType,
|
||||
value: failureValue,
|
||||
},
|
||||
},
|
||||
],
|
||||
providerOptions: part.providerOptions,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (part.toolCall.status === 'completed') {
|
||||
marshalledMessages.push({
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: part.toolCall.id,
|
||||
toolName: part.toolCall.toolName,
|
||||
output: {
|
||||
type: 'json',
|
||||
value: JSON.stringify(part.toolCall.output!.value, null, 2),
|
||||
},
|
||||
},
|
||||
],
|
||||
providerOptions: part.providerOptions,
|
||||
});
|
||||
break;
|
||||
}
|
||||
} break;
|
||||
default:
|
||||
throw new Error(`Unknown part type: ${part.type}`);
|
||||
}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown message role: ${message.role}`);
|
||||
}
|
||||
});
|
||||
|
||||
return marshalledMessages;
|
||||
};
|
||||
|
||||
const sendMessage = async (
|
||||
message: string,
|
||||
topic: Entity<typeof schema, 'topics'>,
|
||||
topicMessages: Message[],
|
||||
agent: Entity<typeof schema, 'agents'>,
|
||||
provider: Entity<typeof schema, 'providers'>,
|
||||
model: Entity<typeof schema, 'models'>,
|
||||
) => {
|
||||
const newMessage = await triplit.insert('messages', {
|
||||
topicId: topic.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: message,
|
||||
role: 'user',
|
||||
}) as Message;
|
||||
|
||||
const messages = marshallMessages(
|
||||
agent,
|
||||
topicMessages.concat(newMessage)
|
||||
);
|
||||
|
||||
|
||||
let providerApiKey: string | undefined = undefined;
|
||||
if (provider.config.apiKey !== undefined) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
|
||||
providerApiKey = await decrypt(
|
||||
key,
|
||||
base64ToUint8Array(provider.config.apiKey)
|
||||
);
|
||||
}
|
||||
|
||||
return $fetch('/api/chat/generate', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
messages,
|
||||
topicId: topic.id,
|
||||
model: {
|
||||
providerId: provider.id,
|
||||
modelId: model.id,
|
||||
args: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 100,
|
||||
top_p: 1,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
},
|
||||
},
|
||||
providerApiKey: providerApiKey,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
createTopic,
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { Ref } from 'vue';
|
||||
import { onMounted, onUnmounted } 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()
|
||||
}
|
||||
}
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (target.value && !target.value.contains(event.target as Node)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onClick)
|
||||
})
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onClick);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClick)
|
||||
})
|
||||
}
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClick);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const useFillIds = (namespace: string, length: number) => {
|
||||
const instanceId = useId();
|
||||
|
||||
return Array.from({ length }, (_, i) => {
|
||||
const id = `veridian-icons-${namespace}-${instanceId}-${i}`;
|
||||
|
||||
return {
|
||||
fill: `url(#${id})`,
|
||||
id,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,23 +1,35 @@
|
||||
export const useKeyboardShortcuts = () => {
|
||||
const { toggle: toggleSidebar } = useSidebar();
|
||||
const { toggle: toggleSidebar } = useSidebar();
|
||||
const { toggle: openSettings, open: isSettingsOpen } = useSettings();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Ctrl+[ to collapse sidebar
|
||||
if (event.ctrlKey && event.key === '[') {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Ctrl+[ to collapse sidebar
|
||||
if (event.ctrlKey && event.key === '[') {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
if (event.ctrlKey && event.key === ',') {
|
||||
event.preventDefault();
|
||||
if (isSettingsOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
openSettings();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleKeyDown
|
||||
};
|
||||
};
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
return {
|
||||
handleKeyDown,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { schema } from '#triplit/schema';
|
||||
import type { Entity } from '@triplit/client';
|
||||
|
||||
export type ModelWithProvider = Entity<typeof schema, 'models'> & {
|
||||
provider: Entity<typeof schema, 'providers'>;
|
||||
};
|
||||
|
||||
export type ProviderWithModels = Entity<typeof schema, 'providers'> & {
|
||||
models: Entity<typeof schema, 'models'>[];
|
||||
};
|
||||
|
||||
export const useModels = async () => {
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const providersQuery = triplit
|
||||
.query('providers')
|
||||
.Include('models')
|
||||
|
||||
const { results: providers, unsubscribe } = await useQuery('providers', triplit, providersQuery);
|
||||
|
||||
console.log("GET PROVIDERS", providers.value);
|
||||
|
||||
// const enabledProvidersWithModels = computed<ProviderWithModels[]>(() => {
|
||||
// if (!providers.value) return [];
|
||||
|
||||
// return (providers.value as unknown as ProviderWithModels[]).filter(
|
||||
// (provider: ProviderWithModels) => provider.models && provider.models.length > 0
|
||||
// );
|
||||
// });
|
||||
|
||||
const allEnabledModels = computed<ModelWithProvider[]>(() => {
|
||||
return providers.value?.flatMap((provider) =>
|
||||
provider.models.map((model) => ({
|
||||
...model,
|
||||
provider,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
const getFirstAvailableModel = (): ModelWithProvider | null => {
|
||||
if (allEnabledModels.value.length === 0) return null;
|
||||
return allEnabledModels.value[0]!;
|
||||
};
|
||||
|
||||
return {
|
||||
providers,
|
||||
allModels: allEnabledModels,
|
||||
getFirstAvailableModel,
|
||||
unsubscribe,
|
||||
};
|
||||
};
|
||||
@@ -1,13 +1,28 @@
|
||||
export const useSettings = () => {
|
||||
const open = useState<boolean>('settings:open', () => false)
|
||||
const currentPage = useState<string>('settings:currentPage', () => 'page1')
|
||||
const open = useState<boolean>('settings:open', () => false);
|
||||
const currentPage = useState('settings-page', () => 'general');
|
||||
const pageParams = useState('settings-params', () => [] as string[]);
|
||||
|
||||
const toggle = () => {
|
||||
open.value = !open.value;
|
||||
};
|
||||
|
||||
const setPage = (id: string, params?: string | string[]) => {
|
||||
currentPage.value = id;
|
||||
if (params) {
|
||||
if (typeof params === 'string') {
|
||||
params = [params];
|
||||
}
|
||||
pageParams.value = params;
|
||||
} else {
|
||||
pageParams.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = () => { open.value = !open.value }
|
||||
const setPage = (page: string) => { currentPage.value = page }
|
||||
const close = () => {
|
||||
open.value = false
|
||||
currentPage.value = 'page1'
|
||||
}
|
||||
open.value = false;
|
||||
currentPage.value = 'page1';
|
||||
};
|
||||
|
||||
return { open, currentPage, toggle, setPage, close }
|
||||
}
|
||||
return { open, currentPage, pageParams, toggle, setPage, close };
|
||||
};
|
||||
|
||||
@@ -1,31 +1,49 @@
|
||||
export const useSidebar = () => {
|
||||
const open = useState<boolean>('sidebar:open', () => true)
|
||||
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)
|
||||
})
|
||||
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)
|
||||
})
|
||||
sidebarWidth.value = Number(value);
|
||||
});
|
||||
|
||||
const toggle = () => { open.value = !open.value }
|
||||
const close = () => { open.value = false }
|
||||
const openSidebar = () => { open.value = true }
|
||||
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 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()
|
||||
}
|
||||
useCookie('sidebar:width').value = sidebarWidth.value.toString();
|
||||
};
|
||||
|
||||
return { open, toggle, close, openSidebar, sidebarWidth: readonly(sidebarWidth), resize, saveWidth }
|
||||
}
|
||||
return {
|
||||
open,
|
||||
toggle,
|
||||
close,
|
||||
openSidebar,
|
||||
sidebarWidth: readonly(sidebarWidth),
|
||||
resize,
|
||||
saveWidth,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
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 })
|
||||
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 })
|
||||
const hinting = useCookie('hinting', {
|
||||
default: () => '0',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
});
|
||||
|
||||
return {
|
||||
accent,
|
||||
neutral,
|
||||
hinting,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Topic } from "~~/types";
|
||||
|
||||
export const useTopics = async () => {
|
||||
const appState = useAppState()
|
||||
const fetchingTopics = ref(false);
|
||||
const topics: Ref<any[] | null> = useState('topics', () => null);
|
||||
|
||||
/**
|
||||
* Compute topics for the currently active agent
|
||||
*/
|
||||
const topicsForActiveAgent = computed(() => {
|
||||
if (topics.value === null || !appState.activeAgentId.value) return [];
|
||||
return topics.value.filter(topic => topic.agentId === appState.activeAgentId.value);
|
||||
});
|
||||
|
||||
const activeTopic = computed(() => {
|
||||
if (topicsForActiveAgent.value.length === 0) return;
|
||||
if (!appState.activeTopicId.value) return;
|
||||
|
||||
const topic = topicsForActiveAgent.value.find(topic => topic.id === appState.activeTopicId.value);
|
||||
return topic;
|
||||
});
|
||||
|
||||
const refreshTopics = async () => {
|
||||
if (fetchingTopics.value) return;
|
||||
fetchingTopics.value = true;
|
||||
|
||||
try {
|
||||
const { data, error } = await useFetch('/api/topics');
|
||||
if (error.value) throw error;
|
||||
topics.value = data.value!;
|
||||
} finally {
|
||||
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')
|
||||
}
|
||||
|
||||
const newTopic = await res.json()
|
||||
if (topics.value === null) topics.value = []
|
||||
topics.value.push(newTopic)
|
||||
return newTopic
|
||||
}
|
||||
|
||||
return { createTopic, activeTopic, topics, topicsForActiveAgent, fetchingTopics, refreshTopics }
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
const { open: sidebarOpen, openSidebar } = useSidebar()
|
||||
useKeyboardShortcuts()
|
||||
import SettingsDialog from '~/components/Settings/Dialog.vue';
|
||||
|
||||
const { open: sidebarOpen, openSidebar } = useSidebar();
|
||||
useKeyboardShortcuts();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -10,19 +12,18 @@ useKeyboardShortcuts()
|
||||
<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">
|
||||
class="flex 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 class="flex items-center gap-2">
|
||||
<div id="primary-loader-target"></div>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<SettingsDialog />
|
||||
<LoadingSpinner />
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const { loggedIn } = useAuth()
|
||||
const { session, fetchSession } = useAuth();
|
||||
|
||||
// if authenticated, and on a signin/signup page, redirect to home page
|
||||
if (to.path.toLowerCase().includes('/auth/') && loggedIn.value) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
if (!session.value) {
|
||||
await fetchSession();
|
||||
}
|
||||
|
||||
// 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')
|
||||
}
|
||||
})
|
||||
const loggedIn = computed(() => !!session.value);
|
||||
|
||||
// if authenticated, and on a signin/signup page, redirect to home page
|
||||
if (to.path.toLowerCase().includes("/auth/") && loggedIn.value) {
|
||||
return await navigateTo((to.query.to as string) ?? "/");
|
||||
}
|
||||
|
||||
// If not authenticated, and not on a signin/signup page, redirect to login page
|
||||
if (!loggedIn.value && !to.path.toLowerCase().includes("/auth/")) {
|
||||
return await navigateTo(`/auth/login?to=${to.path}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,37 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
const { createTopic, fetchingTopics } = await useTopics();
|
||||
const { activeAgent } = await useAgents();
|
||||
import type { ModelWithProvider } from '~/composables/useModels';
|
||||
|
||||
const creatingTopic = ref(false);
|
||||
const route = useRoute();
|
||||
|
||||
const handleSubmit = async (message: string) => {
|
||||
// create a new topic, and send the message to it
|
||||
console.log('handleSubmit', message);
|
||||
if (!activeAgent.value) return;
|
||||
const { createTopic, sendMessage } = useChat(route.params.id as string);
|
||||
const { getAgent } = await useAgents();
|
||||
const { providers, unsubscribe: unsubscribeModels } = await useModels();
|
||||
|
||||
creatingTopic.value = true
|
||||
try {
|
||||
const newTopic = await createTopic('New Topic', activeAgent.value.id)
|
||||
// Navigate to the new topic
|
||||
await navigateTo(`/agent/${activeAgent.value.id}/topic/${newTopic.id}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to create topic:', error)
|
||||
} finally {
|
||||
creatingTopic.value = false
|
||||
const agent = computed(() => {
|
||||
if (route.params.id === null || typeof route.params.id !== 'string') {
|
||||
throw new Error('Invalid agent ID');
|
||||
}
|
||||
}
|
||||
|
||||
return getAgent(route.params.id)!;
|
||||
});
|
||||
|
||||
const handleSubmit = async (message: string, model: ModelWithProvider | null) => {
|
||||
if (!model) {
|
||||
console.error('No model selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const topic = await createTopic();
|
||||
if (!topic) throw new Error('Failed to create topic');
|
||||
|
||||
sendMessage(message, topic, [], agent.value!, model.provider, model);
|
||||
|
||||
return navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribeModels?.()
|
||||
});
|
||||
</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-2 justify-end mb-28">
|
||||
<h1 v-if="activeAgent" class="font-bold">{{ activeAgent.name }}</h1>
|
||||
<p class="text-[var(--color-subtle)]">Select a topic to continue or create a new one</p>
|
||||
<div class="h-full w-full">
|
||||
<!-- chat pane -->
|
||||
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
|
||||
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
|
||||
<div class="flex-grow w-full flex justify-center">
|
||||
<div class="flex h-full max-w-4xl w-full flex-col gap-2 justify-end">
|
||||
<h1 v-if="agent" class="font-bold">{{ agent.name }}</h1>
|
||||
<p class="mb-28 text-[var(--color-muted)]">Select a topic to continue or create a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatInput @submit="handleSubmit" :loading="fetchingTopics" />
|
||||
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center">
|
||||
<div class="pb-4 w-full max-w-4xl bg-[var(--color-neutral)] rounded-t-2xl">
|
||||
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" :agent="agent"
|
||||
:providers="providers" @submit="handleSubmit"></ChatInput>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
const { activeAgent: agent, updateAgent } = await useAgents();
|
||||
const route = useRoute()
|
||||
const { getAgent } = await useAgents();
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const agent = computed(() => {
|
||||
if (route.params.id === null || typeof route.params.id !== 'string') {
|
||||
throw new Error('Invalid agent ID');
|
||||
}
|
||||
|
||||
return getAgent(route.params.id)!;
|
||||
});
|
||||
|
||||
// if (agent.value === undefined) navigateTo('/');
|
||||
|
||||
const handleInput = (e: Event) => {
|
||||
const handleInput = async (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.value.length === 0) {
|
||||
if (target.value.trimStart().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateAgent(agent.value!.id, { name: target.value });
|
||||
await triplit.update('agents', agent.value.id, { name: target.value });
|
||||
};
|
||||
|
||||
const changeSystemPrompt = async (e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
let value: string | undefined = target.value;
|
||||
|
||||
if (value.trimStart().length === 0) {
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
await triplit.update('agents', agent.value.id, {
|
||||
systemPrompt: target.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-14">
|
||||
<div class="flex flex-col gap-4 px-14 w-full h-full">
|
||||
<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" />
|
||||
@@ -25,9 +48,10 @@ const handleInput = (e: Event) => {
|
||||
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 class="flex items-center gap-2 w-full h-full mb-14">
|
||||
<textarea placeholder="System Message..."
|
||||
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-highlight)]"
|
||||
:value="agent?.systemPrompt" @input="changeSystemPrompt"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,235 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { Message } from '~~/types';
|
||||
import type { Message } from '~/composables/useChat';
|
||||
import type { ModelWithProvider } from '~/composables/useModels';
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const chatPane = ref<HTMLElement | null>(null);
|
||||
const route = useRoute();
|
||||
const appState = useAppState();
|
||||
const { activeTopic, topicsForActiveAgent } = await useTopics();
|
||||
const { activeAgent } = await useAgents();
|
||||
const { sendMessage } = useChat(route.params.id as string);
|
||||
const { getAgent } = await useAgents();
|
||||
const { providers, unsubscribe: unsubscribeModels } = await useModels();
|
||||
|
||||
const loading = ref(false);
|
||||
const messages = useState<Message[]>('messages', () => []);
|
||||
const generatingMessage = ref('');
|
||||
|
||||
// Fetch messages for this topic
|
||||
if (activeTopic.value) {
|
||||
const topicData = await useFetch(`/api/topics/${activeTopic.value.id}`);
|
||||
if (topicData.error.value) {
|
||||
console.error('Failed to load topic:', topicData.error.value);
|
||||
} else if (topicData.data.value?.messages) {
|
||||
messages.value = topicData.data.value.messages;
|
||||
const agent = computed(() => {
|
||||
if (route.params.id === null || typeof route.params.id !== 'string') {
|
||||
throw new Error('Invalid agent ID');
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (message: string) => {
|
||||
if (!activeTopic.value) {
|
||||
console.error('No active topic');
|
||||
return getAgent(route.params.id)!;
|
||||
});
|
||||
|
||||
const topicQuery = computed(() =>
|
||||
triplit
|
||||
.query('topics')
|
||||
.Where(['id', '=', route.params.topicId])
|
||||
.Include('generations')
|
||||
.Include('messages', (rel) =>
|
||||
rel('messages')
|
||||
.Include('generation')
|
||||
.Include('parts', (rel) => rel('parts').Include('toolCall')),
|
||||
)
|
||||
.Limit(1)
|
||||
);
|
||||
|
||||
const { results, unsubscribe: unsubscribeTopic } = await useQuery('topic', triplit, topicQuery);
|
||||
|
||||
const topic = computed(() => {
|
||||
if (results.value?.length === 0) return null;
|
||||
|
||||
// copy messages to a mutable object and sort by createdAt
|
||||
const messages = results!.value![0]!.messages.map((message) => ({
|
||||
...message,
|
||||
parts: message.parts.map((part) => ({
|
||||
...part,
|
||||
toolCall: part.toolCall ? { ...part.toolCall } : null,
|
||||
})),
|
||||
}));
|
||||
messages.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
|
||||
// for each message, sort parts by createdAt
|
||||
messages.forEach((message) => {
|
||||
message.parts = message.parts
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.filter((part) => part.content !== '' || part.toolCall !== null);
|
||||
});
|
||||
return { ...results.value![0]!, messages };
|
||||
});
|
||||
|
||||
const activeGeneration = computed(() => {
|
||||
if (topic.value === null) return null;
|
||||
return topic.value?.generations?.find((generation) => generation.status === 'pending') ?? null;
|
||||
});
|
||||
|
||||
const { scrollToBottom } = useAutoScroll(chatPane);
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom('instant');
|
||||
});
|
||||
|
||||
const handleCancel = async () => {
|
||||
await $fetch(`/api/chat/cancel/${activeGeneration.value!.id}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (message: string, model: ModelWithProvider | null) => {
|
||||
if (!model) {
|
||||
console.error('No model selected');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
// Add user message to local state
|
||||
const userMessage: Message = {
|
||||
id: `temp_${Date.now()}`,
|
||||
topicId: activeTopic.value.id,
|
||||
userId: '',
|
||||
content: message,
|
||||
isUser: true,
|
||||
regeneratedFromId: null,
|
||||
isRegenerated: false,
|
||||
editedAt: null,
|
||||
createdAt: new Date() as any
|
||||
};
|
||||
messages.value.push(userMessage);
|
||||
|
||||
// Create generation
|
||||
const generation = await $fetch(`/api/chat/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
topicId: activeTopic.value.id,
|
||||
messages: messages.value
|
||||
.filter(m => m.content)
|
||||
.map(m => ({
|
||||
type: m.isUser ? 'user' : 'agent',
|
||||
message: m.content
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
appState.startGeneration(generation.generationId);
|
||||
|
||||
// Stream the response
|
||||
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
|
||||
method: 'get',
|
||||
responseType: 'stream',
|
||||
});
|
||||
|
||||
const reader = response.pipeThrough(new TextDecoderStream()).getReader();
|
||||
generatingMessage.value = '';
|
||||
let hasError = false;
|
||||
|
||||
while (!hasError) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
try {
|
||||
const lines = value.split('\n').filter(line => line.trim());
|
||||
for (const line of lines) {
|
||||
const event = JSON.parse(line);
|
||||
|
||||
if (event.type === 'start') {
|
||||
generatingMessage.value = '';
|
||||
} else if (event.type === 'token') {
|
||||
generatingMessage.value += event.data;
|
||||
} else if (event.type === 'complete') {
|
||||
// Add the completed message to the list
|
||||
console.log(event, event.data);
|
||||
if (event.data) {
|
||||
messages.value.concat(event.data);
|
||||
generatingMessage.value = '';
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
console.error('Generation error:', event.data);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse event:', parseError);
|
||||
}
|
||||
}
|
||||
|
||||
appState.endGeneration();
|
||||
} catch (error) {
|
||||
console.error('Failed to submit message:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
await sendMessage(message, topic.value!, topic.value!.messages as unknown as Message[], agent.value!, model.provider, model);
|
||||
scrollToBottom('instant');
|
||||
};
|
||||
|
||||
const handleRegenerate = async (messageId: string) => {
|
||||
if (!activeTopic.value) return;
|
||||
|
||||
// Find the user message before this one to regenerate context
|
||||
const messageIndex = messages.value.findIndex(m => m.id === messageId);
|
||||
if (messageIndex === -1) return;
|
||||
|
||||
const previousUserMessage = messages.value[messageIndex - 1];
|
||||
if (!previousUserMessage) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
// Create generation with previous user message
|
||||
const generation = await $fetch(`/api/chat/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
topicId: activeTopic.value.id,
|
||||
regeneratesFrom: messageId,
|
||||
messages: messages.value.slice(0, messageIndex)
|
||||
.map(m => ({
|
||||
type: m.isUser ? 'user' : 'agent',
|
||||
message: m.content
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
appState.startGeneration(generation.generationId);
|
||||
|
||||
// Stream the response
|
||||
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
|
||||
method: 'get',
|
||||
responseType: 'stream',
|
||||
});
|
||||
|
||||
const reader = response.pipeThrough(new TextDecoderStream()).getReader();
|
||||
generatingMessage.value = '';
|
||||
let hasError = false;
|
||||
|
||||
while (!hasError) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
try {
|
||||
const lines = value.split('\n').filter(line => line.trim());
|
||||
for (const line of lines) {
|
||||
const event = JSON.parse(line);
|
||||
|
||||
if (event.type === 'start') {
|
||||
generatingMessage.value = '';
|
||||
} else if (event.type === 'token') {
|
||||
generatingMessage.value += event.data;
|
||||
} else if (event.type === 'complete') {
|
||||
// Add the new regenerated message
|
||||
if (event.data) {
|
||||
messages.value.push(event.data);
|
||||
generatingMessage.value = '';
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
console.error('Generation error:', event.data);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse event:', parseError);
|
||||
}
|
||||
}
|
||||
|
||||
appState.endGeneration();
|
||||
} catch (error) {
|
||||
console.error('Failed to regenerate:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRegeneration = (messageId: string) => {
|
||||
const index = messages.value.findIndex(m => m.id === messageId);
|
||||
if (index === -1) return;
|
||||
|
||||
// In a real app, you'd update the UI to show the selected version
|
||||
// For now, just highlight it
|
||||
console.log('Selected regeneration:', messageId);
|
||||
};
|
||||
|
||||
const handleDelete = (messageId: string) => {
|
||||
const index = messages.value.findIndex(m => m.id === messageId);
|
||||
if (index === -1) return;
|
||||
|
||||
messages.value.splice(index, 1);
|
||||
};
|
||||
onUnmounted(() => {
|
||||
unsubscribeTopic?.();
|
||||
unsubscribeModels?.();
|
||||
});
|
||||
</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">
|
||||
<div class="flex h-full flex-col gap-6 overflow-y-auto p-4">
|
||||
<Message v-for="msg in messages" :key="msg.id" :message="msg" @regenerate="handleRegenerate"
|
||||
@select="handleSelectRegeneration" @delete="handleDelete" />
|
||||
|
||||
<div v-if="generatingMessage" class="flex gap-3">
|
||||
<div
|
||||
class="flex-shrink-0 w-8 h-8 rounded-lg bg-[var(--color-neutral)] border border-[var(--color-highlight)] flex items-center justify-center">
|
||||
<Icon name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-[var(--color-neutral)]">Agent</p>
|
||||
<p class="text-sm text-[var(--color-text)]">{{ generatingMessage }}</p>
|
||||
</div>
|
||||
<div class="h-full w-full">
|
||||
<!-- chat pane -->
|
||||
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
|
||||
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
|
||||
<div class="flex-grow w-full flex justify-center">
|
||||
<div class="max-w-4xl w-full flex flex-col gap-2 pb-9"
|
||||
v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
|
||||
<Message v-for="message in topic.messages" :key="message.id" :message="message" />
|
||||
</div>
|
||||
|
||||
<p v-if="messages.length === 0 && !generatingMessage" class="text-center text-[var(--color-subtle)]">
|
||||
No messages yet. Start the conversation!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ChatInput @submit="handleSubmit" :loading="loading" />
|
||||
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center">
|
||||
<div class="pb-4 w-full max-w-4xl bg-[var(--color-neutral)] rounded-t-2xl">
|
||||
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
|
||||
:loading="activeGeneration !== null" :agent="agent" :providers="providers"
|
||||
@submit="handleSubmit" @cancel="handleCancel"></ChatInput>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
+65
-53
@@ -1,47 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { authClient } from '~~/lib/auth-client';
|
||||
import { deriveKey } from '~/utils/crypto';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
})
|
||||
});
|
||||
|
||||
const { signIn, session, fetchSession, authClient } = useAuth();
|
||||
|
||||
if (session.value !== null) {
|
||||
navigateTo("/");
|
||||
}
|
||||
const to = useRoute().query.to as string | undefined;
|
||||
|
||||
const form = reactive({
|
||||
email: "",
|
||||
password: "",
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const loading = ref(false);
|
||||
|
||||
let emailInputEl = ref<HTMLInputElement | null>(null);
|
||||
let passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||
const emailInputEl = ref<HTMLInputElement | null>(null);
|
||||
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
let tempForm = {
|
||||
email: "",
|
||||
password: "",
|
||||
}
|
||||
const tempForm = {
|
||||
email: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
// prevent text fields from clearing on hydration
|
||||
onBeforeMount(() => {
|
||||
tempForm.email = (document.getElementById("email") as HTMLInputElement)?.value ?? "";
|
||||
tempForm.password = (document.getElementById("password") as HTMLInputElement)?.value ?? "";
|
||||
})
|
||||
tempForm.email = (document.getElementById('email') as HTMLInputElement)?.value ?? '';
|
||||
tempForm.password = (document.getElementById('password') as HTMLInputElement)?.value ?? '';
|
||||
});
|
||||
|
||||
let hydrated = ref(false);
|
||||
const hydrated = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
form.email = tempForm.email;
|
||||
form.password = tempForm.password;
|
||||
hydrated.value = true;
|
||||
|
||||
emailInputEl.value!.addEventListener("input", () => {
|
||||
emailInputEl.value!.setCustomValidity("");
|
||||
emailInputEl.value!.addEventListener('input', () => {
|
||||
emailInputEl.value!.setCustomValidity('');
|
||||
});
|
||||
|
||||
passwordInputEl.value!.addEventListener("input", () => {
|
||||
passwordInputEl.value!.setCustomValidity("");
|
||||
passwordInputEl.value!.addEventListener('input', () => {
|
||||
passwordInputEl.value!.setCustomValidity('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,40 +56,52 @@ const submit = async () => {
|
||||
|
||||
loading.value = true;
|
||||
|
||||
await signIn.email({
|
||||
const { data, error } = await authClient.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;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorCode = error.code! as keyof typeof authClient.$ERROR_CODES;
|
||||
|
||||
// TODO: i18n
|
||||
// ref https://www.better-auth.com/docs/concepts/client#error-codes
|
||||
switch (errorCode) {
|
||||
case 'INVALID_PASSWORD':
|
||||
passwordInputEl.value!.setCustomValidity(error.message!);
|
||||
passwordInputEl.value!.reportValidity();
|
||||
break;
|
||||
case 'ACCOUNT_NOT_FOUND':
|
||||
case 'USER_NOT_FOUND':
|
||||
case 'USER_EMAIL_NOT_FOUND':
|
||||
emailInputEl.value!.setCustomValidity(error.message!);
|
||||
emailInputEl.value!.reportValidity();
|
||||
break;
|
||||
default:
|
||||
console.log(error);
|
||||
alert(`Something went wrong. ${error.message}`);
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await deriveKey(form.password, data.user.id);
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||
|
||||
// force a session refetch
|
||||
clearNuxtData();
|
||||
|
||||
// success
|
||||
const triplit = useTriplitClient();
|
||||
if ('startSession' in triplit) {
|
||||
await triplit.startSession(data.token);
|
||||
}
|
||||
|
||||
return navigateTo(to ?? '/');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -103,9 +114,10 @@ const submit = async () => {
|
||||
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
|
||||
autocomplete="current-password" id="password" v-model="form.password" />
|
||||
<button :disabled="!hydrated" class="accent" type="submit">
|
||||
<Icon v-if="loading" width="24" name="svg-spinners:90-ring-with-bg" />
|
||||
<Icon v-if="loading" class="text-6" name="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>
|
||||
<p class="text-center">Dont have an account? <a
|
||||
:href="to ? `/auth/register?to=${to}` : '/auth/register'">Register</a></p>
|
||||
</template>
|
||||
+83
-68
@@ -1,47 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { authClient } from '~~/lib/auth-client';
|
||||
import { deriveKey } from '~/utils/crypto';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
})
|
||||
});
|
||||
|
||||
const { signUp, session, fetchSession, authClient } = useAuth();
|
||||
const { session } = await useAuth();
|
||||
const to = useRoute().query.to as string | undefined;
|
||||
|
||||
if (session.value !== null) {
|
||||
navigateTo("/");
|
||||
await navigateTo(to ?? '/');
|
||||
}
|
||||
|
||||
if (import.meta.server) {
|
||||
if (process.env.DISABLE_SIGNUP?.toLowerCase() === "true" || process.env.DISABLE_SIGNUP === "1") {
|
||||
navigateTo("/auth/login")
|
||||
if (process.env.DISABLE_SIGNUP?.toLowerCase() === 'true' || process.env.DISABLE_SIGNUP === '1') {
|
||||
await navigateTo(to ? `/auth/login?to=${to}` : '/auth/login');
|
||||
}
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
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);
|
||||
const nameInputEl = ref<HTMLInputElement | null>(null);
|
||||
const emailInputEl = ref<HTMLInputElement | null>(null);
|
||||
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||
const confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
let tempForm = {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
}
|
||||
const tempForm = {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
};
|
||||
|
||||
// prevent text fields from clearing on hydration
|
||||
onBeforeMount(() => {
|
||||
tempForm.name = (document.getElementById("name") as HTMLInputElement)?.value ?? "";
|
||||
tempForm.email = (document.getElementById("email") as HTMLInputElement)?.value ?? "";
|
||||
tempForm.password = (document.getElementById("password") as HTMLInputElement)?.value ?? "";
|
||||
tempForm.confirmPassword = (document.getElementById("confirmPassword") as HTMLInputElement)?.value ?? "";
|
||||
})
|
||||
tempForm.name = (document.getElementById('name') as HTMLInputElement)?.value ?? '';
|
||||
tempForm.email = (document.getElementById('email') as HTMLInputElement)?.value ?? '';
|
||||
tempForm.password = (document.getElementById('password') as HTMLInputElement)?.value ?? '';
|
||||
tempForm.confirmPassword = (document.getElementById('confirmPassword') as HTMLInputElement)?.value ?? '';
|
||||
});
|
||||
|
||||
const hydrated = ref(false);
|
||||
|
||||
@@ -52,25 +56,24 @@ onMounted(() => {
|
||||
form.confirmPassword = tempForm.confirmPassword;
|
||||
hydrated.value = true;
|
||||
|
||||
nameInputEl.value!.addEventListener("input", () => {
|
||||
nameInputEl.value!.setCustomValidity("");
|
||||
nameInputEl.value!.addEventListener('input', () => {
|
||||
nameInputEl.value!.setCustomValidity('');
|
||||
});
|
||||
|
||||
emailInputEl.value!.addEventListener("input", () => {
|
||||
emailInputEl.value!.setCustomValidity("");
|
||||
emailInputEl.value!.addEventListener('input', () => {
|
||||
emailInputEl.value!.setCustomValidity('');
|
||||
});
|
||||
|
||||
passwordInputEl.value!.addEventListener("input", () => {
|
||||
passwordInputEl.value!.setCustomValidity("");
|
||||
passwordInputEl.value!.addEventListener('input', () => {
|
||||
passwordInputEl.value!.setCustomValidity('');
|
||||
});
|
||||
|
||||
|
||||
confirmPasswordInputEl.value!.addEventListener("input", () => {
|
||||
confirmPasswordInputEl.value!.addEventListener('input', () => {
|
||||
if (passwordInputEl.value!.value !== confirmPasswordInputEl.value!.value) {
|
||||
confirmPasswordInputEl.value!.setCustomValidity("Passwords do not match");
|
||||
confirmPasswordInputEl.value!.setCustomValidity('Passwords do not match');
|
||||
confirmPasswordInputEl.value!.reportValidity();
|
||||
} else {
|
||||
confirmPasswordInputEl.value!.setCustomValidity("");
|
||||
confirmPasswordInputEl.value!.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -86,50 +89,62 @@ const submit = async () => {
|
||||
}
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
alert("Passwords do not match")
|
||||
alert('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
await signUp.email({
|
||||
const { data, error } = await authClient.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;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorCode = error.code! as keyof typeof authClient.$ERROR_CODES;
|
||||
|
||||
// TODO: i18n
|
||||
// ref https://www.better-auth.com/docs/concepts/client#error-codes
|
||||
switch (errorCode) {
|
||||
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(error.message!);
|
||||
emailInputEl.value!.reportValidity();
|
||||
break;
|
||||
case 'INVALID_PASSWORD':
|
||||
passwordInputEl.value!.setCustomValidity(error.message!);
|
||||
passwordInputEl.value!.reportValidity();
|
||||
break;
|
||||
default:
|
||||
console.log(error);
|
||||
alert('Something went wrong: ' + error.message);
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await deriveKey(form.password, data.user.id);
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||
|
||||
// force a session refetch
|
||||
clearNuxtData();
|
||||
|
||||
// success
|
||||
const triplit = useTriplitClient();
|
||||
if ('startSession' in triplit) {
|
||||
await triplit.startSession(data.token!);
|
||||
}
|
||||
|
||||
return navigateTo(to ?? '/');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -151,5 +166,5 @@ const submit = async () => {
|
||||
<span v-else>Register</span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="text-center">Already have an account? <a href="/auth/login">Login</a></p>
|
||||
<p class="text-center">Already have an account? <a :href="to ? `/auth/login?to=${to}` : '/auth/login'">Login</a></p>
|
||||
</template>
|
||||
+62
-40
@@ -1,44 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
const { user } = useAuth();
|
||||
|
||||
if (user.value === null) {
|
||||
navigateTo("/auth/login");
|
||||
}
|
||||
const { agents } = await useAgents();
|
||||
|
||||
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"
|
||||
'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"
|
||||
'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",
|
||||
'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"
|
||||
]
|
||||
}
|
||||
'last call for wins',
|
||||
'close it out like a champion',
|
||||
'seal the deal before bed',
|
||||
],
|
||||
};
|
||||
|
||||
const animatedText = ref("");
|
||||
const animatedText = ref('');
|
||||
const currentTaglineIndex = ref(0);
|
||||
const isDeleting = ref(false);
|
||||
|
||||
@@ -55,7 +51,7 @@ const typeWriter = (time: 'morning' | 'afternoon' | 'evening') => {
|
||||
}
|
||||
} else {
|
||||
animatedText.value = currentText.substring(0, animatedText.value.length - 1);
|
||||
if (animatedText.value === "") {
|
||||
if (animatedText.value === '') {
|
||||
isDeleting.value = false;
|
||||
currentTaglineIndex.value = (currentTaglineIndex.value + 1) % currentTaglines.length;
|
||||
}
|
||||
@@ -69,22 +65,23 @@ const typeWriter = (time: 'morning' | 'afternoon' | 'evening') => {
|
||||
setTimeout(() => typeWriter(time), speed);
|
||||
};
|
||||
|
||||
const handleChatSubmit = (message: string) => {
|
||||
console.log('Message submitted:', message);
|
||||
const handleChatSubmit = async (message: string, _model: unknown) => {
|
||||
console.log('Message submitted:', message, agents);
|
||||
await navigateTo(`/agent/${agents.value![0]!.id}`);
|
||||
// TODO: Implement chat functionality
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
let time: 'morning' | 'afternoon' | 'evening' = "morning";
|
||||
let time: 'morning' | 'afternoon' | 'evening' = 'morning';
|
||||
const now = new Date();
|
||||
const hours = now.getHours();
|
||||
|
||||
if (hours < 12) {
|
||||
time = "morning";
|
||||
time = 'morning';
|
||||
} else if (hours < 22) {
|
||||
time = "afternoon";
|
||||
time = 'afternoon';
|
||||
} else {
|
||||
time = "evening";
|
||||
time = 'evening';
|
||||
}
|
||||
|
||||
// randomly select a tagline
|
||||
@@ -95,9 +92,34 @@ onMounted(() => {
|
||||
|
||||
<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>
|
||||
<h1 class="text-center text-3xl font-semibold">{{ animatedText }}<span class="cursor"> </span></h1>
|
||||
<div class="max-w-4xl h-full w-full">
|
||||
<ChatInput @submit="handleChatSubmit" />
|
||||
<!-- TODO: view transitions have caused me issues with the page flashing with no content (so just a black or white screen depending on the theme) so I have disabled them for now. -->
|
||||
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" @submit="handleChatSubmit" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+22
-10
@@ -1,10 +1,22 @@
|
||||
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()
|
||||
})
|
||||
}
|
||||
})
|
||||
export default defineNuxtPlugin({
|
||||
name: 'better-auth-triplit',
|
||||
enforce: 'pre',
|
||||
async setup(nuxtApp) {
|
||||
if (import.meta.client) {
|
||||
const triplit = useTriplitClient();
|
||||
const { session, fetchSession } = useAuth();
|
||||
|
||||
nuxtApp.hook('app:mounted', async () => {
|
||||
if (!session.value) {
|
||||
await fetchSession();
|
||||
}
|
||||
|
||||
if (!session.value) return;
|
||||
|
||||
if ('startSession' in triplit) {
|
||||
await triplit.startSession(session.value.token);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,10 +2,24 @@ export default defineNuxtPlugin({
|
||||
name: 'better-auth-fetch-plugin',
|
||||
enforce: 'pre',
|
||||
async setup(nuxtApp) {
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
// Flag if request is cached
|
||||
nuxtApp.payload.isCached = Boolean(useRequestEvent()?.context.cache)
|
||||
nuxtApp.payload.isCached = Boolean(useRequestEvent()?.context.cache);
|
||||
if (nuxtApp.payload.serverRendered && !nuxtApp.payload.prerenderedAt && !nuxtApp.payload.isCached) {
|
||||
await useAuth().fetchSession()
|
||||
const { session, fetchSession } = useAuth();
|
||||
|
||||
if (!session.value) {
|
||||
await fetchSession();
|
||||
}
|
||||
|
||||
if (!session.value) return;
|
||||
|
||||
if ('updateOptions' in triplit) {
|
||||
triplit.updateOptions({
|
||||
token: session.value.token,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { unified } from 'unified';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const remark =
|
||||
unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype, { allowDangerousHtml: true });
|
||||
|
||||
return {
|
||||
provide: {
|
||||
remark,
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Plugin to sync appState with route changes
|
||||
* Ensures that activeAgentId and activeTopicId stay in sync with the URL
|
||||
*/
|
||||
export default defineNuxtPlugin(() => {
|
||||
const route = useRoute();
|
||||
const appState = useAppState();
|
||||
|
||||
// Sync agent ID from route params
|
||||
watch(
|
||||
() => route.params.id,
|
||||
(newId) => {
|
||||
if (newId) {
|
||||
const agentId = Array.isArray(newId) ? newId[0] : newId;
|
||||
appState.setActiveAgent(agentId);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Sync topic ID from route params
|
||||
watch(
|
||||
() => route.params.topicId,
|
||||
(newId) => {
|
||||
if (newId) {
|
||||
const topicId = Array.isArray(newId) ? newId[0] : newId;
|
||||
appState.setActiveTopic(topicId);
|
||||
} else {
|
||||
appState.setActiveTopic(null);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
export default defineNuxtPlugin(() => {
|
||||
const route = useRoute();
|
||||
const appState = useAppState();
|
||||
|
||||
if (route.params.id) {
|
||||
const agentId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id;
|
||||
appState.setActiveAgent(agentId);
|
||||
}
|
||||
|
||||
if (route.params.topicId) {
|
||||
const topicId = Array.isArray(route.params.topicId) ? route.params.topicId[0] : route.params.topicId;
|
||||
appState.setActiveTopic(topicId);
|
||||
}
|
||||
});
|
||||
@@ -5,4 +5,4 @@ export interface DropdownItem {
|
||||
value?: string | number;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { schema } from '#triplit/schema';
|
||||
import type { Entity } from '@triplit/client';
|
||||
|
||||
export const Providers = ['openrouter'] as const;
|
||||
|
||||
export const providerBaseUrls = {
|
||||
openrouter: 'https://openrouter.ai/api/v1',
|
||||
};
|
||||
|
||||
export type Model = Entity<typeof schema, 'models'> & { provider: Entity<typeof schema, 'providers'> };
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface SettingPage {
|
||||
id: string;
|
||||
label: string | Ref<string> | ComputedRef<string>;
|
||||
icon: string;
|
||||
component: Component;
|
||||
sidebar?: Component;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
export async function deriveKey(password: string, userId: string) {
|
||||
const iterations = 1_000_000;
|
||||
const saltBuffer = new TextEncoder().encode(userId);
|
||||
const passwordBuffer = new TextEncoder().encode(password);
|
||||
|
||||
const baseKey = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
passwordBuffer,
|
||||
{ name: "PBKDF2" },
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
|
||||
const derivedKey = await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: saltBuffer,
|
||||
iterations: iterations,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
baseKey,
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: 256
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
|
||||
const encryptionKey = await crypto.subtle.exportKey(
|
||||
'jwk',
|
||||
derivedKey
|
||||
);
|
||||
|
||||
return encryptionKey;
|
||||
}
|
||||
|
||||
function generateIv() {
|
||||
return window.crypto.getRandomValues(new Uint8Array(12));
|
||||
}
|
||||
|
||||
export function uint8ArrayToBase64(bytes: Uint8Array<ArrayBuffer>) {
|
||||
let binaryString = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binaryString += String.fromCharCode(bytes[i]!);
|
||||
}
|
||||
return window.btoa(binaryString);
|
||||
}
|
||||
|
||||
export function base64ToUint8Array(base64: string) {
|
||||
const binaryString = window.atob(base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function encryptData(key: CryptoKey, plaintext: string) {
|
||||
const iv = generateIv();
|
||||
const data = new TextEncoder().encode(plaintext);
|
||||
|
||||
const encryptedContent = await window.crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: iv },
|
||||
key,
|
||||
data
|
||||
);
|
||||
|
||||
// Combine IV and Ciphertext into one Uint8Array for easy storage
|
||||
const combined = new Uint8Array(iv.length + encryptedContent.byteLength);
|
||||
combined.set(iv);
|
||||
combined.set(new Uint8Array(encryptedContent), iv.length);
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
export async function decrypt(key: CryptoKey, combinedData: Uint8Array) {
|
||||
try {
|
||||
const iv = combinedData.slice(0, 12);
|
||||
const ciphertext = combinedData.slice(12);
|
||||
|
||||
const decryptedBuffer = await window.crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: iv },
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decryptedBuffer);
|
||||
} catch (error) {
|
||||
throw new Error("Decryption failed. Incorrect password or corrupted data.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const hash = async (text: string) => {
|
||||
const msgBuffer = new TextEncoder().encode(text);
|
||||
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
||||
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
return hashHex;
|
||||
}
|
||||
|
||||
export const hashSync = (text: string) => {
|
||||
let hash = 5381;
|
||||
let i = 0;
|
||||
for (const char of text) {
|
||||
hash = ((hash << 5) - hash) + char.charCodeAt(0);
|
||||
hash |= 0;
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
LogoGrok,
|
||||
LogoGemini
|
||||
} from '#components';
|
||||
|
||||
interface ModelConfig {
|
||||
icon: any;
|
||||
keywords: RegExp[];
|
||||
brandColor: string;
|
||||
}
|
||||
|
||||
const MODEL_MAPPINGS: ModelConfig[] = [
|
||||
{
|
||||
icon: markRaw(LogoGrok),
|
||||
keywords: [/^grok-/, /^x-ai\//],
|
||||
brandColor: '#000000'
|
||||
},
|
||||
{
|
||||
icon: markRaw(LogoGemini),
|
||||
keywords: [/gemini-/],
|
||||
brandColor: '#000000'
|
||||
}
|
||||
];
|
||||
|
||||
export function getModelConfig(modelId: string) {
|
||||
const cleanId = modelId.toLowerCase();
|
||||
|
||||
const match = MODEL_MAPPINGS.find(cfg =>
|
||||
cfg.keywords.some(regex => regex.test(cleanId))
|
||||
);
|
||||
|
||||
return match || { icon: null, brandColor: '#64748b' };
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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],
|
||||
}),
|
||||
}));
|
||||
@@ -1,7 +0,0 @@
|
||||
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! });
|
||||
@@ -1,61 +0,0 @@
|
||||
import { integer, pgTable, text, boolean, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { user } from "./auth/auth.schema";
|
||||
import { relations } from "drizzle-orm";
|
||||
export * from "./auth/auth.schema";
|
||||
|
||||
export const agents = pgTable("agents", {
|
||||
id: text("id").primaryKey().$defaultFn(() => 'agents_' + nanoid()),
|
||||
userId: text("user_id").references(() => user.id).notNull(),
|
||||
name: text("name").notNull(),
|
||||
systemPrompt: text("system_prompt"),
|
||||
imageUrl: text("image_url")
|
||||
});
|
||||
|
||||
export const topics = pgTable("topics", {
|
||||
id: text("id").primaryKey().$defaultFn(() => 'topics_' + nanoid()),
|
||||
userId: text("user_id").references(() => user.id).notNull(),
|
||||
agentId: text("agent_id").references(() => agents.id).notNull(),
|
||||
name: text("name").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
||||
});
|
||||
|
||||
export const messages = pgTable("messages", {
|
||||
id: text("id").primaryKey().$defaultFn(() => 'messages_' + nanoid()),
|
||||
userId: text("user_id").references(() => user.id).notNull(),
|
||||
topicId: text("topic_id").references(() => topics.id).notNull(),
|
||||
content: text("content").notNull(),
|
||||
isUser: boolean("is_user").notNull(),
|
||||
regeneratedFromId: text("regenerated_from_id").references((): any => messages.id),
|
||||
isRegenerated: boolean("is_regenerated").default(false),
|
||||
editedAt: timestamp("edited_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
||||
});
|
||||
|
||||
export const generations = pgTable("generations", {
|
||||
id: text("id").primaryKey().$defaultFn(() => 'generations_' + nanoid()),
|
||||
userId: text("user_id").references(() => user.id).notNull(),
|
||||
topicId: text("topic_id").references(() => topics.id).notNull(),
|
||||
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||
messageId: text("message_id").references(() => messages.id),
|
||||
regeneratesFrom: text("regenerates_from").references(() => messages.id),
|
||||
model: text("model"),
|
||||
tokensGenerated: integer("tokens_generated"),
|
||||
tokensUsedThinking: integer("tokens_used_thinking"),
|
||||
error: text("error"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
startedAt: timestamp("started_at"),
|
||||
completedAt: timestamp("completed_at")
|
||||
});
|
||||
|
||||
export const messagesRelations = relations(messages, ({ one, many }) => ({
|
||||
generations: many(generations),
|
||||
regeneratedFrom: one(messages, {
|
||||
fields: [messages.regeneratedFromId],
|
||||
references: [messages.id],
|
||||
relationName: 'regeneratedFrom'
|
||||
}),
|
||||
regenerations: many(messages, {
|
||||
relationName: 'regeneratedFrom'
|
||||
})
|
||||
}));
|
||||
+10
-14
@@ -1,29 +1,25 @@
|
||||
name: veridian-development
|
||||
services:
|
||||
postgresql:
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: veridian-postgres
|
||||
command: postgres -c wal_level=logical
|
||||
triplit:
|
||||
image: aspencloud/triplit-server:latest
|
||||
container_name: veridian-triplit
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "8080:6543"
|
||||
volumes:
|
||||
- "data:/var/lib/postgresql/data"
|
||||
- "triplit_data:/data"
|
||||
environment:
|
||||
- "POSTGRES_DB=${VERIDIAN_DB_NAME}"
|
||||
- "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
env_file:
|
||||
- .env
|
||||
LOCAL_DATABASE_URL: /data/triplit.db
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
test: ["CMD", "curl", "-f", "http://localhost:6543/healthcheck"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: always
|
||||
networks:
|
||||
- veridian-network
|
||||
|
||||
volumes:
|
||||
data:
|
||||
triplit_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Encryption
|
||||
|
||||
Veridian uses AES-256-GCM to encrypt sensitive data at rest. All API keys are
|
||||
encrypted using a key derived from the user's password at login. This key is
|
||||
stored in the browser's local storage, and is never sent to the server.
|
||||
|
||||
When you store your API key, your browser sends an encrypted version of the key
|
||||
to the server and when you make a request to the server, your browser decrypts
|
||||
your API key and sends it to the server.
|
||||
@@ -1,11 +0,0 @@
|
||||
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!,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const secret = process.env.TRIPLIT_JWT_SECRET ?? process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('No secret provided');
|
||||
}
|
||||
|
||||
const anonKey = jwt.sign(
|
||||
{
|
||||
'x-triplit-token-type': 'anon',
|
||||
'x-triplit-project-id': 'local-project-id',
|
||||
},
|
||||
secret,
|
||||
{
|
||||
noTimestamp: true,
|
||||
algorithm: 'HS256',
|
||||
},
|
||||
);
|
||||
|
||||
const serviceKey = jwt.sign(
|
||||
{
|
||||
'x-triplit-token-type': 'secret',
|
||||
'x-triplit-project-id': 'local-project-id',
|
||||
},
|
||||
secret,
|
||||
{
|
||||
noTimestamp: true,
|
||||
algorithm: 'HS256',
|
||||
},
|
||||
);
|
||||
|
||||
console.log('ANON_KEY:', anonKey);
|
||||
console.log('SERVICE_KEY:', serviceKey);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createAuthClient } from 'better-auth/vue';
|
||||
|
||||
export const authClient = createAuthClient({});
|
||||
+25
-12
@@ -1,19 +1,32 @@
|
||||
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";
|
||||
import { triplitAdapter } from '@daveyplate/better-auth-triplit';
|
||||
import { HttpClient } from '@triplit/client';
|
||||
import { betterAuth } from 'better-auth/minimal';
|
||||
import { schema } from '../triplit/schema';
|
||||
|
||||
const httpClient = new HttpClient({
|
||||
schema,
|
||||
serverUrl: process.env.NUXT_PUBLIC_TRIPLIT_URL,
|
||||
token: process.env.TRIPLIT_SERVICE_TOKEN,
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(useDrizzle(), {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
...schema
|
||||
}
|
||||
database: triplitAdapter({
|
||||
httpClient,
|
||||
secretKey: process.env.BETTER_AUTH_SECRET,
|
||||
}),
|
||||
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60,
|
||||
},
|
||||
},
|
||||
|
||||
trustedOrigins: [process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000'],
|
||||
|
||||
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",
|
||||
enabled: process.env.DISABLE_LOCAL_AUTH?.toLowerCase() !== 'true' && process.env.DISABLE_LOCAL_AUTH !== '1',
|
||||
disableSignUp: process.env.DISABLE_SIGNUP?.toLowerCase() === 'true' || process.env.DISABLE_SIGNUP === '1',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
+40
-11
@@ -6,26 +6,49 @@ export default defineNuxtConfig({
|
||||
head: {
|
||||
title: 'Veridian',
|
||||
htmlAttrs: {
|
||||
lang: 'en'
|
||||
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' }
|
||||
]
|
||||
}
|
||||
{
|
||||
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'],
|
||||
// bizarely, when I enable this, it adds an 8 second or so lag when I navigate to a different page occasionally
|
||||
// experimental: {
|
||||
// viewTransition: true,
|
||||
// },
|
||||
|
||||
features: {
|
||||
inlineStyles: true,
|
||||
modules: ['@nuxt/hints', '@nuxt/icon', '@unocss/nuxt', '@nuxtjs/color-mode', 'triplit-nuxt', 'nuxt-shiki'],
|
||||
|
||||
shiki: {
|
||||
bundledThemes: ['vitesse-dark', 'vitesse-light'],
|
||||
bundledLangs: ['js', 'jsx', 'json', 'ts', 'tsx', 'vue', 'css', 'html', 'bash', 'md', 'mdc', 'yaml', 'py'],
|
||||
defaultTheme: 'vitesse-dark',
|
||||
},
|
||||
|
||||
triplit: {
|
||||
schema_path: './triplit/schema.ts',
|
||||
serverUrl: process.env.NUXT_PUBLIC_TRIPLIT_URL,
|
||||
token: process.env.NUXT_TRIPLIT_ANON_TOKEN,
|
||||
storage: 'memory',
|
||||
// we will connect automatically in the auth plugin when we call startSession
|
||||
autoConnect: false,
|
||||
},
|
||||
|
||||
colorMode: {
|
||||
@@ -34,7 +57,13 @@ export default defineNuxtConfig({
|
||||
storage: 'cookie',
|
||||
},
|
||||
|
||||
devtools: { enabled: true },
|
||||
devtools: {
|
||||
enabled: true,
|
||||
|
||||
timeline: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
|
||||
compatibilityDate: '2025-07-15',
|
||||
})
|
||||
});
|
||||
|
||||
+37
-11
@@ -4,31 +4,57 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"triplit": "triplit dev -s sqlite",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/vue": "^3.0.48",
|
||||
"@daveyplate/better-auth-triplit": "^0.2.2",
|
||||
"@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",
|
||||
"@openrouter/ai-sdk-provider": "^2.1.1",
|
||||
"@triplit/client": "^1.0.50",
|
||||
"@unocss/reset": "^66.6.0",
|
||||
"ai": "^6.0.48",
|
||||
"better-auth": "^1.4.17",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"glob": "^13.0.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"nuxt": "^4.2.2",
|
||||
"pg": "^8.16.3",
|
||||
"uuidv7": "^1.1.0",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4"
|
||||
"nuxt": "4.2.2",
|
||||
"nuxt-shiki": "0.3.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"triplit-nuxt": "0.3.1-prerelease.5",
|
||||
"unified": "^11.0.5",
|
||||
"vue": "^3.5.27",
|
||||
"vue-router": "^4.6.4",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.16.0",
|
||||
"@unocss/nuxt": "^66.5.12",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"@biomejs/biome": "2.3.13",
|
||||
"@iconify-json/logos": "^1.2.10",
|
||||
"@iconify-json/svg-spinners": "^1.2.4",
|
||||
"@triplit/cli": "^1.0.61",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@unocss/nuxt": "^66.6.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"tsx": "^4.21.0",
|
||||
"unocss": "^66.5.12"
|
||||
"unocss": "^66.6.0"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@parcel/watcher",
|
||||
"core-js",
|
||||
"esbuild",
|
||||
"unrs-resolver"
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@triplit/db@1.1.10": "patches/@triplit%2Fdb@1.1.10.patch",
|
||||
"@triplit/client@1.0.50": "patches/@triplit%2Fclient@1.0.50.patch"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
diff --git a/dist/client/triplit-client.d.ts b/dist/client/triplit-client.d.ts
|
||||
index 85dc8f6d97ded0e0e4e7bfdfedb1d02ea33405d7..dd9e6e78114f174e97f28cc9e6ac95e3d3037d44 100644
|
||||
--- a/dist/client/triplit-client.d.ts
|
||||
+++ b/dist/client/triplit-client.d.ts
|
||||
@@ -15,6 +15,7 @@ export declare class TriplitClient<M extends Models<M> = Models> {
|
||||
* The sync engine is responsible for managing the connection to the server and syncing data
|
||||
*/
|
||||
syncEngine: SyncEngine;
|
||||
+ private hasPendingWrites;
|
||||
private _token;
|
||||
private claimsPath;
|
||||
private _serverUrl;
|
||||
@@ -36,6 +37,11 @@ export declare class TriplitClient<M extends Models<M> = Models> {
|
||||
*/
|
||||
constructor(options?: ClientOptions<M>);
|
||||
get ready(): Promise<void>;
|
||||
+ /**
|
||||
+ * Flushes updates to the database and syncs with the server. This function may be a no-op if no
|
||||
+ * writes have been made since the last flush.
|
||||
+ */
|
||||
+ flush(syncWrites?: boolean): Promise<void>;
|
||||
/**
|
||||
* Gets the schema of the database
|
||||
*
|
||||
@@ -99,7 +105,7 @@ export declare class TriplitClient<M extends Models<M> = Models> {
|
||||
*
|
||||
* @param collectionName - The name of the collection to insert into
|
||||
* @param object - The entity to insert
|
||||
- * @returns The transaction ID and the inserted entity, if successful
|
||||
+ * @returns - The inserted entity, if successful
|
||||
*/
|
||||
insert<CN extends CollectionNameFromModels<M>>(collectionName: CN, object: WriteModel<M, CN>): Promise<import("@triplit/db").Unalias<import("@triplit/db").Decoded<M[CN]["schema"]>>>;
|
||||
/**
|
||||
@@ -108,7 +114,6 @@ export declare class TriplitClient<M extends Models<M> = Models> {
|
||||
* @param collectionName - The name of the collection to update
|
||||
* @param entityId - The id of the entity to update
|
||||
* @param updater - A function that provides the current entity and allows you to modify it
|
||||
- * @returns The transaction ID
|
||||
*/
|
||||
update<CN extends CollectionNameFromModels<M>>(collectionName: CN, entityId: string, data: UpdatePayload<M, CN>): Promise<void>;
|
||||
/**
|
||||
@@ -116,7 +121,6 @@ export declare class TriplitClient<M extends Models<M> = Models> {
|
||||
*
|
||||
* @param collectionName - The name of the collection to delete from
|
||||
* @param entityId - The id of the entity to delete
|
||||
- * @returns The transaction ID
|
||||
*/
|
||||
delete<CN extends CollectionNameFromModels<M>>(collectionName: CN, entityId: string): Promise<void>;
|
||||
entityIsInCache(collection: string, entityId: string): Promise<boolean>;
|
||||
diff --git a/dist/client/triplit-client.js b/dist/client/triplit-client.js
|
||||
index c1f54b5b9abb11c4c983cf368d66c8d22379172a..4b9b19f7cf65a603e228b9aec68c1a1218b91eb2 100644
|
||||
--- a/dist/client/triplit-client.js
|
||||
+++ b/dist/client/triplit-client.js
|
||||
@@ -23,6 +23,7 @@ export class TriplitClient {
|
||||
* The sync engine is responsible for managing the connection to the server and syncing data
|
||||
*/
|
||||
syncEngine;
|
||||
+ hasPendingWrites = false;
|
||||
_token = undefined;
|
||||
claimsPath = undefined;
|
||||
_serverUrl = undefined;
|
||||
@@ -69,13 +70,21 @@ export class TriplitClient {
|
||||
this.db = decoded ? this.db.withSessionVars(decoded) : this.db;
|
||||
}
|
||||
});
|
||||
- this.db.onCommit(
|
||||
- // @ts-expect-error
|
||||
- throttle(async (tx) => {
|
||||
- await this.db.updateQueryViews();
|
||||
- this.db.broadcastToQuerySubscribers();
|
||||
- await this.syncEngine.syncWrites();
|
||||
- }, 20, { leading: false, trailing: true }));
|
||||
+ let writeTimeout = undefined;
|
||||
+ this.db.onCommit(async () => {
|
||||
+ this.hasPendingWrites = true;
|
||||
+ if (writeTimeout) {
|
||||
+ clearTimeout(writeTimeout);
|
||||
+ }
|
||||
+ else {
|
||||
+ // on the very first write in a batch, flush without writing to the server
|
||||
+ await this.flush(false);
|
||||
+ }
|
||||
+ writeTimeout = setTimeout(() => {
|
||||
+ this.flush();
|
||||
+ writeTimeout = undefined;
|
||||
+ }, 20);
|
||||
+ });
|
||||
this.db.onSchemaChange((change) => {
|
||||
if (change.successful) {
|
||||
this.http.updateOptions({
|
||||
@@ -155,6 +164,20 @@ export class TriplitClient {
|
||||
return this.awaitReady;
|
||||
return Promise.resolve();
|
||||
}
|
||||
+ /**
|
||||
+ * Flushes updates to the database and syncs with the server. This function may be a no-op if no
|
||||
+ * writes have been made since the last flush.
|
||||
+ */
|
||||
+ async flush(syncWrites = true) {
|
||||
+ if (!this.hasPendingWrites)
|
||||
+ return;
|
||||
+ await this.db.updateQueryViews();
|
||||
+ this.db.broadcastToQuerySubscribers();
|
||||
+ if (syncWrites) {
|
||||
+ await this.syncEngine.syncWrites();
|
||||
+ this.hasPendingWrites = false;
|
||||
+ }
|
||||
+ }
|
||||
/**
|
||||
* Gets the schema of the database
|
||||
*
|
||||
@@ -314,7 +337,7 @@ export class TriplitClient {
|
||||
*
|
||||
* @param collectionName - The name of the collection to insert into
|
||||
* @param object - The entity to insert
|
||||
- * @returns The transaction ID and the inserted entity, if successful
|
||||
+ * @returns - The inserted entity, if successful
|
||||
*/
|
||||
async insert(collectionName, object) {
|
||||
if (this.awaitReady)
|
||||
@@ -332,7 +355,6 @@ export class TriplitClient {
|
||||
* @param collectionName - The name of the collection to update
|
||||
* @param entityId - The id of the entity to update
|
||||
* @param updater - A function that provides the current entity and allows you to modify it
|
||||
- * @returns The transaction ID
|
||||
*/
|
||||
async update(collectionName, entityId, data) {
|
||||
if (this.awaitReady)
|
||||
@@ -349,7 +371,6 @@ export class TriplitClient {
|
||||
*
|
||||
* @param collectionName - The name of the collection to delete from
|
||||
* @param entityId - The id of the entity to delete
|
||||
- * @returns The transaction ID
|
||||
*/
|
||||
async delete(collectionName, entityId) {
|
||||
if (this.awaitReady)
|
||||
@@ -976,32 +997,6 @@ function flipOrder(order) {
|
||||
return undefined;
|
||||
return order.map((o) => [o[0], o[1] === 'ASC' ? 'DESC' : 'ASC']);
|
||||
}
|
||||
-function throttle(func, limit, options) {
|
||||
- let inThrottle;
|
||||
- let lastArgs = null;
|
||||
- return function () {
|
||||
- const args = arguments;
|
||||
- if (!inThrottle) {
|
||||
- if (options?.leading !== false) {
|
||||
- func(args);
|
||||
- }
|
||||
- else {
|
||||
- lastArgs = args;
|
||||
- }
|
||||
- inThrottle = true;
|
||||
- setTimeout(() => {
|
||||
- if (options?.trailing && lastArgs) {
|
||||
- func(lastArgs);
|
||||
- lastArgs = null;
|
||||
- }
|
||||
- inThrottle = false;
|
||||
- }, limit);
|
||||
- }
|
||||
- else {
|
||||
- lastArgs = args;
|
||||
- }
|
||||
- };
|
||||
-}
|
||||
function validateServerUrl(serverUrl) {
|
||||
if (serverUrl &&
|
||||
!serverUrl.startsWith('http://') &&
|
||||
@@ -0,0 +1,15 @@
|
||||
diff --git a/dist/db.js b/dist/db.js
|
||||
index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..9c4d3060894285218d2d1f961930b5dad52d7bc5 100644
|
||||
--- a/dist/db.js
|
||||
+++ b/dist/db.js
|
||||
@@ -307,9 +307,7 @@ export class DB {
|
||||
// TODO call the listeners in the entity store
|
||||
// Trigger subscription updates
|
||||
await this.ivm.bufferChanges(changes);
|
||||
- for (const listener of this.onCommitListeners) {
|
||||
- listener(changes);
|
||||
- }
|
||||
+ await Promise.all([...this.onCommitListeners].map((listener) => listener(changes)));
|
||||
return output;
|
||||
}
|
||||
async applyChanges(changes, options) {
|
||||
@@ -1,29 +0,0 @@
|
||||
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;
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { agents } from "~~/db/schema";
|
||||
import { protectRoute } from "~~/server/utils/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const db = useDrizzle();
|
||||
const userId = event.context.user.id;
|
||||
|
||||
// Only return agents for the authenticated user
|
||||
const rows = await db.select().from(agents).where(eq(agents.userId, userId));
|
||||
return rows;
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
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;
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { auth } from "~~/lib/auth";
|
||||
import { auth } from '~~/lib/auth';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
return auth.handler(toWebRequest(event));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { httpClient } from '~~/server/lib/triplit';
|
||||
import { cancelPendingGeneration } from '~~/server/utils/generations';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const { generationId } = event.context.params!;
|
||||
|
||||
const success = cancelPendingGeneration(generationId!);
|
||||
|
||||
if (!success) {
|
||||
const generation = await httpClient.fetchOne(httpClient.query('generations').Where('id', '=', generationId!));
|
||||
if (generation !== null && generation.status === 'pending') {
|
||||
await httpClient.update('generations', generationId!, {
|
||||
status: 'cancelled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Generation not found or already completed',
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
@@ -1,40 +1,658 @@
|
||||
import { protectRoute } from '~~/server/utils/auth';
|
||||
import { createPendingGeneration } from '~~/server/utils/generation';
|
||||
import type { GenerateRequestBody } from '~~/server/types/chat';
|
||||
import { createOpenRouter, type OpenRouterProvider } from '@openrouter/ai-sdk-provider';
|
||||
import type { Entity } from '@triplit/client';
|
||||
import { type ModelMessage, modelMessageSchema, streamText, tool } from 'ai';
|
||||
import { promises as fs } from 'fs';
|
||||
import { glob } from 'glob';
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'path';
|
||||
import * as z from 'zod';
|
||||
import { httpClient } from '~~/server/lib/triplit';
|
||||
import { addPendingGeneration, completeGeneration } from '~~/server/utils/generations';
|
||||
import type { schema } from '~~/triplit/schema';
|
||||
|
||||
export const messagesSchema = z.array(modelMessageSchema);
|
||||
|
||||
// quick access
|
||||
// const ACTIVE_MODEL_ID = 'openrouter/free';
|
||||
// const ACTIVE_MODEL_ID = 'x-ai/grok-4.1-fast';
|
||||
const ACTIVE_MODEL_ID = 'google/gemini-3-flash-preview';
|
||||
// const ACTIVE_MODEL_ID = 'arcee-ai/trinity-mini:free';
|
||||
|
||||
type ModelGateway = OpenRouterProvider;
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const body = await readBody(event) as GenerateRequestBody;
|
||||
const { topicId, messages, regeneratesFrom } = body;
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
messages: messagesSchema.min(1),
|
||||
topicId: z.string(),
|
||||
model: z.object({
|
||||
providerId: z.string(),
|
||||
modelId: z.string(),
|
||||
args: z.any(),
|
||||
}),
|
||||
providerApiKey: z.string().optional(),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!topicId || !messages) {
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Missing required fields: topicId and messages'
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
const userId = event.context.user!.id;
|
||||
|
||||
const { messages, topicId, model: { modelId, providerId }, providerApiKey } = result.data;
|
||||
|
||||
const provider = await httpClient.fetchOne(httpClient.query('providers').Where('id', '=', providerId));
|
||||
if (provider === null || provider.userId !== userId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Messages array cannot be empty'
|
||||
message: 'Invalid provider',
|
||||
});
|
||||
}
|
||||
|
||||
const model = await httpClient.fetchOne(httpClient.query('models').Where('id', '=', modelId));
|
||||
if (model === null || model.providerId !== model.providerId || model.userId !== userId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Invalid model',
|
||||
});
|
||||
}
|
||||
|
||||
const existingPendingGenerations = await httpClient.fetchOne(
|
||||
httpClient.query('generations').Where('topicId', '=', topicId).Where('status', '=', 'pending'),
|
||||
);
|
||||
if (existingPendingGenerations !== null) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'There cannot be more than one active generation per topic',
|
||||
});
|
||||
}
|
||||
|
||||
let gateway: ModelGateway;
|
||||
|
||||
switch (provider.type) {
|
||||
case 'openrouter': {
|
||||
if (providerApiKey === undefined) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'OpenRouter provider requires an API key',
|
||||
});
|
||||
}
|
||||
|
||||
gateway = createOpenRouter({
|
||||
apiKey: providerApiKey,
|
||||
headers: {
|
||||
'HTTP-Referer': 'https://localhost:3000',
|
||||
'X-Title': 'Veridian',
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown provider type: ${provider.type}`);
|
||||
}
|
||||
|
||||
const generationId = nanoid();
|
||||
const message = await httpClient.insert('messages', {
|
||||
topicId,
|
||||
generationId,
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
});
|
||||
await httpClient.insert('generations', {
|
||||
id: generationId,
|
||||
topicId,
|
||||
modelId: model.externalId,
|
||||
status: 'pending',
|
||||
messageId: message.id,
|
||||
});
|
||||
|
||||
let logFile: fs.FileHandle | undefined;
|
||||
let logMessage: ((message: string) => void) | undefined;
|
||||
|
||||
if (process.env.GENERATION_DEBUG) {
|
||||
logFile = await fs.open(path.join(process.env.LOG_DIR!, `${Date.now()}-${generationId}.log`), 'w');
|
||||
logMessage = (message: string) => {
|
||||
logFile!.write(message + '\n');
|
||||
};
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
generateResponse(message, { gateway, model: model.externalId }, generationId, userId, messages, logMessage, logFile),
|
||||
);
|
||||
|
||||
return {
|
||||
generationId,
|
||||
messageId: message.id,
|
||||
};
|
||||
});
|
||||
|
||||
const INTERNAL_ERROR = 'An internal error occurred';
|
||||
|
||||
// todo message takes in variadics like console.log
|
||||
const todo = (...args: any[]) => {
|
||||
console.error('TODO', ...args);
|
||||
throw new Error('TODO');
|
||||
};
|
||||
|
||||
async function generateResponse(
|
||||
message: Entity<typeof schema, 'messages'>,
|
||||
model: {
|
||||
gateway: ModelGateway,
|
||||
model: string,
|
||||
},
|
||||
generationId: string,
|
||||
userId: string,
|
||||
messages: ModelMessage[],
|
||||
log?: (message: string) => void,
|
||||
logFile?: fs.FileHandle,
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
addPendingGeneration(generationId, controller);
|
||||
|
||||
const activeParts = new Map<string, { id: string; accumulatedContent: string; providerOptions?: any }>();
|
||||
const activeToolCalls = new Map<string, { id: string }>();
|
||||
|
||||
const response = streamText({
|
||||
model: model.gateway(model.model),
|
||||
messages,
|
||||
// a little trick that makes it so that the stream doesnt stop because of tool calls, and will continue an unbounded amount of time and steps
|
||||
stopWhen: [],
|
||||
// tools: {
|
||||
// // writeFile: tool({
|
||||
// // inputSchema: z.object({
|
||||
// // path: z.string(),
|
||||
// // content: z.string(),
|
||||
// // }),
|
||||
// // outputSchema: z.object({
|
||||
// // success: z.boolean(),
|
||||
// // }),
|
||||
// // execute: async ({ path, content }) => {
|
||||
// // await fs.writeFile(path, content);
|
||||
// // return {
|
||||
// // success: true,
|
||||
// // };
|
||||
// // }
|
||||
// // }),
|
||||
// listDirectory: tool({
|
||||
// inputSchema: z.object({
|
||||
// path: z.string(),
|
||||
// }),
|
||||
// outputSchema: z.object({
|
||||
// files: z.array(z.object({ name: z.string(), type: z.string() })),
|
||||
// }),
|
||||
// execute: async ({ path }) => {
|
||||
// const rawFiles = await fs.readdir(path, { withFileTypes: true });
|
||||
// const files = rawFiles.map((file) => ({
|
||||
// name: file.name,
|
||||
// type: file.isFile() ? 'file' : 'directory',
|
||||
// }));
|
||||
|
||||
// return {
|
||||
// files,
|
||||
// };
|
||||
// },
|
||||
// }),
|
||||
// glob: tool({
|
||||
// inputSchema: z.object({
|
||||
// pattern: z.string(),
|
||||
// }),
|
||||
// outputSchema: z.object({
|
||||
// files: z.array(z.object({ name: z.string(), type: z.string() })),
|
||||
// }),
|
||||
// execute: async ({ pattern }) => {
|
||||
// const rawFiles = await glob(pattern, { withFileTypes: true });
|
||||
// const files = rawFiles.map((file) => ({
|
||||
// name: file.name,
|
||||
// type: file.isFile() ? 'file' : 'directory',
|
||||
// }));
|
||||
|
||||
// return {
|
||||
// files,
|
||||
// };
|
||||
// },
|
||||
// }),
|
||||
// readFile: tool({
|
||||
// inputSchema: z.object({
|
||||
// path: z.string(),
|
||||
// }),
|
||||
// outputSchema: z.object({
|
||||
// path: z.string(),
|
||||
// content: z.string(),
|
||||
// }),
|
||||
// execute: async ({ path }) => {
|
||||
// const file = await fs.readFile(path);
|
||||
// return {
|
||||
// path,
|
||||
// content: file.toString(),
|
||||
// };
|
||||
// },
|
||||
// }),
|
||||
// readFiles: tool({
|
||||
// inputSchema: z.object({
|
||||
// paths: z.array(z.string()).describe('The file paths to read'),
|
||||
// }),
|
||||
// outputSchema: z.object({
|
||||
// files: z.array(
|
||||
// z.object({
|
||||
// path: z.string(),
|
||||
// content: z.string(),
|
||||
// }),
|
||||
// ),
|
||||
// }),
|
||||
// execute: async ({ paths }) => {
|
||||
// const files = await Promise.all(
|
||||
// paths.map(async (path) => {
|
||||
// const file = await fs.readFile(path);
|
||||
// return {
|
||||
// path: path,
|
||||
// content: file.toString(),
|
||||
// };
|
||||
// }),
|
||||
// );
|
||||
|
||||
// return {
|
||||
// files,
|
||||
// };
|
||||
// },
|
||||
// }),
|
||||
// fetchUrl: tool({
|
||||
// inputSchema: z.object({
|
||||
// url: z.string(),
|
||||
// }),
|
||||
// outputSchema: z.object({
|
||||
// content: z.string(),
|
||||
// }),
|
||||
// execute: async ({ url }) => {
|
||||
// const response = await fetch(url);
|
||||
// const content = await response.text();
|
||||
// return {
|
||||
// content,
|
||||
// };
|
||||
// },
|
||||
// }),
|
||||
// },
|
||||
onStepFinish: async (result) => {
|
||||
if (result.toolResults.length > 0) {
|
||||
for (const toolResult of result.toolResults) {
|
||||
let outputType: 'text' | 'json' = 'text';
|
||||
let outputValue: string = '';
|
||||
|
||||
switch (typeof toolResult.output) {
|
||||
case 'string':
|
||||
outputType = 'text';
|
||||
outputValue = toolResult.output;
|
||||
break;
|
||||
case 'object':
|
||||
outputType = 'json';
|
||||
outputValue = JSON.stringify(toolResult.output, null, 2);
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown output type', toolResult.output);
|
||||
await httpClient.update('tool_calls', toolResult.toolCallId, {
|
||||
status: 'failed',
|
||||
error: {
|
||||
type: 'text',
|
||||
value: 'Tool returned invalid output',
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
await httpClient.update('tool_calls', toolResult.toolCallId, {
|
||||
status: 'completed',
|
||||
output: {
|
||||
type: outputType,
|
||||
value: outputValue,
|
||||
},
|
||||
});
|
||||
|
||||
activeToolCalls.delete(toolResult.toolCallId);
|
||||
}
|
||||
}
|
||||
},
|
||||
onFinish: async (result) => {
|
||||
await httpClient.update('generations', generationId, {
|
||||
status: 'completed',
|
||||
tokens: {
|
||||
input: result.totalUsage.inputTokens,
|
||||
cache: {
|
||||
read: result.totalUsage.inputTokenDetails.cacheReadTokens,
|
||||
write: result.totalUsage.inputTokenDetails.cacheWriteTokens,
|
||||
},
|
||||
output: result.totalUsage.outputTokens,
|
||||
thinking: result.totalUsage.outputTokenDetails.reasoningTokens,
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: async (error: any) => {
|
||||
console.error('generation error', error);
|
||||
log?.(error);
|
||||
// TODO: the docs say "The stream processing will pause until the callback promise is resolved." Suggesting that this error might not be fatal?
|
||||
for (const activePart of activeParts.values()) {
|
||||
await httpClient.update('message_parts', activePart.id, {
|
||||
finished: true,
|
||||
lastUpdatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
for (const activeToolCall of activeToolCalls.values()) {
|
||||
await httpClient.update('tool_calls', activeToolCall.id, {
|
||||
status: 'failed',
|
||||
error: {
|
||||
type: 'text',
|
||||
value: 'An unknown error occurred',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await httpClient.update('generations', generationId, {
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
});
|
||||
},
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
let curStepIdx = -1;
|
||||
let key, part, type;
|
||||
|
||||
const pendingUpdates = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
const TARGET_UPDATES_PER_SECOND = 24;
|
||||
|
||||
const scheduleUpdate = (key: string) => {
|
||||
const part = activeParts.get(key);
|
||||
if (!part || pendingUpdates.has(part.id)) return;
|
||||
|
||||
pendingUpdates.set(part.id, setTimeout(async () => {
|
||||
const currentPart = activeParts.get(key);
|
||||
// Only update if the part is still active and we haven't deleted it at 'text-end'
|
||||
if (currentPart) {
|
||||
try {
|
||||
await httpClient.update('message_parts', currentPart.id, {
|
||||
content: currentPart.accumulatedContent,
|
||||
providerOptions: currentPart.providerOptions,
|
||||
lastUpdatedAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
// the update failed, but it doesnt matter because the full message will be updated on step finish
|
||||
console.warn('Failed to update message part', error);
|
||||
}
|
||||
}
|
||||
pendingUpdates.delete(part.id);
|
||||
}, 1000 / TARGET_UPDATES_PER_SECOND));
|
||||
};
|
||||
|
||||
try {
|
||||
const generationId = await createPendingGeneration(event.context.user.id, topicId, messages, regeneratesFrom);
|
||||
for await (const token of response.fullStream) {
|
||||
log?.(JSON.stringify(token, null, 2));
|
||||
|
||||
return {
|
||||
generationId,
|
||||
status: 'pending',
|
||||
regeneratesFrom
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create generation:', error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to create generation'
|
||||
switch (token.type) {
|
||||
case 'start-step': {
|
||||
curStepIdx++;
|
||||
} break;
|
||||
case 'tool-input-start': {
|
||||
key = `tool-call-${curStepIdx}`;
|
||||
|
||||
const toolCallId = token.id;
|
||||
|
||||
part = await httpClient.insert('message_parts', {
|
||||
messageId: message.id,
|
||||
toolCallId,
|
||||
type: 'tool-call',
|
||||
content: '',
|
||||
finished: false,
|
||||
createdAt: new Date(),
|
||||
lastUpdatedAt: new Date(),
|
||||
});
|
||||
|
||||
await httpClient.insert('tool_calls', {
|
||||
id: toolCallId,
|
||||
userId: userId,
|
||||
toolName: token.toolName,
|
||||
status: 'pending',
|
||||
input: null,
|
||||
output: null,
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
activeToolCalls.set(key, { id: toolCallId });
|
||||
|
||||
activeParts.set(key, { id: part.id, accumulatedContent: '' });
|
||||
} break;
|
||||
case 'text-start':
|
||||
case 'reasoning-start': {
|
||||
type = token.type.split('-')[0];
|
||||
key = `${type}-${curStepIdx}`;
|
||||
|
||||
part = await httpClient.insert('message_parts', {
|
||||
messageId: message.id,
|
||||
type: type as 'text' | 'reasoning',
|
||||
content: '',
|
||||
finished: false,
|
||||
createdAt: new Date(),
|
||||
lastUpdatedAt: new Date(),
|
||||
});
|
||||
|
||||
activeParts.set(key, { id: part.id, accumulatedContent: '' });
|
||||
} break;
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta': {
|
||||
type = token.type.split('-')[0] as 'text' | 'reasoning';
|
||||
key = `${type}-${curStepIdx}`;
|
||||
part = activeParts.get(key);
|
||||
if (part === undefined) {
|
||||
console.error('Received delta without a start');
|
||||
break;
|
||||
}
|
||||
|
||||
let shouldUpdate = false;
|
||||
|
||||
// TODO: we should potentially merge providerOptions, but for now, just overwrite them
|
||||
if (token.providerMetadata !== undefined) {
|
||||
shouldUpdate = true;
|
||||
part.providerOptions = token.providerMetadata;
|
||||
}
|
||||
|
||||
// OpenRouter sometimes puts [REDACTED] in thinking if reasoning is encrypted, so we need to remove it and hide it;
|
||||
// do not trim or else we lose intentional whitespace and newlines potentially breaking the UI and having words comebined e.g. "the" "\n\n" "assistant" would become "theassistant"
|
||||
const text = token.text.replaceAll('[REDACTED]', '');
|
||||
if (text !== '') {
|
||||
shouldUpdate = true;
|
||||
part.accumulatedContent += token.text;
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
scheduleUpdate(key);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'text-end':
|
||||
case 'reasoning-end': {
|
||||
type = token.type.split('-')[0];
|
||||
key = `${type}-${curStepIdx}`;
|
||||
part = activeParts.get(key);
|
||||
if (part === undefined) {
|
||||
console.error('Received end without a start');
|
||||
break;
|
||||
}
|
||||
|
||||
activeParts.delete(key);
|
||||
|
||||
if (part.accumulatedContent === '' && part.providerOptions === undefined) {
|
||||
// completely empty, delete it
|
||||
await httpClient.delete('message_parts', part.id);
|
||||
break;
|
||||
}
|
||||
|
||||
await httpClient.update('message_parts', part.id, {
|
||||
content: part.accumulatedContent,
|
||||
providerOptions: part.providerOptions,
|
||||
finished: true,
|
||||
lastUpdatedAt: new Date(),
|
||||
});
|
||||
} break;
|
||||
case 'tool-call': {
|
||||
let inputType: 'text' | 'json' = 'text';
|
||||
let inputValue: string = '';
|
||||
|
||||
switch (typeof token.input) {
|
||||
case 'string':
|
||||
inputType = 'text';
|
||||
inputValue = token.input;
|
||||
break;
|
||||
case 'object':
|
||||
inputType = 'json';
|
||||
inputValue = JSON.stringify(token.input, null, 2);
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown input type', token.input);
|
||||
break;
|
||||
}
|
||||
|
||||
await httpClient.update('tool_calls', token.toolCallId, {
|
||||
status: 'pending',
|
||||
input: {
|
||||
type: inputType,
|
||||
value: inputValue,
|
||||
},
|
||||
});
|
||||
} break;
|
||||
case 'tool-error': {
|
||||
const toolCall = activeToolCalls.get(token.toolCallId);
|
||||
if (toolCall === undefined) {
|
||||
console.error('Received tool-error without a start');
|
||||
break;
|
||||
}
|
||||
|
||||
let outputType: 'text' | 'json';
|
||||
let outputValue: string;
|
||||
|
||||
switch (typeof token.error) {
|
||||
case 'string':
|
||||
outputType = 'text';
|
||||
outputValue = token.error;
|
||||
break;
|
||||
case 'object':
|
||||
outputType = 'json';
|
||||
outputValue = JSON.stringify(token.error, null, 2);
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown error type', token.error);
|
||||
outputType = 'text';
|
||||
outputValue = 'Tool returned invalid output';
|
||||
break;
|
||||
}
|
||||
|
||||
await httpClient.update('tool_calls', toolCall.id, {
|
||||
status: 'failed',
|
||||
error: {
|
||||
type: outputType,
|
||||
value: outputValue,
|
||||
},
|
||||
});
|
||||
} break;
|
||||
case 'error': {
|
||||
let error = INTERNAL_ERROR;
|
||||
if (typeof token.error === 'string') {
|
||||
error = token.error;
|
||||
} else if (typeof token.error === 'object') {
|
||||
error = JSON.stringify(token.error, null, 2);
|
||||
}
|
||||
|
||||
await httpClient.update('generations', generationId, {
|
||||
status: 'failed',
|
||||
error,
|
||||
});
|
||||
} break;
|
||||
case 'finish': {
|
||||
switch (token.finishReason) {
|
||||
case 'error':
|
||||
await httpClient.update('generations', generationId, {
|
||||
status: 'failed',
|
||||
error: INTERNAL_ERROR,
|
||||
});
|
||||
break;
|
||||
case 'content-filter':
|
||||
await httpClient.update('generations', generationId, {
|
||||
status: 'failed',
|
||||
error: 'Content was filtered',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// I hate you switch fallthroughs
|
||||
} break;
|
||||
case 'abort': {
|
||||
for (const activeToolCall of activeToolCalls.values()) {
|
||||
await httpClient.update('tool_calls', activeToolCall.id, {
|
||||
status: 'cancelled',
|
||||
});
|
||||
}
|
||||
|
||||
await httpClient.update('generations', generationId, {
|
||||
status: 'cancelled',
|
||||
});
|
||||
} break;
|
||||
case 'file':
|
||||
todo('file token type', token);
|
||||
break;
|
||||
case 'raw':
|
||||
todo('raw token type', token);
|
||||
break;
|
||||
case 'source':
|
||||
todo('source token type', token);
|
||||
break;
|
||||
case 'tool-approval-request':
|
||||
todo('tool-approval-request token type', token);
|
||||
break;
|
||||
case 'tool-output-denied':
|
||||
todo('tool-output-denied token type', token);
|
||||
break;
|
||||
case 'start':
|
||||
case 'finish-step':
|
||||
case 'tool-input-delta':
|
||||
case 'tool-input-end':
|
||||
case 'tool-result':
|
||||
// handled or irrelevant
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
||||
for (const activePart of activeParts.values()) {
|
||||
await httpClient.update('message_parts', activePart.id, {
|
||||
finished: true,
|
||||
lastUpdatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
for (const activeToolCall of activeToolCalls.values()) {
|
||||
await httpClient.update('tool_calls', activeToolCall.id, {
|
||||
status: 'failed',
|
||||
error: {
|
||||
type: 'text',
|
||||
value: 'An unknown error occurred',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await httpClient.update('generations', generationId, {
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
completeGeneration(generationId);
|
||||
if (logFile !== undefined) logFile.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { protectRoute } from '~~/server/utils/auth';
|
||||
import { getGenerationStatus } from '~~/server/utils/generation';
|
||||
import type { GenerationStatusResponse } 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 generation = await getGenerationStatus(generationId);
|
||||
|
||||
if (!generation) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Generation not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (generation.userId !== event.context.user.id) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Unauthorized'
|
||||
});
|
||||
}
|
||||
|
||||
const status: GenerationStatusResponse = {
|
||||
generationId,
|
||||
status: generation.status as any,
|
||||
topicId: generation.topicId,
|
||||
content: generation.content,
|
||||
error: generation.error || undefined
|
||||
};
|
||||
|
||||
return status;
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { protectRoute } from '~~/server/utils/auth';
|
||||
import { startGeneration, addClientToGeneration, removeClientFromGeneration, sendToClient, isGenerationStreaming, getGenerationStatus } from '~~/server/utils/generation';
|
||||
import { eventHandler, setHeader, setResponseStatus } from 'h3';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { useDrizzle } from '~~/server/utils/drizzle';
|
||||
import { generations, messages } from '~~/db/schema';
|
||||
|
||||
export default eventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const generationId = getRouterParam(event, 'id');
|
||||
|
||||
if (!generationId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Missing generation ID'
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch generation from database
|
||||
const generation = await getGenerationStatus(generationId);
|
||||
if (!generation) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Generation not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (generation.userId !== event.context.user.id) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Unauthorized'
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
// If generation is already completed, send the completed message
|
||||
if (generation.status === 'completed' && generation.messageId) {
|
||||
const db = useDrizzle();
|
||||
const [message] = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.id, generation.messageId));
|
||||
|
||||
if (message) {
|
||||
sendToClient(controller, {
|
||||
type: 'complete',
|
||||
data: message
|
||||
});
|
||||
}
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// If generation failed, send the error
|
||||
if (generation.status === 'failed') {
|
||||
sendToClient(controller, {
|
||||
type: 'error',
|
||||
data: { error: generation.error || 'Generation failed' }
|
||||
});
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// If already streaming, just add this client
|
||||
if (isGenerationStreaming(generationId)) {
|
||||
addClientToGeneration(generationId, controller);
|
||||
} else {
|
||||
// Start generation if in pending status
|
||||
if (generation.status === 'pending') {
|
||||
// Fetch the original messages context (stored in topic messages)
|
||||
const db = useDrizzle();
|
||||
const topicMessages = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.topicId, generation.topicId));
|
||||
|
||||
const chatMessages = topicMessages.map(m => ({
|
||||
type: m.isUser ? 'user' as const : ('agent' as const),
|
||||
message: m.content
|
||||
}));
|
||||
|
||||
addClientToGeneration(generationId, controller);
|
||||
await startGeneration(generationId, generation.userId, generation.topicId, chatMessages, controller);
|
||||
}
|
||||
}
|
||||
|
||||
event.node.req.on('close', () => {
|
||||
removeClientFromGeneration(generationId, controller);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Stream start error:', error);
|
||||
sendToClient(controller, {
|
||||
type: 'error',
|
||||
data: { error: 'Stream initialization failed' }
|
||||
});
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return sendStream(event, stream);
|
||||
} catch (error) {
|
||||
console.error('Stream error:', error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Stream error'
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { messages, topics } from "~~/db/schema";
|
||||
import { protectRoute } from "~~/server/utils/auth";
|
||||
import type { Message, Topic } from '~~/types'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const db = useDrizzle();
|
||||
const userId = event.context.user.id;
|
||||
const topicId = getRouterParam(event, 'id');
|
||||
|
||||
if (!topicId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Topic ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(topics)
|
||||
.where(and(eq(topics.userId, userId), eq(topics.id, topicId)));
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Topic not found'
|
||||
});
|
||||
}
|
||||
|
||||
const topic = rows[0] as Topic & { messages: Message[] };
|
||||
|
||||
// Fetch messages for this topic, ordered chronologically
|
||||
topic.messages = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.topicId, topic.id))
|
||||
.orderBy(asc(messages.createdAt));
|
||||
|
||||
return topic;
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { messages, topics } from "~~/db/schema";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const db = useDrizzle();
|
||||
|
||||
const { id } = event.context.params!;
|
||||
|
||||
const [topic] = await db.select().from(topics).where(eq(topics.id, id));
|
||||
if (topic === undefined || topic.userId !== event.context.user.id) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Topic not found' });
|
||||
}
|
||||
|
||||
const { content } = await readBody(event);
|
||||
if (!content) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'No content provided' });
|
||||
}
|
||||
|
||||
const [message] = await db.insert(messages).values({
|
||||
topicId: topic.id,
|
||||
userId: event.context.user.id,
|
||||
content,
|
||||
isUser: true,
|
||||
}).returning();
|
||||
|
||||
return message;
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user