streaming, markdown, model selecting, and lots more

This commit is contained in:
Zoe
2026-02-02 23:16:06 -06:00
parent 8c28946703
commit d5a5945c03
114 changed files with 6109 additions and 2938 deletions
+6 -5
View File
@@ -1,5 +1,6 @@
VERIDIAN_DB_NAME=veridian
POSTGRES_PASSWORD=password
NUXT_AUTH_SECRET="YOUR-SUPER-SECURE-SECRET" # openssl rand -base64 32
DATABASE_URL=postgresql://postgres:password@localhost:5432/veridian
TRIPLIT_SERVICE_TOKEN="your-triplit-service-token" # given to you on startup, or by running `tsx generate-tokens.ts`
NUXT_TRIPLIT_ANON_TOKEN="your-anonymous-triplit-token" # also given to you on startup, or by running `tsx generate-tokens.ts`
BETTER_AUTH_SECRET="super-secret" # openssl rand -base64 32
TRIPLIT_JWT_SECRET="super-secret"
EXTERNAL_JWT_SECRET=${BETTER_AUTH_SECRET} # no need to change this
NUXT_PUBLIC_TRIPLIT_URL="http://localhost:6543" # your triplit server url
+3
View File
@@ -18,6 +18,9 @@ logs
.fleet
.idea
# tunneling config
frpc.toml
# Local env files
.env
.env.*
+21 -25
View File
@@ -5,11 +5,11 @@ the Veridian repository.
## Project Overview
Veridian is a Nuxt 4 application built with TypeScript, using PostgreSQL with
Drizzle ORM, better-auth for authentication, and UnoCSS for styling. The app is
an agnetic chat platform that is meant to provide a well rounded experience for
interacting with LLMs as well as providing a way to manage agents and provide
helpful tools like RAG (retrieval augmented generation) and web scraping/search.
Veridian is a Nuxt 4 application built with TypeScript, using Triplit with
better-auth for authentication, and UnoCSS for styling. The app is an agnetic
chat platform that is meant to provide a well rounded experience for interacting
with LLMs as well as providing a way to manage agents and provide helpful tools
like RAG (retrieval augmented generation) and web scraping/search.
You should be connected to the Nuxt docs MCP server, if you need to reference
the documentation or are unsure on how to implement something, consult the
@@ -24,18 +24,14 @@ database commands.
### Database Commands
- `tsx db/migrate.ts` - Run database migrations
- `bun x @better-auth/cli@latest generate --output db/auth/auth.schema.ts` -
Generate authentication schema based on `lib/auth.ts`
- `bun x drizzle-kit generate` - Generate migration files
- `bun x drizzle-kit push` - Push schema changes to database
- `bun x drizzle-kit studio` - Open Drizzle Studio for database inspection
- `bunx triplit schema push` - Push schema changes to database
## Tech Stack & Dependencies
- **Framework**: Nuxt 4 (SSR enabled)
- **Language**: TypeScript with strict configuration
- **Database**: PostgreSQL with Drizzle ORM
- **Database**: Triplit (next generation fullstack syncing database, sort of
like convex)
- **Auth**: better-auth with email/password and social providers
- **Styling**: UnoCSS with presetMini
- **Icons**: @nuxt/icon with Iconify, use Myna UI Icons
@@ -116,12 +112,10 @@ the source is uniform and easy to follow.**
### Database Patterns
- Use Drizzle ORM with PostgreSQL
- Export all schemas from `db/schema.ts`
- Use Triplit for database operations
- Export all schemas from `triplit/schema.ts`
- Use environment variables for database credentials
- `db/auth/auth.schema.ts` is a generated file, do not touch it. If you need to
change the authentication schema, edit `lib/auth.ts` and run the generation
script.
- Triplit schemas are defined in `triplit/schema.ts` and pushed using `bunx triplit schema push`
### Error Handling
@@ -142,7 +136,7 @@ the source is uniform and easy to follow.**
The app uses better-auth with:
- Email/password authentication (configurable via env vars)
- Drizzle adapter for PostgreSQL
- Triplit adapter for authentication
- Session management through cookies
- Auth plugins in `app/plugins/` for client/server initialization
- Global auth middleware in `app/middleware/auth.global.ts`
@@ -165,10 +159,10 @@ user must be authenticated.
│ \-- plugins/ # Vue/Nuxt plugins
|-- server/ # Server-side code
│ \-- api/ # API routes
|-- db/ # Database related files
|-- triplit/ # Database related files
│ |-- schema.ts # Database schema
│ |-- migrate.ts # Migration runner
│ \-- migrations/ # Migration files
│ |-- client.ts # Interacts with the database on the client
│ \-- server.ts # Interacts with the database on the server
\-- lib/ # Shared utilities
```
@@ -181,10 +175,12 @@ requirement.
Key environment variables:
- `DATABASE_URL` - PostgreSQL connection string
- `BETTER_AUTH_SECRET` - Authentication secret
- `DISABLE_LOCAL_AUTH` - Disable email/password auth
- `DISABLE_SIGNUP` - Disable user registration
- `TRIPLIT_SERVICE_TOKEN` - Triplit service token (admin token. **SECRET**)
- `NUXT_TRIPLIT_ANON_TOKEN` - Triplit anonymous token (for anonymous access)
- `BETTER_AUTH_SECRET` - Authentication secret (for better-auth)
- `EXTERNAL_JWT_SECRET` - Will always be BETTER_AUTH_SECRET (used for
verifying JWT tokens from better-auth)
- `NUXT_PUBLIC_TRIPLIT_URL` - Triplit server URL
## Common Patterns
+29
View File
@@ -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.
+3 -1
View File
@@ -1,3 +1,5 @@
# Todo
- [ ] Standardize on one icon set rather than 4 (lmao)
- [X] Standardize on one icon set rather than 4 (lmao)
- [ ] Make dropdowns a singleton component
- [ ] Make the sidebar better :kekdoggo:. It's annoying to manage across routes
+32 -10
View File
@@ -1,29 +1,46 @@
<script setup lang="ts">
import '~/assets/css/reset.css';
import '~/assets/css/base.css';
const { accent, neutral, hinting } = useTheme()
const { accent, neutral, hinting } = useTheme();
// by default, disable hinting
if (Number.isNaN(Number(hinting.value))) hinting.value = '0';
useHead({
htmlAttrs: {
style: `--color-accent: var(--accent-${accent.value}, var(--accent-violet)); --color-accent-hover: var(--accent-${accent.value}-hover, var(--accent-violet-hover)); --neutral-dark: var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark)); --neutral-light: var(--neutral-${neutral.value}-light, var(--neutral-zinc-light)); --accent-hinting: ${hinting.value}%; --base-hinting: calc(100% - var(--accent-hinting));`
}
})
style: `--color-accent: var(--accent-${accent.value}, var(--accent-violet)); --color-accent-hover: var(--accent-${accent.value}-hover, var(--accent-violet-hover)); --neutral-dark: var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark)); --neutral-light: var(--neutral-${neutral.value}-light, var(--neutral-zinc-light)); --accent-hinting: ${hinting.value}%; --base-hinting: calc(100% - var(--accent-hinting));`,
},
});
if (import.meta.client) {
// force shiki into browser rendering only
window.sessionStorage.setItem('mdc-shiki-highlighter', 'browser');
watchEffect(() => {
document.documentElement.style.setProperty('--color-accent', `var(--accent-${accent.value}, var(--accent-violet))`)
document.documentElement.style.setProperty('--color-accent-hover', `var(--accent-${accent.value}-hover, var(--accent-violet-hover))`)
document.documentElement.style.setProperty('--neutral-dark', `var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark))`)
document.documentElement.style.setProperty('--neutral-light', `var(--neutral-${neutral.value}-light, var(--neutral-zinc-light))`)
document.documentElement.style.setProperty('--accent-hinting', `${hinting.value}%`)
})
document.documentElement.style.setProperty(
'--color-accent',
`var(--accent-${accent.value}, var(--accent-violet))`,
);
document.documentElement.style.setProperty(
'--color-accent-hover',
`var(--accent-${accent.value}-hover, var(--accent-violet-hover))`,
);
document.documentElement.style.setProperty(
'--neutral-dark',
`var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark))`,
);
document.documentElement.style.setProperty(
'--neutral-light',
`var(--neutral-${neutral.value}-light, var(--neutral-zinc-light))`,
);
document.documentElement.style.setProperty('--accent-hinting', `${hinting.value}%`);
});
}
</script>
<template>
<NuxtLoadingIndicator />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
@@ -31,7 +48,12 @@ if (import.meta.client) {
</template>
<style>
body {
height: 100%;
}
#__nuxt {
padding: 0.5rem;
height: 100%;
display: flex;
}
+131 -220
View File
@@ -1,243 +1,154 @@
@layer reset, base, components, utilities;
:root {
--font-sans: system-ui, sans-serif;
--sidebar-width: 400px;
--spacing: 0.25rem;
@layer reset {
/*
Josh's Custom CSS Reset slightly Modified
https://www.joshwcomeau.com/css/custom-css-reset/
*/
--color-accent-text: #000;
*,
*::before,
*::after {
box-sizing: border-box;
}
--reasoning-accent: #a62bcb;
* {
border: 0 solid;
line-height: calc(1em + 0.5rem);
margin: 0;
padding: 0;
}
/* Accent Color Options */
body {
-webkit-font-smoothing: antialiased;
}
/* violet (current default) */
--accent-violet: #a010ff;
--accent-violet-hover: #8d00e8;
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
/* volcanic heat - orange-red */
--accent-volcano: #ef4410;
--accent-volcano-hover: #dc2626;
input,
button,
textarea,
select {
color: inherit;
font: inherit;
}
/* neon lime */
--accent-lime: #77fb6b;
--accent-lime-hover: #5ee04f;
input:focus {
outline: none;
}
/* electric sky */
--accent-sky: #38d3fa;
--accent-sky-hover: #2bc7ee;
button {
cursor: pointer;
}
/* crushed coral - warm pink-orange */
--accent-coral: #ff6b6b;
--accent-coral-hover: #e55555;
p,
h1,
h2,
h3,
h4,
h5,
h6 {
word-break: break-word;
}
/* deep emerald - rich green */
--accent-emerald: #10b981;
--accent-emerald-hover: #059669;
p {
text-wrap: pretty;
hyphens: auto;
}
/* golden hour - warm amber */
--accent-amber: #f59e0b;
--accent-amber-hover: #d97706;
h1,
h2,
h3,
h4,
h5,
h6 {
text-wrap: balance;
}
/* rose quartz - soft pink-red */
--accent-rose: #f43f5e;
--accent-rose-hover: #e11d48;
a {
color: #fff;
}
/* arctic cyan - crisp blue */
--accent-cyan: #06b6d4;
--accent-cyan-hover: #0891b2;
a:hover {
text-decoration: none;
}
/* midnight indigo - deep blue-purple */
--accent-indigo: #6366f1;
--accent-indigo-hover: #4f46e5;
/* sunset magenta - vibrant pink-purple */
--accent-magenta: #ec4899;
--accent-magenta-hover: #db2777;
/* Neutral Base Colors - different gray variations */
/* zinc - deep gray-blue */
--neutral-zinc-dark: #171619;
--neutral-zinc-light: #fbfafd;
/* charcoal - deep black-gray */
--neutral-charcoal-dark: #0a0a0a;
--neutral-charcoal-light: #fefdff;
}
@layer base {
:root {
--sidebar-width: 400px;
--spacing: 0.25rem;
--color-accent-text: #000;
/* Accent Color Options */
/* violet (current default) */
--accent-violet: #A010FF;
--accent-violet-hover: #8D00E8;
/* volcanic heat - orange-red */
--accent-volcano: #ef4410;
--accent-volcano-hover: #dc2626;
/* neon lime */
--accent-lime: #77fb6b;
--accent-lime-hover: #5ee04f;
/* electric sky */
--accent-sky: #38d3fa;
--accent-sky-hover: #2bc7ee;
/* crushed coral - warm pink-orange */
--accent-coral: #FF6B6B;
--accent-coral-hover: #E55555;
/* deep emerald - rich green */
--accent-emerald: #10B981;
--accent-emerald-hover: #059669;
/* golden hour - warm amber */
--accent-amber: #F59E0B;
--accent-amber-hover: #D97706;
/* rose quartz - soft pink-red */
--accent-rose: #F43F5E;
--accent-rose-hover: #E11D48;
/* arctic cyan - crisp blue */
--accent-cyan: #06B6D4;
--accent-cyan-hover: #0891B2;
/* midnight indigo - deep blue-purple */
--accent-indigo: #6366F1;
--accent-indigo-hover: #4F46E5;
/* sunset magenta - vibrant pink-purple */
--accent-magenta: #EC4899;
--accent-magenta-hover: #DB2777;
/* Neutral Base Colors - different gray variations */
/* zinc - deep gray-blue */
--neutral-zinc-dark: #171619;
--neutral-zinc-light: #fbfafd;
/* charcoal - deep black-gray */
--neutral-charcoal-dark: #0a0a0a;
--neutral-charcoal-light: #fefdff;
}
:root.dark {
--color-base: color-mix(in srgb, var(--base-hinting) #040305, var(--accent-hinting) var(--color-accent));
--color-highlight-low: color-mix(in srgb, var(--base-hinting) rgba(255, 255, 255, 0.05), var(--accent-hinting) var(--color-accent));
--color-highlight: color-mix(in srgb, var(--base-hinting) rgba(255, 255, 255, 0.10), var(--accent-hinting) var(--color-accent));
--color-highlight-high: color-mix(in srgb, var(--base-hinting) rgba(255, 255, 255, 0.18), var(--accent-hinting) var(--color-accent));
--color-text: color-mix(in srgb, var(--base-hinting) #fafafa, var(--accent-hinting) var(--color-accent));
--color-subtle: color-mix(in srgb, var(--base-hinting) #a1a1aa, var(--accent-hinting) var(--color-accent));
--color-input: color-mix(in srgb, var(--base-hinting) #222124, var(--accent-hinting) var(--color-accent));
--color-neutral: color-mix(in srgb, var(--base-hinting) var(--neutral-dark), var(--accent-hinting) var(--color-accent));
}
:root.light {
--color-base: color-mix(in srgb, var(--base-hinting) #f2f0f0, var(--accent-hinting) var(--color-accent));
--color-highlight-low: color-mix(in srgb, var(--base-hinting) rgba(0, 0, 0, 0.06), var(--accent-hinting) var(--color-accent));
--color-highlight: color-mix(in srgb, var(--base-hinting) rgba(0, 0, 0, 0.08), var(--accent-hinting) var(--color-accent));
--color-highlight-high: color-mix(in srgb, var(--base-hinting) rgba(0, 0, 0, 0.12), var(--accent-hinting) var(--color-accent));
--color-text: color-mix(in srgb, var(--base-hinting) #1a1a1a, var(--accent-hinting) var(--color-accent));
--color-subtle: color-mix(in srgb, var(--base-hinting) #6b7280, var(--accent-hinting) var(--color-accent));
--color-input: color-mix(in srgb, var(--base-hinting) #ffffff, var(--accent-hinting) var(--color-accent));
--color-neutral: color-mix(in srgb, var(--base-hinting) var(--neutral-light), var(--accent-hinting) var(--color-accent));
}
html,
body {
padding: 0;
margin: 0;
font-family: system-ui, sans-serif;
background-color: var(--color-base);
color: var(--color-text);
}
html {
height: 100vh;
overflow: hidden;
}
body {
padding: 0.5rem;
height: 100%;
}
button {
color: inherit;
display: flex;
}
button.accent {
display: flex;
justify-content: center;
cursor: pointer;
background-color: var(--color-accent);
color: var(--color-accent-text);
border-radius: calc(var(--spacing) * 1.5);
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 1);
transition: background-color 150ms cubic-bezier(0.45, 0, 0.55, 1);
}
button.accent:hover {
background-color: var(--color-accent-hover);
}
:root.dark {
--color-base: color-mix(in srgb, var(--base-hinting) #040305, var(--accent-hinting) var(--color-accent));
--color-highlight-low: color-mix(in srgb,
var(--base-hinting) rgba(255, 255, 255, 0.05),
var(--accent-hinting) var(--color-accent));
--color-highlight: color-mix(in srgb,
var(--base-hinting) rgba(255, 255, 255, 0.1),
var(--accent-hinting) var(--color-accent));
--color-highlight-high: color-mix(in srgb,
var(--base-hinting) rgba(255, 255, 255, 0.15),
var(--accent-hinting) var(--color-accent));
--color-text: color-mix(in srgb, var(--base-hinting) #fafafa, var(--accent-hinting) var(--color-accent));
--color-muted: color-mix(in srgb, var(--base-hinting) #a1a1a0, var(--accent-hinting) var(--color-accent));
--color-subtle: color-mix(in srgb, var(--base-hinting) #d1d1d0, var(--accent-hinting) var(--color-accent));
--color-input: color-mix(in srgb, var(--base-hinting) #222124, var(--accent-hinting) var(--color-accent));
--color-neutral: color-mix(in srgb,
var(--base-hinting) var(--neutral-dark),
var(--accent-hinting) var(--color-accent));
--color-reasoning: color-mix(in srgb, var(--base-hinting) #66666a, var(--accent-hinting) var(--color-accent));
}
@layer utilities {
.object-cover {
object-fit: cover;
}
.backdrop-blur-md {
backdrop-filter: blur(12px);
}
:root.light {
--color-base: color-mix(in srgb, var(--base-hinting) #f2f0f0, var(--accent-hinting) var(--color-accent));
--color-highlight-low: color-mix(in srgb,
var(--base-hinting) rgba(0, 0, 0, 0.06),
var(--accent-hinting) var(--color-accent));
--color-highlight: color-mix(in srgb,
var(--base-hinting) rgba(0, 0, 0, 0.08),
var(--accent-hinting) var(--color-accent));
--color-highlight-high: color-mix(in srgb,
var(--base-hinting) rgba(0, 0, 0, 0.13),
var(--accent-hinting) var(--color-accent));
--color-text: color-mix(in srgb, var(--base-hinting) #1a1a1a, var(--accent-hinting) var(--color-accent));
--color-muted: color-mix(in srgb, var(--base-hinting) #6b7270, var(--accent-hinting) var(--color-accent));
--color-subtle: color-mix(in srgb, var(--base-hinting) #9ba2a0, var(--accent-hinting) var(--color-accent));
--color-input: color-mix(in srgb, var(--base-hinting) #ffffff, var(--accent-hinting) var(--color-accent));
--color-neutral: color-mix(in srgb,
var(--base-hinting) var(--neutral-light),
var(--accent-hinting) var(--color-accent));
--color-reasoning: color-mix(in srgb, var(--base-hinting) #99979c, var(--accent-hinting) var(--color-accent));
}
@layer components {
.cursor {
display: inline-block;
width: 1rem;
height: 0.9em;
background-color: currentColor;
animation: blink 1s step-end infinite;
vertical-align: middle;
margin-left: 0.125rem;
}
html,
body {
padding: 0;
margin: 0;
font-family: var(--font-sans);
background-color: var(--color-base);
color: var(--color-text);
}
@keyframes blink {
html.dark {
color-scheme: dark;
}
0%,
100% {
opacity: 1;
}
html.light {
color-scheme: light;
}
50% {
opacity: 0;
}
}
html {
height: 100vh;
overflow: hidden;
}
body {
height: 100%;
}
button {
color: inherit;
display: flex;
}
button.accent {
display: flex;
justify-content: center;
cursor: pointer;
background-color: var(--color-accent);
color: var(--color-accent-text);
border-radius: calc(var(--spacing) * 1.5);
padding-inline: calc(var(--spacing) * 2);
padding-block: calc(var(--spacing) * 1);
transition: background-color 150ms cubic-bezier(0.45, 0, 0.55, 1);
}
button.accent:hover {
background-color: var(--color-accent-hover);
}
+80
View File
@@ -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
View File
@@ -1,38 +1,112 @@
<script setup lang="ts">
import { onMounted, ref, watch, computed } from 'vue';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import type { Entity } from '@triplit/client';
import { schema } from '#triplit/schema';
const inputRef = ref<HTMLTextAreaElement | null>(null);
let tempInput = '';
const inputValue = ref('');
const isFocused = ref(false);
const triplit = useTriplitClient();
const emit = defineEmits<{
submit: [value: string];
submit: [value: string, model: ModelWithProvider | null];
cancel: [];
}>();
const props = defineProps({
loading: {
type: Boolean,
default: false
}
})
const props = defineProps<{
loading?: boolean;
agent?: Entity<typeof schema, 'agents'>;
providers?: ProviderWithModels[];
}>();
const handleSubmit = () => {
if (inputValue.value.trim()) {
emit('submit', inputValue.value);
inputValue.value = '';
// Model selection state
const selectedModel = ref<ModelWithProvider | null>(null);
// Get all available models from all providers
const allModels = computed(() => {
if (!props.providers) return [];
return props.providers.flatMap((provider) =>
provider.models.map((model) => ({
...model,
provider,
}))
);
});
// Initialize model selection based on agent's defaultModelId or first available
const initializeModel = () => {
if (selectedModel.value) return;
if (!props.providers || !props.agent) return;
// Try to use agent's default model
if (props.agent.defaultModelId) {
const agentModel = allModels.value.find((m) => m.id === props.agent!.defaultModelId);
if (agentModel) {
selectedModel.value = agentModel;
return;
}
}
// Fall back to first available model
if (allModels.value.length > 0) {
selectedModel.value = allModels.value[0]!;
// Update agent's default model
updateAgentDefaultModel(selectedModel.value.id);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
// Update agent's default model in Triplit
const updateAgentDefaultModel = async (modelId: string) => {
if (!props.agent) return;
try {
await triplit.update('agents', props.agent.id, (agent) => {
agent.defaultModelId = modelId;
});
} catch (error) {
console.error('Failed to update agent default model:', error);
}
};
// Watch for model changes and persist to agent
watch(selectedModel, (newModel) => {
if (newModel && props.agent && newModel.id !== props.agent.defaultModelId) {
updateAgentDefaultModel(newModel.id);
}
});
// Initialize when providers change
watch(() => props.providers, initializeModel, { immediate: true });
const handleSubmit = () => {
if (props.loading) {
emit('cancel');
return;
}
if (inputValue.value.trim()) {
emit('submit', inputValue.value, selectedModel.value);
inputValue.value = '';
}
// Reset height after sending
if (inputRef.value) inputRef.value.style.height = 'auto';
};
const handleKeyDown = async (event: KeyboardEvent) => {
if (event.key === 'Enter') {
if (event.shiftKey) return;
if (event.ctrlKey || event.metaKey) {
if (inputRef.value === null) return;
// inset new line
let cursorPosition = inputRef.value.selectionStart;
const cursorPosition = inputRef.value.selectionStart;
if (cursorPosition === undefined) return;
if (cursorPosition !== inputRef.value.selectionEnd) return;
inputValue.value = inputValue.value.slice(0, cursorPosition) + "\n" + inputValue.value.slice(cursorPosition);
// inputRef.value.selectionStart = cursorPosition + 1;
inputValue.value =
inputValue.value.slice(0, cursorPosition) + '\n' + inputValue.value.slice(cursorPosition);
await nextTick();
handleInput();
return;
}
event.preventDefault();
@@ -40,41 +114,75 @@ const handleKeyDown = (event: KeyboardEvent) => {
}
};
const handleInput = () => {
const textarea = inputRef.value;
if (!textarea) return;
textarea.style.height = 'auto';
const lineHeight = 24;
const maxLines = 10;
const maxHeight = maxLines * lineHeight;
const newHeight = textarea.scrollHeight;
if (newHeight > maxHeight) {
textarea.style.height = `${maxHeight}px`;
} else {
textarea.style.height = `${newHeight}px`;
}
};
let hasCommandKey = false;
if (import.meta.server) {
let headers = useRequestHeaders();
const headers = useRequestHeaders();
hasCommandKey = headers['user-agent']?.includes('Mac OS') ?? false;
} else {
hasCommandKey = navigator.userAgent.includes('Mac OS');
}
onBeforeMount(() => {
tempInput = (document.getElementById('chat') as HTMLInputElement)?.value ?? '';
});
onMounted(() => {
inputValue.value = tempInput;
handleInput();
});
</script>
<template>
<div class="w-full max-w-full mx-auto">
<div class="relative flex flex-col gap-3 p-3 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--color-input)]
<div :class="['w-full flex max-h-full', $attrs.class]">
<div class="relative w-full flex flex-shrink-1 flex-col gap-3 p-3 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--color-input)]
border-[var(--color-highlight)] focus-within:border-[var(--color-highlight-high)]">
<!-- Text Input -->
<div class="flex-1 min-w-0">
<textarea v-model="inputValue" ref="inputRef"
<div class="flex-1 min-w-0 max-h-full">
<!-- Grammarly literally breaks everything, go fuck yourself -->
<textarea data-gramm="false" id="chat" v-model="inputValue" ref="inputRef"
:placeholder="`Start something great. Press ${hasCommandKey ? '⌘ + Enter' : 'ctrl + Enter'} to insert a new line.`"
@focus="isFocused = true" @blur="isFocused = false" @keydown="handleKeyDown"
class="w-full bg-transparent text-[var(--color-text)] placeholder-white/50 resize-none outline-none text-[15px] leading-6 min-h-[24px] max-h-32 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
rows="2"></textarea>
@keydown="handleKeyDown" @input="handleInput"
class="[scrollbar-width:none] w-full bg-transparent text-[var(--color-text)] resize-none outline-none text-[15px] leading-6 min-h-0 overflow-y-auto">
</textarea>
</div>
<!-- Toolbar -->
<div class="flex">
<div class="flex-1"></div>
<!-- Send Button -->
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() || loading"
<div class="flex items-center gap-2">
<div class="flex-1">
<ModelSelector v-if="providers && providers.length > 0" v-model="selectedModel"
:providers="providers"></ModelSelector>
</div>
<!-- Send/Stop Button -->
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() && !loading"
:class="[
'p-2 rounded-xl transition-all duration-200 flex items-center justify-center',
inputValue.trim()
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
inputValue.trim() && !loading
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)]'
: 'bg-[var(--color-highlight)] text-[var(--color-highlight-high)] cursor-not-allowed'
: 'text-[var(--color-highlight-high)]',
loading && 'bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)]',
]">
<Icon v-if="loading" name="svg-spinners:ring-resize" class="w-4 h-4" />
<Icon v-else name="mynaui:send-solid" class="w-4 h-4" />
<Icon v-if="loading" name="mynaui:stop-solid" class="text-6.5" />
<Icon v-else name="mynaui:send-solid" class="text-5" />
</button>
</div>
</div>
+30 -35
View File
@@ -2,71 +2,66 @@
import type { DropdownItem } from '~/types/dropdown';
interface Props {
items: DropdownItem[];
modelValue?: boolean;
items?: DropdownItem[];
placement?: 'right' | 'left' | 'center';
verticality?: 'asscending' | 'descending';
width?: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
placement: 'right',
verticality: 'descending',
width: 'auto'
})
width: 'auto',
});
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'select', item: DropdownItem): void;
}>()
const emit = defineEmits<(e: 'select', item: DropdownItem) => void>();
const triggerRef = ref<HTMLElement | null>(null)
const isOpen = defineModel<boolean>({ required: true })
const triggerRef = ref<HTMLElement | null>(null);
const isOpen = defineModel<boolean>({ required: false });
const toggle = () => {
isOpen.value = !isOpen.value
}
isOpen.value = !isOpen.value;
};
const select = (item: DropdownItem) => {
if (item.disabled || item.divider) return
emit('select', item)
isOpen.value = false
}
if (item.disabled || item.divider) return;
emit('select', item);
isOpen.value = false;
};
const placementClasses = computed(() => {
let classes = ''
let classes = '';
switch (props.placement) {
case 'right':
classes += 'right-0 '
break
classes += 'right-0 ';
break;
case 'left':
classes += 'left-0 '
break
classes += 'left-0 ';
break;
case 'center':
classes += 'left-1/2 -translate-x-1/2 '
break
classes += 'left-1/2 -translate-x-1/2 ';
break;
default:
classes += 'left-0 '
break
classes += 'left-0 ';
break;
}
switch (props.verticality) {
case 'asscending':
classes += 'bottom-full mb-1.5'
break
classes += 'bottom-full mb-1.5';
break;
case 'descending':
classes += 'top-full mt-1.5'
break
classes += 'top-full mt-1.5';
break;
}
return classes
})
return classes;
});
useClickOutside(triggerRef, () => {
isOpen.value = false
})
isOpen.value = false;
});
</script>
<template>
@@ -88,7 +83,7 @@ useClickOutside(triggerRef, () => {
:class="[
item.disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-[var(--color-highlight)] cursor-pointer'
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] cursor-pointer'
]">
<Icon v-if="item.icon" :name="item.icon" class="w-4 h-4 flex-shrink-0" />
<span class="text-sm whitespace-nowrap">{{ item.label }}</span>
-13
View File
@@ -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>
+58
View File
@@ -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>
+23
View File
@@ -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>
+40
View File
@@ -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>
+289
View File
@@ -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>
-100
View File
@@ -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>
+12
View File
@@ -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>
+12
View File
@@ -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>
+177
View File
@@ -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>
+55
View File
@@ -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>
+13
View File
@@ -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>
+21
View File
@@ -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>
+20
View File
@@ -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>
+181
View File
@@ -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>
+125
View File
@@ -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>
-91
View File
@@ -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>
+31 -15
View File
@@ -1,45 +1,61 @@
<script setup lang="ts">
import type { DropdownItem } from '~/types/dropdown'
import type { DropdownItem } from '~/types/dropdown';
import { authClient } from '~~/lib/auth-client';
const { user, signOut } = useAuth()
const { toggle: toggleSettings } = useSettings()
const triplit = useTriplitClient();
const { user } = await useAuth();
const hovering = defineModel<boolean>({ required: true })
const { toggle: toggleSettings } = useSettings();
const profileOpen = ref(false)
const hovering = defineModel<boolean>({ required: true });
const profileOpen = ref(false);
const handleLogout = async () => {
await signOut()
}
await authClient.signOut();
if ('endSession' in triplit) {
await triplit.endSession();
}
clearNuxtData();
await navigateTo('/auth/login');
};
const profileItems: DropdownItem[] = [
{ label: 'Settings', icon: 'mynaui:cog-four', onClick: toggleSettings },
{ label: 'Log out', icon: 'mynaui:logout', divider: true, onClick: handleLogout },
]
{
label: 'Log out',
icon: 'mynaui:logout',
divider: true,
onClick: handleLogout,
},
];
</script>
<template>
<header class="flex items-center justify-between overflow-hidden">
<Dropdown v-model="profileOpen" :items="profileItems" placement="left" verticality="descending" width="100%">
<Dropdown class="overflow-hidden" v-model="profileOpen" :items="profileItems" placement="left"
verticality="descending" width="100%">
<template #trigger="{ toggle }">
<div role="button" aria-label="open user dropdown"
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-highlight)] cursor-pointer transition-colors max-w-full"
<button aria-label="open user dropdown"
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] cursor-pointer transition-colors max-w-full"
@click="toggle">
<div
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-highlight-high)]']">
<img v-if="user?.image" :src="user.image" class="w-full h-full object-cover" />
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--color-subtle)]" />
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--color-muted)]" />
</div>
<span
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">{{
user!.name
}}</span>
<div :class="['flex-shrink-0 w-4 h-4 text-[var(--color-subtle)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left',
<div :class="['flex-shrink-0 w-4 h-4 text-[var(--color-muted)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left',
hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-x-0 scale-y-90'
]">
<Icon class="text-4" name="mynaui:chevron-down" />
</div>
</div>
</button>
</template>
</Dropdown>
</header>
+45 -28
View File
@@ -1,36 +1,53 @@
<script setup lang="ts">
import type { DropdownItem } from '~/types/dropdown'
import type { DropdownItem } from '~/types/dropdown';
const { user } = useAuth()
const { agents, activeAgent } = await useAgents()
const homeButtonRef = ref<HTMLElement | null>(null)
const route = useRoute();
const { agents, getAgent } = await useAgents();
const homeButtonRef = ref<HTMLElement | null>(null);
const hovering = defineModel<boolean>({ required: true })
const initialized = ref(false)
const activeAgent = computed(() => getAgent(route.params.id as string));
const hovering = defineModel<boolean>({ required: true });
const initialized = ref(false);
let lastHovering: boolean | null = null;
onMounted(() => {
if (hovering.value) {
const width = homeButtonRef.value!.scrollWidth
homeButtonRef.value!.style.width = `calc(${width}px + 0.5rem)`
console.log(hovering.value);
if (hovering.value && homeButtonRef.value) {
const width = homeButtonRef.value.scrollWidth;
homeButtonRef.value.style.width = `calc(${width}px + 0.5rem)`;
}
watch(hovering, (value) => {
if (!initialized.value) {
initialized.value = true
if (lastHovering === value) {
console.warn('Hovering value did not change, but watcher was triggered');
}
console.log(value, lastHovering);
lastHovering = value;
if (!initialized.value) {
initialized.value = true;
}
if (!homeButtonRef.value) return;
if (value) {
const width = homeButtonRef.value!.scrollWidth
homeButtonRef.value!.style.width = `calc(${width}px + 0.5rem)`
const width = homeButtonRef.value.scrollWidth;
homeButtonRef.value.style.width = `calc(${width}px + 0.5rem)`;
} else {
homeButtonRef.value!.style.width = '0'
homeButtonRef.value.style.width = '0';
}
})
})
});
});
const agentDropdownOpen = ref(false)
onUnmounted(() => {
console.log('unmounted');
});
const agentItems: DropdownItem[] = []
const agentDropdownOpen = ref(false);
const agentItems: DropdownItem[] = [];
</script>
<template>
@@ -38,14 +55,16 @@ const agentItems: DropdownItem[] = []
<div ref="homeButtonRef" style="width: 0;"
:class="['flex flex-shrink-0 items-center overflow-hidden', initialized ? 'transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]' : '', hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']">
<NuxtLink to="/"
class="flex hover:bg-[var(--color-highlight)] rounded-lg decoration-none transition-inherit text-[var(--color-subtle)] p-1.5">
class="flex hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg decoration-none transition-inherit text-[var(--color-muted)] p-1.5">
<Icon name="mynaui:chevron-left" class="w-4.5 h-4.5" />
</NuxtLink>
</div>
<Dropdown v-model="agentDropdownOpen" :items="agentItems" placement="center" width="calc(80% - 1rem)">
<Dropdown class="overflow-hidden" v-model="agentDropdownOpen" :items="agentItems" placement="center"
width="calc(80% - 1rem)">
<template #trigger="{ toggle }">
<div class="flex overflow-hidden gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-highlight)] rounded-lg"
<button
class="flex max-w-full gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg"
@click="toggle">
<div
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-highlight-high)]']">
@@ -57,20 +76,18 @@ const agentItems: DropdownItem[] = []
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">
{{ activeAgent?.name }}
</span>
<div class="w-4 h-4 text-[var(--color-subtle)]">
<div class="w-4 h-4 text-[var(--color-muted)]">
<Icon
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
name="mynaui:chevron-up-down" />
</div>
</div>
</button>
</template>
<template #content>
<div class="flex flex-col gap-1.5 max-h-[calc(2.25rem*4+0.375rem*3)] overflow-y-auto">
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id" :class="['decoration-none whitespace-nowrap', activeAgent?.id === agent.id ? 'text-[var(--color-text)]' :
'text-[var(--color-subtle)]']" @click="agentDropdownOpen = false">
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon"
:active="activeAgent?.id === agent.id" />
</NuxtLink>
<SidenavItem draggable="false" v-for="agent in agents" :to="`/agent/${agent.id}`" :name="agent.name"
class="whitespace-nowrap" icon="mynaui:check-hexagon" :key="agent.id"
:active="activeAgent?.id === agent.id" />
</div>
</template>
</Dropdown>
+40 -10
View File
@@ -1,17 +1,47 @@
<script setup lang="ts">
const props = defineProps<{
name: string,
icon: string,
active?: boolean
}>()
name: string;
icon?: string;
to?: string;
active?: boolean;
}>();
</script>
<template>
<div role="button"
:class="['flex items-center gap-2 px-1 rounded-lg hover:bg-[var(--color-highlight)] transition-colors cursor-pointer h-9 overflow-hidden', props.active ? 'bg-[var(--color-highlight)]' : '']">
<div class="h-7 w-7 flex items-center justify-center">
<Icon class="text-4.5" :name="props.icon" />
<NuxtLink v-if="props.to" v-bind="$attrs" :to="props.to" :aria-label="props.name" :class="[
'decoration-none text-[var(--color-muted)] flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
props.icon ? 'px-1' : 'px-2',
props.active
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
]">
<div class="flex items-center gap-2 max-w-full flex-1">
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
<Icon class="text-4.5" :name="props.icon" />
</div>
<div class="flex justify-between items-center w-full">
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
}}</span>
<slot />
</div>
</div>
<span class="text-sm font-medium overflow-hidden text-ellipsis">{{ props.name }}</span>
</div>
</NuxtLink>
<button v-else v-bind="$attrs" :aria-label="props.name" :class="[
'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9',
props.icon ? 'px-1' : 'px-2',
props.active
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
]">
<div class="flex items-center gap-2 max-w-full flex-1">
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
<Icon class="text-4.5" :name="props.icon" />
</div>
<div class="flex justify-between items-center w-full">
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
}}</span>
<slot />
</div>
</div>
</button>
</template>
+128 -7
View File
@@ -1,5 +1,23 @@
<script setup lang="ts">
const route = useRoute();
const topicsListRef = ref<HTMLElement | null>(null);
const topicsListHeight = ref('auto');
const topicsListOpacity = ref(1);
const topicsListScale = ref(1);
const { getAgent } = await useAgents();
const triplit = useTriplitClient();
const activeAgent = computed(() => getAgent(route.params.id as string));
const topics = computed(() => {
if (activeAgent.value === undefined) return [];
// return the todos but sorted and in a new array do not add messages or anything to the object, JUST SORT IT
return activeAgent.value.topics
.map((topic) => topic)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.reverse();
});
const routeParts = computed(() => {
return route.path.replace('/agent/', '').split('/');
@@ -18,19 +36,122 @@ const pageInfo = computed(() => {
return 'agent-profile';
}
});
const topicsOpen = ref(true);
function easeInOutQuad(x: number): number {
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
}
const toggleAgentsList = () => {
if (!topicsListRef.value) return;
const animationLength = 200;
let animationStart: number | null = null;
let startHeight: number;
const startOpacity = topicsListOpacity.value;
const startScale = topicsListScale.value;
if (topicsListHeight.value === 'auto') {
startHeight = topicsListRef.value.clientHeight;
} else {
startHeight = Number(topicsListHeight.value.replace('px', ''));
}
const targetHeight = topicsOpen.value ? 0 : topicsListRef.value.scrollHeight;
const targetOpacity = topicsOpen.value ? 0 : 1;
const targetScale = topicsOpen.value ? 0.95 : 1;
topicsOpen.value = !topicsOpen.value;
const animate = (timestamp: number) => {
if (!animationStart) animationStart = timestamp;
const elapsed = timestamp - animationStart;
const progress = Math.min(elapsed / animationLength, 1);
const currentHeight = startHeight + (targetHeight - startHeight) * easeInOutQuad(progress);
const currentOpacity = startOpacity + (targetOpacity - startOpacity) * easeInOutQuad(progress);
const currentScale = startScale + (targetScale - startScale) * easeInOutQuad(progress);
topicsListOpacity.value = currentOpacity;
topicsListScale.value = currentScale;
topicsListHeight.value = `${currentHeight}px`;
if (progress < 1) {
requestAnimationFrame(animate);
} else {
if (topicsOpen.value) {
topicsListHeight.value = 'auto';
}
}
};
requestAnimationFrame(animate);
};
const renameTopic = (topicId: string) => {
console.log('renameTopic', topicId);
};
const deleteTopic = async (topicId: string) => {
if (route.params.topicId === topicId) {
if (route.params.id) {
await navigateTo(`/agent/${route.params.id}`);
} else {
await navigateTo('/');
}
}
await triplit.delete('topics', topicId);
};
</script>
<template>
<nav class="flex flex-col gap-1">
<!-- Agent Info Link -->
<div class="mt-2">
<NuxtLink :to="`/agent/${routeParts[0]}/profile`" class="decoration-none text-[var(--color-subtle)]">
<SidenavItem name="Agent Info" icon="mynaui:info-square" :active="pageInfo === 'agent-profile'" />
</NuxtLink>
</div>
<div class="mt-2 flex flex-col">
<SidenavItem :to="`/agent/${routeParts[0]}/profile`" name="Agent Info" icon="mynaui:info-square"
:active="pageInfo === 'agent-profile'" />
<!-- Topics Section -->
<SidenavNavAgentTopics />
<!-- Topics Section -->
<button @click="toggleAgentsList"
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors w-full text-left">
<span class="text-sm font-medium">Topics</span>
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', topicsOpen ? '' : '-rotate-90']" />
</button>
<div ref="topicsListRef" :inert="!topicsOpen"
:style="{ height: topicsListHeight, opacity: topicsListOpacity, transform: `scale(${topicsListScale})` }"
class="mt-1 gap-1 flex flex-col transform-origin-center-top overflow-y-hidden">
<SidenavItem draggable="false" class="[&>div>div>div>[dots]]:hover:opacity-100 relative"
v-if="activeAgent?.topics !== undefined" v-for="topic in topics"
:to="`/agent/${activeAgent.id}/topic/${topic.id}`" :active="topic.id === route.params.topicId"
:name="topic.name" :key="topic.id">
<Dropdown class="shrink-0" verticality="descending" placement="right">
<template #trigger="{ toggle, isOpen }">
<div dots @click.prevent.stop="toggle"
class="opacity-0 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
viewBox="0 0 24 24"><!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ -->
<path fill="currentColor"
d="M7 12a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0" />
</svg>
</div>
</template>
<template #content>
<div class="shadow-lg rounded p-1 flex flex-col min-w-[120px] gap-1">
<button @click.prevent="renameTopic(topic.id)"
class="text-left px-3 py-1.5 text-sm hover:bg-[var(--color-highlight)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
Rename
</button>
<button @click.prevent="deleteTopic(topic.id)"
class="text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-600/20 rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
Delete
</button>
</div>
</template>
</Dropdown>
</SidenavItem>
</div>
</div>
</nav>
</template>
-106
View File
@@ -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>
+54 -32
View File
@@ -1,33 +1,33 @@
<script setup lang="ts">
const { agents, createAgent } = await useAgents()
const agentsListRef = ref<HTMLElement | null>(null)
const agentsOpen = ref(true)
const agentsListHeight = ref('auto')
const agentsListOpacity = ref(1)
const agentsListScale = ref(1)
const creatingAgent = ref(false)
const { agents } = await useAgents();
const agentsListRef = ref<HTMLElement | null>(null);
const agentsOpen = ref(true);
const agentsListHeight = ref('auto');
const agentsListOpacity = ref(1);
const agentsListScale = ref(1);
const creatingAgent = ref(false);
function easeInOutQuad(x: number): number {
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
}
const toggleAgentsList = () => {
if (!agentsListRef.value) return;
let animationLength = 200;
const animationLength = 200;
let animationStart: number | null = null;
let startHeight: number;
let startOpacity = agentsListOpacity.value;
let startScale = agentsListScale.value;
const startOpacity = agentsListOpacity.value;
const startScale = agentsListScale.value;
if (agentsListHeight.value === 'auto') {
startHeight = agentsListRef.value.clientHeight;
} else {
startHeight = Number(agentsListHeight.value.replace('px', ''));
}
let targetHeight = agentsOpen.value ? 0 : agentsListRef.value.scrollHeight;
let targetOpacity = agentsOpen.value ? 0 : 1;
let targetScale = agentsOpen.value ? 0.95 : 1;
const targetHeight = agentsOpen.value ? 0 : agentsListRef.value.scrollHeight;
const targetOpacity = agentsOpen.value ? 0 : 1;
const targetScale = agentsOpen.value ? 0.95 : 1;
agentsOpen.value = !agentsOpen.value;
const animate = (timestamp: number) => {
@@ -54,36 +54,59 @@ const toggleAgentsList = () => {
};
requestAnimationFrame(animate);
}
};
const newAgent = async () => {
creatingAgent.value = true;
const agent = await createAgent();
creatingAgent.value = false;
navigateTo(`/agent/${agent!.id}`);
}
const triplit = useTriplitClient();
const { user } = useAuth();
if (!user.value) throw new Error('User not logged in');
const agent = await triplit.insert('agents', {
name: 'New Agent',
userId: user.value.id,
systemPrompt: 'You are a helpful assistant.',
imageUrl: null,
createdAt: new Date(),
});
console.log(agents.value, agent);
if (!agent) throw new Error('Failed to create agent');
let agentExists: () => void;
const agentExistsPromise = new Promise<void>((resolve) => {
agentExists = resolve;
});
watch(agents, () => {
agentExists();
});
await agentExistsPromise;
navigateTo(`/agent/${agent.id}`);
};
</script>
<template>
<nav class="flex flex-col gap-1">
<SidenavItem name="Search" icon="mynaui:search" />
<NuxtLink to="/" class="decoration-none text-[var(--color-text)]">
<SidenavItem class="bg-[var(--color-highlight)]" name="Home" icon="mynaui:home" />
</NuxtLink>
<SidenavItem to="/" :active="true" name="Home" icon="mynaui:home" />
<!-- Agents Section -->
<div class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] transition-colors w-full text-left"
<button
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors w-full text-left"
@click="toggleAgentsList()">
<span class="text-sm">Agents</span>
<Icon name="mynaui:chevron-down"
:class="['w-4 h-4 transition-transform duration-250 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center', agentsOpen ? '' : '-rotate-90']" />
</div>
</button>
<div ref="agentsListRef"
<div ref="agentsListRef" :inert="!agentsOpen"
:style="{ height: agentsListHeight, opacity: agentsListOpacity, transform: `scale(${agentsListScale})` }"
class="mt-1 gap-1 flex flex-col overflow-hidden transform-origin-center-top">
class="mt-1 gap-1 flex flex-col transform-origin-center-top overflow-y-hidden">
<button @click="newAgent" :disabled="creatingAgent"
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--color-subtle)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors disabled:opacity-50 w-full">
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--color-muted)] bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors disabled:opacity-50 w-full">
<div class="h-7 w-7 flex items-center justify-center">
<Icon v-if="creatingAgent" class="text-4.5" name="svg-spinners:ring-resize" />
<Icon v-else class="text-4.5" name="mynaui:plus" />
@@ -91,10 +114,9 @@ const newAgent = async () => {
<span>New Agent</span>
</button>
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id"
class="decoration-none text-[var(--color-subtle)]">
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon" />
</NuxtLink>
<SidenavItem
v-for="agent in agents?.map((agent) => agent)?.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())"
:to="`/agent/${agent.id}`" :key="agent.id" :name="agent.name" icon="mynaui:check-hexagon" />
</div>
</nav>
</template>
+73 -52
View File
@@ -1,93 +1,105 @@
<script setup lang="ts">
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar()
const route = useRoute()
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar();
const route = useRoute();
const isResizing = ref(false)
const startX = ref(0)
const initialWidth = ref(0)
const isResizing = ref(false);
const startX = ref(0);
const initialWidth = ref(0);
const closeSidenavRef = ref<HTMLElement | null>(null)
const sidenavRef = ref<HTMLElement | null>(null);
const closeSidenavRef = ref<HTMLElement | null>(null);
const { toggle: toggleSettings } = useSettings();
const onResizeStart = (event: MouseEvent) => {
isResizing.value = true
startX.value = event.clientX
initialWidth.value = sidebarWidth.value
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}
isResizing.value = true;
startX.value = event.clientX;
initialWidth.value = sidebarWidth.value;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
};
const onResizeMove = (event: MouseEvent) => {
if (!isResizing.value) return
if (!isResizing.value) return;
if (resizeAnimationFrame) return;
resizeAnimationFrame = requestAnimationFrame(() => {
const deltaX = event.clientX - startX.value
const newWidth = initialWidth.value + deltaX
resize(newWidth)
resizeAnimationFrame = null
})
}
const deltaX = event.clientX - startX.value;
const newWidth = initialWidth.value + deltaX;
resize(newWidth);
resizeAnimationFrame = null;
});
};
const onResizeEnd = () => {
if (!isResizing.value) return
if (!isResizing.value) return;
isResizing.value = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
saveWidth()
}
isResizing.value = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
saveWidth();
};
let resizeAnimationFrame: number | null = null
let resizeAnimationFrame: number | null = null;
onMounted(() => {
document.addEventListener('mousemove', onResizeMove)
document.addEventListener('mouseup', onResizeEnd)
document.addEventListener('mousemove', onResizeMove);
document.addEventListener('mouseup', onResizeEnd);
watch(hovering, (value) => {
if (!closeSidenavRef.value) return;
if (value) {
const width = closeSidenavRef.value!.scrollWidth
closeSidenavRef.value!.style.width = `${width}px`
const width = closeSidenavRef.value.scrollWidth;
closeSidenavRef.value.style.width = `${width}px`;
} else {
closeSidenavRef.value!.style.width = '0'
closeSidenavRef.value.style.width = '0';
}
})
})
});
});
onUnmounted(() => {
document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', onResizeEnd)
})
document.removeEventListener('mousemove', onResizeMove);
document.removeEventListener('mouseup', onResizeEnd);
});
const hovering = ref(false)
const hovering = ref(false);
const navKind = computed(() => {
if (route.path === '/') return 'home'
if (route.path.startsWith('/agent/')) return 'agent'
return null
})
if (route.path === '/') return 'home';
if (route.path.startsWith('/agent/')) return 'agent';
return null;
});
const onFocusOut = (e: FocusEvent) => {
const isMovingOutside = sidenavRef.value && !sidenavRef.value.contains(e.relatedTarget as Node);
if (isMovingOutside) {
hovering.value = false;
}
};
</script>
<template>
<div class="relative">
<aside :class="[
'h-full max-w-fit bg-[var(--color-base)] overflow-hidden will-change-width text-[var(--color-subtle)] select-none',
<aside ref="sidenavRef" :class="[
'h-full max-w-fit bg-[var(--color-base)] overflow-hidden will-change-width text-[var(--color-muted)] select-none',
open ? 'w-full mr-2' : 'w-0 mr-0',
isResizing ? '' : 'transition-[width,margin] duration-250 ease-[cubic-bezier(0,0.55,0.45,1)]'
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="hovering = true"
@mouseleave="hovering = false">
@mouseleave="hovering = false" @focusin="hovering = true" @focusout="onFocusOut">
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
<div class="flex flex-col">
<div class="flex flex-col h-full max-h-full overflow-y-hidden">
<!-- Header -->
<div class="relative flex flex-row gap-2 justify-between items-center mb-1.5">
<SidenavHeader v-if="navKind === 'home'" v-model="hovering" />
<SidenavHeaderAgent v-else-if="navKind === 'agent'" v-model="hovering" />
<div class="flex items-center justify-end text-[var(--color-subtle)] gap-0.5">
<div class="flex items-center justify-end text-[var(--color-muted)] gap-0.5">
<div ref="closeSidenavRef" style="width: 0;"
:class="['flex-shrink-0 overflow-hidden rounded-lg transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center-right']">
<button aria-label="close sidebar" @click="closeSidebar" :class="[
'text-5 p-1.5 hover:bg-[var(--color-highlight)] bg-transparent transition-inherit',
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] scale-100 bg-transparent transition-inherit',
]">
<Icon name="mynaui:panel-left-close"
:class="['transition-inherit', hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" />
@@ -95,7 +107,7 @@ const navKind = computed(() => {
</div>
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
<NuxtLink aria-label="Start a new topic" :to="`/agent/${route.params.id}`" :class="[
'flex text-5 p-1.5 hover:bg-[var(--color-highlight)] bg-transparent text-inherit',
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] bg-transparent text-inherit',
]">
<Icon name="mynaui:book-plus" />
</NuxtLink>
@@ -104,15 +116,24 @@ const navKind = computed(() => {
</div>
<!-- Main Menu -->
<div class="max-h-full overflow-auto">
<div class="max-h-full h-full overflow-auto" style="scrollbar-width: thin;">
<SidenavNavHome v-if="navKind === 'home'" />
<SidenavNavAgent v-else-if="navKind === 'agent'" />
</div>
</div>
<!-- Theme Switcher -->
<div class="flex justify-end">
<ThemeSwitcher />
<div class="flex justify-between pt-2">
<div class="flex">
<button @click="toggleSettings()"
class="flex items-center justify-center h-7 w-7 cursor-pointer hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg transition-colors text-[var(--color-muted)] active:text-[var(--color-text)]">
<Icon name="mynaui:cog-four" class="text-4" />
</button>
</div>
<!-- Theme Switcher -->
<div class="flex justify-end gap-1">
<ThemeSwitcher />
</div>
</div>
</div>
+77
View File
@@ -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>
+15 -16
View File
@@ -1,36 +1,35 @@
<script setup lang="ts">
import type { DropdownItem } from '~/types/dropdown'
import type { DropdownItem } from '~/types/dropdown';
type Theme = 'light' | 'dark' | 'system'
type Theme = 'light' | 'dark' | 'system';
const colorMode = useColorMode()
const colorMode = useColorMode();
const themeOptions: DropdownItem[] = [
{ value: 'light', label: 'Light', icon: 'mynaui:sun' },
{ value: 'dark', label: 'Dark', icon: 'mynaui:moon' },
{ value: 'system', label: 'System', icon: 'mynaui:desktop' }
]
{ value: 'system', label: 'System', icon: 'mynaui:desktop' },
];
const currentOption = computed(() =>
themeOptions.find(option => option.value === colorMode.preference) || themeOptions[2]
)
const currentOption = computed(
() => themeOptions.find((option) => option.value === colorMode.preference) || themeOptions[2],
);
const isOpen = ref(false)
const isOpen = ref(false);
const selectTheme = (item: DropdownItem) => {
colorMode.preference = item.value as Theme
}
colorMode.preference = item.value as Theme;
};
</script>
<template>
<Dropdown class="relative" v-model="isOpen" @select="selectTheme" :items="themeOptions" verticality="asscending"
placement="right" width="140px">
<template #trigger="{ toggle, isOpen }">
<button aria-label="Open theme switcher" @click="toggle"
class="p-2 flex items-center justify-center rounded-lg hover:bg-[var(--color-highlight)] transition-colors group"
:class="isOpen ? 'bg-[var(--color-highlight)]' : 'bg-transparent'">
<Icon :name="currentOption!.icon!"
class="text-5 text-[var(--color-subtle)] group-hover:text-[var(--color-text)] transition-colors" />
<button aria-label="Open theme switcher" @click="toggle" :class="[isOpen ? 'bg-[var(--color-highlight)] hover:text-[var(--color-subtle)] focus-visible:text-[var(--color-subtle)]' : 'bg-transparent text-[var(--color-muted)]',
'h-7 w-7 flex items-center justify-center rounded-lg hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors active:text-[var(--color-text)]'
]">
<Icon :name="currentOption!.icon!" class="text-4" />
</button>
</template>
</Dropdown>
-68
View File
@@ -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
View File
@@ -1,107 +1,14 @@
import { ref } from 'vue'
import type { Agent } from '~~/types'
export const useAgents = async () => {
const { addTask, completeTask } = useTasks()
const appState = useAppState()
const triplit = useTriplitClient();
const fetchingAgents = ref(false);
const agents: Ref<Agent[] | null> = useState('agents', () => null);
const activeAgent = computed(() => {
if (agents.value === null) return;
if (!appState.activeAgentId.value) return;
const { results: agents } = await useQuery('agents', triplit, triplit.query('agents').Include('topics'));
const agent = agents.value.find(agent => agent.id === appState.activeAgentId.value);
return agent;
});
const getAgent = (id: string) => {
return agents.value?.find((a) => a.id === id);
};
const refreshAgents = async () => {
if (fetchingAgents.value) return;
fetchingAgents.value = true;
const { data, error } = await useFetch('/api/agents');
if (error.value) throw error;
agents.value = data.value!;
fetchingAgents.value = false;
}
if (agents.value === null) await refreshAgents();
const createAgent = async () => {
const taskHandle = addTask()
try {
const agent = await $fetch('/api/agents', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'New Agent',
systemPrompt: 'You are a helpful assistant.'
})
});
if (agent === undefined) throw new Error('Failed to create agent');
if (agents.value === null) agents.value = [];
agents.value.push(agent);
completeTask(taskHandle)
return agent;
} catch (error) {
console.error(error);
completeTask(taskHandle)
return;
}
}
let debounceTimeout: NodeJS.Timeout | null = null;
const updateAgent = async (id: string, data: Partial<Agent>) => {
if (agents.value === null) agents.value = [];
// update the local state always
agents.value = agents.value.map(agent => {
if (agent.id === id) return { ...agent, ...data };
return agent;
});
// falling edge debounce (when the user stops typing)
if (debounceTimeout !== null) clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(async () => {
const taskHandle = addTask()
try {
const agent = await $fetch(`/api/agents/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (agent === undefined) throw new Error('Failed to update agent');
const index = agents.value!.findIndex(agent => agent.id === id);
if (index === -1) throw new Error('Agent not found');
if (agents.value![index] === null) throw new Error('Agent not found');
agents.value![index] = agent;
completeTask(taskHandle)
return agent;
} catch (error) {
console.error(error);
completeTask(taskHandle)
return;
}
}, 500);
}
return { agents, createAgent, activeAgent, updateAgent };
}
return {
agents,
getAgent,
};
};
-90
View File
@@ -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
};
};
+66
View File
@@ -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,
};
};
+64
View File
@@ -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,
};
}
+232
View File
@@ -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,
};
}
+14 -14
View File
@@ -1,18 +1,18 @@
import { onMounted, onUnmounted } from 'vue'
import type { Ref } from 'vue'
import type { Ref } from 'vue';
import { onMounted, onUnmounted } from 'vue';
export const useClickOutside = (target: Ref<HTMLElement | null>, callback: () => void) => {
const onClick = (event: MouseEvent) => {
if (target.value && !target.value.contains(event.target as Node)) {
callback()
}
}
const onClick = (event: MouseEvent) => {
if (target.value && !target.value.contains(event.target as Node)) {
callback();
}
};
onMounted(() => {
document.addEventListener('click', onClick)
})
onMounted(() => {
document.addEventListener('click', onClick);
});
onUnmounted(() => {
document.removeEventListener('click', onClick)
})
}
onUnmounted(() => {
document.removeEventListener('click', onClick);
});
};
+12
View File
@@ -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,
};
});
}
+30 -18
View File
@@ -1,23 +1,35 @@
export const useKeyboardShortcuts = () => {
const { toggle: toggleSidebar } = useSidebar();
const { toggle: toggleSidebar } = useSidebar();
const { toggle: openSettings, open: isSettingsOpen } = useSettings();
const handleKeyDown = (event: KeyboardEvent) => {
// Ctrl+[ to collapse sidebar
if (event.ctrlKey && event.key === '[') {
event.preventDefault();
toggleSidebar();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
// Ctrl+[ to collapse sidebar
if (event.ctrlKey && event.key === '[') {
event.preventDefault();
toggleSidebar();
return;
}
onMounted(() => {
document.addEventListener('keydown', handleKeyDown);
});
if (event.ctrlKey && event.key === ',') {
event.preventDefault();
if (isSettingsOpen.value) {
return;
}
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown);
});
openSettings();
return;
}
};
return {
handleKeyDown
};
};
onMounted(() => {
document.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown);
});
return {
handleKeyDown,
};
};
+51
View File
@@ -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,
};
};
+24 -9
View File
@@ -1,13 +1,28 @@
export const useSettings = () => {
const open = useState<boolean>('settings:open', () => false)
const currentPage = useState<string>('settings:currentPage', () => 'page1')
const open = useState<boolean>('settings:open', () => false);
const currentPage = useState('settings-page', () => 'general');
const pageParams = useState('settings-params', () => [] as string[]);
const toggle = () => {
open.value = !open.value;
};
const setPage = (id: string, params?: string | string[]) => {
currentPage.value = id;
if (params) {
if (typeof params === 'string') {
params = [params];
}
pageParams.value = params;
} else {
pageParams.value = [];
}
};
const toggle = () => { open.value = !open.value }
const setPage = (page: string) => { currentPage.value = page }
const close = () => {
open.value = false
currentPage.value = 'page1'
}
open.value = false;
currentPage.value = 'page1';
};
return { open, currentPage, toggle, setPage, close }
}
return { open, currentPage, pageParams, toggle, setPage, close };
};
+36 -18
View File
@@ -1,31 +1,49 @@
export const useSidebar = () => {
const open = useState<boolean>('sidebar:open', () => true)
const open = useState<boolean>('sidebar:open', () => true);
const sidebarWidth = useState<number>('sidebar:width', () => {
return Number(useCookie('sidebar:width', { default: () => "226", maxAge: 60 * 60 * 24 * 30 }).value)
})
return Number(
useCookie('sidebar:width', {
default: () => '226',
maxAge: 60 * 60 * 24 * 30,
}).value,
);
});
// I still want the state to update when the cookie change, like it does for the theme cookies
// but I dont want to use the cookie value as the state value because then when we change the
// cookie value, we thrash the hell out of the cookie and gobble CPU cycles
watch(useCookie('sidebar:width'), (value) => {
console.log(value)
sidebarWidth.value = Number(value)
})
sidebarWidth.value = Number(value);
});
const toggle = () => { open.value = !open.value }
const close = () => { open.value = false }
const openSidebar = () => { open.value = true }
const toggle = () => {
open.value = !open.value;
};
const close = () => {
open.value = false;
};
const openSidebar = () => {
open.value = true;
};
const resize = (width: number) => {
const minWidth = 200
const maxWidth = 400
const clampedWidth = Math.max(minWidth, Math.min(maxWidth, width))
sidebarWidth.value = clampedWidth
}
const minWidth = 200;
const maxWidth = 400;
const clampedWidth = Math.max(minWidth, Math.min(maxWidth, width));
sidebarWidth.value = clampedWidth;
};
const saveWidth = () => {
useCookie('sidebar:width').value = sidebarWidth.value.toString()
}
useCookie('sidebar:width').value = sidebarWidth.value.toString();
};
return { open, toggle, close, openSidebar, sidebarWidth: readonly(sidebarWidth), resize, saveWidth }
}
return {
open,
toggle,
close,
openSidebar,
sidebarWidth: readonly(sidebarWidth),
resize,
saveWidth,
};
};
-21
View File
@@ -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 }
}
+14 -5
View File
@@ -1,12 +1,21 @@
export const useTheme = () => {
const accent = useCookie('accent', { default: () => 'violet', maxAge: 60 * 60 * 24 * 365 })
const neutral = useCookie('neutral', { default: () => 'zinc', maxAge: 60 * 60 * 24 * 365 })
const accent = useCookie('accent', {
default: () => 'violet',
maxAge: 60 * 60 * 24 * 365,
});
const neutral = useCookie('neutral', {
default: () => 'zinc',
maxAge: 60 * 60 * 24 * 365,
});
// disable hinting by default
const hinting = useCookie('hinting', { default: () => '0', maxAge: 60 * 60 * 24 * 365 })
const hinting = useCookie('hinting', {
default: () => '0',
maxAge: 60 * 60 * 24 * 365,
});
return {
accent,
neutral,
hinting,
}
}
};
};
-59
View File
@@ -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 }
}
+7 -6
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
const { open: sidebarOpen, openSidebar } = useSidebar()
useKeyboardShortcuts()
import SettingsDialog from '~/components/Settings/Dialog.vue';
const { open: sidebarOpen, openSidebar } = useSidebar();
useKeyboardShortcuts();
</script>
<template>
@@ -10,19 +12,18 @@ useKeyboardShortcuts()
<div class="h-14 flex items-center justify-between px-4">
<div class="flex items-center gap-2">
<button v-if="!sidebarOpen" @click="openSidebar"
class="p-2 rounded-lg text-[var(--color-text)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors">
class="flex p-2 rounded-lg text-[var(--color-text)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors">
<Icon class="text-5" name="mynaui:panel-left-open" />
</button>
</div>
<div class="flex items-center gap-2">
<!-- <div class="flex items-center gap-2">
<div id="primary-loader-target"></div>
</div>
</div> -->
</div>
<div class="flex-1 overflow-y-auto">
<slot />
</div>
</main>
<SettingsDialog />
<LoadingSpinner />
</template>
+16 -10
View File
@@ -1,13 +1,19 @@
export default defineNuxtRouteMiddleware(async (to) => {
const { loggedIn } = useAuth()
const { session, fetchSession } = useAuth();
// if authenticated, and on a signin/signup page, redirect to home page
if (to.path.toLowerCase().includes('/auth/') && loggedIn.value) {
return navigateTo('/')
}
if (!session.value) {
await fetchSession();
}
// If not authenticated, and not on a signin/signup page, redirect to login page
if (!loggedIn.value && !to.path.toLowerCase().includes('/auth/')) {
return navigateTo('/auth/login')
}
})
const loggedIn = computed(() => !!session.value);
// if authenticated, and on a signin/signup page, redirect to home page
if (to.path.toLowerCase().includes("/auth/") && loggedIn.value) {
return await navigateTo((to.query.to as string) ?? "/");
}
// If not authenticated, and not on a signin/signup page, redirect to login page
if (!loggedIn.value && !to.path.toLowerCase().includes("/auth/")) {
return await navigateTo(`/auth/login?to=${to.path}`);
}
});
+46 -25
View File
@@ -1,37 +1,58 @@
<script setup lang="ts">
const { createTopic, fetchingTopics } = await useTopics();
const { activeAgent } = await useAgents();
import type { ModelWithProvider } from '~/composables/useModels';
const creatingTopic = ref(false);
const route = useRoute();
const handleSubmit = async (message: string) => {
// create a new topic, and send the message to it
console.log('handleSubmit', message);
if (!activeAgent.value) return;
const { createTopic, sendMessage } = useChat(route.params.id as string);
const { getAgent } = await useAgents();
const { providers, unsubscribe: unsubscribeModels } = await useModels();
creatingTopic.value = true
try {
const newTopic = await createTopic('New Topic', activeAgent.value.id)
// Navigate to the new topic
await navigateTo(`/agent/${activeAgent.value.id}/topic/${newTopic.id}`)
} catch (error) {
console.error('Failed to create topic:', error)
} finally {
creatingTopic.value = false
const agent = computed(() => {
if (route.params.id === null || typeof route.params.id !== 'string') {
throw new Error('Invalid agent ID');
}
}
return getAgent(route.params.id)!;
});
const handleSubmit = async (message: string, model: ModelWithProvider | null) => {
if (!model) {
console.error('No model selected');
return;
}
const topic = await createTopic();
if (!topic) throw new Error('Failed to create topic');
sendMessage(message, topic, [], agent.value!, model.provider, model);
return navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
};
onUnmounted(() => {
unsubscribeModels?.()
});
</script>
<template>
<div class="flex h-full w-full pb-4 justify-center">
<div class="max-w-4xl h-full w-full flex flex-col">
<!-- chat pane -->
<div class="flex h-full flex-col gap-2 justify-end mb-28">
<h1 v-if="activeAgent" class="font-bold">{{ activeAgent.name }}</h1>
<p class="text-[var(--color-subtle)]">Select a topic to continue or create a new one</p>
<div class="h-full w-full">
<!-- chat pane -->
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
<div class="flex-grow w-full flex justify-center">
<div class="flex h-full max-w-4xl w-full flex-col gap-2 justify-end">
<h1 v-if="agent" class="font-bold">{{ agent.name }}</h1>
<p class="mb-28 text-[var(--color-muted)]">Select a topic to continue or create a new one</p>
</div>
</div>
<ChatInput @submit="handleSubmit" :loading="fetchingTopics" />
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center">
<div class="pb-4 w-full max-w-4xl bg-[var(--color-neutral)] rounded-t-2xl">
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" :agent="agent"
:providers="providers" @submit="handleSubmit"></ChatInput>
</div>
</div>
</div>
</div>
</template>
</template>
+33 -9
View File
@@ -1,21 +1,44 @@
<script setup lang="ts">
const { activeAgent: agent, updateAgent } = await useAgents();
const route = useRoute()
const { getAgent } = await useAgents();
const triplit = useTriplitClient();
const route = useRoute();
const agent = computed(() => {
if (route.params.id === null || typeof route.params.id !== 'string') {
throw new Error('Invalid agent ID');
}
return getAgent(route.params.id)!;
});
// if (agent.value === undefined) navigateTo('/');
const handleInput = (e: Event) => {
const handleInput = async (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.value.length === 0) {
if (target.value.trimStart().length === 0) {
return;
}
updateAgent(agent.value!.id, { name: target.value });
await triplit.update('agents', agent.value.id, { name: target.value });
};
const changeSystemPrompt = async (e: Event) => {
const target = e.target as HTMLTextAreaElement;
let value: string | undefined = target.value;
if (value.trimStart().length === 0) {
value = undefined;
}
await triplit.update('agents', agent.value.id, {
systemPrompt: target.value,
});
};
</script>
<template>
<div class="flex flex-col gap-4 px-14">
<div class="flex flex-col gap-4 px-14 w-full h-full">
<div class="flex items-center gap-4">
<div>
<img v-if="agent?.imageUrl" :src="agent.imageUrl" class="w-16 h-16 rounded-full object-cover" />
@@ -25,9 +48,10 @@ const handleInput = (e: Event) => {
class="placeholder:text-[var(--color-highlight)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-highlight-high)] text-12 p-0"
type="text" :value="agent?.name" />
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--color-subtle)]">Agent ID:</span>
<span class="text-sm font-semibold">{{ route.params.id }}</span>
<div class="flex items-center gap-2 w-full h-full mb-14">
<textarea placeholder="System Message..."
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-highlight)]"
:value="agent?.systemPrompt" @input="changeSystemPrompt"></textarea>
</div>
</div>
</template>
+93 -216
View File
@@ -1,235 +1,112 @@
<script setup lang="ts">
import type { Message } from '~~/types';
import type { Message } from '~/composables/useChat';
import type { ModelWithProvider } from '~/composables/useModels';
const triplit = useTriplitClient();
const chatPane = ref<HTMLElement | null>(null);
const route = useRoute();
const appState = useAppState();
const { activeTopic, topicsForActiveAgent } = await useTopics();
const { activeAgent } = await useAgents();
const { sendMessage } = useChat(route.params.id as string);
const { getAgent } = await useAgents();
const { providers, unsubscribe: unsubscribeModels } = await useModels();
const loading = ref(false);
const messages = useState<Message[]>('messages', () => []);
const generatingMessage = ref('');
// Fetch messages for this topic
if (activeTopic.value) {
const topicData = await useFetch(`/api/topics/${activeTopic.value.id}`);
if (topicData.error.value) {
console.error('Failed to load topic:', topicData.error.value);
} else if (topicData.data.value?.messages) {
messages.value = topicData.data.value.messages;
const agent = computed(() => {
if (route.params.id === null || typeof route.params.id !== 'string') {
throw new Error('Invalid agent ID');
}
}
const handleSubmit = async (message: string) => {
if (!activeTopic.value) {
console.error('No active topic');
return getAgent(route.params.id)!;
});
const topicQuery = computed(() =>
triplit
.query('topics')
.Where(['id', '=', route.params.topicId])
.Include('generations')
.Include('messages', (rel) =>
rel('messages')
.Include('generation')
.Include('parts', (rel) => rel('parts').Include('toolCall')),
)
.Limit(1)
);
const { results, unsubscribe: unsubscribeTopic } = await useQuery('topic', triplit, topicQuery);
const topic = computed(() => {
if (results.value?.length === 0) return null;
// copy messages to a mutable object and sort by createdAt
const messages = results!.value![0]!.messages.map((message) => ({
...message,
parts: message.parts.map((part) => ({
...part,
toolCall: part.toolCall ? { ...part.toolCall } : null,
})),
}));
messages.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
// for each message, sort parts by createdAt
messages.forEach((message) => {
message.parts = message.parts
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.filter((part) => part.content !== '' || part.toolCall !== null);
});
return { ...results.value![0]!, messages };
});
const activeGeneration = computed(() => {
if (topic.value === null) return null;
return topic.value?.generations?.find((generation) => generation.status === 'pending') ?? null;
});
const { scrollToBottom } = useAutoScroll(chatPane);
onMounted(() => {
scrollToBottom('instant');
});
const handleCancel = async () => {
await $fetch(`/api/chat/cancel/${activeGeneration.value!.id}`, {
method: 'POST',
});
};
const handleSubmit = async (message: string, model: ModelWithProvider | null) => {
if (!model) {
console.error('No model selected');
return;
}
loading.value = true;
try {
// Add user message to local state
const userMessage: Message = {
id: `temp_${Date.now()}`,
topicId: activeTopic.value.id,
userId: '',
content: message,
isUser: true,
regeneratedFromId: null,
isRegenerated: false,
editedAt: null,
createdAt: new Date() as any
};
messages.value.push(userMessage);
// Create generation
const generation = await $fetch(`/api/chat/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
topicId: activeTopic.value.id,
messages: messages.value
.filter(m => m.content)
.map(m => ({
type: m.isUser ? 'user' : 'agent',
message: m.content
}))
})
});
appState.startGeneration(generation.generationId);
// Stream the response
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
method: 'get',
responseType: 'stream',
});
const reader = response.pipeThrough(new TextDecoderStream()).getReader();
generatingMessage.value = '';
let hasError = false;
while (!hasError) {
const { done, value } = await reader.read();
if (done) break;
try {
const lines = value.split('\n').filter(line => line.trim());
for (const line of lines) {
const event = JSON.parse(line);
if (event.type === 'start') {
generatingMessage.value = '';
} else if (event.type === 'token') {
generatingMessage.value += event.data;
} else if (event.type === 'complete') {
// Add the completed message to the list
console.log(event, event.data);
if (event.data) {
messages.value.concat(event.data);
generatingMessage.value = '';
}
} else if (event.type === 'error') {
console.error('Generation error:', event.data);
hasError = true;
}
}
} catch (parseError) {
console.error('Failed to parse event:', parseError);
}
}
appState.endGeneration();
} catch (error) {
console.error('Failed to submit message:', error);
} finally {
loading.value = false;
}
await sendMessage(message, topic.value!, topic.value!.messages as unknown as Message[], agent.value!, model.provider, model);
scrollToBottom('instant');
};
const handleRegenerate = async (messageId: string) => {
if (!activeTopic.value) return;
// Find the user message before this one to regenerate context
const messageIndex = messages.value.findIndex(m => m.id === messageId);
if (messageIndex === -1) return;
const previousUserMessage = messages.value[messageIndex - 1];
if (!previousUserMessage) return;
loading.value = true;
try {
// Create generation with previous user message
const generation = await $fetch(`/api/chat/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
topicId: activeTopic.value.id,
regeneratesFrom: messageId,
messages: messages.value.slice(0, messageIndex)
.map(m => ({
type: m.isUser ? 'user' : 'agent',
message: m.content
}))
})
});
appState.startGeneration(generation.generationId);
// Stream the response
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
method: 'get',
responseType: 'stream',
});
const reader = response.pipeThrough(new TextDecoderStream()).getReader();
generatingMessage.value = '';
let hasError = false;
while (!hasError) {
const { done, value } = await reader.read();
if (done) break;
try {
const lines = value.split('\n').filter(line => line.trim());
for (const line of lines) {
const event = JSON.parse(line);
if (event.type === 'start') {
generatingMessage.value = '';
} else if (event.type === 'token') {
generatingMessage.value += event.data;
} else if (event.type === 'complete') {
// Add the new regenerated message
if (event.data) {
messages.value.push(event.data);
generatingMessage.value = '';
}
} else if (event.type === 'error') {
console.error('Generation error:', event.data);
hasError = true;
}
}
} catch (parseError) {
console.error('Failed to parse event:', parseError);
}
}
appState.endGeneration();
} catch (error) {
console.error('Failed to regenerate:', error);
} finally {
loading.value = false;
}
};
const handleSelectRegeneration = (messageId: string) => {
const index = messages.value.findIndex(m => m.id === messageId);
if (index === -1) return;
// In a real app, you'd update the UI to show the selected version
// For now, just highlight it
console.log('Selected regeneration:', messageId);
};
const handleDelete = (messageId: string) => {
const index = messages.value.findIndex(m => m.id === messageId);
if (index === -1) return;
messages.value.splice(index, 1);
};
onUnmounted(() => {
unsubscribeTopic?.();
unsubscribeModels?.();
});
</script>
<template>
<div class="flex h-full w-full pb-4 justify-center">
<div class="max-w-4xl h-full w-full flex flex-col">
<div class="flex h-full flex-col gap-6 overflow-y-auto p-4">
<Message v-for="msg in messages" :key="msg.id" :message="msg" @regenerate="handleRegenerate"
@select="handleSelectRegeneration" @delete="handleDelete" />
<div v-if="generatingMessage" class="flex gap-3">
<div
class="flex-shrink-0 w-8 h-8 rounded-lg bg-[var(--color-neutral)] border border-[var(--color-highlight)] flex items-center justify-center">
<Icon name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
</div>
<div class="flex-1">
<p class="text-sm font-medium text-[var(--color-neutral)]">Agent</p>
<p class="text-sm text-[var(--color-text)]">{{ generatingMessage }}</p>
</div>
<div class="h-full w-full">
<!-- chat pane -->
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
<div class="flex-grow w-full flex justify-center">
<div class="max-w-4xl w-full flex flex-col gap-2 pb-9"
v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
<Message v-for="message in topic.messages" :key="message.id" :message="message" />
</div>
<p v-if="messages.length === 0 && !generatingMessage" class="text-center text-[var(--color-subtle)]">
No messages yet. Start the conversation!
</p>
</div>
<ChatInput @submit="handleSubmit" :loading="loading" />
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center">
<div class="pb-4 w-full max-w-4xl bg-[var(--color-neutral)] rounded-t-2xl">
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
:loading="activeGeneration !== null" :agent="agent" :providers="providers"
@submit="handleSubmit" @cancel="handleCancel"></ChatInput>
</div>
</div>
</div>
</div>
</template>
</template>
+65 -53
View File
@@ -1,47 +1,46 @@
<script setup lang="ts">
import { authClient } from '~~/lib/auth-client';
import { deriveKey } from '~/utils/crypto';
definePageMeta({
layout: 'auth',
})
});
const { signIn, session, fetchSession, authClient } = useAuth();
if (session.value !== null) {
navigateTo("/");
}
const to = useRoute().query.to as string | undefined;
const form = reactive({
email: "",
password: "",
email: '',
password: '',
});
const loading = ref(false);
let emailInputEl = ref<HTMLInputElement | null>(null);
let passwordInputEl = ref<HTMLInputElement | null>(null);
const emailInputEl = ref<HTMLInputElement | null>(null);
const passwordInputEl = ref<HTMLInputElement | null>(null);
let tempForm = {
email: "",
password: "",
}
const tempForm = {
email: '',
password: '',
};
// prevent text fields from clearing on hydration
onBeforeMount(() => {
tempForm.email = (document.getElementById("email") as HTMLInputElement)?.value ?? "";
tempForm.password = (document.getElementById("password") as HTMLInputElement)?.value ?? "";
})
tempForm.email = (document.getElementById('email') as HTMLInputElement)?.value ?? '';
tempForm.password = (document.getElementById('password') as HTMLInputElement)?.value ?? '';
});
let hydrated = ref(false);
const hydrated = ref(false);
onMounted(() => {
form.email = tempForm.email;
form.password = tempForm.password;
hydrated.value = true;
emailInputEl.value!.addEventListener("input", () => {
emailInputEl.value!.setCustomValidity("");
emailInputEl.value!.addEventListener('input', () => {
emailInputEl.value!.setCustomValidity('');
});
passwordInputEl.value!.addEventListener("input", () => {
passwordInputEl.value!.setCustomValidity("");
passwordInputEl.value!.addEventListener('input', () => {
passwordInputEl.value!.setCustomValidity('');
});
});
@@ -57,40 +56,52 @@ const submit = async () => {
loading.value = true;
await signIn.email({
const { data, error } = await authClient.signIn.email({
email: form.email,
password: form.password,
}, {
onSuccess: async () => {
await fetchSession();
navigateTo("/")
},
onError: (ctx) => {
const error = ctx.error.code as keyof typeof authClient.$ERROR_CODES;
// TODO: i18n
// ref https://www.better-auth.com/docs/concepts/client#error-codes
switch (error) {
case "INVALID_PASSWORD":
passwordInputEl.value!.setCustomValidity(ctx.error.message);
passwordInputEl.value!.reportValidity();
break;
case "ACCOUNT_NOT_FOUND":
case "USER_NOT_FOUND":
case "USER_EMAIL_NOT_FOUND":
emailInputEl.value!.setCustomValidity(ctx.error.message);
emailInputEl.value!.reportValidity();
break;
default:
console.log(ctx.error);
alert(`Something went wrong. ${ctx.error.message}`);
break;
}
}
});
loading.value = false;
}
if (error) {
const errorCode = error.code! as keyof typeof authClient.$ERROR_CODES;
// TODO: i18n
// ref https://www.better-auth.com/docs/concepts/client#error-codes
switch (errorCode) {
case 'INVALID_PASSWORD':
passwordInputEl.value!.setCustomValidity(error.message!);
passwordInputEl.value!.reportValidity();
break;
case 'ACCOUNT_NOT_FOUND':
case 'USER_NOT_FOUND':
case 'USER_EMAIL_NOT_FOUND':
emailInputEl.value!.setCustomValidity(error.message!);
emailInputEl.value!.reportValidity();
break;
default:
console.log(error);
alert(`Something went wrong. ${error.message}`);
break;
}
return;
}
const key = await deriveKey(form.password, data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key));
// force a session refetch
clearNuxtData();
// success
const triplit = useTriplitClient();
if ('startSession' in triplit) {
await triplit.startSession(data.token);
}
return navigateTo(to ?? '/');
};
</script>
<template>
@@ -103,9 +114,10 @@ const submit = async () => {
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
autocomplete="current-password" id="password" v-model="form.password" />
<button :disabled="!hydrated" class="accent" type="submit">
<Icon v-if="loading" width="24" name="svg-spinners:90-ring-with-bg" />
<Icon v-if="loading" class="text-6" name="svg-spinners:90-ring-with-bg" />
<span v-else>Login</span>
</button>
</form>
<p class="text-center">Dont have an account? <a href="/auth/register">Register</a></p>
<p class="text-center">Dont have an account? <a
:href="to ? `/auth/register?to=${to}` : '/auth/register'">Register</a></p>
</template>
+83 -68
View File
@@ -1,47 +1,51 @@
<script setup lang="ts">
import { authClient } from '~~/lib/auth-client';
import { deriveKey } from '~/utils/crypto';
definePageMeta({
layout: 'auth',
})
});
const { signUp, session, fetchSession, authClient } = useAuth();
const { session } = await useAuth();
const to = useRoute().query.to as string | undefined;
if (session.value !== null) {
navigateTo("/");
await navigateTo(to ?? '/');
}
if (import.meta.server) {
if (process.env.DISABLE_SIGNUP?.toLowerCase() === "true" || process.env.DISABLE_SIGNUP === "1") {
navigateTo("/auth/login")
if (process.env.DISABLE_SIGNUP?.toLowerCase() === 'true' || process.env.DISABLE_SIGNUP === '1') {
await navigateTo(to ? `/auth/login?to=${to}` : '/auth/login');
}
}
const form = reactive({
name: "",
email: "",
password: "",
confirmPassword: "",
name: '',
email: '',
password: '',
confirmPassword: '',
});
const loading = ref(false);
let nameInputEl = ref<HTMLInputElement | null>(null);
let emailInputEl = ref<HTMLInputElement | null>(null);
let passwordInputEl = ref<HTMLInputElement | null>(null);
let confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
const nameInputEl = ref<HTMLInputElement | null>(null);
const emailInputEl = ref<HTMLInputElement | null>(null);
const passwordInputEl = ref<HTMLInputElement | null>(null);
const confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
let tempForm = {
name: "",
email: "",
password: "",
confirmPassword: "",
}
const tempForm = {
name: '',
email: '',
password: '',
confirmPassword: '',
};
// prevent text fields from clearing on hydration
onBeforeMount(() => {
tempForm.name = (document.getElementById("name") as HTMLInputElement)?.value ?? "";
tempForm.email = (document.getElementById("email") as HTMLInputElement)?.value ?? "";
tempForm.password = (document.getElementById("password") as HTMLInputElement)?.value ?? "";
tempForm.confirmPassword = (document.getElementById("confirmPassword") as HTMLInputElement)?.value ?? "";
})
tempForm.name = (document.getElementById('name') as HTMLInputElement)?.value ?? '';
tempForm.email = (document.getElementById('email') as HTMLInputElement)?.value ?? '';
tempForm.password = (document.getElementById('password') as HTMLInputElement)?.value ?? '';
tempForm.confirmPassword = (document.getElementById('confirmPassword') as HTMLInputElement)?.value ?? '';
});
const hydrated = ref(false);
@@ -52,25 +56,24 @@ onMounted(() => {
form.confirmPassword = tempForm.confirmPassword;
hydrated.value = true;
nameInputEl.value!.addEventListener("input", () => {
nameInputEl.value!.setCustomValidity("");
nameInputEl.value!.addEventListener('input', () => {
nameInputEl.value!.setCustomValidity('');
});
emailInputEl.value!.addEventListener("input", () => {
emailInputEl.value!.setCustomValidity("");
emailInputEl.value!.addEventListener('input', () => {
emailInputEl.value!.setCustomValidity('');
});
passwordInputEl.value!.addEventListener("input", () => {
passwordInputEl.value!.setCustomValidity("");
passwordInputEl.value!.addEventListener('input', () => {
passwordInputEl.value!.setCustomValidity('');
});
confirmPasswordInputEl.value!.addEventListener("input", () => {
confirmPasswordInputEl.value!.addEventListener('input', () => {
if (passwordInputEl.value!.value !== confirmPasswordInputEl.value!.value) {
confirmPasswordInputEl.value!.setCustomValidity("Passwords do not match");
confirmPasswordInputEl.value!.setCustomValidity('Passwords do not match');
confirmPasswordInputEl.value!.reportValidity();
} else {
confirmPasswordInputEl.value!.setCustomValidity("");
confirmPasswordInputEl.value!.setCustomValidity('');
}
});
});
@@ -86,50 +89,62 @@ const submit = async () => {
}
if (form.password !== form.confirmPassword) {
alert("Passwords do not match")
alert('Passwords do not match');
return;
}
loading.value = true;
await signUp.email({
const { data, error } = await authClient.signUp.email({
name: form.name,
email: form.email,
password: form.password,
}, {
onSuccess: async () => {
await fetchSession();
navigateTo("/")
},
onError: (ctx) => {
const error = ctx.error.code as keyof typeof authClient.$ERROR_CODES;
// TODO: i18n
// ref https://www.better-auth.com/docs/concepts/client#error-codes
switch (error) {
case "USER_ALREADY_EXISTS":
case "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL":
emailInputEl.value!.setCustomValidity("Account with this email already exists");
emailInputEl.value!.reportValidity();
break;
case "INVALID_EMAIL":
emailInputEl.value!.setCustomValidity(ctx.error.message);
emailInputEl.value!.reportValidity();
break;
case "INVALID_PASSWORD":
passwordInputEl.value!.setCustomValidity(ctx.error.message);
passwordInputEl.value!.reportValidity();
break;
default:
console.log(ctx.error);
alert("Something went wrong")
break;
}
}
});
loading.value = false;
}
if (error) {
const errorCode = error.code! as keyof typeof authClient.$ERROR_CODES;
// TODO: i18n
// ref https://www.better-auth.com/docs/concepts/client#error-codes
switch (errorCode) {
case 'USER_ALREADY_EXISTS':
case 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL':
emailInputEl.value!.setCustomValidity('Account with this email already exists');
emailInputEl.value!.reportValidity();
break;
case 'INVALID_EMAIL':
emailInputEl.value!.setCustomValidity(error.message!);
emailInputEl.value!.reportValidity();
break;
case 'INVALID_PASSWORD':
passwordInputEl.value!.setCustomValidity(error.message!);
passwordInputEl.value!.reportValidity();
break;
default:
console.log(error);
alert('Something went wrong: ' + error.message);
break;
}
return;
}
const key = await deriveKey(form.password, data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key));
// force a session refetch
clearNuxtData();
// success
const triplit = useTriplitClient();
if ('startSession' in triplit) {
await triplit.startSession(data.token!);
}
return navigateTo(to ?? '/');
};
</script>
<template>
@@ -151,5 +166,5 @@ const submit = async () => {
<span v-else>Register</span>
</button>
</form>
<p class="text-center">Already have an account? <a href="/auth/login">Login</a></p>
<p class="text-center">Already have an account? <a :href="to ? `/auth/login?to=${to}` : '/auth/login'">Login</a></p>
</template>
+62 -40
View File
@@ -1,44 +1,40 @@
<script setup lang="ts">
const { user } = useAuth();
if (user.value === null) {
navigateTo("/auth/login");
}
const { agents } = await useAgents();
const taglines = {
morning: [
"crush your goals before breakfast",
"turn your productivity to 11",
"start your day like a boss",
"make it happen before noon",
"rise and grind, repeat",
"morning MVP, all day",
"fuel your fire early",
"own the first half of your day"
'crush your goals before breakfast',
'turn your productivity to 11',
'start your day like a boss',
'make it happen before noon',
'rise and grind, repeat',
'morning MVP, all day',
'fuel your fire early',
'own the first half of your day',
],
afternoon: [
"afternoon focus session",
"making afternoon moves",
"steady progress continues",
"keeping the flow going",
"afternoon productivity boost",
"momentum is building",
"making it happen today",
"afternoon hustle mode"
'afternoon focus session',
'making afternoon moves',
'steady progress continues',
'keeping the flow going',
'afternoon productivity boost',
'momentum is building',
'making it happen today',
'afternoon hustle mode',
],
evening: [
"finish strong today",
"end on a high note",
"wrap it up like a pro",
"leave it all on the field",
'finish strong today',
'end on a high note',
'wrap it up like a pro',
'leave it all on the field',
"tomorrow's success starts tonight",
"last call for wins",
"close it out like a champion",
"seal the deal before bed"
]
}
'last call for wins',
'close it out like a champion',
'seal the deal before bed',
],
};
const animatedText = ref("");
const animatedText = ref('');
const currentTaglineIndex = ref(0);
const isDeleting = ref(false);
@@ -55,7 +51,7 @@ const typeWriter = (time: 'morning' | 'afternoon' | 'evening') => {
}
} else {
animatedText.value = currentText.substring(0, animatedText.value.length - 1);
if (animatedText.value === "") {
if (animatedText.value === '') {
isDeleting.value = false;
currentTaglineIndex.value = (currentTaglineIndex.value + 1) % currentTaglines.length;
}
@@ -69,22 +65,23 @@ const typeWriter = (time: 'morning' | 'afternoon' | 'evening') => {
setTimeout(() => typeWriter(time), speed);
};
const handleChatSubmit = (message: string) => {
console.log('Message submitted:', message);
const handleChatSubmit = async (message: string, _model: unknown) => {
console.log('Message submitted:', message, agents);
await navigateTo(`/agent/${agents.value![0]!.id}`);
// TODO: Implement chat functionality
};
onMounted(() => {
let time: 'morning' | 'afternoon' | 'evening' = "morning";
let time: 'morning' | 'afternoon' | 'evening' = 'morning';
const now = new Date();
const hours = now.getHours();
if (hours < 12) {
time = "morning";
time = 'morning';
} else if (hours < 22) {
time = "afternoon";
time = 'afternoon';
} else {
time = "evening";
time = 'evening';
}
// randomly select a tagline
@@ -95,9 +92,34 @@ onMounted(() => {
<template>
<div class="flex flex-col items-center pt-12 px-4 h-full gap-12">
<h1 class="text-center">{{ animatedText }}<span class="cursor">&nbsp;</span></h1>
<h1 class="text-center text-3xl font-semibold">{{ animatedText }}<span class="cursor">&nbsp;</span></h1>
<div class="max-w-4xl h-full w-full">
<ChatInput @submit="handleChatSubmit" />
<!-- TODO: view transitions have caused me issues with the page flashing with no content (so just a black or white screen depending on the theme) so I have disabled them for now. -->
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" @submit="handleChatSubmit" />
</div>
</div>
</template>
<style>
.cursor {
display: inline-block;
width: 1rem;
height: 0.9em;
background-color: currentColor;
animation: blink 1s step-end infinite;
vertical-align: middle;
margin-left: 0.125rem;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
</style>
+22 -10
View File
@@ -1,10 +1,22 @@
export default defineNuxtPlugin(async (nuxtApp) => {
if (!nuxtApp.payload.serverRendered) {
await useAuth().fetchSession()
} else if (Boolean(nuxtApp.payload.prerenderedAt) || Boolean(nuxtApp.payload.isCached)) {
// To avoid hydration mismatch
nuxtApp.hook('app:mounted', async () => {
await useAuth().fetchSession()
})
}
})
export default defineNuxtPlugin({
name: 'better-auth-triplit',
enforce: 'pre',
async setup(nuxtApp) {
if (import.meta.client) {
const triplit = useTriplitClient();
const { session, fetchSession } = useAuth();
nuxtApp.hook('app:mounted', async () => {
if (!session.value) {
await fetchSession();
}
if (!session.value) return;
if ('startSession' in triplit) {
await triplit.startSession(session.value.token);
}
});
}
},
});
+18 -4
View File
@@ -2,10 +2,24 @@ export default defineNuxtPlugin({
name: 'better-auth-fetch-plugin',
enforce: 'pre',
async setup(nuxtApp) {
const triplit = useTriplitClient();
// Flag if request is cached
nuxtApp.payload.isCached = Boolean(useRequestEvent()?.context.cache)
nuxtApp.payload.isCached = Boolean(useRequestEvent()?.context.cache);
if (nuxtApp.payload.serverRendered && !nuxtApp.payload.prerenderedAt && !nuxtApp.payload.isCached) {
await useAuth().fetchSession()
const { session, fetchSession } = useAuth();
if (!session.value) {
await fetchSession();
}
if (!session.value) return;
if ('updateOptions' in triplit) {
triplit.updateOptions({
token: session.value.token,
});
}
}
}
})
},
});
+18
View File
@@ -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,
}
}
})
-34
View File
@@ -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 }
);
});
-14
View File
@@ -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);
}
});
+1 -1
View File
@@ -5,4 +5,4 @@ export interface DropdownItem {
value?: string | number;
disabled?: boolean;
divider?: boolean;
}
}
+10
View File
@@ -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'> };
+7
View File
@@ -0,0 +1,7 @@
export interface SettingPage {
id: string;
label: string | Ref<string> | ComputedRef<string>;
icon: string;
component: Component;
sidebar?: Component;
}
+95
View File
@@ -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.");
}
}
+20
View File
@@ -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);
}
+33
View File
@@ -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' };
}
+832 -276
View File
File diff suppressed because it is too large Load Diff
-93
View File
@@ -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],
}),
}));
-7
View File
@@ -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! });
-61
View File
@@ -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
View File
@@ -1,29 +1,25 @@
name: veridian-development
services:
postgresql:
image: pgvector/pgvector:pg17
container_name: veridian-postgres
command: postgres -c wal_level=logical
triplit:
image: aspencloud/triplit-server:latest
container_name: veridian-triplit
ports:
- "5432:5432"
- "8080:6543"
volumes:
- "data:/var/lib/postgresql/data"
- "triplit_data:/data"
environment:
- "POSTGRES_DB=${VERIDIAN_DB_NAME}"
- "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
env_file:
- .env
LOCAL_DATABASE_URL: /data/triplit.db
NODE_OPTIONS: --max-old-space-size=4096
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
test: ["CMD", "curl", "-f", "http://localhost:6543/healthcheck"]
interval: 10s
timeout: 5s
retries: 5
restart: always
networks:
- veridian-network
volumes:
data:
triplit_data:
driver: local
networks:
+9
View File
@@ -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.
-11
View File
@@ -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!,
},
});
+38
View File
@@ -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);
+3
View File
@@ -0,0 +1,3 @@
import { createAuthClient } from 'better-auth/vue';
export const authClient = createAuthClient({});
+25 -12
View File
@@ -1,19 +1,32 @@
import { betterAuth } from "better-auth/minimal";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import * as schema from "~~/db/schema";
import { useDrizzle } from "~~/server/utils/drizzle";
import { triplitAdapter } from '@daveyplate/better-auth-triplit';
import { HttpClient } from '@triplit/client';
import { betterAuth } from 'better-auth/minimal';
import { schema } from '../triplit/schema';
const httpClient = new HttpClient({
schema,
serverUrl: process.env.NUXT_PUBLIC_TRIPLIT_URL,
token: process.env.TRIPLIT_SERVICE_TOKEN,
});
export const auth = betterAuth({
database: drizzleAdapter(useDrizzle(), {
provider: "pg",
schema: {
...schema
}
database: triplitAdapter({
httpClient,
secretKey: process.env.BETTER_AUTH_SECRET,
}),
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60,
},
},
trustedOrigins: [process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000'],
emailAndPassword: {
// if DISABLE_LOCAL_AUTH is set to "1" or "true" then local auth is disabled
enabled: process.env.DISABLE_LOCAL_AUTH?.toLowerCase() !== "true" && process.env.DISABLE_LOCAL_AUTH !== "1",
disableSignUp: process.env.DISABLE_SIGNUP?.toLowerCase() === "true" || process.env.DISABLE_SIGNUP === "1",
enabled: process.env.DISABLE_LOCAL_AUTH?.toLowerCase() !== 'true' && process.env.DISABLE_LOCAL_AUTH !== '1',
disableSignUp: process.env.DISABLE_SIGNUP?.toLowerCase() === 'true' || process.env.DISABLE_SIGNUP === '1',
},
})
});
+40 -11
View File
@@ -6,26 +6,49 @@ export default defineNuxtConfig({
head: {
title: 'Veridian',
htmlAttrs: {
lang: 'en'
lang: 'en',
},
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: 'A chat-based AI assistant for your LLMs.' },
{ name: 'keywords', content: 'LLM, AI, Chat, Assistant, Agent, LLMs, OpenAI, GPT, GPT-3, GPT-4, Claude, ChatGPT, Whisper, Bard, Bing, Anthropic, DeepAI, Dolly, StableLM, Vicuna, Llama, Alpaca, ChatGLM, MOSS, MPT, Codex, Codex2, Codex 3, Codex 4, Falcon, Flan-T5, T5, Llama2, LLaMA, LLaMA2, StableLM, OpenAssistant, OpenChat' }
]
}
{
name: 'description',
content: 'A chat-based AI assistant for your LLMs.',
},
{
name: 'keywords',
content:
'LLM, AI, Chat, Assistant, Agent, LLMs, OpenAI, GPT, GPT-3, GPT-4, Claude, ChatGPT, Whisper, Bard, Bing, Anthropic, DeepAI, Dolly, StableLM, Vicuna, Llama, Alpaca, ChatGLM, MOSS, MPT, Codex, Codex2, Codex 3, Codex 4, Falcon, Flan-T5, T5, Llama2, LLaMA, LLaMA2, StableLM, OpenAssistant, OpenChat',
},
],
},
},
vite: {
server: {
allowedHosts: true,
}
},
},
modules: ['@nuxt/hints', '@nuxt/icon', '@unocss/nuxt', '@nuxtjs/color-mode'],
// bizarely, when I enable this, it adds an 8 second or so lag when I navigate to a different page occasionally
// experimental: {
// viewTransition: true,
// },
features: {
inlineStyles: true,
modules: ['@nuxt/hints', '@nuxt/icon', '@unocss/nuxt', '@nuxtjs/color-mode', 'triplit-nuxt', 'nuxt-shiki'],
shiki: {
bundledThemes: ['vitesse-dark', 'vitesse-light'],
bundledLangs: ['js', 'jsx', 'json', 'ts', 'tsx', 'vue', 'css', 'html', 'bash', 'md', 'mdc', 'yaml', 'py'],
defaultTheme: 'vitesse-dark',
},
triplit: {
schema_path: './triplit/schema.ts',
serverUrl: process.env.NUXT_PUBLIC_TRIPLIT_URL,
token: process.env.NUXT_TRIPLIT_ANON_TOKEN,
storage: 'memory',
// we will connect automatically in the auth plugin when we call startSession
autoConnect: false,
},
colorMode: {
@@ -34,7 +57,13 @@ export default defineNuxtConfig({
storage: 'cookie',
},
devtools: { enabled: true },
devtools: {
enabled: true,
timeline: {
enabled: true,
},
},
compatibilityDate: '2025-07-15',
})
});
+37 -11
View File
@@ -4,31 +4,57 @@
"private": true,
"scripts": {
"build": "nuxt build",
"triplit": "triplit dev -s sqlite",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@ai-sdk/vue": "^3.0.48",
"@daveyplate/better-auth-triplit": "^0.2.2",
"@iconify-json/mynaui": "^1.2.17",
"@nuxt/hints": "1.0.0-alpha.5",
"@nuxt/icon": "2.2.0",
"@nuxtjs/color-mode": "4.0.0",
"better-auth": "^1.4.10",
"@openrouter/ai-sdk-provider": "^2.1.1",
"@triplit/client": "^1.0.50",
"@unocss/reset": "^66.6.0",
"ai": "^6.0.48",
"better-auth": "^1.4.17",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"glob": "^13.0.0",
"nanoid": "^5.1.6",
"nuxt": "^4.2.2",
"pg": "^8.16.3",
"uuidv7": "^1.1.0",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
"nuxt": "4.2.2",
"nuxt-shiki": "0.3.2",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"triplit-nuxt": "0.3.1-prerelease.5",
"unified": "^11.0.5",
"vue": "^3.5.27",
"vue-router": "^4.6.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/pg": "^8.16.0",
"@unocss/nuxt": "^66.5.12",
"drizzle-kit": "^0.31.8",
"@biomejs/biome": "2.3.13",
"@iconify-json/logos": "^1.2.10",
"@iconify-json/svg-spinners": "^1.2.4",
"@triplit/cli": "^1.0.61",
"@types/jsonwebtoken": "^9.0.10",
"@unocss/nuxt": "^66.6.0",
"jsonwebtoken": "^9.0.3",
"tsx": "^4.21.0",
"unocss": "^66.5.12"
"unocss": "^66.6.0"
},
"trustedDependencies": [
"@parcel/watcher",
"core-js",
"esbuild",
"unrs-resolver"
],
"patchedDependencies": {
"@triplit/db@1.1.10": "patches/@triplit%2Fdb@1.1.10.patch",
"@triplit/client@1.0.50": "patches/@triplit%2Fclient@1.0.50.patch"
}
}
+169
View File
@@ -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://') &&
+15
View File
@@ -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) {
-29
View File
@@ -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;
});
-14
View File
@@ -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;
});
-17
View File
@@ -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;
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { auth } from "~~/lib/auth";
import { auth } from '~~/lib/auth';
export default defineEventHandler((event) => {
return auth.handler(toWebRequest(event));
});
});
@@ -0,0 +1,27 @@
import { httpClient } from '~~/server/lib/triplit';
import { cancelPendingGeneration } from '~~/server/utils/generations';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const { generationId } = event.context.params!;
const success = cancelPendingGeneration(generationId!);
if (!success) {
const generation = await httpClient.fetchOne(httpClient.query('generations').Where('id', '=', generationId!));
if (generation !== null && generation.status === 'pending') {
await httpClient.update('generations', generationId!, {
status: 'cancelled',
});
return;
}
throw createError({
statusCode: 400,
message: 'Generation not found or already completed',
});
}
return;
});
+639 -21
View File
@@ -1,40 +1,658 @@
import { protectRoute } from '~~/server/utils/auth';
import { createPendingGeneration } from '~~/server/utils/generation';
import type { GenerateRequestBody } from '~~/server/types/chat';
import { createOpenRouter, type OpenRouterProvider } from '@openrouter/ai-sdk-provider';
import type { Entity } from '@triplit/client';
import { type ModelMessage, modelMessageSchema, streamText, tool } from 'ai';
import { promises as fs } from 'fs';
import { glob } from 'glob';
import { nanoid } from 'nanoid';
import path from 'path';
import * as z from 'zod';
import { httpClient } from '~~/server/lib/triplit';
import { addPendingGeneration, completeGeneration } from '~~/server/utils/generations';
import type { schema } from '~~/triplit/schema';
export const messagesSchema = z.array(modelMessageSchema);
// quick access
// const ACTIVE_MODEL_ID = 'openrouter/free';
// const ACTIVE_MODEL_ID = 'x-ai/grok-4.1-fast';
const ACTIVE_MODEL_ID = 'google/gemini-3-flash-preview';
// const ACTIVE_MODEL_ID = 'arcee-ai/trinity-mini:free';
type ModelGateway = OpenRouterProvider;
export default defineEventHandler(async (event) => {
await protectRoute(event);
const body = await readBody(event) as GenerateRequestBody;
const { topicId, messages, regeneratesFrom } = body;
const result = await readValidatedBody(event, (body) =>
z
.object({
messages: messagesSchema.min(1),
topicId: z.string(),
model: z.object({
providerId: z.string(),
modelId: z.string(),
args: z.any(),
}),
providerApiKey: z.string().optional(),
})
.safeParse(body),
);
if (!topicId || !messages) {
if (!result.success) {
throw createError({
statusCode: 400,
statusMessage: 'Missing required fields: topicId and messages'
message: result.error.issues[0]!.message,
});
}
if (messages.length === 0) {
const userId = event.context.user!.id;
const { messages, topicId, model: { modelId, providerId }, providerApiKey } = result.data;
const provider = await httpClient.fetchOne(httpClient.query('providers').Where('id', '=', providerId));
if (provider === null || provider.userId !== userId) {
throw createError({
statusCode: 400,
statusMessage: 'Messages array cannot be empty'
message: 'Invalid provider',
});
}
const model = await httpClient.fetchOne(httpClient.query('models').Where('id', '=', modelId));
if (model === null || model.providerId !== model.providerId || model.userId !== userId) {
throw createError({
statusCode: 400,
message: 'Invalid model',
});
}
const existingPendingGenerations = await httpClient.fetchOne(
httpClient.query('generations').Where('topicId', '=', topicId).Where('status', '=', 'pending'),
);
if (existingPendingGenerations !== null) {
throw createError({
statusCode: 400,
message: 'There cannot be more than one active generation per topic',
});
}
let gateway: ModelGateway;
switch (provider.type) {
case 'openrouter': {
if (providerApiKey === undefined) {
throw createError({
statusCode: 400,
message: 'OpenRouter provider requires an API key',
});
}
gateway = createOpenRouter({
apiKey: providerApiKey,
headers: {
'HTTP-Referer': 'https://localhost:3000',
'X-Title': 'Veridian',
},
});
break;
}
default:
throw new Error(`Unknown provider type: ${provider.type}`);
}
const generationId = nanoid();
const message = await httpClient.insert('messages', {
topicId,
generationId,
content: '',
role: 'assistant',
});
await httpClient.insert('generations', {
id: generationId,
topicId,
modelId: model.externalId,
status: 'pending',
messageId: message.id,
});
let logFile: fs.FileHandle | undefined;
let logMessage: ((message: string) => void) | undefined;
if (process.env.GENERATION_DEBUG) {
logFile = await fs.open(path.join(process.env.LOG_DIR!, `${Date.now()}-${generationId}.log`), 'w');
logMessage = (message: string) => {
logFile!.write(message + '\n');
};
}
event.waitUntil(
generateResponse(message, { gateway, model: model.externalId }, generationId, userId, messages, logMessage, logFile),
);
return {
generationId,
messageId: message.id,
};
});
const INTERNAL_ERROR = 'An internal error occurred';
// todo message takes in variadics like console.log
const todo = (...args: any[]) => {
console.error('TODO', ...args);
throw new Error('TODO');
};
async function generateResponse(
message: Entity<typeof schema, 'messages'>,
model: {
gateway: ModelGateway,
model: string,
},
generationId: string,
userId: string,
messages: ModelMessage[],
log?: (message: string) => void,
logFile?: fs.FileHandle,
) {
const controller = new AbortController();
addPendingGeneration(generationId, controller);
const activeParts = new Map<string, { id: string; accumulatedContent: string; providerOptions?: any }>();
const activeToolCalls = new Map<string, { id: string }>();
const response = streamText({
model: model.gateway(model.model),
messages,
// a little trick that makes it so that the stream doesnt stop because of tool calls, and will continue an unbounded amount of time and steps
stopWhen: [],
// tools: {
// // writeFile: tool({
// // inputSchema: z.object({
// // path: z.string(),
// // content: z.string(),
// // }),
// // outputSchema: z.object({
// // success: z.boolean(),
// // }),
// // execute: async ({ path, content }) => {
// // await fs.writeFile(path, content);
// // return {
// // success: true,
// // };
// // }
// // }),
// listDirectory: tool({
// inputSchema: z.object({
// path: z.string(),
// }),
// outputSchema: z.object({
// files: z.array(z.object({ name: z.string(), type: z.string() })),
// }),
// execute: async ({ path }) => {
// const rawFiles = await fs.readdir(path, { withFileTypes: true });
// const files = rawFiles.map((file) => ({
// name: file.name,
// type: file.isFile() ? 'file' : 'directory',
// }));
// return {
// files,
// };
// },
// }),
// glob: tool({
// inputSchema: z.object({
// pattern: z.string(),
// }),
// outputSchema: z.object({
// files: z.array(z.object({ name: z.string(), type: z.string() })),
// }),
// execute: async ({ pattern }) => {
// const rawFiles = await glob(pattern, { withFileTypes: true });
// const files = rawFiles.map((file) => ({
// name: file.name,
// type: file.isFile() ? 'file' : 'directory',
// }));
// return {
// files,
// };
// },
// }),
// readFile: tool({
// inputSchema: z.object({
// path: z.string(),
// }),
// outputSchema: z.object({
// path: z.string(),
// content: z.string(),
// }),
// execute: async ({ path }) => {
// const file = await fs.readFile(path);
// return {
// path,
// content: file.toString(),
// };
// },
// }),
// readFiles: tool({
// inputSchema: z.object({
// paths: z.array(z.string()).describe('The file paths to read'),
// }),
// outputSchema: z.object({
// files: z.array(
// z.object({
// path: z.string(),
// content: z.string(),
// }),
// ),
// }),
// execute: async ({ paths }) => {
// const files = await Promise.all(
// paths.map(async (path) => {
// const file = await fs.readFile(path);
// return {
// path: path,
// content: file.toString(),
// };
// }),
// );
// return {
// files,
// };
// },
// }),
// fetchUrl: tool({
// inputSchema: z.object({
// url: z.string(),
// }),
// outputSchema: z.object({
// content: z.string(),
// }),
// execute: async ({ url }) => {
// const response = await fetch(url);
// const content = await response.text();
// return {
// content,
// };
// },
// }),
// },
onStepFinish: async (result) => {
if (result.toolResults.length > 0) {
for (const toolResult of result.toolResults) {
let outputType: 'text' | 'json' = 'text';
let outputValue: string = '';
switch (typeof toolResult.output) {
case 'string':
outputType = 'text';
outputValue = toolResult.output;
break;
case 'object':
outputType = 'json';
outputValue = JSON.stringify(toolResult.output, null, 2);
break;
default:
console.error('Unknown output type', toolResult.output);
await httpClient.update('tool_calls', toolResult.toolCallId, {
status: 'failed',
error: {
type: 'text',
value: 'Tool returned invalid output',
},
});
break;
}
await httpClient.update('tool_calls', toolResult.toolCallId, {
status: 'completed',
output: {
type: outputType,
value: outputValue,
},
});
activeToolCalls.delete(toolResult.toolCallId);
}
}
},
onFinish: async (result) => {
await httpClient.update('generations', generationId, {
status: 'completed',
tokens: {
input: result.totalUsage.inputTokens,
cache: {
read: result.totalUsage.inputTokenDetails.cacheReadTokens,
write: result.totalUsage.inputTokenDetails.cacheWriteTokens,
},
output: result.totalUsage.outputTokens,
thinking: result.totalUsage.outputTokenDetails.reasoningTokens,
},
});
},
onError: async (error: any) => {
console.error('generation error', error);
log?.(error);
// TODO: the docs say "The stream processing will pause until the callback promise is resolved." Suggesting that this error might not be fatal?
for (const activePart of activeParts.values()) {
await httpClient.update('message_parts', activePart.id, {
finished: true,
lastUpdatedAt: new Date(),
});
}
for (const activeToolCall of activeToolCalls.values()) {
await httpClient.update('tool_calls', activeToolCall.id, {
status: 'failed',
error: {
type: 'text',
value: 'An unknown error occurred',
},
});
}
await httpClient.update('generations', generationId, {
status: 'failed',
error: error.message,
});
},
abortSignal: controller.signal,
});
let curStepIdx = -1;
let key, part, type;
const pendingUpdates = new Map<string, NodeJS.Timeout>();
const TARGET_UPDATES_PER_SECOND = 24;
const scheduleUpdate = (key: string) => {
const part = activeParts.get(key);
if (!part || pendingUpdates.has(part.id)) return;
pendingUpdates.set(part.id, setTimeout(async () => {
const currentPart = activeParts.get(key);
// Only update if the part is still active and we haven't deleted it at 'text-end'
if (currentPart) {
try {
await httpClient.update('message_parts', currentPart.id, {
content: currentPart.accumulatedContent,
providerOptions: currentPart.providerOptions,
lastUpdatedAt: new Date(),
});
} catch (error) {
// the update failed, but it doesnt matter because the full message will be updated on step finish
console.warn('Failed to update message part', error);
}
}
pendingUpdates.delete(part.id);
}, 1000 / TARGET_UPDATES_PER_SECOND));
};
try {
const generationId = await createPendingGeneration(event.context.user.id, topicId, messages, regeneratesFrom);
for await (const token of response.fullStream) {
log?.(JSON.stringify(token, null, 2));
return {
generationId,
status: 'pending',
regeneratesFrom
};
} catch (error) {
console.error('Failed to create generation:', error);
throw createError({
statusCode: 500,
statusMessage: 'Failed to create generation'
switch (token.type) {
case 'start-step': {
curStepIdx++;
} break;
case 'tool-input-start': {
key = `tool-call-${curStepIdx}`;
const toolCallId = token.id;
part = await httpClient.insert('message_parts', {
messageId: message.id,
toolCallId,
type: 'tool-call',
content: '',
finished: false,
createdAt: new Date(),
lastUpdatedAt: new Date(),
});
await httpClient.insert('tool_calls', {
id: toolCallId,
userId: userId,
toolName: token.toolName,
status: 'pending',
input: null,
output: null,
error: null,
createdAt: new Date(),
});
activeToolCalls.set(key, { id: toolCallId });
activeParts.set(key, { id: part.id, accumulatedContent: '' });
} break;
case 'text-start':
case 'reasoning-start': {
type = token.type.split('-')[0];
key = `${type}-${curStepIdx}`;
part = await httpClient.insert('message_parts', {
messageId: message.id,
type: type as 'text' | 'reasoning',
content: '',
finished: false,
createdAt: new Date(),
lastUpdatedAt: new Date(),
});
activeParts.set(key, { id: part.id, accumulatedContent: '' });
} break;
case 'text-delta':
case 'reasoning-delta': {
type = token.type.split('-')[0] as 'text' | 'reasoning';
key = `${type}-${curStepIdx}`;
part = activeParts.get(key);
if (part === undefined) {
console.error('Received delta without a start');
break;
}
let shouldUpdate = false;
// TODO: we should potentially merge providerOptions, but for now, just overwrite them
if (token.providerMetadata !== undefined) {
shouldUpdate = true;
part.providerOptions = token.providerMetadata;
}
// OpenRouter sometimes puts [REDACTED] in thinking if reasoning is encrypted, so we need to remove it and hide it;
// do not trim or else we lose intentional whitespace and newlines potentially breaking the UI and having words comebined e.g. "the" "\n\n" "assistant" would become "theassistant"
const text = token.text.replaceAll('[REDACTED]', '');
if (text !== '') {
shouldUpdate = true;
part.accumulatedContent += token.text;
}
if (shouldUpdate) {
scheduleUpdate(key);
}
}
break;
case 'text-end':
case 'reasoning-end': {
type = token.type.split('-')[0];
key = `${type}-${curStepIdx}`;
part = activeParts.get(key);
if (part === undefined) {
console.error('Received end without a start');
break;
}
activeParts.delete(key);
if (part.accumulatedContent === '' && part.providerOptions === undefined) {
// completely empty, delete it
await httpClient.delete('message_parts', part.id);
break;
}
await httpClient.update('message_parts', part.id, {
content: part.accumulatedContent,
providerOptions: part.providerOptions,
finished: true,
lastUpdatedAt: new Date(),
});
} break;
case 'tool-call': {
let inputType: 'text' | 'json' = 'text';
let inputValue: string = '';
switch (typeof token.input) {
case 'string':
inputType = 'text';
inputValue = token.input;
break;
case 'object':
inputType = 'json';
inputValue = JSON.stringify(token.input, null, 2);
break;
default:
console.error('Unknown input type', token.input);
break;
}
await httpClient.update('tool_calls', token.toolCallId, {
status: 'pending',
input: {
type: inputType,
value: inputValue,
},
});
} break;
case 'tool-error': {
const toolCall = activeToolCalls.get(token.toolCallId);
if (toolCall === undefined) {
console.error('Received tool-error without a start');
break;
}
let outputType: 'text' | 'json';
let outputValue: string;
switch (typeof token.error) {
case 'string':
outputType = 'text';
outputValue = token.error;
break;
case 'object':
outputType = 'json';
outputValue = JSON.stringify(token.error, null, 2);
break;
default:
console.error('Unknown error type', token.error);
outputType = 'text';
outputValue = 'Tool returned invalid output';
break;
}
await httpClient.update('tool_calls', toolCall.id, {
status: 'failed',
error: {
type: outputType,
value: outputValue,
},
});
} break;
case 'error': {
let error = INTERNAL_ERROR;
if (typeof token.error === 'string') {
error = token.error;
} else if (typeof token.error === 'object') {
error = JSON.stringify(token.error, null, 2);
}
await httpClient.update('generations', generationId, {
status: 'failed',
error,
});
} break;
case 'finish': {
switch (token.finishReason) {
case 'error':
await httpClient.update('generations', generationId, {
status: 'failed',
error: INTERNAL_ERROR,
});
break;
case 'content-filter':
await httpClient.update('generations', generationId, {
status: 'failed',
error: 'Content was filtered',
});
break;
}
// I hate you switch fallthroughs
} break;
case 'abort': {
for (const activeToolCall of activeToolCalls.values()) {
await httpClient.update('tool_calls', activeToolCall.id, {
status: 'cancelled',
});
}
await httpClient.update('generations', generationId, {
status: 'cancelled',
});
} break;
case 'file':
todo('file token type', token);
break;
case 'raw':
todo('raw token type', token);
break;
case 'source':
todo('source token type', token);
break;
case 'tool-approval-request':
todo('tool-approval-request token type', token);
break;
case 'tool-output-denied':
todo('tool-output-denied token type', token);
break;
case 'start':
case 'finish-step':
case 'tool-input-delta':
case 'tool-input-end':
case 'tool-result':
// handled or irrelevant
break;
}
}
} catch (error: any) {
console.error(error);
for (const activePart of activeParts.values()) {
await httpClient.update('message_parts', activePart.id, {
finished: true,
lastUpdatedAt: new Date(),
});
}
for (const activeToolCall of activeToolCalls.values()) {
await httpClient.update('tool_calls', activeToolCall.id, {
status: 'failed',
error: {
type: 'text',
value: 'An unknown error occurred',
},
});
}
await httpClient.update('generations', generationId, {
status: 'failed',
error: error.message,
});
} finally {
completeGeneration(generationId);
if (logFile !== undefined) logFile.close();
}
});
}
-43
View File
@@ -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;
});
-121
View File
@@ -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'
});
}
});
-42
View File
@@ -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