streaming, markdown, model selecting, and lots more
This commit is contained in:
+6
-5
@@ -1,5 +1,6 @@
|
|||||||
VERIDIAN_DB_NAME=veridian
|
TRIPLIT_SERVICE_TOKEN="your-triplit-service-token" # given to you on startup, or by running `tsx generate-tokens.ts`
|
||||||
POSTGRES_PASSWORD=password
|
NUXT_TRIPLIT_ANON_TOKEN="your-anonymous-triplit-token" # also given to you on startup, or by running `tsx generate-tokens.ts`
|
||||||
NUXT_AUTH_SECRET="YOUR-SUPER-SECURE-SECRET" # openssl rand -base64 32
|
BETTER_AUTH_SECRET="super-secret" # openssl rand -base64 32
|
||||||
|
TRIPLIT_JWT_SECRET="super-secret"
|
||||||
DATABASE_URL=postgresql://postgres:password@localhost:5432/veridian
|
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
|
.fleet
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
# tunneling config
|
||||||
|
frpc.toml
|
||||||
|
|
||||||
# Local env files
|
# Local env files
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ the Veridian repository.
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Veridian is a Nuxt 4 application built with TypeScript, using PostgreSQL with
|
Veridian is a Nuxt 4 application built with TypeScript, using Triplit with
|
||||||
Drizzle ORM, better-auth for authentication, and UnoCSS for styling. The app is
|
better-auth for authentication, and UnoCSS for styling. The app is an agnetic
|
||||||
an agnetic chat platform that is meant to provide a well rounded experience for
|
chat platform that is meant to provide a well rounded experience for interacting
|
||||||
interacting with LLMs as well as providing a way to manage agents and provide
|
with LLMs as well as providing a way to manage agents and provide helpful tools
|
||||||
helpful tools like RAG (retrieval augmented generation) and web scraping/search.
|
like RAG (retrieval augmented generation) and web scraping/search.
|
||||||
|
|
||||||
You should be connected to the Nuxt docs MCP server, if you need to reference
|
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
|
the documentation or are unsure on how to implement something, consult the
|
||||||
@@ -24,18 +24,14 @@ database commands.
|
|||||||
|
|
||||||
### Database Commands
|
### Database Commands
|
||||||
|
|
||||||
- `tsx db/migrate.ts` - Run database migrations
|
- `bunx triplit schema push` - Push schema changes to database
|
||||||
- `bun x @better-auth/cli@latest generate --output db/auth/auth.schema.ts` -
|
|
||||||
Generate authentication schema based on `lib/auth.ts`
|
|
||||||
- `bun x drizzle-kit generate` - Generate migration files
|
|
||||||
- `bun x drizzle-kit push` - Push schema changes to database
|
|
||||||
- `bun x drizzle-kit studio` - Open Drizzle Studio for database inspection
|
|
||||||
|
|
||||||
## Tech Stack & Dependencies
|
## Tech Stack & Dependencies
|
||||||
|
|
||||||
- **Framework**: Nuxt 4 (SSR enabled)
|
- **Framework**: Nuxt 4 (SSR enabled)
|
||||||
- **Language**: TypeScript with strict configuration
|
- **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
|
- **Auth**: better-auth with email/password and social providers
|
||||||
- **Styling**: UnoCSS with presetMini
|
- **Styling**: UnoCSS with presetMini
|
||||||
- **Icons**: @nuxt/icon with Iconify, use Myna UI Icons
|
- **Icons**: @nuxt/icon with Iconify, use Myna UI Icons
|
||||||
@@ -116,12 +112,10 @@ the source is uniform and easy to follow.**
|
|||||||
|
|
||||||
### Database Patterns
|
### Database Patterns
|
||||||
|
|
||||||
- Use Drizzle ORM with PostgreSQL
|
- Use Triplit for database operations
|
||||||
- Export all schemas from `db/schema.ts`
|
- Export all schemas from `triplit/schema.ts`
|
||||||
- Use environment variables for database credentials
|
- Use environment variables for database credentials
|
||||||
- `db/auth/auth.schema.ts` is a generated file, do not touch it. If you need to
|
- Triplit schemas are defined in `triplit/schema.ts` and pushed using `bunx triplit schema push`
|
||||||
change the authentication schema, edit `lib/auth.ts` and run the generation
|
|
||||||
script.
|
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
@@ -142,7 +136,7 @@ the source is uniform and easy to follow.**
|
|||||||
The app uses better-auth with:
|
The app uses better-auth with:
|
||||||
|
|
||||||
- Email/password authentication (configurable via env vars)
|
- Email/password authentication (configurable via env vars)
|
||||||
- Drizzle adapter for PostgreSQL
|
- Triplit adapter for authentication
|
||||||
- Session management through cookies
|
- Session management through cookies
|
||||||
- Auth plugins in `app/plugins/` for client/server initialization
|
- Auth plugins in `app/plugins/` for client/server initialization
|
||||||
- Global auth middleware in `app/middleware/auth.global.ts`
|
- Global auth middleware in `app/middleware/auth.global.ts`
|
||||||
@@ -165,10 +159,10 @@ user must be authenticated.
|
|||||||
│ \-- plugins/ # Vue/Nuxt plugins
|
│ \-- plugins/ # Vue/Nuxt plugins
|
||||||
|-- server/ # Server-side code
|
|-- server/ # Server-side code
|
||||||
│ \-- api/ # API routes
|
│ \-- api/ # API routes
|
||||||
|-- db/ # Database related files
|
|-- triplit/ # Database related files
|
||||||
│ |-- schema.ts # Database schema
|
│ |-- schema.ts # Database schema
|
||||||
│ |-- migrate.ts # Migration runner
|
│ |-- client.ts # Interacts with the database on the client
|
||||||
│ \-- migrations/ # Migration files
|
│ \-- server.ts # Interacts with the database on the server
|
||||||
\-- lib/ # Shared utilities
|
\-- lib/ # Shared utilities
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -181,10 +175,12 @@ requirement.
|
|||||||
|
|
||||||
Key environment variables:
|
Key environment variables:
|
||||||
|
|
||||||
- `DATABASE_URL` - PostgreSQL connection string
|
- `TRIPLIT_SERVICE_TOKEN` - Triplit service token (admin token. **SECRET**)
|
||||||
- `BETTER_AUTH_SECRET` - Authentication secret
|
- `NUXT_TRIPLIT_ANON_TOKEN` - Triplit anonymous token (for anonymous access)
|
||||||
- `DISABLE_LOCAL_AUTH` - Disable email/password auth
|
- `BETTER_AUTH_SECRET` - Authentication secret (for better-auth)
|
||||||
- `DISABLE_SIGNUP` - Disable user registration
|
- `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
|
## 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
|
# 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">
|
<script setup lang="ts">
|
||||||
|
import '~/assets/css/reset.css';
|
||||||
import '~/assets/css/base.css';
|
import '~/assets/css/base.css';
|
||||||
|
|
||||||
const { accent, neutral, hinting } = useTheme()
|
const { accent, neutral, hinting } = useTheme();
|
||||||
|
|
||||||
// by default, disable hinting
|
// by default, disable hinting
|
||||||
if (Number.isNaN(Number(hinting.value))) hinting.value = '0';
|
if (Number.isNaN(Number(hinting.value))) hinting.value = '0';
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
htmlAttrs: {
|
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) {
|
if (import.meta.client) {
|
||||||
|
// force shiki into browser rendering only
|
||||||
|
window.sessionStorage.setItem('mdc-shiki-highlighter', 'browser');
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
document.documentElement.style.setProperty('--color-accent', `var(--accent-${accent.value}, var(--accent-violet))`)
|
document.documentElement.style.setProperty(
|
||||||
document.documentElement.style.setProperty('--color-accent-hover', `var(--accent-${accent.value}-hover, var(--accent-violet-hover))`)
|
'--color-accent',
|
||||||
document.documentElement.style.setProperty('--neutral-dark', `var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark))`)
|
`var(--accent-${accent.value}, var(--accent-violet))`,
|
||||||
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-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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<NuxtLoadingIndicator />
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
@@ -31,7 +48,12 @@ if (import.meta.client) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
#__nuxt {
|
#__nuxt {
|
||||||
|
padding: 0.5rem;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
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 {
|
--color-accent-text: #000;
|
||||||
/*
|
|
||||||
Josh's Custom CSS Reset slightly Modified
|
|
||||||
https://www.joshwcomeau.com/css/custom-css-reset/
|
|
||||||
*/
|
|
||||||
|
|
||||||
*,
|
--reasoning-accent: #a62bcb;
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
/* Accent Color Options */
|
||||||
border: 0 solid;
|
|
||||||
line-height: calc(1em + 0.5rem);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
/* violet (current default) */
|
||||||
-webkit-font-smoothing: antialiased;
|
--accent-violet: #a010ff;
|
||||||
}
|
--accent-violet-hover: #8d00e8;
|
||||||
|
|
||||||
img,
|
/* volcanic heat - orange-red */
|
||||||
picture,
|
--accent-volcano: #ef4410;
|
||||||
video,
|
--accent-volcano-hover: #dc2626;
|
||||||
canvas,
|
|
||||||
svg {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
/* neon lime */
|
||||||
button,
|
--accent-lime: #77fb6b;
|
||||||
textarea,
|
--accent-lime-hover: #5ee04f;
|
||||||
select {
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus {
|
/* electric sky */
|
||||||
outline: none;
|
--accent-sky: #38d3fa;
|
||||||
}
|
--accent-sky-hover: #2bc7ee;
|
||||||
|
|
||||||
button {
|
/* crushed coral - warm pink-orange */
|
||||||
cursor: pointer;
|
--accent-coral: #ff6b6b;
|
||||||
}
|
--accent-coral-hover: #e55555;
|
||||||
|
|
||||||
p,
|
/* deep emerald - rich green */
|
||||||
h1,
|
--accent-emerald: #10b981;
|
||||||
h2,
|
--accent-emerald-hover: #059669;
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
/* golden hour - warm amber */
|
||||||
text-wrap: pretty;
|
--accent-amber: #f59e0b;
|
||||||
hyphens: auto;
|
--accent-amber-hover: #d97706;
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
/* rose quartz - soft pink-red */
|
||||||
h2,
|
--accent-rose: #f43f5e;
|
||||||
h3,
|
--accent-rose-hover: #e11d48;
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
text-wrap: balance;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
/* arctic cyan - crisp blue */
|
||||||
color: #fff;
|
--accent-cyan: #06b6d4;
|
||||||
}
|
--accent-cyan-hover: #0891b2;
|
||||||
|
|
||||||
a:hover {
|
/* midnight indigo - deep blue-purple */
|
||||||
text-decoration: none;
|
--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.dark {
|
||||||
:root {
|
--color-base: color-mix(in srgb, var(--base-hinting) #040305, var(--accent-hinting) var(--color-accent));
|
||||||
--sidebar-width: 400px;
|
--color-highlight-low: color-mix(in srgb,
|
||||||
--spacing: 0.25rem;
|
var(--base-hinting) rgba(255, 255, 255, 0.05),
|
||||||
|
var(--accent-hinting) var(--color-accent));
|
||||||
--color-accent-text: #000;
|
--color-highlight: color-mix(in srgb,
|
||||||
|
var(--base-hinting) rgba(255, 255, 255, 0.1),
|
||||||
/* Accent Color Options */
|
var(--accent-hinting) var(--color-accent));
|
||||||
|
--color-highlight-high: color-mix(in srgb,
|
||||||
/* violet (current default) */
|
var(--base-hinting) rgba(255, 255, 255, 0.15),
|
||||||
--accent-violet: #A010FF;
|
var(--accent-hinting) var(--color-accent));
|
||||||
--accent-violet-hover: #8D00E8;
|
--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));
|
||||||
/* volcanic heat - orange-red */
|
--color-subtle: color-mix(in srgb, var(--base-hinting) #d1d1d0, var(--accent-hinting) var(--color-accent));
|
||||||
--accent-volcano: #ef4410;
|
--color-input: color-mix(in srgb, var(--base-hinting) #222124, var(--accent-hinting) var(--color-accent));
|
||||||
--accent-volcano-hover: #dc2626;
|
--color-neutral: color-mix(in srgb,
|
||||||
|
var(--base-hinting) var(--neutral-dark),
|
||||||
/* neon lime */
|
var(--accent-hinting) var(--color-accent));
|
||||||
--accent-lime: #77fb6b;
|
--color-reasoning: color-mix(in srgb, var(--base-hinting) #66666a, var(--accent-hinting) var(--color-accent));
|
||||||
--accent-lime-hover: #5ee04f;
|
|
||||||
|
|
||||||
/* electric sky */
|
|
||||||
--accent-sky: #38d3fa;
|
|
||||||
--accent-sky-hover: #2bc7ee;
|
|
||||||
|
|
||||||
/* crushed coral - warm pink-orange */
|
|
||||||
--accent-coral: #FF6B6B;
|
|
||||||
--accent-coral-hover: #E55555;
|
|
||||||
|
|
||||||
/* deep emerald - rich green */
|
|
||||||
--accent-emerald: #10B981;
|
|
||||||
--accent-emerald-hover: #059669;
|
|
||||||
|
|
||||||
/* golden hour - warm amber */
|
|
||||||
--accent-amber: #F59E0B;
|
|
||||||
--accent-amber-hover: #D97706;
|
|
||||||
|
|
||||||
/* rose quartz - soft pink-red */
|
|
||||||
--accent-rose: #F43F5E;
|
|
||||||
--accent-rose-hover: #E11D48;
|
|
||||||
|
|
||||||
/* arctic cyan - crisp blue */
|
|
||||||
--accent-cyan: #06B6D4;
|
|
||||||
--accent-cyan-hover: #0891B2;
|
|
||||||
|
|
||||||
/* midnight indigo - deep blue-purple */
|
|
||||||
--accent-indigo: #6366F1;
|
|
||||||
--accent-indigo-hover: #4F46E5;
|
|
||||||
|
|
||||||
/* sunset magenta - vibrant pink-purple */
|
|
||||||
--accent-magenta: #EC4899;
|
|
||||||
--accent-magenta-hover: #DB2777;
|
|
||||||
|
|
||||||
/* Neutral Base Colors - different gray variations */
|
|
||||||
|
|
||||||
/* zinc - deep gray-blue */
|
|
||||||
--neutral-zinc-dark: #171619;
|
|
||||||
--neutral-zinc-light: #fbfafd;
|
|
||||||
|
|
||||||
/* charcoal - deep black-gray */
|
|
||||||
--neutral-charcoal-dark: #0a0a0a;
|
|
||||||
--neutral-charcoal-light: #fefdff;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.dark {
|
|
||||||
--color-base: color-mix(in srgb, var(--base-hinting) #040305, var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-highlight-low: color-mix(in srgb, var(--base-hinting) rgba(255, 255, 255, 0.05), var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-highlight: color-mix(in srgb, var(--base-hinting) rgba(255, 255, 255, 0.10), var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-highlight-high: color-mix(in srgb, var(--base-hinting) rgba(255, 255, 255, 0.18), var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-text: color-mix(in srgb, var(--base-hinting) #fafafa, var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-subtle: color-mix(in srgb, var(--base-hinting) #a1a1aa, var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-input: color-mix(in srgb, var(--base-hinting) #222124, var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-neutral: color-mix(in srgb, var(--base-hinting) var(--neutral-dark), var(--accent-hinting) var(--color-accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light {
|
|
||||||
--color-base: color-mix(in srgb, var(--base-hinting) #f2f0f0, var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-highlight-low: color-mix(in srgb, var(--base-hinting) rgba(0, 0, 0, 0.06), var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-highlight: color-mix(in srgb, var(--base-hinting) rgba(0, 0, 0, 0.08), var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-highlight-high: color-mix(in srgb, var(--base-hinting) rgba(0, 0, 0, 0.12), var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-text: color-mix(in srgb, var(--base-hinting) #1a1a1a, var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-subtle: color-mix(in srgb, var(--base-hinting) #6b7280, var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-input: color-mix(in srgb, var(--base-hinting) #ffffff, var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-neutral: color-mix(in srgb, var(--base-hinting) var(--neutral-light), var(--accent-hinting) var(--color-accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
background-color: var(--color-base);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
padding: 0.5rem;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
color: inherit;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.accent {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
color: var(--color-accent-text);
|
|
||||||
border-radius: calc(var(--spacing) * 1.5);
|
|
||||||
padding-inline: calc(var(--spacing) * 2);
|
|
||||||
padding-block: calc(var(--spacing) * 1);
|
|
||||||
transition: background-color 150ms cubic-bezier(0.45, 0, 0.55, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
button.accent:hover {
|
|
||||||
background-color: var(--color-accent-hover);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
:root.light {
|
||||||
.object-cover {
|
--color-base: color-mix(in srgb, var(--base-hinting) #f2f0f0, var(--accent-hinting) var(--color-accent));
|
||||||
object-fit: cover;
|
--color-highlight-low: color-mix(in srgb,
|
||||||
}
|
var(--base-hinting) rgba(0, 0, 0, 0.06),
|
||||||
|
var(--accent-hinting) var(--color-accent));
|
||||||
.backdrop-blur-md {
|
--color-highlight: color-mix(in srgb,
|
||||||
backdrop-filter: blur(12px);
|
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 {
|
html,
|
||||||
.cursor {
|
body {
|
||||||
display: inline-block;
|
padding: 0;
|
||||||
width: 1rem;
|
margin: 0;
|
||||||
height: 0.9em;
|
font-family: var(--font-sans);
|
||||||
background-color: currentColor;
|
background-color: var(--color-base);
|
||||||
animation: blink 1s step-end infinite;
|
color: var(--color-text);
|
||||||
vertical-align: middle;
|
}
|
||||||
margin-left: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
0%,
|
html.light {
|
||||||
100% {
|
color-scheme: light;
|
||||||
opacity: 1;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
html {
|
||||||
opacity: 0;
|
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">
|
<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);
|
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
let tempInput = '';
|
||||||
const inputValue = ref('');
|
const inputValue = ref('');
|
||||||
const isFocused = ref(false);
|
const triplit = useTriplitClient();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
submit: [value: string];
|
submit: [value: string, model: ModelWithProvider | null];
|
||||||
|
cancel: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps<{
|
||||||
loading: {
|
loading?: boolean;
|
||||||
type: Boolean,
|
agent?: Entity<typeof schema, 'agents'>;
|
||||||
default: false
|
providers?: ProviderWithModels[];
|
||||||
}
|
}>();
|
||||||
})
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
// Model selection state
|
||||||
if (inputValue.value.trim()) {
|
const selectedModel = ref<ModelWithProvider | null>(null);
|
||||||
emit('submit', inputValue.value);
|
|
||||||
inputValue.value = '';
|
// 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.key === 'Enter') {
|
||||||
if (event.shiftKey) return;
|
if (event.shiftKey) return;
|
||||||
if (event.ctrlKey || event.metaKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
if (inputRef.value === null) return;
|
if (inputRef.value === null) return;
|
||||||
|
|
||||||
// inset new line
|
// inset new line
|
||||||
let cursorPosition = inputRef.value.selectionStart;
|
const cursorPosition = inputRef.value.selectionStart;
|
||||||
if (cursorPosition === undefined) return;
|
if (cursorPosition === undefined) return;
|
||||||
if (cursorPosition !== inputRef.value.selectionEnd) return;
|
if (cursorPosition !== inputRef.value.selectionEnd) return;
|
||||||
|
|
||||||
inputValue.value = inputValue.value.slice(0, cursorPosition) + "\n" + inputValue.value.slice(cursorPosition);
|
inputValue.value =
|
||||||
// inputRef.value.selectionStart = cursorPosition + 1;
|
inputValue.value.slice(0, cursorPosition) + '\n' + inputValue.value.slice(cursorPosition);
|
||||||
|
await nextTick();
|
||||||
|
handleInput();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
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;
|
let hasCommandKey = false;
|
||||||
if (import.meta.server) {
|
if (import.meta.server) {
|
||||||
let headers = useRequestHeaders();
|
const headers = useRequestHeaders();
|
||||||
hasCommandKey = headers['user-agent']?.includes('Mac OS') ?? false;
|
hasCommandKey = headers['user-agent']?.includes('Mac OS') ?? false;
|
||||||
} else {
|
} else {
|
||||||
hasCommandKey = navigator.userAgent.includes('Mac OS');
|
hasCommandKey = navigator.userAgent.includes('Mac OS');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
tempInput = (document.getElementById('chat') as HTMLInputElement)?.value ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
inputValue.value = tempInput;
|
||||||
|
handleInput();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full max-w-full mx-auto">
|
<div :class="['w-full flex max-h-full', $attrs.class]">
|
||||||
<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="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)]">
|
border-[var(--color-highlight)] focus-within:border-[var(--color-highlight-high)]">
|
||||||
<!-- Text Input -->
|
<!-- Text Input -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0 max-h-full">
|
||||||
<textarea v-model="inputValue" ref="inputRef"
|
<!-- 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.`"
|
:placeholder="`Start something great. Press ${hasCommandKey ? '⌘ + Enter' : 'ctrl + Enter'} to insert a new line.`"
|
||||||
@focus="isFocused = true" @blur="isFocused = false" @keydown="handleKeyDown"
|
@keydown="handleKeyDown" @input="handleInput"
|
||||||
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"
|
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">
|
||||||
rows="2"></textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="flex">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1">
|
||||||
<!-- Send Button -->
|
<ModelSelector v-if="providers && providers.length > 0" v-model="selectedModel"
|
||||||
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() || loading"
|
:providers="providers"></ModelSelector>
|
||||||
|
</div>
|
||||||
|
<!-- Send/Stop Button -->
|
||||||
|
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() && !loading"
|
||||||
:class="[
|
:class="[
|
||||||
'p-2 rounded-xl transition-all duration-200 flex items-center justify-center',
|
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
|
||||||
inputValue.trim()
|
inputValue.trim() && !loading
|
||||||
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)]'
|
? '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-if="loading" name="mynaui:stop-solid" class="text-6.5" />
|
||||||
<Icon v-else name="mynaui:send-solid" class="w-4 h-4" />
|
<Icon v-else name="mynaui:send-solid" class="text-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+30
-35
@@ -2,71 +2,66 @@
|
|||||||
import type { DropdownItem } from '~/types/dropdown';
|
import type { DropdownItem } from '~/types/dropdown';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: DropdownItem[];
|
items?: DropdownItem[];
|
||||||
modelValue?: boolean;
|
|
||||||
placement?: 'right' | 'left' | 'center';
|
placement?: 'right' | 'left' | 'center';
|
||||||
verticality?: 'asscending' | 'descending';
|
verticality?: 'asscending' | 'descending';
|
||||||
width?: string;
|
width?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
modelValue: false,
|
|
||||||
placement: 'right',
|
placement: 'right',
|
||||||
verticality: 'descending',
|
verticality: 'descending',
|
||||||
width: 'auto'
|
width: 'auto',
|
||||||
})
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<(e: 'select', item: DropdownItem) => void>();
|
||||||
(e: 'update:modelValue', value: boolean): void;
|
|
||||||
(e: 'select', item: DropdownItem): void;
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const triggerRef = ref<HTMLElement | null>(null)
|
const triggerRef = ref<HTMLElement | null>(null);
|
||||||
const isOpen = defineModel<boolean>({ required: true })
|
const isOpen = defineModel<boolean>({ required: false });
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
isOpen.value = !isOpen.value
|
isOpen.value = !isOpen.value;
|
||||||
}
|
};
|
||||||
|
|
||||||
const select = (item: DropdownItem) => {
|
const select = (item: DropdownItem) => {
|
||||||
if (item.disabled || item.divider) return
|
if (item.disabled || item.divider) return;
|
||||||
emit('select', item)
|
emit('select', item);
|
||||||
isOpen.value = false
|
isOpen.value = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
const placementClasses = computed(() => {
|
const placementClasses = computed(() => {
|
||||||
let classes = ''
|
let classes = '';
|
||||||
|
|
||||||
switch (props.placement) {
|
switch (props.placement) {
|
||||||
case 'right':
|
case 'right':
|
||||||
classes += 'right-0 '
|
classes += 'right-0 ';
|
||||||
break
|
break;
|
||||||
case 'left':
|
case 'left':
|
||||||
classes += 'left-0 '
|
classes += 'left-0 ';
|
||||||
break
|
break;
|
||||||
case 'center':
|
case 'center':
|
||||||
classes += 'left-1/2 -translate-x-1/2 '
|
classes += 'left-1/2 -translate-x-1/2 ';
|
||||||
break
|
break;
|
||||||
default:
|
default:
|
||||||
classes += 'left-0 '
|
classes += 'left-0 ';
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (props.verticality) {
|
switch (props.verticality) {
|
||||||
case 'asscending':
|
case 'asscending':
|
||||||
classes += 'bottom-full mb-1.5'
|
classes += 'bottom-full mb-1.5';
|
||||||
break
|
break;
|
||||||
case 'descending':
|
case 'descending':
|
||||||
classes += 'top-full mt-1.5'
|
classes += 'top-full mt-1.5';
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return classes
|
return classes;
|
||||||
})
|
});
|
||||||
|
|
||||||
useClickOutside(triggerRef, () => {
|
useClickOutside(triggerRef, () => {
|
||||||
isOpen.value = false
|
isOpen.value = false;
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -88,7 +83,7 @@ useClickOutside(triggerRef, () => {
|
|||||||
:class="[
|
:class="[
|
||||||
item.disabled
|
item.disabled
|
||||||
? 'opacity-50 cursor-not-allowed'
|
? '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" />
|
<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>
|
<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">
|
<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 triplit = useTriplitClient();
|
||||||
const { toggle: toggleSettings } = useSettings()
|
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 () => {
|
const handleLogout = async () => {
|
||||||
await signOut()
|
await authClient.signOut();
|
||||||
}
|
if ('endSession' in triplit) {
|
||||||
|
await triplit.endSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearNuxtData();
|
||||||
|
|
||||||
|
await navigateTo('/auth/login');
|
||||||
|
};
|
||||||
|
|
||||||
const profileItems: DropdownItem[] = [
|
const profileItems: DropdownItem[] = [
|
||||||
{ label: 'Settings', icon: 'mynaui:cog-four', onClick: toggleSettings },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="flex items-center justify-between overflow-hidden">
|
<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 }">
|
<template #trigger="{ toggle }">
|
||||||
<div role="button" aria-label="open user dropdown"
|
<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"
|
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">
|
@click="toggle">
|
||||||
<div
|
<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)]']">
|
: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" />
|
<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>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">{{
|
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">{{
|
||||||
user!.name
|
user!.name
|
||||||
}}</span>
|
}}</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'
|
hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-x-0 scale-y-90'
|
||||||
]">
|
]">
|
||||||
<Icon class="text-4" name="mynaui:chevron-down" />
|
<Icon class="text-4" name="mynaui:chevron-down" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,36 +1,53 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DropdownItem } from '~/types/dropdown'
|
import type { DropdownItem } from '~/types/dropdown';
|
||||||
|
|
||||||
const { user } = useAuth()
|
const route = useRoute();
|
||||||
const { agents, activeAgent } = await useAgents()
|
const { agents, getAgent } = await useAgents();
|
||||||
const homeButtonRef = ref<HTMLElement | null>(null)
|
const homeButtonRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
const hovering = defineModel<boolean>({ required: true })
|
const activeAgent = computed(() => getAgent(route.params.id as string));
|
||||||
const initialized = ref(false)
|
|
||||||
|
|
||||||
|
const hovering = defineModel<boolean>({ required: true });
|
||||||
|
const initialized = ref(false);
|
||||||
|
|
||||||
|
let lastHovering: boolean | null = null;
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (hovering.value) {
|
console.log(hovering.value);
|
||||||
const width = homeButtonRef.value!.scrollWidth
|
|
||||||
homeButtonRef.value!.style.width = `calc(${width}px + 0.5rem)`
|
if (hovering.value && homeButtonRef.value) {
|
||||||
|
const width = homeButtonRef.value.scrollWidth;
|
||||||
|
homeButtonRef.value.style.width = `calc(${width}px + 0.5rem)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(hovering, (value) => {
|
watch(hovering, (value) => {
|
||||||
if (!initialized.value) {
|
if (lastHovering === value) {
|
||||||
initialized.value = true
|
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) {
|
if (value) {
|
||||||
const width = homeButtonRef.value!.scrollWidth
|
const width = homeButtonRef.value.scrollWidth;
|
||||||
homeButtonRef.value!.style.width = `calc(${width}px + 0.5rem)`
|
homeButtonRef.value.style.width = `calc(${width}px + 0.5rem)`;
|
||||||
} else {
|
} 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -38,14 +55,16 @@ const agentItems: DropdownItem[] = []
|
|||||||
<div ref="homeButtonRef" style="width: 0;"
|
<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']">
|
: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="/"
|
<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" />
|
<Icon name="mynaui:chevron-left" class="w-4.5 h-4.5" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</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 }">
|
<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">
|
@click="toggle">
|
||||||
<div
|
<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)]']">
|
: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">
|
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">
|
||||||
{{ activeAgent?.name }}
|
{{ activeAgent?.name }}
|
||||||
</span>
|
</span>
|
||||||
<div class="w-4 h-4 text-[var(--color-subtle)]">
|
<div class="w-4 h-4 text-[var(--color-muted)]">
|
||||||
<Icon
|
<Icon
|
||||||
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
|
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" />
|
name="mynaui:chevron-up-down" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="flex flex-col gap-1.5 max-h-[calc(2.25rem*4+0.375rem*3)] overflow-y-auto">
|
<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)]' :
|
<SidenavItem draggable="false" v-for="agent in agents" :to="`/agent/${agent.id}`" :name="agent.name"
|
||||||
'text-[var(--color-subtle)]']" @click="agentDropdownOpen = false">
|
class="whitespace-nowrap" icon="mynaui:check-hexagon" :key="agent.id"
|
||||||
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon"
|
:active="activeAgent?.id === agent.id" />
|
||||||
:active="activeAgent?.id === agent.id" />
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|||||||
@@ -1,17 +1,47 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: string,
|
name: string;
|
||||||
icon: string,
|
icon?: string;
|
||||||
active?: boolean
|
to?: string;
|
||||||
}>()
|
active?: boolean;
|
||||||
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div role="button"
|
<NuxtLink v-if="props.to" v-bind="$attrs" :to="props.to" :aria-label="props.name" :class="[
|
||||||
: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)]' : '']">
|
'decoration-none text-[var(--color-muted)] flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
|
||||||
<div class="h-7 w-7 flex items-center justify-center">
|
props.icon ? 'px-1' : 'px-2',
|
||||||
<Icon class="text-4.5" :name="props.icon" />
|
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>
|
</div>
|
||||||
<span class="text-sm font-medium overflow-hidden text-ellipsis">{{ props.name }}</span>
|
</NuxtLink>
|
||||||
</div>
|
<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>
|
</template>
|
||||||
@@ -1,5 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const route = useRoute();
|
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(() => {
|
const routeParts = computed(() => {
|
||||||
return route.path.replace('/agent/', '').split('/');
|
return route.path.replace('/agent/', '').split('/');
|
||||||
@@ -18,19 +36,122 @@ const pageInfo = computed(() => {
|
|||||||
return 'agent-profile';
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="flex flex-col gap-1">
|
<nav class="flex flex-col gap-1">
|
||||||
|
|
||||||
<!-- Agent Info Link -->
|
<!-- Agent Info Link -->
|
||||||
<div class="mt-2">
|
<div class="mt-2 flex flex-col">
|
||||||
<NuxtLink :to="`/agent/${routeParts[0]}/profile`" class="decoration-none text-[var(--color-subtle)]">
|
<SidenavItem :to="`/agent/${routeParts[0]}/profile`" name="Agent Info" icon="mynaui:info-square"
|
||||||
<SidenavItem name="Agent Info" icon="mynaui:info-square" :active="pageInfo === 'agent-profile'" />
|
:active="pageInfo === 'agent-profile'" />
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Topics Section -->
|
<!-- Topics Section -->
|
||||||
<SidenavNavAgentTopics />
|
<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>
|
</nav>
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
const { agents, createAgent } = await useAgents()
|
const { agents } = await useAgents();
|
||||||
const agentsListRef = ref<HTMLElement | null>(null)
|
const agentsListRef = ref<HTMLElement | null>(null);
|
||||||
const agentsOpen = ref(true)
|
const agentsOpen = ref(true);
|
||||||
const agentsListHeight = ref('auto')
|
const agentsListHeight = ref('auto');
|
||||||
const agentsListOpacity = ref(1)
|
const agentsListOpacity = ref(1);
|
||||||
const agentsListScale = ref(1)
|
const agentsListScale = ref(1);
|
||||||
const creatingAgent = ref(false)
|
const creatingAgent = ref(false);
|
||||||
|
|
||||||
function easeInOutQuad(x: number): number {
|
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 = () => {
|
const toggleAgentsList = () => {
|
||||||
if (!agentsListRef.value) return;
|
if (!agentsListRef.value) return;
|
||||||
let animationLength = 200;
|
const animationLength = 200;
|
||||||
let animationStart: number | null = null;
|
let animationStart: number | null = null;
|
||||||
|
|
||||||
let startHeight: number;
|
let startHeight: number;
|
||||||
let startOpacity = agentsListOpacity.value;
|
const startOpacity = agentsListOpacity.value;
|
||||||
let startScale = agentsListScale.value;
|
const startScale = agentsListScale.value;
|
||||||
if (agentsListHeight.value === 'auto') {
|
if (agentsListHeight.value === 'auto') {
|
||||||
startHeight = agentsListRef.value.clientHeight;
|
startHeight = agentsListRef.value.clientHeight;
|
||||||
} else {
|
} else {
|
||||||
startHeight = Number(agentsListHeight.value.replace('px', ''));
|
startHeight = Number(agentsListHeight.value.replace('px', ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetHeight = agentsOpen.value ? 0 : agentsListRef.value.scrollHeight;
|
const targetHeight = agentsOpen.value ? 0 : agentsListRef.value.scrollHeight;
|
||||||
let targetOpacity = agentsOpen.value ? 0 : 1;
|
const targetOpacity = agentsOpen.value ? 0 : 1;
|
||||||
let targetScale = agentsOpen.value ? 0.95 : 1;
|
const targetScale = agentsOpen.value ? 0.95 : 1;
|
||||||
agentsOpen.value = !agentsOpen.value;
|
agentsOpen.value = !agentsOpen.value;
|
||||||
|
|
||||||
const animate = (timestamp: number) => {
|
const animate = (timestamp: number) => {
|
||||||
@@ -54,36 +54,59 @@ const toggleAgentsList = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate);
|
||||||
}
|
};
|
||||||
|
|
||||||
const newAgent = async () => {
|
const newAgent = async () => {
|
||||||
creatingAgent.value = true;
|
const triplit = useTriplitClient();
|
||||||
const agent = await createAgent();
|
const { user } = useAuth();
|
||||||
creatingAgent.value = false;
|
|
||||||
navigateTo(`/agent/${agent!.id}`);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="flex flex-col gap-1">
|
<nav class="flex flex-col gap-1">
|
||||||
<SidenavItem name="Search" icon="mynaui:search" />
|
<SidenavItem name="Search" icon="mynaui:search" />
|
||||||
<NuxtLink to="/" class="decoration-none text-[var(--color-text)]">
|
<SidenavItem to="/" :active="true" name="Home" icon="mynaui:home" />
|
||||||
<SidenavItem class="bg-[var(--color-highlight)]" name="Home" icon="mynaui:home" />
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<!-- Agents Section -->
|
<!-- 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()">
|
@click="toggleAgentsList()">
|
||||||
<span class="text-sm">Agents</span>
|
<span class="text-sm">Agents</span>
|
||||||
<Icon name="mynaui:chevron-down"
|
<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']" />
|
: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})` }"
|
: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"
|
<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">
|
<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-if="creatingAgent" class="text-4.5" name="svg-spinners:ring-resize" />
|
||||||
<Icon v-else class="text-4.5" name="mynaui:plus" />
|
<Icon v-else class="text-4.5" name="mynaui:plus" />
|
||||||
@@ -91,10 +114,9 @@ const newAgent = async () => {
|
|||||||
<span>New Agent</span>
|
<span>New Agent</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id"
|
<SidenavItem
|
||||||
class="decoration-none text-[var(--color-subtle)]">
|
v-for="agent in agents?.map((agent) => agent)?.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())"
|
||||||
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon" />
|
:to="`/agent/${agent.id}`" :key="agent.id" :name="agent.name" icon="mynaui:check-hexagon" />
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,93 +1,105 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar()
|
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar();
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
|
|
||||||
const isResizing = ref(false)
|
const isResizing = ref(false);
|
||||||
const startX = ref(0)
|
const startX = ref(0);
|
||||||
const initialWidth = 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) => {
|
const onResizeStart = (event: MouseEvent) => {
|
||||||
isResizing.value = true
|
isResizing.value = true;
|
||||||
startX.value = event.clientX
|
startX.value = event.clientX;
|
||||||
initialWidth.value = sidebarWidth.value
|
initialWidth.value = sidebarWidth.value;
|
||||||
document.body.style.cursor = 'col-resize'
|
document.body.style.cursor = 'col-resize';
|
||||||
document.body.style.userSelect = 'none'
|
document.body.style.userSelect = 'none';
|
||||||
}
|
};
|
||||||
|
|
||||||
const onResizeMove = (event: MouseEvent) => {
|
const onResizeMove = (event: MouseEvent) => {
|
||||||
if (!isResizing.value) return
|
if (!isResizing.value) return;
|
||||||
if (resizeAnimationFrame) return;
|
if (resizeAnimationFrame) return;
|
||||||
|
|
||||||
resizeAnimationFrame = requestAnimationFrame(() => {
|
resizeAnimationFrame = requestAnimationFrame(() => {
|
||||||
const deltaX = event.clientX - startX.value
|
const deltaX = event.clientX - startX.value;
|
||||||
const newWidth = initialWidth.value + deltaX
|
const newWidth = initialWidth.value + deltaX;
|
||||||
resize(newWidth)
|
resize(newWidth);
|
||||||
resizeAnimationFrame = null
|
resizeAnimationFrame = null;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const onResizeEnd = () => {
|
const onResizeEnd = () => {
|
||||||
if (!isResizing.value) return
|
if (!isResizing.value) return;
|
||||||
|
|
||||||
isResizing.value = false
|
isResizing.value = false;
|
||||||
document.body.style.cursor = ''
|
document.body.style.cursor = '';
|
||||||
document.body.style.userSelect = ''
|
document.body.style.userSelect = '';
|
||||||
saveWidth()
|
saveWidth();
|
||||||
}
|
};
|
||||||
|
|
||||||
let resizeAnimationFrame: number | null = null
|
let resizeAnimationFrame: number | null = null;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('mousemove', onResizeMove)
|
document.addEventListener('mousemove', onResizeMove);
|
||||||
document.addEventListener('mouseup', onResizeEnd)
|
document.addEventListener('mouseup', onResizeEnd);
|
||||||
|
|
||||||
watch(hovering, (value) => {
|
watch(hovering, (value) => {
|
||||||
|
if (!closeSidenavRef.value) return;
|
||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
const width = closeSidenavRef.value!.scrollWidth
|
const width = closeSidenavRef.value.scrollWidth;
|
||||||
closeSidenavRef.value!.style.width = `${width}px`
|
closeSidenavRef.value.style.width = `${width}px`;
|
||||||
} else {
|
} else {
|
||||||
closeSidenavRef.value!.style.width = '0'
|
closeSidenavRef.value.style.width = '0';
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('mousemove', onResizeMove)
|
document.removeEventListener('mousemove', onResizeMove);
|
||||||
document.removeEventListener('mouseup', onResizeEnd)
|
document.removeEventListener('mouseup', onResizeEnd);
|
||||||
})
|
});
|
||||||
|
|
||||||
const hovering = ref(false)
|
const hovering = ref(false);
|
||||||
|
|
||||||
const navKind = computed(() => {
|
const navKind = computed(() => {
|
||||||
if (route.path === '/') return 'home'
|
if (route.path === '/') return 'home';
|
||||||
if (route.path.startsWith('/agent/')) return 'agent'
|
if (route.path.startsWith('/agent/')) return 'agent';
|
||||||
return null
|
return null;
|
||||||
})
|
});
|
||||||
|
|
||||||
|
const onFocusOut = (e: FocusEvent) => {
|
||||||
|
const isMovingOutside = sidenavRef.value && !sidenavRef.value.contains(e.relatedTarget as Node);
|
||||||
|
if (isMovingOutside) {
|
||||||
|
hovering.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<aside :class="[
|
<aside ref="sidenavRef" :class="[
|
||||||
'h-full max-w-fit bg-[var(--color-base)] overflow-hidden will-change-width text-[var(--color-subtle)] select-none',
|
'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',
|
open ? 'w-full mr-2' : 'w-0 mr-0',
|
||||||
isResizing ? '' : 'transition-[width,margin] duration-250 ease-[cubic-bezier(0,0.55,0.45,1)]'
|
isResizing ? '' : 'transition-[width,margin] duration-250 ease-[cubic-bezier(0,0.55,0.45,1)]'
|
||||||
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="hovering = true"
|
]" :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 :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 -->
|
<!-- Header -->
|
||||||
<div class="relative flex flex-row gap-2 justify-between items-center mb-1.5">
|
<div class="relative flex flex-row gap-2 justify-between items-center mb-1.5">
|
||||||
|
|
||||||
<SidenavHeader v-if="navKind === 'home'" v-model="hovering" />
|
<SidenavHeader v-if="navKind === 'home'" v-model="hovering" />
|
||||||
<SidenavHeaderAgent v-else-if="navKind === 'agent'" 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;"
|
<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']">
|
: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="[
|
<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"
|
<Icon name="mynaui:panel-left-close"
|
||||||
:class="['transition-inherit', hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" />
|
:class="['transition-inherit', hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" />
|
||||||
@@ -95,7 +107,7 @@ const navKind = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
|
<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="[
|
<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" />
|
<Icon name="mynaui:book-plus" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -104,15 +116,24 @@ const navKind = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Menu -->
|
<!-- 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'" />
|
<SidenavNavHome v-if="navKind === 'home'" />
|
||||||
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme Switcher -->
|
<div class="flex justify-between pt-2">
|
||||||
<div class="flex justify-end">
|
<div class="flex">
|
||||||
<ThemeSwitcher />
|
<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>
|
||||||
</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">
|
<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[] = [
|
const themeOptions: DropdownItem[] = [
|
||||||
{ value: 'light', label: 'Light', icon: 'mynaui:sun' },
|
{ value: 'light', label: 'Light', icon: 'mynaui:sun' },
|
||||||
{ value: 'dark', label: 'Dark', icon: 'mynaui:moon' },
|
{ value: 'dark', label: 'Dark', icon: 'mynaui:moon' },
|
||||||
{ value: 'system', label: 'System', icon: 'mynaui:desktop' }
|
{ value: 'system', label: 'System', icon: 'mynaui:desktop' },
|
||||||
]
|
];
|
||||||
|
|
||||||
const currentOption = computed(() =>
|
const currentOption = computed(
|
||||||
themeOptions.find(option => option.value === colorMode.preference) || themeOptions[2]
|
() => themeOptions.find((option) => option.value === colorMode.preference) || themeOptions[2],
|
||||||
)
|
);
|
||||||
|
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false);
|
||||||
|
|
||||||
const selectTheme = (item: DropdownItem) => {
|
const selectTheme = (item: DropdownItem) => {
|
||||||
colorMode.preference = item.value as Theme
|
colorMode.preference = item.value as Theme;
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dropdown class="relative" v-model="isOpen" @select="selectTheme" :items="themeOptions" verticality="asscending"
|
<Dropdown class="relative" v-model="isOpen" @select="selectTheme" :items="themeOptions" verticality="asscending"
|
||||||
placement="right" width="140px">
|
placement="right" width="140px">
|
||||||
<template #trigger="{ toggle, isOpen }">
|
<template #trigger="{ toggle, isOpen }">
|
||||||
<button aria-label="Open theme switcher" @click="toggle"
|
<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)]',
|
||||||
class="p-2 flex items-center justify-center rounded-lg hover:bg-[var(--color-highlight)] transition-colors group"
|
'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)]'
|
||||||
:class="isOpen ? 'bg-[var(--color-highlight)]' : 'bg-transparent'">
|
]">
|
||||||
<Icon :name="currentOption!.icon!"
|
<Icon :name="currentOption!.icon!" class="text-4" />
|
||||||
class="text-5 text-[var(--color-subtle)] group-hover:text-[var(--color-text)] transition-colors" />
|
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</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 () => {
|
export const useAgents = async () => {
|
||||||
const { addTask, completeTask } = useTasks()
|
const triplit = useTriplitClient();
|
||||||
const appState = useAppState()
|
|
||||||
|
|
||||||
const fetchingAgents = ref(false);
|
const { results: agents } = await useQuery('agents', triplit, triplit.query('agents').Include('topics'));
|
||||||
const agents: Ref<Agent[] | null> = useState('agents', () => null);
|
|
||||||
|
|
||||||
const activeAgent = computed(() => {
|
|
||||||
if (agents.value === null) return;
|
|
||||||
if (!appState.activeAgentId.value) return;
|
|
||||||
|
|
||||||
const agent = agents.value.find(agent => agent.id === appState.activeAgentId.value);
|
const getAgent = (id: string) => {
|
||||||
return agent;
|
return agents.value?.find((a) => a.id === id);
|
||||||
});
|
};
|
||||||
|
|
||||||
const refreshAgents = async () => {
|
return {
|
||||||
if (fetchingAgents.value) return;
|
agents,
|
||||||
fetchingAgents.value = true;
|
getAgent,
|
||||||
|
};
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) => {
|
export const useClickOutside = (target: Ref<HTMLElement | null>, callback: () => void) => {
|
||||||
const onClick = (event: MouseEvent) => {
|
const onClick = (event: MouseEvent) => {
|
||||||
if (target.value && !target.value.contains(event.target as Node)) {
|
if (target.value && !target.value.contains(event.target as Node)) {
|
||||||
callback()
|
callback();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', onClick)
|
document.addEventListener('click', onClick);
|
||||||
})
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', onClick)
|
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 = () => {
|
export const useKeyboardShortcuts = () => {
|
||||||
const { toggle: toggleSidebar } = useSidebar();
|
const { toggle: toggleSidebar } = useSidebar();
|
||||||
|
const { toggle: openSettings, open: isSettingsOpen } = useSettings();
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
// Ctrl+[ to collapse sidebar
|
// Ctrl+[ to collapse sidebar
|
||||||
if (event.ctrlKey && event.key === '[') {
|
if (event.ctrlKey && event.key === '[') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleSidebar();
|
toggleSidebar();
|
||||||
}
|
return;
|
||||||
};
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
if (event.ctrlKey && event.key === ',') {
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
event.preventDefault();
|
||||||
});
|
if (isSettingsOpen.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
openSettings();
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
return;
|
||||||
});
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
onMounted(() => {
|
||||||
handleKeyDown
|
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 = () => {
|
export const useSettings = () => {
|
||||||
const open = useState<boolean>('settings:open', () => false)
|
const open = useState<boolean>('settings:open', () => false);
|
||||||
const currentPage = useState<string>('settings:currentPage', () => 'page1')
|
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 = () => {
|
const close = () => {
|
||||||
open.value = false
|
open.value = false;
|
||||||
currentPage.value = 'page1'
|
currentPage.value = 'page1';
|
||||||
}
|
};
|
||||||
|
|
||||||
return { open, currentPage, toggle, setPage, close }
|
return { open, currentPage, pageParams, toggle, setPage, close };
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,31 +1,49 @@
|
|||||||
export const useSidebar = () => {
|
export const useSidebar = () => {
|
||||||
const open = useState<boolean>('sidebar:open', () => true)
|
const open = useState<boolean>('sidebar:open', () => true);
|
||||||
const sidebarWidth = useState<number>('sidebar:width', () => {
|
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
|
// 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
|
// 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
|
// cookie value, we thrash the hell out of the cookie and gobble CPU cycles
|
||||||
watch(useCookie('sidebar:width'), (value) => {
|
watch(useCookie('sidebar:width'), (value) => {
|
||||||
console.log(value)
|
sidebarWidth.value = Number(value);
|
||||||
sidebarWidth.value = Number(value)
|
});
|
||||||
})
|
|
||||||
|
|
||||||
const toggle = () => { open.value = !open.value }
|
const toggle = () => {
|
||||||
const close = () => { open.value = false }
|
open.value = !open.value;
|
||||||
const openSidebar = () => { open.value = true }
|
};
|
||||||
|
const close = () => {
|
||||||
|
open.value = false;
|
||||||
|
};
|
||||||
|
const openSidebar = () => {
|
||||||
|
open.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const resize = (width: number) => {
|
const resize = (width: number) => {
|
||||||
const minWidth = 200
|
const minWidth = 200;
|
||||||
const maxWidth = 400
|
const maxWidth = 400;
|
||||||
const clampedWidth = Math.max(minWidth, Math.min(maxWidth, width))
|
const clampedWidth = Math.max(minWidth, Math.min(maxWidth, width));
|
||||||
sidebarWidth.value = clampedWidth
|
sidebarWidth.value = clampedWidth;
|
||||||
}
|
};
|
||||||
|
|
||||||
const saveWidth = () => {
|
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 = () => {
|
export const useTheme = () => {
|
||||||
const accent = useCookie('accent', { default: () => 'violet', maxAge: 60 * 60 * 24 * 365 })
|
const accent = useCookie('accent', {
|
||||||
const neutral = useCookie('neutral', { default: () => 'zinc', maxAge: 60 * 60 * 24 * 365 })
|
default: () => 'violet',
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
});
|
||||||
|
const neutral = useCookie('neutral', {
|
||||||
|
default: () => 'zinc',
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
});
|
||||||
// disable hinting by default
|
// 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 {
|
return {
|
||||||
accent,
|
accent,
|
||||||
neutral,
|
neutral,
|
||||||
hinting,
|
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">
|
<script setup lang="ts">
|
||||||
const { open: sidebarOpen, openSidebar } = useSidebar()
|
import SettingsDialog from '~/components/Settings/Dialog.vue';
|
||||||
useKeyboardShortcuts()
|
|
||||||
|
const { open: sidebarOpen, openSidebar } = useSidebar();
|
||||||
|
useKeyboardShortcuts();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -10,19 +12,18 @@ useKeyboardShortcuts()
|
|||||||
<div class="h-14 flex items-center justify-between px-4">
|
<div class="h-14 flex items-center justify-between px-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button v-if="!sidebarOpen" @click="openSidebar"
|
<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" />
|
<Icon class="text-5" name="mynaui:panel-left-open" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<!-- <div class="flex items-center gap-2">
|
||||||
<div id="primary-loader-target"></div>
|
<div id="primary-loader-target"></div>
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<SettingsDialog />
|
<SettingsDialog />
|
||||||
<LoadingSpinner />
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
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 (!session.value) {
|
||||||
if (to.path.toLowerCase().includes('/auth/') && loggedIn.value) {
|
await fetchSession();
|
||||||
return navigateTo('/')
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If not authenticated, and not on a signin/signup page, redirect to login page
|
const loggedIn = computed(() => !!session.value);
|
||||||
if (!loggedIn.value && !to.path.toLowerCase().includes('/auth/')) {
|
|
||||||
return navigateTo('/auth/login')
|
// 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">
|
<script setup lang="ts">
|
||||||
const { createTopic, fetchingTopics } = await useTopics();
|
import type { ModelWithProvider } from '~/composables/useModels';
|
||||||
const { activeAgent } = await useAgents();
|
|
||||||
|
|
||||||
const creatingTopic = ref(false);
|
const route = useRoute();
|
||||||
|
|
||||||
const handleSubmit = async (message: string) => {
|
const { createTopic, sendMessage } = useChat(route.params.id as string);
|
||||||
// create a new topic, and send the message to it
|
const { getAgent } = await useAgents();
|
||||||
console.log('handleSubmit', message);
|
const { providers, unsubscribe: unsubscribeModels } = await useModels();
|
||||||
if (!activeAgent.value) return;
|
|
||||||
|
|
||||||
creatingTopic.value = true
|
const agent = computed(() => {
|
||||||
try {
|
if (route.params.id === null || typeof route.params.id !== 'string') {
|
||||||
const newTopic = await createTopic('New Topic', activeAgent.value.id)
|
throw new Error('Invalid agent 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
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full w-full pb-4 justify-center">
|
<div class="h-full w-full">
|
||||||
<div class="max-w-4xl h-full w-full flex flex-col">
|
<!-- chat pane -->
|
||||||
<!-- chat pane -->
|
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
|
||||||
<div class="flex h-full flex-col gap-2 justify-end mb-28">
|
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
|
||||||
<h1 v-if="activeAgent" class="font-bold">{{ activeAgent.name }}</h1>
|
<div class="flex-grow w-full flex justify-center">
|
||||||
<p class="text-[var(--color-subtle)]">Select a topic to continue or create a new one</p>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,44 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { activeAgent: agent, updateAgent } = await useAgents();
|
const { getAgent } = await useAgents();
|
||||||
const route = useRoute()
|
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('/');
|
// if (agent.value === undefined) navigateTo('/');
|
||||||
|
|
||||||
const handleInput = (e: Event) => {
|
const handleInput = async (e: Event) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
if (target.value.length === 0) {
|
if (target.value.trimStart().length === 0) {
|
||||||
return;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 class="flex items-center gap-4">
|
||||||
<div>
|
<div>
|
||||||
<img v-if="agent?.imageUrl" :src="agent.imageUrl" class="w-16 h-16 rounded-full object-cover" />
|
<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"
|
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" />
|
type="text" :value="agent?.name" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 w-full h-full mb-14">
|
||||||
<span class="text-sm text-[var(--color-subtle)]">Agent ID:</span>
|
<textarea placeholder="System Message..."
|
||||||
<span class="text-sm font-semibold">{{ route.params.id }}</span>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,235 +1,112 @@
|
|||||||
<script setup lang="ts">
|
<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 route = useRoute();
|
||||||
const appState = useAppState();
|
const { sendMessage } = useChat(route.params.id as string);
|
||||||
const { activeTopic, topicsForActiveAgent } = await useTopics();
|
const { getAgent } = await useAgents();
|
||||||
const { activeAgent } = await useAgents();
|
const { providers, unsubscribe: unsubscribeModels } = await useModels();
|
||||||
|
|
||||||
const loading = ref(false);
|
const agent = computed(() => {
|
||||||
const messages = useState<Message[]>('messages', () => []);
|
if (route.params.id === null || typeof route.params.id !== 'string') {
|
||||||
const generatingMessage = ref('');
|
throw new Error('Invalid agent ID');
|
||||||
|
|
||||||
// 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 handleSubmit = async (message: string) => {
|
return getAgent(route.params.id)!;
|
||||||
if (!activeTopic.value) {
|
});
|
||||||
console.error('No active topic');
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true;
|
await sendMessage(message, topic.value!, topic.value!.messages as unknown as Message[], agent.value!, model.provider, model);
|
||||||
|
scrollToBottom('instant');
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRegenerate = async (messageId: string) => {
|
onUnmounted(() => {
|
||||||
if (!activeTopic.value) return;
|
unsubscribeTopic?.();
|
||||||
|
unsubscribeModels?.();
|
||||||
// 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);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full w-full pb-4 justify-center">
|
<div class="h-full w-full">
|
||||||
<div class="max-w-4xl h-full w-full flex flex-col">
|
<!-- chat pane -->
|
||||||
<div class="flex h-full flex-col gap-6 overflow-y-auto p-4">
|
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
|
||||||
<Message v-for="msg in messages" :key="msg.id" :message="msg" @regenerate="handleRegenerate"
|
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
|
||||||
@select="handleSelectRegeneration" @delete="handleDelete" />
|
<div class="flex-grow w-full flex justify-center">
|
||||||
|
<div class="max-w-4xl w-full flex flex-col gap-2 pb-9"
|
||||||
<div v-if="generatingMessage" class="flex gap-3">
|
v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
|
||||||
<div
|
<Message v-for="message in topic.messages" :key="message.id" :message="message" />
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<p v-if="messages.length === 0 && !generatingMessage" class="text-center text-[var(--color-subtle)]">
|
|
||||||
No messages yet. Start the conversation!
|
|
||||||
</p>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
+65
-53
@@ -1,47 +1,46 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { authClient } from '~~/lib/auth-client';
|
||||||
|
import { deriveKey } from '~/utils/crypto';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'auth',
|
layout: 'auth',
|
||||||
})
|
});
|
||||||
|
|
||||||
const { signIn, session, fetchSession, authClient } = useAuth();
|
const to = useRoute().query.to as string | undefined;
|
||||||
|
|
||||||
if (session.value !== null) {
|
|
||||||
navigateTo("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
email: "",
|
email: '',
|
||||||
password: "",
|
password: '',
|
||||||
});
|
});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
let emailInputEl = ref<HTMLInputElement | null>(null);
|
const emailInputEl = ref<HTMLInputElement | null>(null);
|
||||||
let passwordInputEl = ref<HTMLInputElement | null>(null);
|
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
let tempForm = {
|
const tempForm = {
|
||||||
email: "",
|
email: '',
|
||||||
password: "",
|
password: '',
|
||||||
}
|
};
|
||||||
|
|
||||||
// prevent text fields from clearing on hydration
|
// prevent text fields from clearing on hydration
|
||||||
onBeforeMount(() => {
|
onBeforeMount(() => {
|
||||||
tempForm.email = (document.getElementById("email") as HTMLInputElement)?.value ?? "";
|
tempForm.email = (document.getElementById('email') as HTMLInputElement)?.value ?? '';
|
||||||
tempForm.password = (document.getElementById("password") as HTMLInputElement)?.value ?? "";
|
tempForm.password = (document.getElementById('password') as HTMLInputElement)?.value ?? '';
|
||||||
})
|
});
|
||||||
|
|
||||||
let hydrated = ref(false);
|
const hydrated = ref(false);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
form.email = tempForm.email;
|
form.email = tempForm.email;
|
||||||
form.password = tempForm.password;
|
form.password = tempForm.password;
|
||||||
hydrated.value = true;
|
hydrated.value = true;
|
||||||
|
|
||||||
emailInputEl.value!.addEventListener("input", () => {
|
emailInputEl.value!.addEventListener('input', () => {
|
||||||
emailInputEl.value!.setCustomValidity("");
|
emailInputEl.value!.setCustomValidity('');
|
||||||
});
|
});
|
||||||
|
|
||||||
passwordInputEl.value!.addEventListener("input", () => {
|
passwordInputEl.value!.addEventListener('input', () => {
|
||||||
passwordInputEl.value!.setCustomValidity("");
|
passwordInputEl.value!.setCustomValidity('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,40 +56,52 @@ const submit = async () => {
|
|||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
await signIn.email({
|
const { data, error } = await authClient.signIn.email({
|
||||||
email: form.email,
|
email: form.email,
|
||||||
password: form.password,
|
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;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -103,9 +114,10 @@ const submit = async () => {
|
|||||||
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
|
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
|
||||||
autocomplete="current-password" id="password" v-model="form.password" />
|
autocomplete="current-password" id="password" v-model="form.password" />
|
||||||
<button :disabled="!hydrated" class="accent" type="submit">
|
<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>
|
<span v-else>Login</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</template>
|
||||||
+83
-68
@@ -1,47 +1,51 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { authClient } from '~~/lib/auth-client';
|
||||||
|
import { deriveKey } from '~/utils/crypto';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'auth',
|
layout: 'auth',
|
||||||
})
|
});
|
||||||
|
|
||||||
const { signUp, session, fetchSession, authClient } = useAuth();
|
const { session } = await useAuth();
|
||||||
|
const to = useRoute().query.to as string | undefined;
|
||||||
|
|
||||||
if (session.value !== null) {
|
if (session.value !== null) {
|
||||||
navigateTo("/");
|
await navigateTo(to ?? '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.server) {
|
if (import.meta.server) {
|
||||||
if (process.env.DISABLE_SIGNUP?.toLowerCase() === "true" || process.env.DISABLE_SIGNUP === "1") {
|
if (process.env.DISABLE_SIGNUP?.toLowerCase() === 'true' || process.env.DISABLE_SIGNUP === '1') {
|
||||||
navigateTo("/auth/login")
|
await navigateTo(to ? `/auth/login?to=${to}` : '/auth/login');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: "",
|
name: '',
|
||||||
email: "",
|
email: '',
|
||||||
password: "",
|
password: '',
|
||||||
confirmPassword: "",
|
confirmPassword: '',
|
||||||
});
|
});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
let nameInputEl = ref<HTMLInputElement | null>(null);
|
const nameInputEl = ref<HTMLInputElement | null>(null);
|
||||||
let emailInputEl = ref<HTMLInputElement | null>(null);
|
const emailInputEl = ref<HTMLInputElement | null>(null);
|
||||||
let passwordInputEl = ref<HTMLInputElement | null>(null);
|
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||||
let confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
|
const confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
let tempForm = {
|
const tempForm = {
|
||||||
name: "",
|
name: '',
|
||||||
email: "",
|
email: '',
|
||||||
password: "",
|
password: '',
|
||||||
confirmPassword: "",
|
confirmPassword: '',
|
||||||
}
|
};
|
||||||
|
|
||||||
// prevent text fields from clearing on hydration
|
// prevent text fields from clearing on hydration
|
||||||
onBeforeMount(() => {
|
onBeforeMount(() => {
|
||||||
tempForm.name = (document.getElementById("name") as HTMLInputElement)?.value ?? "";
|
tempForm.name = (document.getElementById('name') as HTMLInputElement)?.value ?? '';
|
||||||
tempForm.email = (document.getElementById("email") as HTMLInputElement)?.value ?? "";
|
tempForm.email = (document.getElementById('email') as HTMLInputElement)?.value ?? '';
|
||||||
tempForm.password = (document.getElementById("password") as HTMLInputElement)?.value ?? "";
|
tempForm.password = (document.getElementById('password') as HTMLInputElement)?.value ?? '';
|
||||||
tempForm.confirmPassword = (document.getElementById("confirmPassword") as HTMLInputElement)?.value ?? "";
|
tempForm.confirmPassword = (document.getElementById('confirmPassword') as HTMLInputElement)?.value ?? '';
|
||||||
})
|
});
|
||||||
|
|
||||||
const hydrated = ref(false);
|
const hydrated = ref(false);
|
||||||
|
|
||||||
@@ -52,25 +56,24 @@ onMounted(() => {
|
|||||||
form.confirmPassword = tempForm.confirmPassword;
|
form.confirmPassword = tempForm.confirmPassword;
|
||||||
hydrated.value = true;
|
hydrated.value = true;
|
||||||
|
|
||||||
nameInputEl.value!.addEventListener("input", () => {
|
nameInputEl.value!.addEventListener('input', () => {
|
||||||
nameInputEl.value!.setCustomValidity("");
|
nameInputEl.value!.setCustomValidity('');
|
||||||
});
|
});
|
||||||
|
|
||||||
emailInputEl.value!.addEventListener("input", () => {
|
emailInputEl.value!.addEventListener('input', () => {
|
||||||
emailInputEl.value!.setCustomValidity("");
|
emailInputEl.value!.setCustomValidity('');
|
||||||
});
|
});
|
||||||
|
|
||||||
passwordInputEl.value!.addEventListener("input", () => {
|
passwordInputEl.value!.addEventListener('input', () => {
|
||||||
passwordInputEl.value!.setCustomValidity("");
|
passwordInputEl.value!.setCustomValidity('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
confirmPasswordInputEl.value!.addEventListener('input', () => {
|
||||||
confirmPasswordInputEl.value!.addEventListener("input", () => {
|
|
||||||
if (passwordInputEl.value!.value !== confirmPasswordInputEl.value!.value) {
|
if (passwordInputEl.value!.value !== confirmPasswordInputEl.value!.value) {
|
||||||
confirmPasswordInputEl.value!.setCustomValidity("Passwords do not match");
|
confirmPasswordInputEl.value!.setCustomValidity('Passwords do not match');
|
||||||
confirmPasswordInputEl.value!.reportValidity();
|
confirmPasswordInputEl.value!.reportValidity();
|
||||||
} else {
|
} else {
|
||||||
confirmPasswordInputEl.value!.setCustomValidity("");
|
confirmPasswordInputEl.value!.setCustomValidity('');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -86,50 +89,62 @@ const submit = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (form.password !== form.confirmPassword) {
|
if (form.password !== form.confirmPassword) {
|
||||||
alert("Passwords do not match")
|
alert('Passwords do not match');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
await signUp.email({
|
const { data, error } = await authClient.signUp.email({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
email: form.email,
|
email: form.email,
|
||||||
password: form.password,
|
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;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -151,5 +166,5 @@ const submit = async () => {
|
|||||||
<span v-else>Register</span>
|
<span v-else>Register</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</template>
|
||||||
+62
-40
@@ -1,44 +1,40 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { user } = useAuth();
|
const { agents } = await useAgents();
|
||||||
|
|
||||||
if (user.value === null) {
|
|
||||||
navigateTo("/auth/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const taglines = {
|
const taglines = {
|
||||||
morning: [
|
morning: [
|
||||||
"crush your goals before breakfast",
|
'crush your goals before breakfast',
|
||||||
"turn your productivity to 11",
|
'turn your productivity to 11',
|
||||||
"start your day like a boss",
|
'start your day like a boss',
|
||||||
"make it happen before noon",
|
'make it happen before noon',
|
||||||
"rise and grind, repeat",
|
'rise and grind, repeat',
|
||||||
"morning MVP, all day",
|
'morning MVP, all day',
|
||||||
"fuel your fire early",
|
'fuel your fire early',
|
||||||
"own the first half of your day"
|
'own the first half of your day',
|
||||||
],
|
],
|
||||||
afternoon: [
|
afternoon: [
|
||||||
"afternoon focus session",
|
'afternoon focus session',
|
||||||
"making afternoon moves",
|
'making afternoon moves',
|
||||||
"steady progress continues",
|
'steady progress continues',
|
||||||
"keeping the flow going",
|
'keeping the flow going',
|
||||||
"afternoon productivity boost",
|
'afternoon productivity boost',
|
||||||
"momentum is building",
|
'momentum is building',
|
||||||
"making it happen today",
|
'making it happen today',
|
||||||
"afternoon hustle mode"
|
'afternoon hustle mode',
|
||||||
],
|
],
|
||||||
evening: [
|
evening: [
|
||||||
"finish strong today",
|
'finish strong today',
|
||||||
"end on a high note",
|
'end on a high note',
|
||||||
"wrap it up like a pro",
|
'wrap it up like a pro',
|
||||||
"leave it all on the field",
|
'leave it all on the field',
|
||||||
"tomorrow's success starts tonight",
|
"tomorrow's success starts tonight",
|
||||||
"last call for wins",
|
'last call for wins',
|
||||||
"close it out like a champion",
|
'close it out like a champion',
|
||||||
"seal the deal before bed"
|
'seal the deal before bed',
|
||||||
]
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
const animatedText = ref("");
|
const animatedText = ref('');
|
||||||
const currentTaglineIndex = ref(0);
|
const currentTaglineIndex = ref(0);
|
||||||
const isDeleting = ref(false);
|
const isDeleting = ref(false);
|
||||||
|
|
||||||
@@ -55,7 +51,7 @@ const typeWriter = (time: 'morning' | 'afternoon' | 'evening') => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
animatedText.value = currentText.substring(0, animatedText.value.length - 1);
|
animatedText.value = currentText.substring(0, animatedText.value.length - 1);
|
||||||
if (animatedText.value === "") {
|
if (animatedText.value === '') {
|
||||||
isDeleting.value = false;
|
isDeleting.value = false;
|
||||||
currentTaglineIndex.value = (currentTaglineIndex.value + 1) % currentTaglines.length;
|
currentTaglineIndex.value = (currentTaglineIndex.value + 1) % currentTaglines.length;
|
||||||
}
|
}
|
||||||
@@ -69,22 +65,23 @@ const typeWriter = (time: 'morning' | 'afternoon' | 'evening') => {
|
|||||||
setTimeout(() => typeWriter(time), speed);
|
setTimeout(() => typeWriter(time), speed);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChatSubmit = (message: string) => {
|
const handleChatSubmit = async (message: string, _model: unknown) => {
|
||||||
console.log('Message submitted:', message);
|
console.log('Message submitted:', message, agents);
|
||||||
|
await navigateTo(`/agent/${agents.value![0]!.id}`);
|
||||||
// TODO: Implement chat functionality
|
// TODO: Implement chat functionality
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
let time: 'morning' | 'afternoon' | 'evening' = "morning";
|
let time: 'morning' | 'afternoon' | 'evening' = 'morning';
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const hours = now.getHours();
|
const hours = now.getHours();
|
||||||
|
|
||||||
if (hours < 12) {
|
if (hours < 12) {
|
||||||
time = "morning";
|
time = 'morning';
|
||||||
} else if (hours < 22) {
|
} else if (hours < 22) {
|
||||||
time = "afternoon";
|
time = 'afternoon';
|
||||||
} else {
|
} else {
|
||||||
time = "evening";
|
time = 'evening';
|
||||||
}
|
}
|
||||||
|
|
||||||
// randomly select a tagline
|
// randomly select a tagline
|
||||||
@@ -95,9 +92,34 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center pt-12 px-4 h-full gap-12">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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) => {
|
export default defineNuxtPlugin({
|
||||||
if (!nuxtApp.payload.serverRendered) {
|
name: 'better-auth-triplit',
|
||||||
await useAuth().fetchSession()
|
enforce: 'pre',
|
||||||
} else if (Boolean(nuxtApp.payload.prerenderedAt) || Boolean(nuxtApp.payload.isCached)) {
|
async setup(nuxtApp) {
|
||||||
// To avoid hydration mismatch
|
if (import.meta.client) {
|
||||||
nuxtApp.hook('app:mounted', async () => {
|
const triplit = useTriplitClient();
|
||||||
await useAuth().fetchSession()
|
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',
|
name: 'better-auth-fetch-plugin',
|
||||||
enforce: 'pre',
|
enforce: 'pre',
|
||||||
async setup(nuxtApp) {
|
async setup(nuxtApp) {
|
||||||
|
const triplit = useTriplitClient();
|
||||||
|
|
||||||
// Flag if request is cached
|
// 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) {
|
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;
|
value?: string | number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
divider?: 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
|
name: veridian-development
|
||||||
services:
|
services:
|
||||||
postgresql:
|
triplit:
|
||||||
image: pgvector/pgvector:pg17
|
image: aspencloud/triplit-server:latest
|
||||||
container_name: veridian-postgres
|
container_name: veridian-triplit
|
||||||
command: postgres -c wal_level=logical
|
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "8080:6543"
|
||||||
volumes:
|
volumes:
|
||||||
- "data:/var/lib/postgresql/data"
|
- "triplit_data:/data"
|
||||||
environment:
|
environment:
|
||||||
- "POSTGRES_DB=${VERIDIAN_DB_NAME}"
|
LOCAL_DATABASE_URL: /data/triplit.db
|
||||||
- "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD", "curl", "-f", "http://localhost:6543/healthcheck"]
|
||||||
interval: 5s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
restart: always
|
|
||||||
networks:
|
networks:
|
||||||
- veridian-network
|
- veridian-network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
triplit_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
networks:
|
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 { triplitAdapter } from '@daveyplate/better-auth-triplit';
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { HttpClient } from '@triplit/client';
|
||||||
import * as schema from "~~/db/schema";
|
import { betterAuth } from 'better-auth/minimal';
|
||||||
import { useDrizzle } from "~~/server/utils/drizzle";
|
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({
|
export const auth = betterAuth({
|
||||||
database: drizzleAdapter(useDrizzle(), {
|
database: triplitAdapter({
|
||||||
provider: "pg",
|
httpClient,
|
||||||
schema: {
|
secretKey: process.env.BETTER_AUTH_SECRET,
|
||||||
...schema
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
session: {
|
||||||
|
cookieCache: {
|
||||||
|
enabled: true,
|
||||||
|
maxAge: 5 * 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
trustedOrigins: [process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000'],
|
||||||
|
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
// if DISABLE_LOCAL_AUTH is set to "1" or "true" then local auth is disabled
|
// 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",
|
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",
|
disableSignUp: process.env.DISABLE_SIGNUP?.toLowerCase() === 'true' || process.env.DISABLE_SIGNUP === '1',
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
+40
-11
@@ -6,26 +6,49 @@ export default defineNuxtConfig({
|
|||||||
head: {
|
head: {
|
||||||
title: 'Veridian',
|
title: 'Veridian',
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
lang: 'en'
|
lang: 'en',
|
||||||
},
|
},
|
||||||
meta: [
|
meta: [
|
||||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
{ 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: {
|
vite: {
|
||||||
server: {
|
server: {
|
||||||
allowedHosts: true,
|
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: {
|
modules: ['@nuxt/hints', '@nuxt/icon', '@unocss/nuxt', '@nuxtjs/color-mode', 'triplit-nuxt', 'nuxt-shiki'],
|
||||||
inlineStyles: true,
|
|
||||||
|
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: {
|
colorMode: {
|
||||||
@@ -34,7 +57,13 @@ export default defineNuxtConfig({
|
|||||||
storage: 'cookie',
|
storage: 'cookie',
|
||||||
},
|
},
|
||||||
|
|
||||||
devtools: { enabled: true },
|
devtools: {
|
||||||
|
enabled: true,
|
||||||
|
|
||||||
|
timeline: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
})
|
});
|
||||||
|
|||||||
+37
-11
@@ -4,31 +4,57 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
|
"triplit": "triplit dev -s sqlite",
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/vue": "^3.0.48",
|
||||||
|
"@daveyplate/better-auth-triplit": "^0.2.2",
|
||||||
"@iconify-json/mynaui": "^1.2.17",
|
"@iconify-json/mynaui": "^1.2.17",
|
||||||
"@nuxt/hints": "1.0.0-alpha.5",
|
"@nuxt/hints": "1.0.0-alpha.5",
|
||||||
"@nuxt/icon": "2.2.0",
|
"@nuxt/icon": "2.2.0",
|
||||||
"@nuxtjs/color-mode": "4.0.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",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.45.1",
|
"glob": "^13.0.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"nuxt": "^4.2.2",
|
"nuxt": "4.2.2",
|
||||||
"pg": "^8.16.3",
|
"nuxt-shiki": "0.3.2",
|
||||||
"uuidv7": "^1.1.0",
|
"remark-gfm": "^4.0.1",
|
||||||
"vue": "^3.5.26",
|
"remark-parse": "^11.0.0",
|
||||||
"vue-router": "^4.6.4"
|
"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": {
|
"devDependencies": {
|
||||||
"@types/pg": "^8.16.0",
|
"@biomejs/biome": "2.3.13",
|
||||||
"@unocss/nuxt": "^66.5.12",
|
"@iconify-json/logos": "^1.2.10",
|
||||||
"drizzle-kit": "^0.31.8",
|
"@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",
|
"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) => {
|
export default defineEventHandler((event) => {
|
||||||
return auth.handler(toWebRequest(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 { createOpenRouter, type OpenRouterProvider } from '@openrouter/ai-sdk-provider';
|
||||||
import { createPendingGeneration } from '~~/server/utils/generation';
|
import type { Entity } from '@triplit/client';
|
||||||
import type { GenerateRequestBody } from '~~/server/types/chat';
|
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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await protectRoute(event);
|
await protectRoute(event);
|
||||||
|
|
||||||
const body = await readBody(event) as GenerateRequestBody;
|
const result = await readValidatedBody(event, (body) =>
|
||||||
const { topicId, messages, regeneratesFrom } = 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({
|
throw createError({
|
||||||
statusCode: 400,
|
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({
|
throw createError({
|
||||||
statusCode: 400,
|
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 {
|
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 {
|
switch (token.type) {
|
||||||
generationId,
|
case 'start-step': {
|
||||||
status: 'pending',
|
curStepIdx++;
|
||||||
regeneratesFrom
|
} break;
|
||||||
};
|
case 'tool-input-start': {
|
||||||
} catch (error) {
|
key = `tool-call-${curStepIdx}`;
|
||||||
console.error('Failed to create generation:', error);
|
|
||||||
throw createError({
|
const toolCallId = token.id;
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: 'Failed to create generation'
|
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