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

Some files were not shown because too many files have changed in this diff Show More