feat: ditch triplit, move to postgresql + drizzle orm

This commit is contained in:
Zoe
2026-04-08 17:03:07 -05:00
parent c341c96798
commit e2e3ac6e86
121 changed files with 6680 additions and 4373 deletions
+54 -26
View File
@@ -1,6 +1,6 @@
# Veridian AGENTS.md # Veridian AGENTS.md
**Nuxt 4 AI chat platform with Triplit database + better-auth** **Nuxt 4 AI chat platform with Drizzle + PostgreSQL + better-auth**
## Architecture Overview ## Architecture Overview
@@ -21,9 +21,10 @@ server/ # Server-side API routes
├── chat/ # Generation, cancel endpoints ├── chat/ # Generation, cancel endpoints
└── provider/ # Model fetching from providers └── provider/ # Model fetching from providers
triplit/ # Database drizzle/ # Database (migrated from Triplit)
├── schema.ts # Full schema with auth + app collections ├── schema.ts # Full schema with auth + app tables
└── auth-schema.ts # Auth collections (users, sessions, accounts) ├── relations.ts # Table relationships
└── migrations/ # SQL migration files
lib/ # Shared utilities lib/ # Shared utilities
├── auth.ts # Better-auth server config ├── auth.ts # Better-auth server config
@@ -39,7 +40,9 @@ database commands.
### Database Commands ### Database Commands
- `bunx triplit schema push` - Push schema changes to database - `bunx drizzle-kit push` - Push schema changes to database
- `bunx drizzle-kit generate` - Generate migration from schema changes
- `bunx drizzle-kit migrate` - Run pending migrations
## Code Style ## Code Style
@@ -57,15 +60,12 @@ database commands.
Group imports in this order (alphabetical within groups): Group imports in this order (alphabetical within groups):
1. Type imports (`types/`) 1. Type imports (`types/`)
2. Dependency imports (npm packages) 2. Dependency imports (npm packages)
3. Triplit imports (`#triplit/`, `@triplit/`) 3. Vue/Nuxt imports (`vue`, `#app`, `~~/`, `~/`, `@/`)
4. Vue/Nuxt imports (`vue`, `#app`, `~~/`, `~/`, `@/`) 4. Local imports (`lib/`, `server/`)
5. Local imports (`lib/`, `server/`)
```typescript ```typescript
// Example import order // Example import order
import type { Result, Ok, Err } from '~~/types/result'; import type { Result, Ok, Err } from '~~/types/result';
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
import type { Foo } from 'vue'; import type { Foo } from 'vue';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useFeature } from '~/composables/useFeature'; import { useFeature } from '~/composables/useFeature';
@@ -128,23 +128,28 @@ export default defineEventHandler(async (event) => {
}); });
``` ```
## Database Query Pattern (Triplit) ## Database Query Pattern (Drizzle)
```typescript ```typescript
// Client queries use useQuery() with auto-includes // Server-side queries use Drizzle ORM
useQuery('collection', triplit, triplit.query('collection').Include('relation')); import { db } from '~~/server/db';
import { agents } from '~~/drizzle/schema';
// Server-side // Query with relations
import { httpClient } from '~~/server/lib/triplit'; const result = await db.query.agents.findMany({
await httpClient.fetchOne(httpClient.query('collection').Where('id', '=', providerId)); where: eq(agents.userId, userId),
with: { topics: true, messages: true },
});
// Insert with conflict handling
await db.insert(agents).values(data).onConflictDoNothing();
``` ```
## Authentication Flow ## Authentication Flow
1. **Login**: Client uses `authClient.signIn.email()` → derives encryption key from password 1. **Login**: Client uses `authClient.signIn.email()` → derives encryption key from password
2. **Session**: Better-auth creates session token → stored in cookie 2. **Session**: Better-auth creates session token → stored in cookie
3. **Triplit**: Session token passed to `triplit.startSession(token)` for DB access 3. **API Keys**: Encrypted client-side with AES-GCM (key derived from password + userId)
4. **API Keys**: Encrypted client-side with AES-GCM (key derived from password + userId)
**Key files**: `lib/auth.ts`, `lib/auth-client.ts`, `app/plugins/auth.*.ts`, `app/middleware/auth.global.ts` **Key files**: `lib/auth.ts`, `lib/auth-client.ts`, `app/plugins/auth.*.ts`, `app/middleware/auth.global.ts`
@@ -159,10 +164,8 @@ await httpClient.fetchOne(httpClient.query('collection').Where('id', '=', provid
| Variable | Purpose | | Variable | Purpose |
|----------|---------| |----------|---------|
| `TRIPLIT_SERVICE_TOKEN` | Admin DB access | | `DATABASE_URL` | PostgreSQL connection string |
| `NUXT_TRIPLIT_ANON_TOKEN` | Anonymous DB access |
| `BETTER_AUTH_SECRET` | Auth encryption | | `BETTER_AUTH_SECRET` | Auth encryption |
| `NUXT_PUBLIC_TRIPLIT_URL` | DB server URL |
## Key Composables ## Key Composables
@@ -198,15 +201,40 @@ await httpClient.fetchOne(httpClient.query('collection').Where('id', '=', provid
## Known Issues (from BUGS.md) ## Known Issues (from BUGS.md)
1. Triplit occasionally makes duplicate connections (race condition) 1. Sidebar hover animation occasionally glitches on agent routes
2. Sidebar hover animation occasionally glitches on agent routes 2. Theme switcher + sidebar interaction bug
3. Theme switcher + sidebar interaction bug
## Critical Architecture Notes
### Chat Endpoint God File
- `server/api/topic/[topicId]/chat/index.post.ts` is 1263 lines handling route validation, tool definitions, streaming orchestration, DB transactions, and event emission
- Files that change together: this file + `server/utils/events.ts` + `server/utils/generations.ts`
- Tool definitions (bash, python, file access) should be extracted to separate module before extending
### Security: Code Execution Tools Have No Sandboxing
- `bashTool` uses `exec()` and `pythonTool` uses `python3 -c` with user-controlled input — full RCE risk
- Do NOT extend these tools without adding: timeouts, path restrictions, resource limits
- Location: `server/api/topic/[topicId]/chat/index.post.ts` ~lines 261-455
### Crash Risk: `todo()` Function
- `todo()` literally throws `new Error('TODO')` on unhandled token types in chat handler
- Handle gracefully before production deployment
- Location: `server/api/topic/[topicId]/chat/index.post.ts` ~line 256
### Module-Scoped Mutable State Limitations
- `useDialog.ts` has module-scoped `actionCallback` — not SSR-safe, overwritten on concurrent dialogs
- `pendingGenerations` in `server/utils/generations.ts` and in-memory event channels in `server/utils/events.ts` won't work in multi-process deployments
- Fine for single-instance dev, but architectural ceiling for scaling
### Pagination Params Validated But Unused
- `agents.get.ts` validates `page`/`limit` query params but never applies them to the query
- All agent queries return everything regardless of pagination params
## Development Notes ## Development Notes
- **Never run dev server as agent** - just complete tasks and signal done - **Never run dev server as agent** - just complete tasks and signal done
- Always run `bunx triplit schema push` after schema changes - Always run `bunx drizzle-kit push` after schema changes
- Use `protectRoute()` in all API routes for auth - Use `protectRoute()` in all API routes for auth
- All UI state that persists → use cookies (`useCookie()`) - All UI state that persists → use cookies (`useCookie()`)
- Real-time data → Triplit subscriptions via `useQuery()` - Real-time data → SSE events via `server/utils/events.ts`
- API keys → encrypted client-side before DB storage - API keys → encrypted client-side before DB storage
+5 -4
View File
@@ -2,10 +2,11 @@
import '~/assets/css/reset.css'; import '~/assets/css/reset.css';
import '~/assets/css/base.css'; import '~/assets/css/base.css';
const { accent, neutral, hinting } = useUserSettings(); const { user } = useAuth();
const { accent, neutral, hinting, refresh: refreshSettings } = await useUserSettings();
// by default, disable hinting watch(user, () => {
if (Number.isNaN(Number(hinting.value))) hinting.value = '0'; refreshSettings();
})
watchEffect(() => { watchEffect(() => {
useHead({ useHead({
+1 -6
View File
@@ -32,7 +32,7 @@ const fileExtension = computed(() => {
</script> </script>
<template> <template>
<div class="relative h-full w-fit"> <div class="relative h-full w-fit flex">
<img v-if="isImage" :src="props.file.url" class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover" /> <img v-if="isImage" :src="props.file.url" class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover" />
<video v-else-if="isVideo" controls :src="props.file.url" <video v-else-if="isVideo" controls :src="props.file.url"
class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover" /> class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover" />
@@ -53,10 +53,5 @@ const fileExtension = computed(() => {
<span class="text-xs text-white">{{ file.progress || 0 }}%</span> <span class="text-xs text-white">{{ file.progress || 0 }}%</span>
</div> </div>
</div> </div>
<button @click="$emit('delete')"
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 flex items-center justify-center w-4 h-4 rounded-full bg-[var(--bg-base)] border border-[var(--color-border)] text-xs text-[#ff3b3b]">
<span class="i-mynaui-x text-3"></span>
</button>
</div> </div>
</template> </template>
+1 -1
View File
@@ -21,7 +21,7 @@ const handleDelete = async () => {
</script> </script>
<template> <template>
<div class="relative h-full w-fit"> <div class="relative h-full w-fit flex">
<Display :file="props.file" /> <Display :file="props.file" />
<button @click="handleDelete" <button @click="handleDelete"
+13 -14
View File
@@ -1,14 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { BaseMessage } from '~/composables/useChat'; import type { BaseMessage } from '~/composables/useChat';
import { onMounted, ref, watch, onUnmounted, nextTick, type Ref } from 'vue'; import { onMounted, ref, watch, onUnmounted, nextTick, type Ref } from 'vue';
import { nanoid } from 'nanoid';
import { assert } from '~~/utils/assert';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels'; import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import type { Agent } from '~/composables/useAgents'; import type { Agent } from '~/composables/useAgents';
import type FileSelector from './FileSelector.vue'; import type FileSelector from './FileSelector.vue';
const { allModels } = useModels(); const { allModels } = await useModels();
const { user } = useAuth();
const inputHeight: Ref<string> = ref('auto'); const inputHeight: Ref<string> = ref('auto');
const fileSelectorRef = ref<InstanceType<typeof FileSelector> | null>(null); const fileSelectorRef = ref<InstanceType<typeof FileSelector> | null>(null);
@@ -30,7 +27,6 @@ watch(textAreaValue, (newValue) => {
watch(files, (newFiles) => { watch(files, (newFiles) => {
inputValue.value.fileIds = newFiles.map(f => f.id); inputValue.value.fileIds = newFiles.map(f => f.id);
}); });
const triplit = useTriplitClient();
const emit = defineEmits<{ const emit = defineEmits<{
submit: [value: BaseMessage, model: ModelWithProvider | null]; submit: [value: BaseMessage, model: ModelWithProvider | null];
@@ -39,7 +35,7 @@ const emit = defineEmits<{
const props = defineProps<{ const props = defineProps<{
loading?: boolean; loading?: boolean;
agent: Agent | null; agent: Readonly<Agent> | null;
providers?: ProviderWithModels[]; providers?: ProviderWithModels[];
}>(); }>();
@@ -82,8 +78,11 @@ const initializeModel = () => {
const updateAgentDefaultModel = async (modelId: string) => { const updateAgentDefaultModel = async (modelId: string) => {
if (!props.agent) return; if (!props.agent) return;
try { try {
await triplit.update('agents', props.agent.id, (agent) => { await $fetch(`/api/agent/${props.agent.id}`, {
agent.defaultModelId = modelId; method: 'PATCH',
body: {
defaultModelId: modelId,
},
}); });
} catch (error) { } catch (error) {
console.error('Failed to update agent default model:', error); console.error('Failed to update agent default model:', error);
@@ -114,8 +113,8 @@ const handleSubmit = () => {
} }
if (inputValue.value.content.trim() || inputValue.value.fileIds.length > 0) { if (inputValue.value.content.trim() || inputValue.value.fileIds.length > 0) {
emit('submit', inputValue.value, selectedModel.value); emit('submit', structuredClone(toRaw(inputValue.value)), selectedModel.value);
inputValue.value = { content: '', fileIds: [] }; files.value = [];
textAreaValue.value = ''; textAreaValue.value = '';
} }
// Reset height after sending // Reset height after sending
@@ -225,12 +224,12 @@ onUnmounted(() => {
<textarea data-gramm="false" id="chat" v-model="textAreaValue" ref="inputRef" <textarea data-gramm="false" id="chat" v-model="textAreaValue" 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.`"
@keydown="handleKeyDown" :style="{ height: inputHeight }" @keydown="handleKeyDown" :style="{ height: inputHeight }"
class="[scrollbar-width:none] w-full bg-transparent resize-none text-[0.95em] placeholder:text-[var(--text-tertiary)]"></textarea> class="[scrollbar-width:none] w-full bg-transparent resize-none placeholder:text-[var(--text-tertiary)]"></textarea>
</div> </div>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<!-- TODO: since we dont want to model selector dropdown to potentially overflow, it has max-width: 100%, so, we need to maake the trigger large enough to fit the entire width of the dropdown --> <!-- TODO: since we dont want to model selector dropdown to potentially overflow, it has max-width: 100%, so, we need to maake the trigger large enough to fit the entire width of the dropdown -->
<div class="flex flex-1 gap-1"> <div class="flex flex-1 gap-1 min-w-0">
<ModelSelector v-if="providers !== undefined" :add-hotkey="true" v-model="selectedModel" <ModelSelector v-if="providers !== undefined" :add-hotkey="true" v-model="selectedModel"
:providers="providers" /> :providers="providers" />
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files" /> <FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files" />
@@ -239,9 +238,9 @@ onUnmounted(() => {
:disabled="(!inputValue.content.trim() && files.length === 0) && !loading" :class="[ :disabled="(!inputValue.content.trim() && files.length === 0) && !loading" :class="[
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent', 'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
(inputValue.content.trim() || files.length > 0) && !loading (inputValue.content.trim() || files.length > 0) && !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)]'
: 'text-[var(--text-dim)]', : 'text-[var(--text-dim)]',
loading && 'bg-[var(--color-hover)] hover:bg-[var(--color-active)]', loading && 'bg-[var(--color-hover)] @hover:bg-[var(--color-active)]',
]"> ]">
<span v-if="loading" class="text-6.5 i-mynaui-stop-solid"></span> <span v-if="loading" class="text-6.5 i-mynaui-stop-solid"></span>
<span v-else class="text-5 i-mynaui-send-solid"></span> <span v-else class="text-5 i-mynaui-send-solid"></span>
+3 -3
View File
@@ -2,7 +2,7 @@
import { useAgents } from '~/composables/useAgents'; import { useAgents } from '~/composables/useAgents';
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue'; import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
const { agents } = useAgents(); const { agents } = await useAgents();
const route = useRoute(); const route = useRoute();
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
@@ -134,7 +134,7 @@ onMounted(() => { inputRef.value?.focus(); });
<!-- AGENT ROW --> <!-- AGENT ROW -->
<div v-if="item.type === 'agent'" :id="item.uiId" role="option" <div v-if="item.type === 'agent'" :id="item.uiId" role="option"
:aria-selected="selectedIndex === index" @click="selectItem(item.uiId)" :aria-selected="selectedIndex === index" @click="selectItem(item.uiId)"
class="group flex items-center px-3 h-10 rounded-md cursor-pointer transition-colors hover:bg-[var(--color-hover)]" class="group flex items-center px-3 h-10 rounded-md cursor-pointer transition-colors @hover:bg-[var(--color-hover)]"
:class="selectedIndex === index ? 'bg-[var(--color-hover)] text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'"> :class="selectedIndex === index ? 'bg-[var(--color-hover)] text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
<div <div
:class="['mr-2 w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', item?.imageUrl ? '' : 'border border-[var(--color-border)]']"> :class="['mr-2 w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', item?.imageUrl ? '' : 'border border-[var(--color-border)]']">
@@ -148,7 +148,7 @@ onMounted(() => { inputRef.value?.focus(); });
<!-- TOPIC ROW --> <!-- TOPIC ROW -->
<div v-else :id="item.uiId" role="option" :aria-selected="selectedIndex === index" <div v-else :id="item.uiId" role="option" :aria-selected="selectedIndex === index"
@click="selectItem(item.uiId)" @click="selectItem(item.uiId)"
class="group flex items-center h-10 pr-3 rounded-md cursor-pointer transition-colors relative ml-10 hover:bg-[var(--color-hover)]" class="group flex items-center h-10 pr-3 rounded-md cursor-pointer transition-colors relative ml-10 @hover:bg-[var(--color-hover)]"
:class="selectedIndex === index ? 'bg-[var(--color-hover)] text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'"> :class="selectedIndex === index ? 'bg-[var(--color-hover)] text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
<!-- Visual Tree Lines --> <!-- Visual Tree Lines -->
<div class="absolute -left-4 top-0 bottom-0 w-[1px] h-[110%] bg-[var(--text-dim)]"></div> <div class="absolute -left-4 top-0 bottom-0 w-[1px] h-[110%] bg-[var(--text-dim)]"></div>
+3 -3
View File
@@ -6,7 +6,7 @@ import SystemAssistants from '~/components/Settings/SystemAssistants.vue';
import AppearanceSettings from '~/components/Settings/AppearanceSettings.vue'; import AppearanceSettings from '~/components/Settings/AppearanceSettings.vue';
import AIServiceProvider from '~/components/Settings/AIServiceProvider.vue'; import AIServiceProvider from '~/components/Settings/AIServiceProvider.vue';
const { providers } = useModels(); const { providers } = await useModels();
const PAGES_CONFIG = { const PAGES_CONFIG = {
general: { general: {
@@ -77,7 +77,7 @@ const runtimePage = computed(() => {
@navigate="(p: string, params?: string) => setOptions({ page: p, params })" :params="props.params" /> @navigate="(p: string, params?: string) => setOptions({ page: p, params })" :params="props.params" />
<button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="setOptions({ page: id })" <button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="setOptions({ page: id })"
:class="[page === id ? 'bg-[var(--color-hover)]' : 'hover:bg-[var(--color-hover)]', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9']"> :class="[page === id ? 'bg-[var(--color-hover)]' : '@hover:bg-[var(--color-hover)]', '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"> <div class="flex items-center gap-2 max-w-full flex-1">
<span :class="['w-5 h-5 text-5', config.icon]"></span> <span :class="['w-5 h-5 text-5', config.icon]"></span>
{{ config.label }} {{ config.label }}
@@ -91,7 +91,7 @@ const runtimePage = computed(() => {
<header class="flex items-center justify-between pl-2 pb-2 pt-2 "> <header class="flex items-center justify-between pl-2 pb-2 pt-2 ">
<h2 class="text-lg font-semibold m-0 case-capital">{{ runtimePage.label }}</h2> <h2 class="text-lg font-semibold m-0 case-capital">{{ runtimePage.label }}</h2>
<button <button
class="hover:bg-[var(--color-hover)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]" class="@hover:bg-[var(--color-hover)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]"
@click="close"> @click="close">
<span class="i-mynaui-x-solid"></span> <span class="i-mynaui-x-solid"></span>
</button> </button>
+2 -2
View File
@@ -14,10 +14,10 @@ const text = ref(props.initialValue || '');
<textarea v-model="text" class="bg-[var(--bg-surface)] resize-none h-full w-full p-2 rounded" /> <textarea v-model="text" class="bg-[var(--bg-surface)] resize-none h-full w-full p-2 rounded" />
<div class="flex justify-end gap-2 m-2"> <div class="flex justify-end gap-2 m-2">
<button <button
class="px-2 py-1 rounded-lg border border-[var(--color-border)] hover:bg-[var(--color-hover)] transition-[background-color] duration-200" class="px-2 py-1 rounded-lg border border-[var(--color-border)] @hover:bg-[var(--color-hover)] transition-[background-color] duration-200"
@click="emit('cancel')">Cancel</button> @click="emit('cancel')">Cancel</button>
<button <button
class="px-2 py-1 rounded-lg bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)] transition-[background-color] duration-200" class="px-2 py-1 rounded-lg bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)] transition-[background-color] duration-200"
@click="emit('confirm', text)">Confirm</button> @click="emit('confirm', text)">Confirm</button>
</div> </div>
</div> </div>
+10 -6
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useFloating, offset, flip, shift, autoUpdate, type Placement } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, type Placement, hide } from '@floating-ui/vue';
const props = defineProps<{ const props = defineProps<{
placement?: Placement; placement?: Placement;
@@ -8,7 +8,7 @@ const props = defineProps<{
}>(); }>();
const isOpen = ref(false); const isOpen = ref(false);
const triggerRef = ref(null); const triggerRef = ref<HTMLElement | null>(null);
const dropdownRef = ref(null); const dropdownRef = ref(null);
const setTriggerRef = (el: any) => { const setTriggerRef = (el: any) => {
@@ -18,10 +18,10 @@ const setTriggerRef = (el: any) => {
} }
}; };
const { floatingStyles, placement } = useFloating(triggerRef, dropdownRef, { const { floatingStyles, placement, middlewareData } = useFloating(triggerRef, dropdownRef, {
placement: props.placement ?? 'bottom-start', placement: props.placement ?? 'bottom-start',
whileElementsMounted: autoUpdate, whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 10 })], middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
transform: false, transform: false,
}); });
@@ -55,8 +55,12 @@ defineExpose({ close });
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100" enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95"> leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
<div v-if="isOpen" ref="dropdownRef" :style="floatingStyles" class="fixed z-9999" <div v-if="isOpen" ref="dropdownRef" :style="{
:class="[dropdownClass, transformOrigin]"> ...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}" class="fixed z-9999" :class="[dropdownClass, transformOrigin]">
<div v-click-outside="close" <div v-click-outside="close"
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1"> class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1">
<slot name="dropdown" :close="close" /> <slot name="dropdown" :close="close" />
+14 -14
View File
@@ -8,8 +8,6 @@ const props = defineProps<{
const imageInputRef = ref<HTMLInputElement | null>(null); const imageInputRef = ref<HTMLInputElement | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null); const fileInputRef = ref<HTMLInputElement | null>(null);
const { user } = useAuth();
const files = defineModel<{ const files = defineModel<{
id: string; id: string;
name: string; name: string;
@@ -20,8 +18,6 @@ const files = defineModel<{
}[]>({ required: false, default: [] }); }[]>({ required: false, default: [] });
const rawFiles = ref<File[]>([]); const rawFiles = ref<File[]>([]);
const triplit = useTriplitClient();
const activeUploads = ref<Map<string, XMLHttpRequest>>(new Map()); const activeUploads = ref<Map<string, XMLHttpRequest>>(new Map());
const uploadWithProgress = (file: File, url: string, id: string) => { const uploadWithProgress = (file: File, url: string, id: string) => {
@@ -78,23 +74,25 @@ const uploadFile = async (file: File) => {
const { url: uploadUrl, assetUrl } = await $fetch('/api/upload/presigned', { const { url: uploadUrl, assetUrl } = await $fetch('/api/upload/presigned', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: {
file: { file: {
name: fileName, name: fileName,
mimeType: fileType, mimeType: fileType,
} }
}), },
}) as { url: string; assetUrl: string }; }) as { url: string; assetUrl: string };
await uploadWithProgress(file, uploadUrl, id); await uploadWithProgress(file, uploadUrl, id);
await triplit.insert('files', { await $fetch('/api/file', {
method: 'POST',
body: {
id, id,
userId: user.value!.id,
name: fileName, name: fileName,
mimeType: fileType, mimeType: fileType,
url: assetUrl, url: assetUrl,
}); },
})
files.value = files.value.map(f => { files.value = files.value.map(f => {
if (f.name === file.name) { if (f.name === file.name) {
@@ -151,7 +149,9 @@ watch(files, async (newFiles, oldFiles) => {
// we knpw that if the url starts with blob: it was the first time we uploaded it // we knpw that if the url starts with blob: it was the first time we uploaded it
// so we can just delete it // so we can just delete it
if (removedFile.status === 'uploaded') { if (removedFile.status === 'uploaded') {
await triplit.delete('files', removedFile.id); await $fetch(`/api/file/${removedFile.id}`, {
method: 'DELETE',
})
} }
URL.revokeObjectURL(removedFile.url); URL.revokeObjectURL(removedFile.url);
@@ -185,21 +185,21 @@ onUnmounted(() => {
<Dropdown dropdownClass="text-sm" placement="top"> <Dropdown dropdownClass="text-sm" placement="top">
<template #default="{ toggle, setRef }"> <template #default="{ toggle, setRef }">
<button :ref="setRef" @click="toggle" <button :ref="setRef" @click="toggle"
class="flex items-center justify-center h-8.5 w-8.5 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="flex items-center justify-center h-8.5 w-8.5 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="pointer-events-none i-mynaui-paperclip text-5 text-[var(--text-secondary)]"></span> <span class="pointer-events-none i-mynaui-paperclip text-5 text-[var(--text-secondary)]"></span>
</button> </button>
</template> </template>
<template #dropdown="{ close }"> <template #dropdown="{ close }">
<button <button
:disabled="selectedModel ? [...selectedModel.attributes.inputModalities].filter(p => p !== 'text').length === 0 : true" :disabled="selectedModel ? selectedModel.inputModalities.filter(p => p !== 'text').length === 0 : true"
@click="imageInputRef?.click(); close()" @click="imageInputRef?.click(); close()"
class="text-left px-3 py-1.5 items-center gap-2 enabled:hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50"> class="text-left px-3 py-1.5 items-center gap-2 enabled:@hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50">
<span class="text-4.5 i-tabler-photo-plus"></span> <span class="text-4.5 i-tabler-photo-plus"></span>
<span>Upload image</span> <span>Upload image</span>
</button> </button>
<button @click="fileInputRef?.click(); close()" <button @click="fileInputRef?.click(); close()"
class="text-left px-3 py-1.5 items-center gap-2 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"> class="text-left px-3 py-1.5 items-center gap-2 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150">
<span class="text-4.5 i-mynaui-file-plus"></span> <span class="text-4.5 i-mynaui-file-plus"></span>
<span>Upload file</span> <span>Upload file</span>
</button> </button>
+4 -3
View File
@@ -7,20 +7,21 @@ defineProps<{
const TITLE = 'Cerebras'; const TITLE = 'Cerebras';
const BACKGROUND_COLOR = "#F15A29"; const BACKGROUND_COLOR = "#FFF";
const AVATAR_SCALE = 0.8; const AVATAR_SCALE = 0.8;
</script> </script>
<template> <template>
<div class="inline-flex items-center justify-center" <div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']"> :style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
<svg fill="currentColor" fill-rule="evenodd" :height="size" style="flex: none; line-height: 1;" <svg :fill="avatar ? '#000' : 'currentColor'" fill-rule="evenodd" :height="size"
style="flex: none; line-height: 1;"
:style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']" :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
viewBox="0 0 24 24" :width="size" xmlns="http://www.w3.org/2000/svg"> viewBox="0 0 24 24" :width="size" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title> <title>{{ TITLE }}</title>
<path clipRule="evenodd" <path clipRule="evenodd"
d="M14.121 2.701a9.299 9.299 0 000 18.598V22.7c-5.91 0-10.7-4.791-10.7-10.701S8.21 1.299 14.12 1.299V2.7zm4.752 3.677A7.353 7.353 0 109.42 17.643l-.901 1.074a8.754 8.754 0 01-1.08-12.334 8.755 8.755 0 0112.335-1.08l-.901 1.075zm-2.255.844a5.407 5.407 0 00-5.048 9.563l-.656 1.24a6.81 6.81 0 016.358-12.043l-.654 1.24zM14.12 8.539a3.46 3.46 0 100 6.922v1.402a4.863 4.863 0 010-9.726v1.402z" d="M14.121 2.701a9.299 9.299 0 000 18.598V22.7c-5.91 0-10.7-4.791-10.7-10.701S8.21 1.299 14.12 1.299V2.7zm4.752 3.677A7.353 7.353 0 109.42 17.643l-.901 1.074a8.754 8.754 0 01-1.08-12.334 8.755 8.755 0 0112.335-1.08l-.901 1.075zm-2.255.844a5.407 5.407 0 00-5.048 9.563l-.656 1.24a6.81 6.81 0 016.358-12.043l-.654 1.24zM14.12 8.539a3.46 3.46 0 100 6.922v1.402a4.863 4.863 0 010-9.726v1.402z"
:fill="!avatar && color ? '#F15A29' : ''" :fillRule="!avatar && color ? 'evenodd' : ''" /> :fill="avatar || color ? '#F15A29' : ''" :fillRule="avatar || color ? 'evenodd' : ''" />
<path <path
d="M15.407 10.836a2.24 2.24 0 00-.51-.409 1.084 1.084 0 00-.544-.152c-.255 0-.483.047-.684.14a1.58 1.58 0 00-.84.912c-.074.203-.11.416-.11.631 0 .218.036.43.11.631a1.594 1.594 0 00.84.913c.2.093.43.14.684.14.216 0 .417-.046.602-.135.188-.09.35-.225.475-.392l.928 1.006c-.14.14-.3.261-.482.363a3.367 3.367 0 01-1.083.38c-.17.026-.317.04-.44.04a3.315 3.315 0 01-1.182-.21 2.825 2.825 0 01-.961-.597 2.816 2.816 0 01-.644-.929 2.987 2.987 0 01-.238-1.21c0-.444.08-.847.238-1.21.15-.35.368-.666.643-.929.278-.261.605-.464.962-.596a3.315 3.315 0 011.182-.21c.355 0 .712.068 1.072.204.361.138.685.36.944.649l-.962.97z" /> d="M15.407 10.836a2.24 2.24 0 00-.51-.409 1.084 1.084 0 00-.544-.152c-.255 0-.483.047-.684.14a1.58 1.58 0 00-.84.912c-.074.203-.11.416-.11.631 0 .218.036.43.11.631a1.594 1.594 0 00.84.913c.2.093.43.14.684.14.216 0 .417-.046.602-.135.188-.09.35-.225.475-.392l.928 1.006c-.14.14-.3.261-.482.363a3.367 3.367 0 01-1.083.38c-.17.026-.317.04-.44.04a3.315 3.315 0 01-1.182-.21 2.825 2.825 0 01-.961-.597 2.816 2.816 0 01-.644-.929 2.987 2.987 0 01-.238-1.21c0-.444.08-.847.238-1.21.15-.35.368-.666.643-.929.278-.261.605-.464.962-.596a3.315 3.315 0 011.182-.21c.355 0 .712.068 1.072.204.361.138.685.36.944.649l-.962.97z" />
</svg> </svg>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>();
const TITLE = 'ClosedRouter';
const BACKGROUND_COLOR = "#050816";
const AVATAR_SCALE = 0.7;
</script>
<template>
<div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
<svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title>
<g transform="translate(-10.909 -10)" stroke-width="2.1818">
<path
d="m16.364 14.364v6.5455c0 2.41 1.9537 4.3636 4.3636 4.3636h4.3636c2.41 0 4.3636 1.9537 4.3636 4.3636v0"
:stroke="color ? '#00e87a' : 'currentColor'" stroke-linecap="round" stroke-linejoin="round" />
<g :fill="BACKGROUND_COLOR">
<circle cx="16.364" cy="14.364" r="3.2727" :stroke="color ? '#00e87a' : 'currentColor'" />
<circle cx="29.455" cy="29.636" r="3.2727" :stroke="color ? '#00e87a' : 'currentColor'" />
<circle cx="29.455" cy="14.364" r="3.2727" :stroke="color ? '#7aa2ff' : 'currentColor'" />
</g>
<path d="m20.727 14.364h5.4545" :stroke="color ? '#7aa2ff' : 'currentColor'" stroke-linecap="round" />
</g>
</svg>
</div>
</template>
+2 -2
View File
@@ -15,8 +15,8 @@ const BACKGROUND_COLOR = "#fff";
<div class="inline-flex items-center justify-center" <div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']"> :style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
<svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']" <svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
fill="currentColor" fill-rule="evenodd" style="flex: none; line-height: 1;" viewBox="0 0 24 24" :fill="avatar ? '#000' : 'currentColor'" fill-rule="evenodd" style="flex: none; line-height: 1;"
xmlns="http://www.w3.org/2000/svg"> viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title> <title>{{ TITLE }}</title>
<path <path
d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z" /> d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z" />
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>();
const TITLE = 'vLLM';
const BACKGROUND_COLOR = "#fff";
const AVATAR_SCALE = 0.6;
</script>
<template>
<div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
<svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title>
<path d="M0 4.973h9.324V23L0 4.973z" :fill="color ? '#FDB515' : ''" />
<path d="M13.986 4.351L22.378 0l-6.216 23H9.324l4.662-18.649z" :fill="color ? '#30A2FF' : ''" />
</svg>
</div>
</template>
+2 -2
View File
@@ -73,14 +73,14 @@ console.log("shiki codeblock rendered in", Date.now() - start);
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="copyCode()" <button @click="copyCode()"
class="flex items-center px-1 gap-0.5 rounded-md hover:bg-[var(--color-hover)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="flex items-center px-1 gap-0.5 rounded-md @hover:bg-[var(--color-hover)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
{{ copied ? 'Copied' : 'Copy' }} {{ copied ? 'Copied' : 'Copy' }}
<span v-if="!copied" class="i-mynaui-copy text-4 text-[var(--text-secondary)]"></span> <span v-if="!copied" class="i-mynaui-copy text-4 text-[var(--text-secondary)]"></span>
<span v-else class="i-mynaui-check text-4 text-emerald-500"></span> <span v-else class="i-mynaui-check text-4 text-emerald-500"></span>
</button> </button>
<button @click="collapsed = !collapsed" <button @click="collapsed = !collapsed"
class="flex items-center justify-center h-5.5 w-5.5 rounded-md hover:bg-[var(--color-hover)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="flex items-center justify-center h-5.5 w-5.5 rounded-md @hover:bg-[var(--color-hover)] transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span <span
class="i-mynaui-chevron-down text-4 h-4 w-4 text-[var(--text-secondary)] transition-transform duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" class="i-mynaui-chevron-down text-4 h-4 w-4 text-[var(--text-secondary)] transition-transform duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="collapsed ? '-rotate-90' : ''"></span> :class="collapsed ? '-rotate-90' : ''"></span>
+10 -5
View File
@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Entity } from '@triplit/client'; import type { MessagePart } from '~/composables/useChat';
import type schema from '#triplit/schema';
const props = defineProps<{ const props = defineProps<{
part: Readonly<Entity<typeof schema, 'message_parts'>>; part: Readonly<MessagePart>;
}>(); }>();
const reasoningOpen = ref(!props.part.finished); const reasoningOpen = ref(!props.part.finished);
@@ -30,6 +29,12 @@ const handleScroll = () => {
const topThreshold = 0.02 * clientHeight; const topThreshold = 0.02 * clientHeight;
if (scrollHeight <= clientHeight) {
// the container does not have enough content to scroll
scrollState.value = '';
return;
}
if (scrollTop <= topThreshold) { if (scrollTop <= topThreshold) {
scrollState.value = 'top'; scrollState.value = 'top';
} else if (scrollTop + 100 >= scrollHeight - clientHeight) { } else if (scrollTop + 100 >= scrollHeight - clientHeight) {
@@ -53,7 +58,7 @@ const toggleReasoning = async () => {
<template> <template>
<div class="text-[--text-tertiary]"> <div class="text-[--text-tertiary]">
<button @click="toggleReasoning" :class="[ <button @click="toggleReasoning" :class="[
'w-full hover:bg-[var(--color-hover)] rounded-lg p-1 flex justify-between items-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', 'w-full @hover:bg-[var(--color-hover)] rounded-lg p-1 flex justify-between items-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
part.finished ? '' : 'cursor-default' part.finished ? '' : 'cursor-default'
]"> ]">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
@@ -69,7 +74,7 @@ const toggleReasoning = async () => {
<div v-if="reasoningOpen" ref="containerRef" @scroll="handleScroll" <div v-if="reasoningOpen" ref="containerRef" @scroll="handleScroll"
:class="['reasoning-contaizner max-h-[min(40vh,320px)] overflow-y-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]', scrollState]"> :class="['reasoning-contaizner max-h-[min(40vh,320px)] overflow-y-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]', scrollState]">
<div class="p-2"> <div class="p-2">
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" /> <MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content!" />
</div> </div>
</div> </div>
</div> </div>
+3 -4
View File
@@ -1,12 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Entity } from '@triplit/client'; import type { MessagePart } from '~/composables/useChat';
import type schema from '#triplit/schema';
const props = defineProps<{ const props = defineProps<{
part: Readonly<Entity<typeof schema, 'message_parts'>>; part: Readonly<MessagePart>;
}>(); }>();
</script> </script>
<template> <template>
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" /> <MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content!" />
</template> </template>
+2 -3
View File
@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Entity } from '@triplit/client'; import type { ToolCall } from '~/composables/useChat';
import type schema from '#triplit/schema';
const props = defineProps<{ const props = defineProps<{
toolCall: Readonly<Entity<typeof schema, 'tool_calls'>>; toolCall: Readonly<ToolCall>;
}>(); }>();
const activeTab = ref('input'); const activeTab = ref('input');
+4 -5
View File
@@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Entity } from '@triplit/client'; import type { ToolCall } from '~/composables/useChat';
import type schema from '#triplit/schema';
import Debug from './Debug.vue'; import Debug from './Debug.vue';
const props = defineProps<{ const props = defineProps<{
toolCall: Readonly<Entity<typeof schema, 'tool_calls'>>; toolCall: Readonly<ToolCall>;
}>(); }>();
const deubgToolCallOpen = ref(false); const deubgToolCallOpen = ref(false);
@@ -17,7 +16,7 @@ const toggleDebugToolCall = () => {
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div <div
class="select-none w-full hover:bg-[var(--color-hover)] group rounded-lg p-1 flex justify-between items-center text-[--text-tertiary] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="select-none w-full @hover:bg-[var(--color-hover)] group rounded-lg p-1 flex justify-between items-center text-[--text-tertiary] 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 justify-between w-full">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div <div
@@ -34,7 +33,7 @@ const toggleDebugToolCall = () => {
<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)]"> 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" <button @click="toggleDebugToolCall"
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden hover:bg-[var(--color-hover)] flex items-center justify-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden @hover:bg-[var(--color-hover)] flex items-center justify-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-search w-3 h-3 text-[var(--text-secondary)]"></span> <span class="i-mynaui-search w-3 h-3 text-[var(--text-secondary)]"></span>
</button> </button>
</div> </div>
+2 -2
View File
@@ -25,7 +25,7 @@ const toggleTokenDropdown = () => {
<template> <template>
<div class="flex flex-col w-full gap-2"> <div class="flex flex-col w-full gap-2">
<div v-for="part in message.parts" :key="part.id"> <div v-for="part in message.parts?.filter(p => p.content?.trim() !== '' || p.toolCall)" :key="part.id">
<Reasoning v-if="part.type === 'reasoning'" :part="part" /> <Reasoning v-if="part.type === 'reasoning'" :part="part" />
<Text v-else-if="part.type === 'text'" :part="part" /> <Text v-else-if="part.type === 'text'" :part="part" />
<Tool v-else-if="part.type === 'tool-call'" :toolCall="part.toolCall!" /> <Tool v-else-if="part.type === 'tool-call'" :toolCall="part.toolCall!" />
@@ -54,7 +54,7 @@ const toggleTokenDropdown = () => {
</div> </div>
<button ref="triggerRef" @click="toggleTokenDropdown" <button ref="triggerRef" @click="toggleTokenDropdown"
class="flex gap-2 hover:bg-[var(--color-hover)] px-1 rounded transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="flex gap-2 @hover:bg-[var(--color-hover)] px-1 rounded transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="flex gap-1 items-center" v-if="message.generation.tokens?.output"> <span class="flex gap-1 items-center" v-if="message.generation.tokens?.output">
<span class="i-tabler-coins"></span> <span class="i-tabler-coins"></span>
{{ message.generation?.tokens?.output }} {{ message.generation?.tokens?.output }}
@@ -1,16 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
const tokenDetailsDropdownRef = ref<HTMLElement | null>(null); const tokenDetailsDropdownRef = ref<HTMLElement | null>(null);
const { triggerElement, activeGeneration: generation, close, isOpen } = useTokenDropdown(); const { triggerElement, activeGeneration: generation, close, isOpen } = useTokenDropdown();
const { floatingStyles } = useFloating(triggerElement, tokenDetailsDropdownRef, { const { floatingStyles, middlewareData } = useFloating(triggerElement, tokenDetailsDropdownRef, {
placement: 'bottom-end', placement: 'bottom-end',
whileElementsMounted: autoUpdate, whileElementsMounted: autoUpdate,
middleware: [ middleware: [
offset(4), offset(4),
flip(), flip(),
shift({ padding: 10 }), shift({ padding: 10 }),
hide(),
], ],
transform: false, transform: false,
}); });
@@ -57,7 +58,12 @@ const formatNumber = (num: number | null | undefined) => {
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95"> leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
<div v-if="isOpen && generation && generation.tokens" v-click-outside="close" ref="tokenDetailsDropdownRef" <div v-if="isOpen && generation && generation.tokens" v-click-outside="close" ref="tokenDetailsDropdownRef"
:style="floatingStyles" :style="{
...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}"
class="absolute z-50 flex flex-col min-w-[280px] bg-[var(--bg-surface)] rounded-md border border-[var(--color-border)] shadow-2xl text-[var(--text-secondary)] font-sans"> class="absolute z-50 flex flex-col min-w-[280px] bg-[var(--bg-surface)] rounded-md border border-[var(--color-border)] shadow-2xl text-[var(--text-secondary)] font-sans">
<!-- Output Details Section --> <!-- Output Details Section -->
+4 -5
View File
@@ -1,21 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Entity } from '@triplit/client'; import * as schema from '~~/drizzle/schema';
import type schema from '#triplit/schema';
defineProps<{ defineProps<{
message: Readonly<Entity<typeof schema, 'messages'> & { attachments: Entity<typeof schema, 'attachments'>[] }>; message: Readonly<typeof schema.messages.$inferSelect & { attachments: (typeof schema.attachments.$inferSelect & { file: typeof schema.files.$inferSelect })[] }>;
}>(); }>();
</script> </script>
<template> <template>
<div class="flex flex-col gap-2 max-w-full bg-[var(--bg-container)] py-2 px-3 rounded-xl"> <div class="flex flex-col gap-2 max-w-full bg-[var(--bg-container)] py-2 px-3 rounded-xl">
<MarkdownRenderer :finished="true" :content="message.content!" :id="message.id" /> <MarkdownRenderer :finished="true" :content="message.content!" :id="message.id" />
<div v-if="message.attachments.length > 0" class="flex flex-col gap-2"> <div v-if="message.attachments && message.attachments.length > 0" class="flex flex-col gap-2">
<div v-for="attachment in message.attachments" :key="attachment.id" <div v-for="attachment in message.attachments" :key="attachment.id"
class="flex flex-wrap items-center gap-2"> class="flex flex-wrap items-center gap-2">
<div <div
class="flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center"> class="flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
<AttachmentDisplay :file="attachment" /> <AttachmentDisplay :file="attachment.file" />
</div> </div>
</div> </div>
</div> </div>
+70 -46
View File
@@ -1,21 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Message } from '~/composables/useChat'; import type { Message } from '~/composables/useChat';
import { assert } from '~~/utils/assert';
const triplit = useTriplitClient();
const { openDialog } = useDialog(); const { openDialog } = useDialog();
const { message } = defineProps<{ const { message } = defineProps<{
message: Message message: Message
}>(); }>();
const focusedIndex = computed({ const emit = defineEmits<{
get: () => { regenerate: [];
return message.focusedIndex || 0; delete: [];
}, edit: [value: string];
set: (newValue) => { patch: [updates: Partial<Message>];
triplit.update('messages', message.id, { }>();
let reqAbortController: AbortController | null = null;
watch(() => message.focusedIndex, async (newValue) => {
if (newValue !== undefined) {
if (reqAbortController) {
reqAbortController.abort();
reqAbortController = null;
}
reqAbortController = new AbortController();
await $fetch(`/api/messages/${message.id}`, {
method: 'PATCH',
body: {
focusedIndex: newValue focusedIndex: newValue
},
signal: reqAbortController.signal,
}); });
} }
}); });
@@ -27,44 +42,42 @@ watch(() => message.children.length, (newCount, oldCount) => {
// e.g. if we we have 4 children, and are focused on the 2nd, if we delete it, // e.g. if we we have 4 children, and are focused on the 2nd, if we delete it,
// we want to keep the focus on the 2nd index. It just makes me feel better // we want to keep the focus on the 2nd index. It just makes me feel better
if (oldCount > newCount) { if (oldCount > newCount) {
if (message.deleted === true && focusedIndex.value === newCount) { if (message.deleted === true && (message.focusedIndex || 0) === newCount) {
focusedIndex.value = Math.max(0, newCount - 1); console.log('focusedIndex Math.max(0, newCount - 1)');
message.focusedIndex = Math.max(0, newCount - 1);
return; return;
} }
if (focusedIndex.value > newCount) { if ((message.focusedIndex || 0) > newCount) {
console.log('focusedIndex newCount');
focusedIndex.value = newCount; message.focusedIndex = newCount;
} }
return; return;
} }
if (message.deleted === true) { if (message.deleted === true) {
focusedIndex.value = newCount - 1; console.log('focusedIndex newCount - 1');
message.focusedIndex = newCount - 1;
return; return;
} }
focusedIndex.value = newCount; console.log('focusedIndex newCount');
message.focusedIndex = newCount;
}); });
const activeMessage = computed(() => { const activeMessage = computed(() => {
if (message.children.length === 0 || (focusedIndex.value === 0 && !message.deleted)) { if (message.children.length === 0 || ((message.focusedIndex || 0) === 0 && !message.deleted)) {
return message; return message;
} }
if (message.deleted === true) { if (message.deleted === true) {
return message.children[Math.min(focusedIndex.value, message.children.length - 1)]; return message.children[Math.min((message.focusedIndex || 0), message.children.length - 1)];
} }
return message.children[focusedIndex.value - 1]; return message.children[message.focusedIndex! - 1];
}); });
const emit = defineEmits<{
regenerate: []
delete: []
}>();
const copied = ref(false); const copied = ref(false);
const copyMessage = async () => { const copyMessage = async () => {
if (activeMessage.value!.role === 'user') { if (activeMessage.value!.role === 'user') {
@@ -91,33 +104,44 @@ const regenerateMessage = async () => {
} }
const handleEdit = async () => { const handleEdit = async () => {
const originalContent = message.content!;
openDialog<string>(DialogType.Textbox, async (res) => { openDialog<string>(DialogType.Textbox, async (res) => {
if (res.ok) { if (res.ok) {
if (!res.data) return; if (!res.data) return;
await triplit.update('messages', message.id, { const req = await $fetch(`/api/messages/${message.id}`, {
method: 'PATCH',
body: {
content: res.data content: res.data
},
onRequest() {
message.content = res.data;
emit('edit', res.data);
},
onResponseError() {
message.content = originalContent;
emit('edit', originalContent);
},
}); });
if (!req.ok) {
assert('flush' in triplit); return;
await triplit.flush(); }
await regenerateMessage(); await regenerateMessage();
} }
}, { }, {
title: 'Edit Message', title: 'Edit Message',
initialValue: message.content initialValue: message.content!
}); });
}; };
const deleteMessage = () => { const deleteMessage = () => {
if (focusedIndex.value !== 0 && focusedIndex.value === message.children.length) { // if (focusedIndex.value !== 0 && focusedIndex.value === message.children.length) {
focusedIndex.value = Math.max(0, focusedIndex.value - 1); // focusedIndex.value = Math.max(0, focusedIndex.value - 1);
} // }
nextTick(() => {
emit('delete'); emit('delete');
});
}; };
const messageCount = computed(() => { const messageCount = computed(() => {
@@ -138,21 +162,21 @@ const messageCount = computed(() => {
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div> <div>
<div v-if="messageCount > 1" class="flex gap-1"> <div v-if="messageCount > 1" class="flex gap-1">
<Tooltip :inert="focusedIndex === 0" :hotkey="['alt', '[']"> <Tooltip :inert="message.focusedIndex === 0" :hotkey="['alt', '[']">
<button @click="focusedIndex = focusedIndex! - 1" <button @click="emit('patch', { focusedIndex: message.focusedIndex! - 1 })"
class="hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="@hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-chevron-left w-4 h-4 text-[var(--text-secondary)]" <span class="i-mynaui-chevron-left w-4 h-4 text-[var(--text-secondary)]"
:class="focusedIndex === 0 ? 'opacity-0' : ''"></span> :class="message.focusedIndex === 0 ? 'opacity-0' : ''"></span>
</button> </button>
</Tooltip> </Tooltip>
<span class="text-xs text-[var(--text-secondary)]"> <span class="text-xs text-[var(--text-secondary)]">
{{ focusedIndex + 1 }} / {{ messageCount }} {{ (message.focusedIndex || 0) + 1 }} / {{ messageCount }}
</span> </span>
<Tooltip :inert="focusedIndex + 1 === messageCount" :hotkey="['alt', ']']"> <Tooltip :inert="(message.focusedIndex || 0) + 1 === messageCount" :hotkey="['alt', ']']">
<button @click="focusedIndex = focusedIndex + 1" <button @click="emit('patch', { focusedIndex: (message.focusedIndex || 0) + 1 })"
class="hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="@hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-chevron-right w-4 h-4 text-[var(--text-secondary)]" <span class="i-mynaui-chevron-right w-4 h-4 text-[var(--text-secondary)]"
:class="focusedIndex + 1 === messageCount ? 'opacity-0' : ''"></span> :class="(message.focusedIndex || 0) + 1 === messageCount ? 'opacity-0' : ''"></span>
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
@@ -162,21 +186,21 @@ const messageCount = computed(() => {
class="self-end mt-1 w-fit bg-[var(--bg-container)] text-[var(--text-secondary)] gap-px flex items-center rounded-md overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="self-end mt-1 w-fit bg-[var(--bg-container)] text-[var(--text-secondary)] gap-px flex items-center rounded-md overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<Tooltip :hotkey="['ctrl', 'shift', 'enter']"> <Tooltip :hotkey="['ctrl', 'shift', 'enter']">
<button @click="regenerateMessage" <button @click="regenerateMessage"
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]"> class="flex justify-center items-center w-7 h-6 @hover:bg-[var(--color-hover)]">
<span class="i-mynaui-refresh text-4.5"></span> <span class="i-mynaui-refresh text-4.5"></span>
</button> </button>
</Tooltip> </Tooltip>
<button v-if="message.role === 'user'" @click="handleEdit" <button v-if="message.role === 'user'" @click="handleEdit"
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]"> class="flex justify-center items-center w-7 h-6 @hover:bg-[var(--color-hover)]">
<span class="i-mynaui-pencil text-4.5"></span> <span class="i-mynaui-pencil text-4.5"></span>
</button> </button>
<button @click="copyMessage" :class="{ 'text-emerald-500': copied }" <button @click="copyMessage" :class="{ 'text-emerald-500': copied }"
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]"> class="flex justify-center items-center w-7 h-6 @hover:bg-[var(--color-hover)]">
<span :class="copied ? 'i-mynaui-check text-emerald-500' : 'i-mynaui-copy text-4.5'"></span> <span :class="copied ? 'i-mynaui-check text-emerald-500' : 'i-mynaui-copy text-4.5'"></span>
</button> </button>
<Tooltip :hotkey="['ctrl', 'shift', 'backspace']"> <Tooltip :hotkey="['ctrl', 'shift', 'backspace']">
<button @click="deleteMessage" <button @click="deleteMessage"
class="flex justify-center items-center w-7 h-6 text-red-500 hover:bg-[var(--color-hover)]"> class="flex justify-center items-center w-7 h-6 text-red-500 @hover:bg-[var(--color-hover)]">
<span class="i-mynaui-trash text-5"></span> <span class="i-mynaui-trash text-5"></span>
</button> </button>
</Tooltip> </Tooltip>
+17 -25
View File
@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Model } from '~/types/model'; import { type Model } from '~/composables/useModels';
const { updateModel, deleteModel } = await useModels();
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
model: Model; model: Model;
@@ -22,8 +24,6 @@ const emit = defineEmits<{
edit: [model: Model]; edit: [model: Model];
}>(); }>();
const triplit = useTriplitClient();
const formatContextWindow = (window: number | null | undefined): string => { const formatContextWindow = (window: number | null | undefined): string => {
if (!window) return ''; if (!window) return '';
if (window >= 1000000) return `${(window / 1000000).toFixed(0)}M`; if (window >= 1000000) return `${(window / 1000000).toFixed(0)}M`;
@@ -31,28 +31,16 @@ const formatContextWindow = (window: number | null | undefined): string => {
return window.toString(); return window.toString();
}; };
const hasCapability = (capability: string): boolean => {
return props.model.attributes.capabilities.has(capability);
};
const hasInputModality = (modality: string): boolean => {
return (props.model.attributes.inputModalities as ReadonlySet<string>).has(modality);
};
const showCost = computed(() => { const showCost = computed(() => {
return props.showCost && (props.model.cost.prompt || props.model.cost.completion || props.model.cost.request); return props.showCost && (props.model.cost.prompt || props.model.cost.completion || props.model.cost.request);
}); });
const toggleModel = async (id: string) => { const toggleModel = async (id: string) => {
await triplit.update('models', id, { await updateModel(id, {
enabled: !props.model.enabled, enabled: !props.model.enabled,
}); });
}; };
const deleteModel = async () => {
await triplit.delete('models', props.model.id);
};
const handleEdit = () => { const handleEdit = () => {
emit('edit', props.model); emit('edit', props.model);
}; };
@@ -79,11 +67,11 @@ const handleEdit = () => {
<div v-if="showEdit" class="flex items-center gap-2"> <div v-if="showEdit" class="flex items-center gap-2">
<!-- Edit button - only for custom models --> <!-- Edit button - only for custom models -->
<button v-if="model.isCustom" @click="handleEdit" <button v-if="model.isCustom" @click="handleEdit"
class="rounded h-5 w-5 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-[var(--color-accent)]/30 text-[var(--color-accent)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="rounded h-5 w-5 flex items-center justify-center opacity-0 group-hover:opacity-100 @hover:bg-[var(--color-accent)]/30 text-[var(--color-accent)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-pencil text-3.5"></span> <span class="i-mynaui-pencil text-3.5"></span>
</button> </button>
<button @click="deleteModel" <button @click="deleteModel(model.id)"
class="rounded h-5 w-5 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-red-500/30 text-red-500 transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="rounded h-5 w-5 flex items-center justify-center opacity-0 group-hover:opacity-100 @hover:bg-red-500/30 text-red-500 transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-trash text-3.5"></span> <span class="i-mynaui-trash text-3.5"></span>
</button> </button>
</div> </div>
@@ -91,7 +79,11 @@ const handleEdit = () => {
<div v-if="details" class="flex items-center gap-1.5 flex-wrap text-[var(--text-tertiary)]"> <div v-if="details" class="flex items-center gap-1.5 flex-wrap text-[var(--text-tertiary)]">
<span v-if="showReleaseDate && model.releasedAt" class="text-xs whitespace-nowrap"> <span v-if="showReleaseDate && model.releasedAt" class="text-xs whitespace-nowrap">
Released on {{ model.releasedAt.toISOString().split('T')[0] }} Released on {{
typeof model.releasedAt === 'string'
? (model.releasedAt as string).split('T')[0]
: model.releasedAt.toISOString().split('T')[0]
}}
</span> </span>
<template v-if="showCost"> <template v-if="showCost">
<span v-if="model.cost.prompt" class="text-xs whitespace-nowrap flex items-center"> <span v-if="model.cost.prompt" class="text-xs whitespace-nowrap flex items-center">
@@ -114,23 +106,23 @@ const handleEdit = () => {
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-0.5">
<div v-if="hasInputModality('image')" title="Vision" <div v-if="model.inputModalities.includes('image')" title="Vision"
class="w-4.5 h-4.5 bg-emerald/10 rounded flex items-center justify-center"> class="w-4.5 h-4.5 bg-emerald/10 rounded flex items-center justify-center">
<span class="i-mynaui-image text-2.5 text-emerald"></span> <span class="i-mynaui-image text-2.5 text-emerald"></span>
</div> </div>
<div v-if="hasCapability('reasoning')" title="Reasoning" <div v-if="model.capabilities.includes('reasoning')" title="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"> class="w-4.5 h-4.5 bg-[color-mix(in_srgb,_transparent_90%,_var(--reasoning-accent)_10%)] rounded flex items-center justify-center">
<span class="i-mynaui-atom text-2.5 text-[var(--reasoning-accent)]"></span> <span class="i-mynaui-atom text-2.5 text-[var(--reasoning-accent)]"></span>
</div> </div>
<div v-if="hasCapability('tools')" title="Tools" <div v-if="model.capabilities.includes('tools')" title="Tools"
class="w-4.5 h-4.5 bg-emerald/10 rounded flex items-center justify-center"> class="w-4.5 h-4.5 bg-emerald/10 rounded flex items-center justify-center">
<span class="i-mynaui-tool text-2.5 text-sky"></span> <span class="i-mynaui-tool text-2.5 text-sky"></span>
</div> </div>
</div> </div>
<span v-if="model.attributes.contextWindow" <span v-if="model.contextWindow"
class="text-xs font-mono text-[var(--text-secondary)] px-1.5 py-0.5 rounded bg-[var(--bg-container)]"> class="text-xs font-mono text-[var(--text-secondary)] px-1.5 py-0.5 rounded bg-[var(--bg-container)]">
{{ formatContextWindow(model.attributes.contextWindow) }} {{ formatContextWindow(model.contextWindow) }}
</span> </span>
<Slider v-if="showEdit" :checked="model.enabled" @click="toggleModel(model.id)" /> <Slider v-if="showEdit" :checked="model.enabled" @click="toggleModel(model.id)" />
+20 -17
View File
@@ -1,14 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'; import { ref, computed, watch, nextTick } from 'vue';
import { useFloating, offset, flip, shift, autoUpdate, size } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, size, hide } from '@floating-ui/vue';
import type { Entity } from '@triplit/client'; import type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels';
import type schema from '#triplit/schema';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import { sortByReleaseDate } from '~/utils/sort'; import { sortByReleaseDate } from '~/utils/sort';
import RowVirtualizerFixed from './RowVirtualizerFixed.vue'; import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
const { openDialog } = useDialog(); const { openDialog } = useDialog();
const { allModels } = useModels(); const { allModels } = await useModels();
const { addShortcut } = useKeyboardShortcuts(); const { addShortcut } = useKeyboardShortcuts();
const props = defineProps<{ const props = defineProps<{
@@ -28,7 +26,7 @@ const virtualizerRef = ref<InstanceType<typeof RowVirtualizerFixed> | null>(null
const navigatingWithKeyboard = ref(false); const navigatingWithKeyboard = ref(false);
const focusedOptionId = ref<string | null>(null); const focusedOptionId = ref<string | null>(null);
const { floatingStyles, placement } = useFloating(dropdownButton, dropdownContent, { const { floatingStyles, placement, middlewareData } = useFloating(dropdownButton, dropdownContent, {
placement: 'bottom-start', placement: 'bottom-start',
whileElementsMounted: autoUpdate, whileElementsMounted: autoUpdate,
middleware: [ middleware: [
@@ -43,6 +41,7 @@ const { floatingStyles, placement } = useFloating(dropdownButton, dropdownConten
}, },
padding: 10, padding: 10,
}), }),
hide()
], ],
transform: false, transform: false,
}); });
@@ -65,8 +64,8 @@ const filteredProviders = computed(() => {
const flatOptions = computed(() => { const flatOptions = computed(() => {
type FlatItem = type FlatItem =
| { type: 'header'; id: string; provider: Entity<typeof schema, 'providers'> } | { type: 'header'; id: string; provider: Provider }
| { type: 'model'; id: string; model: Entity<typeof schema, 'models'>; provider: Entity<typeof schema, 'providers'> }; | { type: 'model'; id: string; model: Model; provider: Provider };
const options: FlatItem[] = []; const options: FlatItem[] = [];
for (const provider of filteredProviders.value) { for (const provider of filteredProviders.value) {
@@ -102,7 +101,7 @@ const scrollFocusedIntoView = () => {
} }
}; };
const selectModel = (model: Entity<typeof schema, 'models'>, provider: Entity<typeof schema, 'providers'>) => { const selectModel = (model: Model, provider: Provider) => {
selectedModel.value = { ...model, provider }; selectedModel.value = { ...model, provider };
closeDropdown(); closeDropdown();
}; };
@@ -212,11 +211,11 @@ onUnmounted(() => {
<template> <template>
<div role="button" @click="toggleDropdown()" ref="dropdownButton" <div role="button" @click="toggleDropdown()" ref="dropdownButton"
class="cursor-pointer flex items-center w-fit select-none gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors duration-200" class="cursor-pointer flex items-center min-w-0 select-none gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors duration-200"
:class="[ :class="[
isOpen isOpen
? 'bg-[var(--color-hover)] text-[var(--text-primary)]' ? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--color-hover)]', : 'text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)]',
]"> ]">
<ModelIcon v-if="selectedModel" class="text-white" :avatar="true" variant="color" <ModelIcon v-if="selectedModel" class="text-white" :avatar="true" variant="color"
:model-id="selectedModel.externalId" size="22" /> :model-id="selectedModel.externalId" size="22" />
@@ -236,8 +235,12 @@ onUnmounted(() => {
<KeepAlive> <KeepAlive>
<div v-if="isOpen" v-click-outside="closeDropdown" ref="dropdownContent" role="listbox" <div v-if="isOpen" v-click-outside="closeDropdown" ref="dropdownContent" role="listbox"
aria-label="Select model" aria-label="Select model"
:aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined" :aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined" :style="{
:style="floatingStyles" ...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}"
class="fixed z-100 w-full max-w-[420px] min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden" class="fixed z-100 w-full max-w-[420px] min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden"
:class="transformOrigin"> :class="transformOrigin">
@@ -259,14 +262,14 @@ onUnmounted(() => {
<template v-else> <template v-else>
<RowVirtualizerFixed ref="virtualizerRef" :items="flatOptions" key-field="id" <RowVirtualizerFixed ref="virtualizerRef" :items="flatOptions" key-field="id"
:scroll-element="scrollContainerRef" :item-size="42" :overscan="10"> :scroll-element="scrollContainerRef" :item-size="42" :overscan="10">
<template v-slot="{ item, index }"> <template v-slot="{ item }">
<template v-if="item.type === 'header'"> <template v-if="item.type === 'header'">
<div <div
class="px-4 py-1.5 text-[13px] h-10.5 items-end font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between"> class="px-4 py-1.5 text-[13px] h-10.5 items-end font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between">
{{ item.provider.name }} {{ item.provider.name }}
<button <button
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers', params: item.provider.id })" @click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers', params: item.provider.id })"
class="flex h-5 w-5 items-center justify-center hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="flex h-5 w-5 items-center justify-center @hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-cog-four text-4"></span> <span class="i-mynaui-cog-four text-4"></span>
</button> </button>
</div> </div>
@@ -275,7 +278,7 @@ onUnmounted(() => {
<button :id="`model-option-${item.model.id}`" role="option" <button :id="`model-option-${item.model.id}`" role="option"
:aria-selected="focusedOptionId === item.model.id" :aria-selected="focusedOptionId === item.model.id"
@click="selectModel(item.model, item.provider)" @click="selectModel(item.model, item.provider)"
class="text-white w-full h-10.5 px-4 py-2 flex items-center justify-between hover:bg-[var(--color-hover)] transition-colors duration-150" class="text-white w-full h-10.5 px-4 py-2 flex items-center justify-between @hover:bg-[var(--color-hover)] transition-colors duration-150"
:class="{ :class="{
'bg-[var(--color-hover)]': selectedModel?.id === item.model.id, 'bg-[var(--color-hover)]': selectedModel?.id === item.model.id,
'ring-2 ring-inset ring-[var(--color-accent)]': focusedOptionId === item.model.id && navigatingWithKeyboard 'ring-2 ring-inset ring-[var(--color-accent)]': focusedOptionId === item.model.id && navigatingWithKeyboard
@@ -290,7 +293,7 @@ onUnmounted(() => {
<div class="p-1 border-t border-[var(--color-border)]"> <div class="p-1 border-t border-[var(--color-border)]">
<button <button
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150" class="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers' });"> @click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers' });">
<span class="i-mynaui-cog-four text-4.5"></span> <span class="i-mynaui-cog-four text-4.5"></span>
<span>Manage Providers</span> <span>Manage Providers</span>
+28 -22
View File
@@ -21,7 +21,28 @@ const updateScrollMargin = () => {
} }
}; };
const resizeObserver = new ResizeObserver(updateScrollMargin); const rowVirtualizer = useVirtualizer(computed(() => ({
count: props.items.length,
getScrollElement: () => props.scrollElement,
estimateSize: () => props.minItemSize,
overscan: props.overscan,
scrollMargin: scrollMargin.value,
getItemKey: (index: number) => props.keyField ? props.items[index]?.[props.keyField] || index : index,
})));
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems());
const totalSize = computed(() => rowVirtualizer.value.getTotalSize());
let resizeRafId: number | undefined = undefined;
const resizeObserver = new ResizeObserver(() => {
if (resizeRafId) return;
resizeRafId = requestAnimationFrame(() => {
resizeRafId = undefined;
rowVirtualizer.value.measure();
updateScrollMargin();
});
});
onMounted(() => { onMounted(() => {
resizeObserver.observe(containerRef.value!); resizeObserver.observe(containerRef.value!);
@@ -32,29 +53,14 @@ onUnmounted(() => {
resizeObserver.disconnect(); resizeObserver.disconnect();
}); });
const rowVirtualizer = useVirtualizer(computed(() => ({
count: props.items.length,
getScrollElement: () => props.scrollElement,
estimateSize: () => props.minItemSize,
overscan: props.overscan,
scrollMargin: scrollMargin.value,
getItemKey: (index: number) => props.keyField ? props.items[index]?.[props.keyField] || index : index,
initialRect: {
width: 0,
height: props.prerender ? props.minItemSize * props.prerender : 0
},
})));
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems());
const totalSize = computed(() => rowVirtualizer.value.getTotalSize());
const measureElement = (el: Element) => { // Keep the measure function simple
if (!el) { const measureElement = (el: any) => {
return if (el) {
rowVirtualizer.value.measureElement(el);
} }
};
rowVirtualizer.value.measureElement(el)
}
watch(() => props.items, () => { watch(() => props.items, () => {
rowVirtualizer.value.measure(); rowVirtualizer.value.measure();
@@ -69,7 +75,7 @@ watch(() => props.items, () => {
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: '100%',
height: `${virtualRow.size}px`, minHeight: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start - scrollMargin}px)`, transform: `translateY(${virtualRow.start - scrollMargin}px)`,
}"> }">
<slot :item="props.items[virtualRow.index]" :index="virtualRow.index" /> <slot :item="props.items[virtualRow.index]" :index="virtualRow.index" />
+1 -1
View File
@@ -2,7 +2,7 @@
import { useVirtualizer } from '@tanstack/vue-virtual'; import { useVirtualizer } from '@tanstack/vue-virtual';
const props = defineProps<{ const props = defineProps<{
items: any[]; items: readonly any[] | any[];
keyField?: string; keyField?: string;
scrollElement: HTMLElement | null; scrollElement: HTMLElement | null;
itemSize: number; itemSize: number;
+107 -95
View File
@@ -1,16 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { sortByReleaseDate } from '~/utils/sort'; import { sortByReleaseDate } from '~/utils/sort';
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto'; import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
import { providerBaseUrls, type Model } from '~/types/model'; import { providerBaseUrls, Providers, type Model } from '~/types/model';
import ModelItem from './ModelItem.vue'; import ModelItem from './ModelItem.vue';
import RowVirtualizerDynamic from '../RowVirtualizerDynamic.vue'; import RowVirtualizerDynamic from '../RowVirtualizerDynamic.vue';
const triplit = useTriplitClient();
const props = defineProps<{ const props = defineProps<{
params?: string; params?: string;
}>(); }>();
const { providers } = useModels(); const { providers, updateProvider, createModel, updateModel } = await useModels();
const scrollContainerRef = ref<HTMLDivElement | null>(null); const scrollContainerRef = ref<HTMLDivElement | null>(null);
@@ -65,11 +64,13 @@ if (import.meta.client) {
}; };
const toggleProvider = async () => { const toggleProvider = async () => {
await triplit.update('providers', provider.value!.id, { await updateProvider(provider.value!.id, {
enabled: !provider.value!.enabled, enabled: !provider.value!.enabled,
}); });
}; };
let apiKeyTimeout: NodeJS.Timeout | undefined;
const updateApiKey = async (value: string) => { const updateApiKey = async (value: string) => {
if (!provider.value) return; if (!provider.value) return;
@@ -82,91 +83,63 @@ const updateApiKey = async (value: string) => {
); );
const encypted = await encryptData(key, value); const encypted = await encryptData(key, value);
await triplit.update('providers', provider.value.id, { if (apiKeyTimeout) {
clearTimeout(apiKeyTimeout);
}
apiKeyTimeout = setTimeout(async () => {
await updateProvider(provider.value!.id, {
config: { config: {
...provider.value.config, ...provider.value!.config,
apiKey: uint8ArrayToBase64(encypted), apiKey: uint8ArrayToBase64(encypted),
}, },
}); });
}, 700);
}; };
let proxyUrlTimeout: NodeJS.Timeout | undefined;
const updateProxyUrl = async (value: string) => { const updateProxyUrl = async (value: string) => {
if (!provider.value) return; if (!provider.value) return;
apiProxyUrl.value = value; apiProxyUrl.value = value;
await triplit.update('providers', provider.value.id, { if (proxyUrlTimeout) {
clearTimeout(proxyUrlTimeout);
}
proxyUrlTimeout = setTimeout(async () => {
await updateProvider(provider.value!.id, {
config: { config: {
...provider.value.config, ...provider.value!.config,
apiProxyUrl: value, apiProxyUrl: value,
}, },
}); });
}, 700);
}; };
const fetchingModels = ref(false); const fetchingModels = ref(false);
const fetchModels = async () => { const fetchModels = async () => {
const { user } = useAuth()
fetchingModels.value = true; fetchingModels.value = true;
try { try {
const modelsData = await $fetch(`/api/provider/${provider.value!.id}/models`, { const response = await $fetch(`/api/provider/${provider.value!.id}/models`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: {
providerApiKey: apiKey.value providerApiKey: apiKey.value
})
}) as any;
const existingModelsMap = new Map(
(provider.value?.models || []).map((m: any) => [m.externalId, m])
);
const toInsert: any[] = [];
const toUpdate: { id: string, data: any }[] = [];
for (const model of modelsData.models) {
const existing = existingModelsMap.get(model.id);
if (existing) {
const { id, ...existingWithoutId } = model;
console.log("existingWithoutId", existingWithoutId);
toUpdate.push({
id: existing.id,
data: existingWithoutId,
});
} else {
toInsert.push({
userId: user.value?.id!,
externalId: model.id,
providerId: provider.value!.id!,
name: model.name || model.id,
cost: model.cost || {},
attributes: model.attributes,
isCustom: false,
enabled: false,
releasedAt: model.releasedAt,
});
} }
});
// Simply update the local state with the returned models
if (provider.value && response.models) {
provider.value.models = response.models as Model[];
} }
// delete models that are not in the API response and are not custom models // Optional: show a success toast
const apiModelIds = new Set(modelsData.models.values().map((m: any) => m.id)); } catch (error: any) {
console.log("apiModelIds", apiModelIds);
const toDelete = (provider.value?.models || []).filter((m: any) =>
!m.isCustom && !apiModelIds.has(m.externalId)
);
console.log({ toUpdate, toInsert, toDelete });
await Promise.all([
...toInsert.map(item => triplit.insert('models', item)),
...toUpdate.map(item => triplit.update('models', item.id, item.data)),
...toDelete.map(item => triplit.delete('models', item.id))
]);
} catch (error) {
console.error('Failed to fetch models:', error); console.error('Failed to fetch models:', error);
// handle error (toast, etc)
} finally { } finally {
fetchingModels.value = false; fetchingModels.value = false;
} }
@@ -175,23 +148,64 @@ const fetchModels = async () => {
const deleteModels = async () => { const deleteModels = async () => {
if (!provider.value) return; if (!provider.value) return;
await Promise.all(provider.value.models.map(m => triplit.delete('models', m.id))); provider.value!.models = [];
await $fetch(`/api/provider/${provider.value!.id}/models`, {
method: 'DELETE',
});
}; };
const enableAllModels = async () => { const enableAllModels = async () => {
if (!provider.value) return; if (!provider.value) return;
await Promise.all(provider.value.models.map(m => triplit.update('models', m.id, { const originalModels = provider.value.models;
await $fetch(`/api/provider/${provider.value.id}/models`, {
method: 'PATCH',
body: {
enabled: true enabled: true
}))); },
onRequest() {
if (provider.value === null || provider.value === undefined) {
return;
}
provider.value.models = provider.value?.models.map(m => ({ ...m, enabled: true })) ?? [];
},
onResponseError() {
if (provider.value === null || provider.value === undefined) {
return;
}
provider.value.models = originalModels;
},
});
}; };
const disableAllModels = async () => { const disableAllModels = async () => {
if (!provider.value) return; if (!provider.value) return;
await Promise.all(provider.value.models.map(m => triplit.update('models', m.id, { const originalModels = provider.value.models;
await $fetch(`/api/provider/${provider.value.id}/models`, {
method: 'PATCH',
body: {
enabled: false enabled: false
}))); },
onRequest() {
if (provider.value === null || provider.value === undefined) {
return;
}
provider.value.models = provider.value.models.map(m => ({ ...m, enabled: false }));
},
onResponseError() {
if (provider.value === null || provider.value === undefined) {
return;
}
provider.value.models = originalModels;
},
});
}; };
const enabledModels = computed(() => const enabledModels = computed(() =>
@@ -272,13 +286,13 @@ const openEditPanel = (model: Model) => {
formData.value = { formData.value = {
name: model.name || '', name: model.name || '',
externalId: model.externalId || '', externalId: model.externalId || '',
contextWindow: model.attributes?.contextWindow?.toString() || '', contextWindow: model.contextWindow?.toString() || '',
capabilities: [...(model.attributes?.capabilities || [])], capabilities: model.capabilities,
promptCost: model.cost?.prompt || '', promptCost: model.cost?.prompt || '',
completionCost: model.cost?.completion || '', completionCost: model.cost?.completion || '',
reasoning: model.attributes?.capabilities?.has('reasoning') || false, reasoning: model.capabilities.includes('reasoning'),
tools: model.attributes?.capabilities?.has('tools') || false, tools: model.capabilities.includes('tools'),
vision: model.attributes?.capabilities?.has('vision') || false, vision: model.capabilities.includes('vision'),
}; };
showAddModelPanel.value = true; showAddModelPanel.value = true;
}; };
@@ -289,11 +303,11 @@ const { user } = useAuth();
const previewModel = computed(() => { const previewModel = computed(() => {
if (!formData.value.name || !formData.value.externalId) return null; if (!formData.value.name || !formData.value.externalId) return null;
const inputModalities = new Set<string>(['text']); const inputModalities = ['text'];
const outputModalities = new Set<string>(['text']); const outputModalities = ['text'];
if (formData.value.capabilities.includes('vision')) { if (formData.value.capabilities.includes('vision')) {
inputModalities.add('image'); inputModalities.push('image');
} }
return { return {
@@ -306,15 +320,15 @@ const previewModel = computed(() => {
prompt: formData.value.promptCost ? formatMoney(formData.value.promptCost) : undefined, prompt: formData.value.promptCost ? formatMoney(formData.value.promptCost) : undefined,
completion: formData.value.completionCost ? formatMoney(formData.value.completionCost) : undefined, completion: formData.value.completionCost ? formatMoney(formData.value.completionCost) : undefined,
}, },
attributes: {
inputModalities, inputModalities,
outputModalities, outputModalities,
capabilities: new Set(formData.value.capabilities.filter(c => c !== 'vision') as any), capabilities: formData.value.capabilities.filter(c => c !== 'vision') as any,
contextWindow: formData.value.contextWindow ? parseInt(formData.value.contextWindow) : null, contextWindow: formData.value.contextWindow ? parseInt(formData.value.contextWindow) : null,
}, supportedParameters: [],
isCustom: true, isCustom: true,
enabled: true, enabled: true,
provider: provider.value!, provider: provider.value!,
releasedAt: null,
} as Model; } as Model;
}); });
@@ -323,11 +337,9 @@ const saveCustomModel = async () => {
const { id, ...previewModelWithoutId } = previewModel.value!; const { id, ...previewModelWithoutId } = previewModel.value!;
if (editingModel.value) { if (editingModel.value) {
// Update existing model await updateModel(editingModel.value!.id, { ...previewModelWithoutId });
await triplit.update('models', editingModel.value.id, previewModelWithoutId);
} else { } else {
// Insert new custom model await createModel(previewModelWithoutId);
await triplit.insert('models', previewModelWithoutId);
} }
showAddModelPanel.value = false; showAddModelPanel.value = false;
@@ -382,7 +394,7 @@ defineEmits(['navigate']);
autocomplete="false" spellcheck="false" autocomplete="false" spellcheck="false"
@input="updateApiKey(($event.target! as HTMLInputElement).value)" /> @input="updateApiKey(($event.target! as HTMLInputElement).value)" />
<button @click="apiKeyVisible = !apiKeyVisible" <button @click="apiKeyVisible = !apiKeyVisible"
class="text-sm p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"> class="text-sm p-2 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
<span class="text-4" :class="apiKeyVisible ? 'i-mynaui-eye' : 'i-mynaui-eye-slash'"></span> <span class="text-4" :class="apiKeyVisible ? 'i-mynaui-eye' : 'i-mynaui-eye-slash'"></span>
</button> </button>
</div> </div>
@@ -391,7 +403,7 @@ defineEmits(['navigate']);
<div class="flex flex-row justify-between gap-16"> <div class="flex flex-row justify-between gap-16">
<label class="whitespace-nowrap" for="provider-api-key">API Proxy URL</label> <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(--bg-container)] items-center gap-1 w-7/10"> <div class="text-sm font-mono flex flex-row rounded-md bg-[var(--bg-container)] items-center gap-1 w-7/10">
<input :placeholder="provider.type ? providerBaseUrls[provider.type] ?? '' : ''" <input :placeholder="provider.type ? providerBaseUrls[provider.type as typeof Providers[number]] : ''"
class="placeholder:text-[var(--text-tertiary)] w-full px-2 py-1 bg-transparent" type="text" class="placeholder:text-[var(--text-tertiary)] w-full px-2 py-1 bg-transparent" type="text"
id="provider-proxy-url" :value="apiProxyUrl" id="provider-proxy-url" :value="apiProxyUrl"
@input="updateProxyUrl(($event.target! as HTMLInputElement).value)" /> @input="updateProxyUrl(($event.target! as HTMLInputElement).value)" />
@@ -411,7 +423,7 @@ defineEmits(['navigate']);
Model List Model List
<span class="text-sm text-[var(--text-secondary)] font-normal text-xs flex items-center gap-1"> <span class="text-sm text-[var(--text-secondary)] font-normal text-xs flex items-center gap-1">
{{ provider?.models.length }} models available <button {{ provider?.models.length }} models available <button
class="p-0.5 hover:bg-[var(--color-hover)] rounded transition-colors duration-200" class="p-0.5 @hover:bg-[var(--color-hover)] rounded transition-colors duration-200"
@click="deleteModels"> @click="deleteModels">
<span class="i-mynaui-x-solid"></span> <span class="i-mynaui-x-solid"></span>
</button> </button>
@@ -423,20 +435,20 @@ defineEmits(['navigate']);
<input class="placeholder:text-[var(--text-tertiary)] p-0 bg-transparent" v-model="modelSearch" <input class="placeholder:text-[var(--text-tertiary)] p-0 bg-transparent" v-model="modelSearch"
type="text" placeholder="Search models..." /> type="text" placeholder="Search models..." />
<button :class="modelSearch.length > 0 ? 'visible' : 'invisible'" @click="modelSearch = ''" <button :class="modelSearch.length > 0 ? 'visible' : 'invisible'" @click="modelSearch = ''"
class="right-1 hover:bg-[var(--color-hover)] rounded transition-colors duration-200 p-0.5"> class="right-1 @hover:bg-[var(--color-hover)] rounded transition-colors duration-200 p-0.5">
<span class="i-mynaui-x-solid text-3.5 block text-[var(--text-secondary)]"></span> <span class="i-mynaui-x-solid text-3.5 block text-[var(--text-secondary)]"></span>
</button> </button>
</div> </div>
<button @click="fetchModels" <button @click="fetchModels"
class="whitespace-nowrap flex bg-[var(--bg-container)] hover:bg-[var(--color-hover)] 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)]"> class="whitespace-nowrap flex bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] 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)]">
<span class="i-mynaui-refresh" :class="{ 'animate-rotate': fetchingModels }"></span> <span class="i-mynaui-refresh" :class="{ 'animate-rotate': fetchingModels }"></span>
fetch models fetch models
</button> </button>
<div class="flex"> <div class="flex">
<button @click="openAddPanel" <button @click="openAddPanel"
class="whitespace-nowrap flex bg-[var(--bg-container)] hover:bg-[var(--color-hover)] text-sm rounded-l-md items-center px-2 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="whitespace-nowrap flex bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] text-sm rounded-l-md items-center px-2 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-plus text-5"></span> <span class="i-mynaui-plus text-5"></span>
</button> </button>
@@ -444,19 +456,19 @@ defineEmits(['navigate']);
<Dropdown placement="bottom-end"> <Dropdown placement="bottom-end">
<template #default="{ toggle, setRef }"> <template #default="{ toggle, setRef }">
<button :ref="setRef" @click="toggle" <button :ref="setRef" @click="toggle"
class="whitespace-nowrap flex bg-[var(--bg-container)] hover:bg-[var(--color-hover)] text-sm rounded-r-md items-center px-1 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="whitespace-nowrap flex bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] text-sm rounded-r-md items-center px-1 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-dots-vertical text-5"></span> <span class="i-mynaui-dots-vertical text-5"></span>
</button> </button>
</template> </template>
<template #dropdown="{ close }"> <template #dropdown="{ close }">
<button @click="enableAllModels(); close()" <button @click="enableAllModels(); close()"
class="truncate flex items-center gap-2 w-full text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"> class="truncate flex items-center gap-2 w-full text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150">
<span class="text-5 i-mynaui-toggle-right-solid"></span> <span class="text-5 i-mynaui-toggle-right-solid"></span>
Enable All Enable All
</button> </button>
<button @click="disableAllModels(); close()" <button @click="disableAllModels(); close()"
class="truncate flex items-center gap-2 w-full text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"> class="truncate flex items-center gap-2 w-full text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150">
<span class="text-5 i-mynaui-toggle-left"></span> <span class="text-5 i-mynaui-toggle-left"></span>
Disable All Disable All
</button> </button>
@@ -481,7 +493,7 @@ defineEmits(['navigate']);
{{ editingModel ? 'Edit Custom Model' : 'Add Custom Model' }} {{ editingModel ? 'Edit Custom Model' : 'Add Custom Model' }}
</h5> </h5>
<button @click="cancelPanel" <button @click="cancelPanel"
class="p-1 hover:bg-[var(--color-hover)] rounded transition-colors duration-200"> class="p-1 @hover:bg-[var(--color-hover)] rounded transition-colors duration-200">
<span class="i-mynaui-x text-4 text-[var(--text-secondary)]"></span> <span class="i-mynaui-x text-4 text-[var(--text-secondary)]"></span>
</button> </button>
</div> </div>
@@ -518,7 +530,7 @@ defineEmits(['navigate']);
class="px-3 py-1.5 text-xs rounded-md border transition-colors duration-200 capitalize" class="px-3 py-1.5 text-xs rounded-md border transition-colors duration-200 capitalize"
:class="formData.capabilities.includes(cap) :class="formData.capabilities.includes(cap)
? 'bg-[var(--color-accent)] text-white border-[var(--color-accent)]' ? 'bg-[var(--color-accent)] text-white border-[var(--color-accent)]'
: 'bg-[var(--bg-surface)] border-[var(--color-border)] text-[var(--text-secondary)] hover:border-[var(--color-accent)]'"> : 'bg-[var(--bg-surface)] border-[var(--color-border)] text-[var(--text-secondary)] @hover:border-[var(--color-accent)]'">
{{ cap }} {{ cap }}
</button> </button>
</div> </div>
@@ -565,11 +577,11 @@ defineEmits(['navigate']);
</span> </span>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="cancelPanel" <button @click="cancelPanel"
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--color-hover)] rounded-md transition-colors duration-200"> class="px-3 py-1.5 text-sm text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] rounded-md transition-colors duration-200">
Cancel Cancel
</button> </button>
<button @click="saveCustomModel" :disabled="!isFormValid" <button @click="saveCustomModel" :disabled="!isFormValid"
class="px-3 py-1.5 text-sm bg-[var(--color-accent)] text-white rounded-md hover:opacity-90 transition-opacity duration-200 disabled:opacity-50 disabled:cursor-not-allowed"> class="px-3 py-1.5 text-sm bg-[var(--color-accent)] text-white rounded-md @hover:opacity-90 transition-opacity duration-200 disabled:opacity-50 disabled:cursor-not-allowed">
{{ editingModel ? 'Save Changes' : 'Add Model' }} {{ editingModel ? 'Save Changes' : 'Add Model' }}
</button> </button>
</div> </div>
@@ -595,7 +607,7 @@ defineEmits(['navigate']);
</span> </span>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<RowVirtualizerDynamic :items="enabledModels" key-field="id" <RowVirtualizerDynamic :items="enabledModels" key-field="id"
:scroll-element="scrollContainerRef" :min-item-size="64" :overscan="20"> :scroll-element="scrollContainerRef" :min-item-size="68" :overscan="20">
<template v-slot="{ item: model }"> <template v-slot="{ item: model }">
<ModelItem :model="model" @edit="openEditPanel" /> <ModelItem :model="model" @edit="openEditPanel" />
</template> </template>
@@ -609,7 +621,7 @@ defineEmits(['navigate']);
</span> </span>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<RowVirtualizerDynamic :items="disabledModels" key-field="id" <RowVirtualizerDynamic :items="disabledModels" key-field="id"
:scroll-element="scrollContainerRef" :min-item-size="64" :overscan="20"> :scroll-element="scrollContainerRef" :min-item-size="68" :overscan="20">
<template v-slot="{ item: model }"> <template v-slot="{ item: model }">
<ModelItem :model="model" @edit="openEditPanel" /> <ModelItem :model="model" @edit="openEditPanel" />
</template> </template>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const { colorScheme, settings, updateSettings } = useUserSettings(); const { colorScheme, settings, updateSettings } = await useUserSettings();
const accents = ['violet', 'volcano', 'lime', 'sky', 'coral', 'emerald', 'amber', 'rose', 'cyan', 'indigo', 'magenta']; const accents = ['violet', 'volcano', 'lime', 'sky', 'coral', 'emerald', 'amber', 'rose', 'cyan', 'indigo', 'magenta'];
const neutrals = ['zinc', 'slate', 'obsidian']; const neutrals = ['zinc', 'slate', 'obsidian'];
@@ -27,19 +27,19 @@ defineEmits(['navigate']);
<h4 class="font-medium">Theme</h4> <h4 class="font-medium">Theme</h4>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="updateSettings({ appearance: { colorScheme: 'system' } })" <button @click="updateSettings({ appearance: { colorScheme: 'system' } })"
class="flex items-center gap-1 px-1 rounded-md hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" class="flex items-center gap-1 px-1 rounded-md @hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="colorScheme.preference.value === 'system' ? 'bg-[var(--color-hover)]' : ''"> :class="colorScheme.preference.value === 'system' ? 'bg-[var(--color-hover)]' : ''">
<span class="i-tabler-device-desktop text-4"></span> <span class="i-tabler-device-desktop text-4"></span>
<span>System</span> <span>System</span>
</button> </button>
<button @click="updateSettings({ appearance: { colorScheme: 'dark' } })" <button @click="updateSettings({ appearance: { colorScheme: 'dark' } })"
class="flex items-center gap-1 px-1 rounded-md hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" class="flex items-center gap-1 px-1 rounded-md @hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="colorScheme.preference.value === 'dark' ? 'bg-[var(--color-hover)]' : ''"> :class="colorScheme.preference.value === 'dark' ? 'bg-[var(--color-hover)]' : ''">
<span class="i-mynaui-moon text-4"></span> <span class="i-mynaui-moon text-4"></span>
<span>Dark</span> <span>Dark</span>
</button> </button>
<button @click="updateSettings({ appearance: { colorScheme: 'light' } })" <button @click="updateSettings({ appearance: { colorScheme: 'light' } })"
class="flex items-center gap-1 px-1 rounded-md hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" class="flex items-center gap-1 px-1 rounded-md @hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="colorScheme.preference.value === 'light' ? 'bg-[var(--color-hover)]' : ''"> :class="colorScheme.preference.value === 'light' ? 'bg-[var(--color-hover)]' : ''">
<span class="i-mynaui-sun text-4"></span> <span class="i-mynaui-sun text-4"></span>
<span>Light</span> <span>Light</span>
@@ -51,7 +51,7 @@ defineEmits(['navigate']);
<h4 class="text-sm font-medium">Accent Color</h4> <h4 class="text-sm font-medium">Accent Color</h4>
<div class="grid grid-cols-5 gap-2"> <div class="grid grid-cols-5 gap-2">
<button v-for="accent in accents" :key="accent" @click="updateAccent(accent)" <button v-for="accent in accents" :key="accent" @click="updateAccent(accent)"
class="h-8 rounded border-2 hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]" class="h-8 rounded border-2 @hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]"
:class="[ :class="[
settings.appearance.accent === accent ? 'dark:border-white/70 border-black/70' : 'border-transparent' settings.appearance.accent === accent ? 'dark:border-white/70 border-black/70' : 'border-transparent'
]" :style="`background-color: var(--accent-${accent})`" :title="accent" /> ]" :style="`background-color: var(--accent-${accent})`" :title="accent" />
@@ -62,9 +62,9 @@ defineEmits(['navigate']);
<h4 class="text-sm font-medium">Neutral Color</h4> <h4 class="text-sm font-medium">Neutral Color</h4>
<div class="grid grid-cols-5 gap-2"> <div class="grid grid-cols-5 gap-2">
<button v-for="neutral in neutrals" :key="neutral" @click="updateNeutral(neutral)" <button v-for="neutral in neutrals" :key="neutral" @click="updateNeutral(neutral)"
class="h-8 rounded border-2 hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]" class="h-8 rounded border-2 @hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]"
:class="[ :class="[
settings.appearance.neutral === neutral ? 'border-[var(--color-accent)]' : 'border-transparent hover:border-zinc-500' settings.appearance.neutral === neutral ? 'border-[var(--color-accent)]' : 'border-transparent @hover:border-zinc-500'
]" :style="`background-color: var(--palette-${neutral}-200)`" :title="neutral" /> ]" :style="`background-color: var(--palette-${neutral}-200)`" :title="neutral" />
</div> </div>
</div> </div>
+1 -1
View File
@@ -14,7 +14,7 @@ const handleEdit = (model: Model) => {
<template> <template>
<div <div
class="p-3 text-white flex max-w-full items-center justify-between gap-2 group hover:bg-[var(--color-hover)] transition duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="min-h-17 p-3 text-white flex max-w-full items-center justify-between gap-2 group @hover:bg-[var(--color-hover)] transition duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<ModelInfo :details="true" :model="model" :show-edit="true" :show-cost="true" :show-external-id="true" <ModelInfo :details="true" :model="model" :show-edit="true" :show-cost="true" :show-external-id="true"
:show-release-date="true" @edit="handleEdit" /> :show-release-date="true" @edit="handleEdit" />
</div> </div>
+4 -31
View File
@@ -1,9 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Providers } from '~/types/model';
import { providerIcons } from '~/utils/model-mapping'; import { providerIcons } from '~/utils/model-mapping';
const triplit = useTriplitClient(); const { providers, updateProvider } = await useModels();
const { providers } = useModels();
const props = defineProps<{ const props = defineProps<{
params?: string; params?: string;
@@ -11,32 +9,11 @@ const props = defineProps<{
if (providers.value === undefined) throw new Error('Providers not loaded'); if (providers.value === undefined) throw new Error('Providers not loaded');
// TODO: sometimes this code can create duplicate providers
const { user } = useAuth();
for (const provider of Providers) {
if (!providers.value?.find(p => p.type === provider)) {
// create a new provider
await triplit.insert('providers', {
name: provider,
userId: user.value!.id,
type: provider,
enabled: false,
config: {},
});
}
}
for (const provider of providers.value) {
if (!Providers.includes(provider.type)) {
await triplit.delete('providers', provider.id);
}
}
const toggleProvider = async (id: string) => { const toggleProvider = async (id: string) => {
const provider = providers.value!.find(p => p.id === id); const provider = providers.value!.find(p => p.id === id);
if (!provider) return; if (!provider) return;
await triplit.update('providers', provider.id, { await updateProvider(provider.id, {
enabled: !provider.enabled, enabled: !provider.enabled,
}); });
}; };
@@ -56,7 +33,7 @@ defineEmits(['navigate']);
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]"> 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)" <button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => p.enabled)"
:key="p.id" :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-border)] hover:border-[var(--color-border-active)]"> 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-border)] @hover:border-[var(--color-border-active)]">
<div class="flex flex-col flex-grow"> <div class="flex flex-col flex-grow">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]" <component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
@@ -66,8 +43,6 @@ defineEmits(['navigate']);
<hr class="border-t border-[var(--color-border)]" /> <hr class="border-t border-[var(--color-border)]" />
</div> </div>
<div class="flex items-center justify-end"> <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-border)]" /> -->
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" /> <Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
</div> </div>
</button> </button>
@@ -82,7 +57,7 @@ defineEmits(['navigate']);
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]"> 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)" <button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => !p.enabled)"
:key="p.id" :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-border)] hover:border-[var(--color-border-active)]"> 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-border)] @hover:border-[var(--color-border-active)]">
<div class="flex flex-col flex-grow"> <div class="flex flex-col flex-grow">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]" <component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
@@ -92,8 +67,6 @@ defineEmits(['navigate']);
<hr class="border-t border-[var(--color-border)]" /> <hr class="border-t border-[var(--color-border)]" />
</div> </div>
<div class="flex items-center justify-end"> <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-border)]" /> -->
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" /> <Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
</div> </div>
</button> </button>
+5 -5
View File
@@ -4,7 +4,7 @@ import { providerIcons } from '~/utils/model-mapping';
defineProps<{ defineProps<{
params?: string; params?: string;
}>(); }>();
const { providers } = useModels(); const { providers } = await useModels();
defineEmits(['navigate']); defineEmits(['navigate']);
</script> </script>
@@ -12,19 +12,19 @@ defineEmits(['navigate']);
<template> <template>
<div class="flex flex-col gap-1 overflow-auto"> <div class="flex flex-col gap-1 overflow-auto">
<button @click="$emit('navigate', 'general')" <button @click="$emit('navigate', 'general')"
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="flex items-center gap-2 p-2 rounded-lg text-sm @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-chevron-left text-4"></span> Back to General <span class="i-mynaui-chevron-left text-4"></span> Back to General
</button> </button>
<button @click="$emit('navigate', 'providers')" <button @click="$emit('navigate', 'providers')"
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="flex items-center gap-2 p-2 rounded-lg text-sm @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-envelope-open text-4"></span> All <span class="i-mynaui-envelope-open text-4"></span> All
</button> </button>
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Enabled Providers</div> <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)" <button v-for="p in providers?.filter(p => p.enabled)" :key="p.id" @click="$emit('navigate', 'providers', p.id)"
:class="['case-capital flex items-center justify-between p-2 hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']"> :class="['case-capital flex items-center justify-between p-2 @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]" <component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
class="w-4 h-4 text-[var(--text-primary)]" /> class="w-4 h-4 text-[var(--text-primary)]" />
@@ -36,7 +36,7 @@ defineEmits(['navigate']);
<button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id" <button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
@click="$emit('navigate', 'providers', p.id)" @click="$emit('navigate', 'providers', p.id)"
:class="['case-capital flex items-center justify-between p-2 hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']"> :class="['case-capital flex items-center justify-between p-2 @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]" <component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
class="w-4 h-4 text-[var(--text-primary)]" /> class="w-4 h-4 text-[var(--text-primary)]" />
+2 -3
View File
@@ -5,8 +5,8 @@ const props = defineProps<{
params?: string; params?: string;
}>(); }>();
const { providers, allModels } = useModels(); const { providers, allModels } = await useModels();
const { settings, updateSettings } = useUserSettings(); const { settings, updateSettings } = await useUserSettings();
const toggle = async (key: string) => { const toggle = async (key: string) => {
const current = (settings.value.systemAssistants as any)[key]; const current = (settings.value.systemAssistants as any)[key];
@@ -39,7 +39,6 @@ const getModel = (id: string | null | undefined) => {
} }
defineEmits(['navigate']); defineEmits(['navigate']);
</script> </script>
<template> <template>
+3 -8
View File
@@ -1,7 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { assert } from '~~/utils/assert';
const triplit = useTriplitClient();
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
// to prevent the user details from going blank for a // to prevent the user details from going blank for a
@@ -23,8 +20,6 @@ const { isHovered } = useSidenavContext();
const handleLogout = async () => { const handleLogout = async () => {
await signOut(); await signOut();
assert('disconnect' in triplit);
triplit.disconnect();
await navigateTo('/auth/login'); await navigateTo('/auth/login');
}; };
@@ -33,7 +28,7 @@ const handleLogout = async () => {
<template> <template>
<header class="flex items-center justify-between overflow-hidden"> <header class="flex items-center justify-between overflow-hidden">
<button @click="dropdownOpen = !dropdownOpen" <button @click="dropdownOpen = !dropdownOpen"
class="flex gap-1 pr-1 items-center hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="flex gap-1 pr-1 items-center @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<div <div
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-border)]']"> :class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-border)]']">
<img v-if="cachedUser?.image" :src="cachedUser.image" class="w-full h-full object-cover" /> <img v-if="cachedUser?.image" :src="cachedUser.image" class="w-full h-full object-cover" />
@@ -58,13 +53,13 @@ const handleLogout = async () => {
<div v-if="dropdownOpen" v-click-outside="() => dropdownOpen = false" <div v-if="dropdownOpen" v-click-outside="() => dropdownOpen = false"
class="w-full top-full text-sm mt-1 absolute z-30 bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 flex flex-col gap-2"> class="w-full top-full text-sm mt-1 absolute z-30 bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 flex flex-col gap-2">
<button @click="dropdownOpen = false; openDialog(DialogType.Settings)" <button @click="dropdownOpen = false; openDialog(DialogType.Settings)"
class="text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-cog-four text-4.5"></span> <span class="i-mynaui-cog-four text-4.5"></span>
<span>Settings</span> <span>Settings</span>
</button> </button>
<hr class="border-t border-[var(--color-border)]" /> <hr class="border-t border-[var(--color-border)]" />
<button @click="handleLogout" <button @click="handleLogout"
class="text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-logout text-4.5"></span> <span class="i-mynaui-logout text-4.5"></span>
<span>Log out</span> <span>Log out</span>
</button> </button>
+4 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute(); const route = useRoute();
const { agents, getAgent } = useAgents(); const { agents, getAgent } = await useAgents();
const agentId = computed(() => route.params.id as string); const agentId = computed(() => route.params.id as string);
const activeAgent = getAgent(agentId); const activeAgent = getAgent(agentId);
@@ -13,7 +13,7 @@ const { isHovered } = useSidenavContext();
<div :style="isHovered ? 'width: 32px;' : 'width: 0px;'" <div :style="isHovered ? 'width: 32px;' : 'width: 0px;'"
:class="['flex flex-shrink-0 transform-origin-left-center items-center overflow-hidden transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-95']"> :class="['flex flex-shrink-0 transform-origin-left-center items-center overflow-hidden transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-95']">
<NuxtLink to="/" <NuxtLink to="/"
class="flex hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg decoration-none transition-inherit text-[var(--text-secondary)] p-1.5"> class="flex @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg decoration-none transition-inherit text-[var(--text-secondary)] p-1.5">
<span class="i-mynaui-chevron-left text-4.5"></span> <span class="i-mynaui-chevron-left text-4.5"></span>
</NuxtLink> </NuxtLink>
</div> </div>
@@ -21,7 +21,7 @@ const { isHovered } = useSidenavContext();
<Dropdown placement="bottom" dropdown-class="max-w-48"> <Dropdown placement="bottom" dropdown-class="max-w-48">
<template #default="{ toggle, setRef }"> <template #default="{ toggle, setRef }">
<button :ref="setRef" @click="toggle" <button :ref="setRef" @click="toggle"
class="shrink-1 max-w-full min-w-0 transition duration-200 pr-2 cursor-pointer hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg"> class="shrink-1 max-w-full min-w-0 transition duration-200 pr-2 cursor-pointer @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg">
<div class="pointer-events-none flex items-center gap-1.5 max-w-full"> <div class="pointer-events-none flex items-center gap-1.5 max-w-full">
<div <div
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-border)]']"> :class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-border)]']">
@@ -43,7 +43,7 @@ const { isHovered } = useSidenavContext();
<template #dropdown="{ close }"> <template #dropdown="{ close }">
<button v-for="agent in agents" :key="agent.id" @click="navigateTo(`/agent/${agent.id}`); close()" <button v-for="agent in agents" :key="agent.id" @click="navigateTo(`/agent/${agent.id}`); close()"
class="truncate block w-full text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"> class="truncate block w-full text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150">
{{ agent.name }} {{ agent.name }}
</button> </button>
</template> </template>
+2 -2
View File
@@ -13,7 +13,7 @@ const props = defineProps<{
props.icon ? 'px-1' : 'px-2', props.icon ? 'px-1' : 'px-2',
props.active props.active
? 'text-[var(--text-primary)] bg-[var(--color-hover)]' ? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
: 'hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]' : '@hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
]"> ]">
<div class="flex items-center gap-2 max-w-full flex-1"> <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"> <div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
@@ -31,7 +31,7 @@ const props = defineProps<{
props.icon ? 'px-1' : 'px-2', props.icon ? 'px-1' : 'px-2',
props.active props.active
? 'text-[var(--text-primary)] bg-[var(--color-hover)]' ? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
: 'hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]' : '@hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
]"> ]">
<div class="flex items-center gap-2 max-w-full flex-1"> <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"> <div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
+31 -66
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { assert } from '~~/utils/assert';
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue'; import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
const dropdownOpen = ref(false); const dropdownOpen = ref(false);
const dropdownTrigger = ref<HTMLElement | null>(null); const dropdownTrigger = ref<HTMLElement | null>(null);
@@ -9,9 +8,8 @@ const dropdownContent = ref(null);
const activeMenuTopicId = ref<string | null>(null); const activeMenuTopicId = ref<string | null>(null);
const route = useRoute(); const route = useRoute();
const { getAgent } = useAgents(); const { getAgent, patchTopicLocally, deleteTopic: deleteAgentTopic } = await useAgents();
const { autoRename } = useTopic();
const triplit = useTriplitClient();
const navRef = ref<HTMLElement | null>(null); const navRef = ref<HTMLElement | null>(null);
const agentId = computed(() => route.params.id as string); const agentId = computed(() => route.params.id as string);
@@ -27,10 +25,10 @@ const topics = computed(() => {
return activeAgent.value?.topics || []; return activeAgent.value?.topics || [];
}) })
const { floatingStyles, placement } = useFloating(dropdownTrigger, dropdownContent, { const { floatingStyles, placement, middlewareData } = useFloating(dropdownTrigger, dropdownContent, {
placement: 'bottom-end', placement: 'bottom-end',
whileElementsMounted: autoUpdate, whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 10 })], middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
transform: false, transform: false,
}); });
@@ -52,59 +50,16 @@ const menuTopic = computed(() =>
); );
const topicsOpen = ref(true); const topicsOpen = ref(true);
let activeAutoRenames = reactive(new Map<string, string>());
const autoRenameTopic = async (topicId: string) => { const autoRenameTopic = async (topicId: string) => {
const { setPage } = useSettings(); autoRename(topicId);
const { autoRename, AutoRenameError } = useChat(agentId.value);
const firstMessage = await triplit.fetchOne(triplit.query('messages').Where('topicId', '=', topicId).Order('createdAt', 'ASC').Limit(1));
if (!firstMessage) return;
const res = await autoRename(topicId, firstMessage.content);
if (res.ok) {
activeAutoRenames.set(topicId, res.data);
return;
}
switch (res.error) {
case AutoRenameError.NoModelSelected:
case AutoRenameError.ModelDisabled:
case AutoRenameError.AutoRenameDisabled: {
setPage('systemAssistants');
} break;
case AutoRenameError.NoModelFound:
case AutoRenameError.DatabaseOperationFailed:
case AutoRenameError.FailedToGenerate:
case AutoRenameError.FailedToDecryptProviderApiKey: {
console.error('Failed to auto-rename:', res.error);
await triplit.update('topics', topicId, {
renaming: false,
});
assert('flush' in triplit);
await triplit.flush();
} break;
}
} }
const cancelAutoRename = async (topicId: string) => { const cancelAutoRename = async (topicId: string) => {
await triplit.update('topics', topicId, { // TODO: make this more optimistic
renaming: false, await $fetch(`/api/topic/${topicId}/auto-rename/cancel`, {
});
const renameId = activeAutoRenames.get(topicId);
if (!renameId) return;
await $fetch(`/api/auto-rename/cancel`, {
body: {
renameId,
},
method: 'POST', method: 'POST',
}); });
activeAutoRenames.delete(topicId);
} }
const renameTopicId = ref<string | null>(null); const renameTopicId = ref<string | null>(null);
@@ -123,10 +78,16 @@ const startRename = (topicId: string, currentName: string) => {
const saveRename = async () => { const saveRename = async () => {
if (renameTopicId.value && newTopicName.value.trim()) { if (renameTopicId.value && newTopicName.value.trim()) {
await triplit.update('topics', renameTopicId.value, { patchTopicLocally(renameTopicId.value, { name: newTopicName.value.trim() });
name: newTopicName.value.trim()
$fetch(`/api/topic/${renameTopicId.value}`, {
method: 'PATCH',
body: {
name: newTopicName.value.trim(),
},
}); });
} }
cancelRename(); cancelRename();
}; };
@@ -143,9 +104,8 @@ const deleteTopic = async (topicId: string) => {
await navigateTo('/'); await navigateTo('/');
} }
} }
await triplit.delete('topics', topicId);
// TODO: deeply delete all messages, generations, and message_parts in the topic deleteAgentTopic(agentId.value!, topicId);
}; };
const handleNavClick = (e: MouseEvent) => { const handleNavClick = (e: MouseEvent) => {
@@ -193,7 +153,7 @@ onMounted(() => {
<!-- Topics Section --> <!-- Topics Section -->
<button @click="topicsOpen = !topicsOpen" <button @click="topicsOpen = !topicsOpen"
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors w-full text-left"> class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors w-full text-left">
<span class="text-sm font-medium">Topics</span> <span class="text-sm font-medium">Topics</span>
<span <span
class="i-mynaui-chevron-down inline-block text-4 transition-transform duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" class="i-mynaui-chevron-down inline-block text-4 transition-transform duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
@@ -207,7 +167,7 @@ onMounted(() => {
<template v-slot="{ item: topic }"> <template v-slot="{ item: topic }">
<a :key="topic.id" data-action="navigate" :data-topic-id="topic.id" <a :key="topic.id" data-action="navigate" :data-topic-id="topic.id"
:href="`/agent/${agentId}/topic/${topic.id}`" :aria-label="topic.name" :href="`/agent/${agentId}/topic/${topic.id}`" :aria-label="topic.name"
class="mt-1 group px-2 decoration-none flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9 text-[var(--text-secondary)] hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] focus:text-[var(--text-primary)]" class="mt-1 group px-2 decoration-none flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9 text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] focus:text-[var(--text-primary)]"
:class="{ 'bg-[var(--color-hover)]': route.params.topicId === topic.id }"> :class="{ 'bg-[var(--color-hover)]': route.params.topicId === topic.id }">
<input v-if="renameTopicId === topic.id && !topic.renaming" id="topic-rename-input" <input v-if="renameTopicId === topic.id && !topic.renaming" id="topic-rename-input"
v-model="newTopicName" @keydown.enter="saveRename" @keydown.escape="cancelRename" v-model="newTopicName" @keydown.enter="saveRename" @keydown.escape="cancelRename"
@@ -221,7 +181,7 @@ onMounted(() => {
</span> </span>
<div data-action="toggle-dropdown" <div data-action="toggle-dropdown"
class="text-[var(--text-secondary)] shrink-0 opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="text-[var(--text-secondary)] shrink-0 opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="pointer-events-none h-4.5 w-4.5 i-tabler-dots"></span> <span class="pointer-events-none h-4.5 w-4.5 i-tabler-dots"></span>
</div> </div>
</a> </a>
@@ -237,32 +197,37 @@ onMounted(() => {
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100 translate-y-0" leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-1"> leave-to-class="opacity-0 scale-95 translate-y-1">
<div v-if="dropdownOpen" ref="dropdownContent" :style="floatingStyles" class="fixed z-15" <div v-if="dropdownOpen" ref="dropdownContent" :style="{
:class="transformOrigin"> ...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}" class="fixed z-15" :class="transformOrigin">
<div v-click-outside="closeDropdown" <div v-click-outside="closeDropdown"
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-40"> class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-40">
<!-- Dynamic content based on menuTopic --> <!-- Dynamic content based on menuTopic -->
<template v-if="menuTopic"> <template v-if="menuTopic">
<button v-if="menuTopic.renaming" @click="cancelAutoRename(menuTopic.id); closeDropdown()" <button v-if="menuTopic.renaming" @click="cancelAutoRename(menuTopic.id); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50"> class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
Cancel Auto Rename Cancel Auto Rename
</button> </button>
<button v-else @click="autoRenameTopic(menuTopic.id); closeDropdown()" <button v-else @click="autoRenameTopic(menuTopic.id); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50"> class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
Auto Rename Auto Rename
</button> </button>
<button :disabled="menuTopic.renaming ?? false" <button :disabled="menuTopic.renaming ?? false"
@click="startRename(menuTopic.id, menuTopic.name); closeDropdown()" @click="startRename(menuTopic.id, menuTopic.name); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50"> class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
Rename Rename
</button> </button>
<div class="h-px bg-[var(--color-border)] my-1" /> <div class="h-px bg-[var(--color-border)] my-1" />
<button @click="deleteTopic(menuTopic.id); closeDropdown()" <button @click="deleteTopic(menuTopic.id); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 text-red-500"> class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 text-red-500">
Delete Delete
</button> </button>
</template> </template>
+31 -33
View File
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
import type { DialogType } from '~/composables/useDialog'; import type { DialogType } from '~/composables/useDialog';
const { openDialog } = useDialog(); const { openDialog } = useDialog();
const { agents, createAgent } = useAgents(); const { agents, createAgent, deleteAgent, updateAgent } = await useAgents();
const agentsOpen = ref(true); const agentsOpen = ref(true);
const creatingAgent = ref(false); const creatingAgent = ref(false);
@@ -12,21 +12,13 @@ const dropdownTrigger = ref<HTMLElement | null>(null);
const dropdownContent = ref(null); const dropdownContent = ref(null);
const activeMenuAgentId = ref<string | null>(null); const activeMenuAgentId = ref<string | null>(null);
const triplit = useTriplitClient();
const navRef = ref<HTMLElement | null>(null); const navRef = ref<HTMLElement | null>(null);
const toggleAgentsList = () => { const toggleAgentsList = () => {
agentsOpen.value = !agentsOpen.value; agentsOpen.value = !agentsOpen.value;
}; };
const newAgent = async () => { const newAgent = async () => {
creatingAgent.value = true; await createAgent();
try {
const agent = await createAgent();
return navigateTo(`/agent/${agent.id}`);
} finally {
creatingAgent.value = false;
}
}; };
const renameAgentId = ref<string | null>(null); const renameAgentId = ref<string | null>(null);
@@ -44,11 +36,7 @@ const startRename = (agentId: string, currentName: string) => {
}; };
const saveRename = async () => { const saveRename = async () => {
if (renameAgentId.value && newAgentName.value.trim()) { updateAgent(renameAgentId.value!, { name: newAgentName.value.trim() });
await triplit.update('agents', renameAgentId.value, {
name: newAgentName.value.trim()
});
}
cancelRename(); cancelRename();
}; };
@@ -57,14 +45,10 @@ const cancelRename = () => {
newAgentName.value = ''; newAgentName.value = '';
}; };
const deleteAgent = async (agentId: string) => { const { floatingStyles, placement, middlewareData } = useFloating(dropdownTrigger, dropdownContent, {
await triplit.delete('agents', agentId);
}
const { floatingStyles, placement } = useFloating(dropdownTrigger, dropdownContent, {
placement: 'bottom-end', placement: 'bottom-end',
whileElementsMounted: autoUpdate, whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 10 })], middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
transform: false, transform: false,
}); });
@@ -81,7 +65,7 @@ const closeDropdown = () => {
}; };
const menuAgent = computed(() => const menuAgent = computed(() =>
agents.value.find(t => t.id === activeMenuAgentId.value) agents.value.find(a => a.id === activeMenuAgentId.value)
); );
const handleNavClick = (e: MouseEvent) => { const handleNavClick = (e: MouseEvent) => {
@@ -121,12 +105,21 @@ onMounted(() => {
<template> <template>
<nav ref="navRef" class="flex flex-col gap-1 overflow-auto"> <nav ref="navRef" class="flex flex-col gap-1 overflow-auto">
<SidenavItem @click="openDialog(DialogType.QuickSwitcher)" name="Search" icon="i-mynaui-search" /> <SidenavItem @click="openDialog(DialogType.QuickSwitcher)" name="Search" icon="i-mynaui-search">
<div class="flex items-center gap-1">
<span class="flex bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
<kbd class="font-mono text-[10px] case-capital">ctrl</kbd>
</span>
<span class="flex bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
<kbd class="font-mono text-[10px] case-capital">k</kbd>
</span>
</div>
</SidenavItem>
<SidenavItem to="/" name="Home" icon="i-mynaui-home" /> <SidenavItem to="/" name="Home" icon="i-mynaui-home" />
<!-- Header Toggle --> <!-- Header Toggle -->
<button <button
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg hover:bg-[var(--color-hover)] transition-colors w-full" class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg @hover:bg-[var(--color-hover)] transition-colors w-full"
@click="toggleAgentsList"> @click="toggleAgentsList">
<span class="text-sm">Agents</span> <span class="text-sm">Agents</span>
<span <span
@@ -138,7 +131,7 @@ onMounted(() => {
<Collapsible :is-open="agentsOpen"> <Collapsible :is-open="agentsOpen">
<div class="flex flex-col transform-origin-top pt-1 pb-1 px-1"> <div class="flex flex-col transform-origin-top pt-1 pb-1 px-1">
<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(--text-secondary)] hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 w-full"> class="disabled:cursor-wait flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] 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">
<span v-if="creatingAgent" class="i-svg-spinners-ring-resize text-4.5"></span> <span v-if="creatingAgent" class="i-svg-spinners-ring-resize text-4.5"></span>
<span v-else class="i-mynaui-plus text-4.5"></span> <span v-else class="i-mynaui-plus text-4.5"></span>
@@ -152,9 +145,10 @@ onMounted(() => {
<template v-slot="{ item: agent }"> <template v-slot="{ item: agent }">
<a data-action="navigate" :data-agent-id="agent.id" :href="`/agent/${agent.id}`" <a data-action="navigate" :data-agent-id="agent.id" :href="`/agent/${agent.id}`"
:aria-label="agent.name" :aria-label="agent.name"
class="mt-1 group px-2 decoration-none flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9 text-[var(--text-secondary)] hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] focus:text-[var(--text-primary)]"> class="mt-1 group px-2 decoration-none flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9 text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] focus:text-[var(--text-primary)]">
<input v-if="renameAgentId === agent.id" id="agent-rename-input" v-model="newAgentName" <input v-if="renameAgentId === agent.id" id="agent-rename-input" v-model="newAgentName"
@keydown.stop.enter="saveRename" @keydown.escape="cancelRename" @blur="saveRename" @keydown.prevent.enter="saveRename" @keydown.escape="cancelRename"
@blur="saveRename"
class="flex-1 bg-transparent border-none outline-none text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" /> class="flex-1 bg-transparent border-none outline-none text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" />
<span v-else <span v-else
class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap"> class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
@@ -162,7 +156,7 @@ onMounted(() => {
</span> </span>
<div data-action="toggle-dropdown" <div data-action="toggle-dropdown"
class="text-[var(--text-secondary)] shrink-0 opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="text-[var(--text-secondary)] shrink-0 opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="pointer-events-none h-4.5 w-4.5 i-tabler-dots"></span> <span class="pointer-events-none h-4.5 w-4.5 i-tabler-dots"></span>
</div> </div>
</a> </a>
@@ -179,21 +173,25 @@ onMounted(() => {
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100 translate-y-0" leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-1"> leave-to-class="opacity-0 scale-95 translate-y-1">
<div v-if="dropdownOpen" ref="dropdownContent" :style="floatingStyles" class="fixed z-15" <div v-if="dropdownOpen" ref="dropdownContent" :style="{
:class="transformOrigin"> ...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}" class="fixed z-15" :class="transformOrigin">
<div v-click-outside="closeDropdown" <div v-click-outside="closeDropdown"
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-40"> class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-40">
<template v-if="menuAgent"> <template v-if="menuAgent">
<button @click="startRename(menuAgent.id, menuAgent.name); closeDropdown()" <button @click="startRename(menuAgent.id, menuAgent.name); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50"> class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
Rename Rename
</button> </button>
<div class="h-px bg-[var(--color-border)] my-1" /> <div class="h-px bg-[var(--color-border)] my-1" />
<button @click="deleteAgent(menuAgent.id); closeDropdown()" <button @click="deleteAgent(menuAgent.id); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 text-red-500"> class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 text-red-500">
Delete Delete
</button> </button>
</template> </template>
+3 -3
View File
@@ -122,7 +122,7 @@ const navKind = computed(() => {
: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']">
<Tooltip :hotkey="['ctrl', '[']"> <Tooltip :hotkey="['ctrl', '[']">
<button aria-label="close sidebar" @click="closeSidebar" :class="[ <button aria-label="close sidebar" @click="closeSidebar" :class="[
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent transition-inherit', 'flex text-5 h-8 w-8 items-center justify-center @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent transition-inherit',
]"> ]">
<span <span
class="i-mynaui-panel-left-close text-5 transition-inherit transform-origin-right-center" class="i-mynaui-panel-left-close text-5 transition-inherit transform-origin-right-center"
@@ -133,7 +133,7 @@ const navKind = computed(() => {
<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">
<Tooltip :hotkey="['ctrl', 'alt', 'n']"> <Tooltip :hotkey="['ctrl', 'alt', 'n']">
<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 h-8 w-8 items-center justify-center hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent text-inherit', 'flex text-5 h-8 w-8 items-center justify-center @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent text-inherit',
]"> ]">
<span class="i-mynaui-book-plus text-5"></span> <span class="i-mynaui-book-plus text-5"></span>
</NuxtLink> </NuxtLink>
@@ -151,7 +151,7 @@ const navKind = computed(() => {
<div class="flex"> <div class="flex">
<Tooltip :hotkey="['ctrl', ',']"> <Tooltip :hotkey="['ctrl', ',']">
<button @click="openDialog(DialogType.Settings)" <button @click="openDialog(DialogType.Settings)"
class="flex items-center justify-center h-7 w-7 cursor-pointer hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors text-[var(--text-secondary)] active:text-[var(--text-primary)]"> class="flex items-center justify-center h-7 w-7 cursor-pointer @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors text-[var(--text-secondary)] active:text-[var(--text-primary)]">
<span class="i-mynaui-cog-four text-5"></span> <span class="i-mynaui-cog-four text-5"></span>
</button> </button>
</Tooltip> </Tooltip>
+4 -4
View File
@@ -7,7 +7,7 @@ const props = defineProps<{
type Theme = 'light' | 'dark' | 'system'; type Theme = 'light' | 'dark' | 'system';
const { updateSettings, settings } = useUserSettings(); const { updateSettings, settings } = await useUserSettings();
const selectTheme = (colorScheme: Theme) => { const selectTheme = (colorScheme: Theme) => {
updateSettings({ appearance: { colorScheme } }); updateSettings({ appearance: { colorScheme } });
@@ -20,7 +20,7 @@ const themeOptions: DropdownItem[] = [
]; ];
const currentOption = computed( const currentOption = computed(
() => themeOptions.find((option) => option.id === settings.value.appearance.colorScheme) || themeOptions[2], () => themeOptions.find((option) => option.id === settings.value?.appearance?.colorScheme) || themeOptions[2],
); );
</script> </script>
@@ -28,7 +28,7 @@ const currentOption = computed(
<Dropdown placement="top-end" dropdown-class="min-w-35"> <Dropdown placement="top-end" dropdown-class="min-w-35">
<template #default="{ toggle, setRef }"> <template #default="{ toggle, setRef }">
<button :ref="setRef" @click="toggle" <button :ref="setRef" @click="toggle"
class="flex items-center justify-center rounded-lg hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors active:text-[var(--text-primary)]" class="flex items-center justify-center rounded-lg @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors active:text-[var(--text-primary)]"
:class="{ :class="{
'h-7 w-7 text-5': size === 'small', 'h-7 w-7 text-5': size === 'small',
'h-9 w-9 text-6': size === 'medium', 'h-9 w-9 text-6': size === 'medium',
@@ -40,7 +40,7 @@ const currentOption = computed(
<template #dropdown="{ close }"> <template #dropdown="{ close }">
<button v-for="option in themeOptions" :key="option.id" @click="option.onClick!(); close()" <button v-for="option in themeOptions" :key="option.id" @click="option.onClick!(); close()"
class="text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150" class="text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"
:class="{ :class="{
'bg-[var(--color-hover)]': option.id === currentOption?.id, 'bg-[var(--color-hover)]': option.id === currentOption?.id,
}"> }">
+7 -1
View File
@@ -6,8 +6,14 @@ const props = defineProps<{
const { show, hide } = useTooltip(); const { show, hide } = useTooltip();
const triggerRef = ref<HTMLElement | null>(null); const triggerRef = ref<HTMLElement | null>(null);
const hasCursor = ref(false);
onMounted(() => {
hasCursor.value = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
});
const onMouseEnter = () => { const onMouseEnter = () => {
if (triggerRef.value) { if (triggerRef.value && hasCursor.value) {
show(triggerRef.value, props.hotkey); show(triggerRef.value, props.hotkey);
} }
}; };
+9 -10
View File
@@ -1,18 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
const { activeElement, activeHotkey, isVisible } = useTooltip(); const { activeElement, activeHotkey, isVisible } = useTooltip();
watch(activeElement, () => {
console.log('activeElement', activeElement.value);
});
const tooltipRef = ref<HTMLElement | null>(null); const tooltipRef = ref<HTMLElement | null>(null);
const { floatingStyles, placement } = useFloating(activeElement, tooltipRef, { const { floatingStyles, placement, middlewareData } = useFloating(activeElement, tooltipRef, {
placement: 'top', placement: 'top',
whileElementsMounted: autoUpdate, whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 8 })], middleware: [offset(6), flip(), shift({ padding: 8 }), hide()],
transform: false, transform: false,
}); });
@@ -29,8 +25,8 @@ watch(activeElement, (newEl, oldEl) => {
}); });
// Also reset isMoving when the tooltip fully closes // Also reset isMoving when the tooltip fully closes
watch(isVisible, (visible) => { watch([isVisible, middlewareData], ([visible, middlewareData]) => {
if (!visible) isMoving.value = false; if (!visible || middlewareData.hide?.referenceHidden) isMoving.value = false;
}); });
const isMac = import.meta.client ? navigator.userAgent.toUpperCase().indexOf('MAC') >= 0 : false; const isMac = import.meta.client ? navigator.userAgent.toUpperCase().indexOf('MAC') >= 0 : false;
@@ -58,7 +54,10 @@ const transformOrigin = computed(() => placement.value.includes('top') ? 'transf
? 'top, left, right, bottom, opacity, transform' ? 'top, left, right, bottom, opacity, transform'
: 'opacity, transform', : 'opacity, transform',
transitionDuration: '150ms', transitionDuration: '150ms',
transitionTimingFunction: 'cubic-bezier(0.5, 1, 0.89, 1)' transitionTimingFunction: 'cubic-bezier(0.5, 1, 0.89, 1)',
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
} }
]" :class="transformOrigin" ]" :class="transformOrigin"
class="pointer-events-none fixed z-35 flex flex-col bg-[var(--bg-surface)] rounded-lg shadow-xl px-1.5 py-1 text-xs"> class="pointer-events-none fixed z-35 flex flex-col bg-[var(--bg-surface)] rounded-lg shadow-xl px-1.5 py-1 text-xs">
+194 -56
View File
@@ -1,75 +1,213 @@
import type { Entity } from "@triplit/client";
import type schema from "#triplit/schema";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { assert } from "~~/utils/assert"; import { attempt } from "~~/types/result";
import * as schema from '~~/drizzle/schema';
export type Agent = Readonly<Entity<typeof schema, 'agents'> & { topics: Readonly<Entity<typeof schema, 'topics'>>[] }>; export type Topic = typeof schema.topics.$inferSelect;
export type Agent = typeof schema.agents.$inferSelect;
export type AgentWithTopics = Agent & { topics: Topic[] };
export const useAgents = () => { export const useAgents = async () => {
const nuxtApp = useNuxtApp(); const agents = useState<AgentWithTopics[]>('agents_state', () => []);
const triplit = useTriplitClient(); const loaded = useState('agents_loaded', () => false);
// dont leaking between different users/requests const { refresh } = await useFetch<AgentWithTopics[]>('/api/agents', {
if (!nuxtApp._agentsState) { key: 'agents_request',
nuxtApp._agentsState = { immediate: !loaded.value,
list: ref<Agent[]>([]), onRequest() {
initPromise: null as Promise<void> | null, loaded.value = true;
}; },
onResponse({ response }) {
if (response.ok) {
agents.value = response._data ?? [];
} }
const state = nuxtApp._agentsState as {
list: Ref<Agent[]>;
initPromise: Promise<void> | null;
};
const init = (): Promise<void> => {
if (state.initPromise) return state.initPromise;
state.initPromise = (async () => {
const query = triplit.query('agents')
.Include('topics', (rel) => rel('topics').Order('createdAt', 'DESC'))
.Order('createdAt', 'ASC');
const { results } = await useQuery('agents', triplit, query)
watch(results, (newAgents) => {
if (newAgents && newAgents.length > 0) {
state.list.value = newAgents as unknown as Agent[];
} }
}, { immediate: true, flush: 'sync' }); });
})();
return state.initPromise; const createAgent = async (navigate: boolean = true) => {
};
const getAgent = (id: MaybeRef<string>) => {
return computed(() => state.list.value.find((agent) => agent.id === toRef(id).value) || null);
}
const createAgent = async () => {
const triplit = useTriplitClient();
const { user } = useAuth(); const { user } = useAuth();
if (!user.value) throw new Error('No user'); if (!user.value) {
console.error('No user');
return null;
}
const id = nanoid(); const agentId = nanoid();
await triplit.insert('agents', {
id, const agent = {
name: 'New Agent', id: agentId,
userId: user.value.id, userId: user.value.id,
name: 'New Agent',
systemPrompt: 'You are a helpful assistant.', systemPrompt: 'You are a helpful assistant.',
defaultModelId: null, defaultModelId: null,
imageUrl: null, imageUrl: null,
createdAt: new Date().toISOString(), } as AgentWithTopics;
const router = useRouter()
// @ts-ignore - stack depth
const res = await attempt($fetch('/api/agent', {
method: 'POST',
body: agent,
onRequest() {
agents.value = [...(agents.value), { ...agent, topics: [] as Topic[], createdAt: new Date() }];
if (navigate) {
router.push(`/agent/${agentId}`);
}
},
onRequestError() {
if (navigate) {
const route = useRoute();
if (route.params.id === agentId) {
router.push('/');
}
}
agents.value = agents.value.filter(a => a.id !== agentId);
},
onResponseError() {
if (navigate) {
const route = useRoute();
if (route.params.id === agentId) {
router.push('/');
}
}
agents.value = agents.value.filter(a => a.id !== agentId);
},
async onResponse() {
await refresh();
}
}));
if (!res.ok) return null;
return agent;
};
const patchAgentLocally = (id: string, updates: Partial<Agent>) => {
if (!agents.value) return null;
agents.value = agents.value.map(a =>
a.id === id ? { ...a, ...updates } : a
);
};
const patchTopicLocally = (id: string, updates: Partial<Topic>) => {
if (!agents.value) return null;
agents.value = agents.value.map(a =>
a.topics.find(t => t.id === id) ? {
...a, topics: a.topics.map(t =>
t.id === id ? { ...t, ...updates } : t
)
} : a
);
};
const updateAgent = async (id: string, updates: Partial<Agent>) => {
const agent = agents.value.find(a => a.id === id);
if (!agent) return;
await $fetch(`/api/agent/${id}`, {
method: 'PATCH',
body: updates,
onRequest() {
agents.value = agents.value.map(a =>
a.id === id ? { ...a, ...updates } : a
);
},
onRequestError() {
agents.value = agents.value.map(a =>
a.id === id ? agent : a
);
},
onResponseError() {
agents.value = agents.value.map(a =>
a.id === id ? agent : a
);
},
async onResponse() {
await refresh();
}
});
}
const getAgent = (id: MaybeRef<string>) => {
return computed(() => agents.value?.find((agent) => agent.id === unref(id)) || null);
}
const deleteAgent = async (id: string) => {
let agent = agents.value.find(a => a.id === id);
if (!agent) return;
await $fetch(`/api/agent/${id}`, {
method: 'DELETE',
onRequest() {
agents.value = agents.value.filter(a => a.id !== id);
},
onResponseError() {
agents.value = [...agents.value.filter(a => a.id !== id), agent];
},
async onResponse() {
await refresh();
}
});
}
// TODO: since topics are contained within each agent struct, all topic
// actions must be done through the agents composable
const createTopic = async (agentId: string) => {
const topicId = nanoid();
const topic = {
id: topicId,
name: 'New Topic',
agentId,
};
await $fetch(`/api/topic`, {
method: 'POST',
body: topic,
onRequest() {
agents.value = agents.value.map(a =>
a.id === agentId ? { ...a, topics: [{ ...topic, createdAt: new Date() }, ...a.topics] } : a
);
},
onResponseError() {
agents.value = agents.value.map(a =>
a.id === agentId ? { ...a, topics: a.topics.filter(t => t.id !== topicId) } : a
);
},
}); });
assert('flush' in triplit); return topic;
await triplit.flush(); }
const deleteTopic = async (agentId: string, topicId: string) => {
const targetTopic = agents.value.flatMap(agent => agent.topics).find(topic => topic.id === topicId);
if (!targetTopic) return;
await $fetch(`/api/topic/${topicId}`, {
method: 'DELETE',
onRequest() {
agents.value = agents.value.map(a =>
a.id === agentId ? { ...a, topics: a.topics.filter(t => t.id !== topicId) } : a
);
},
onResponseError() {
agents.value = agents.value.map(a =>
a.id === agentId ? { ...a, topics: [...a.topics.filter(t => t.id !== topicId), targetTopic] } : a
);
},
});
}
return state.list.value.find((agent) => agent.id === id)!;
};
return { return {
init, agents,
agents: state.list, refresh,
createAgent,
createTopic,
getAgent, getAgent,
createAgent patchAgentLocally,
patchTopicLocally,
updateAgent,
deleteAgent,
deleteTopic
}; };
}; }
+9 -34
View File
@@ -30,11 +30,9 @@ export const useAuth = () => {
return sessionPromise; return sessionPromise;
} }
let finish: (value: Result<sessionData, AuthError>) => void;
sessionFetching.value = true; sessionFetching.value = true;
sessionPromise = new Promise(async (resolve, reject) => { const { promise, resolve } = Promise.withResolvers<Result<sessionData, AuthError>>();
finish = resolve; sessionPromise = promise;
});
let data: { let data: {
session: InferSessionFromClient<BetterAuthClientOptions>; session: InferSessionFromClient<BetterAuthClientOptions>;
user: InferUserFromClient<BetterAuthClientOptions>; user: InferUserFromClient<BetterAuthClientOptions>;
@@ -52,18 +50,17 @@ export const useAuth = () => {
} else { } else {
data = (await authClient.getSession()).data; data = (await authClient.getSession()).data;
} }
} catch (error) {
console.error('Failed to fetch session:', error);
sessionFetching.value = false;
finish!(Err(AuthError.NetworkError));
return sessionPromise;
}
session.value = data?.session || null; session.value = data?.session || null;
user.value = data?.user || null; user.value = data?.user || null;
resolve(Ok({ session: data?.session || null, user: data?.user || null }));
} catch (error) {
console.error('Failed to fetch session:', error);
resolve(Err(AuthError.NetworkError));
} finally {
sessionFetching.value = false; sessionFetching.value = false;
sessionFetching.value = false; }
finish!(Ok({ session: data?.session || null, user: data?.user || null }));
return sessionPromise; return sessionPromise;
}; };
@@ -71,15 +68,6 @@ export const useAuth = () => {
authClient.$store.listen('$sessionSignal', async (signal) => { authClient.$store.listen('$sessionSignal', async (signal) => {
if (!signal) return; if (!signal) return;
await fetchSession(); await fetchSession();
if (!session.value) return;
const triplit = useTriplitClient();
if ('updateOptions' in triplit) {
triplit.updateOptions({
token: session.value.token,
});
}
}); });
} }
@@ -106,11 +94,6 @@ export const useAuth = () => {
user.value = data.user; user.value = data.user;
const triplit = useTriplitClient();
if ('startSession' in triplit && data.token) {
await triplit.startSession(data.token);
}
clearNuxtData(); clearNuxtData();
return Ok({ user: data.user, token: data.token }); return Ok({ user: data.user, token: data.token });
@@ -147,10 +130,6 @@ export const useAuth = () => {
user.value = data.user; user.value = data.user;
const triplit = useTriplitClient();
assert('startSession' in triplit);
await triplit.startSession(data.token);
clearNuxtData(); clearNuxtData();
return Ok({ user: data.user, token: data.token }); return Ok({ user: data.user, token: data.token });
@@ -176,10 +155,6 @@ export const useAuth = () => {
user.value = null; user.value = null;
session.value = null; session.value = null;
const triplit = useTriplitClient();
assert('disconnect' in triplit);
triplit.disconnect();
clearNuxtData(); clearNuxtData();
return Ok(undefined); return Ok(undefined);
+319 -397
View File
@@ -1,33 +1,27 @@
import type schema from "#triplit/schema"; import { nanoid } from 'nanoid';
import type { Entity } from "@triplit/client"; import * as schema from '~~/drizzle/schema';
import type { FilePart, ImagePart, ModelMessage } from "ai"; import { Err, Ok, type Result } from '~~/types/result';
import { nanoid } from "nanoid"; import { buildMessageTree } from '~~/utils/message';
import { decrypt, base64ToUint8Array } from "~/utils/crypto";
import { type Result, Ok, Err, attempt } from "~~/types/result";
import { assert } from "~~/utils/assert";
export type BaseMessage = { export type BaseMessage = {
content: string; content: string;
fileIds: string[]; fileIds: string[];
} }
export type MessageEntity = Entity<typeof schema, 'messages'> & { export type ToolCall = typeof schema.toolCalls.$inferSelect
parts: (Entity<typeof schema, 'message_parts'> & {
toolCall: Entity<typeof schema, 'tool_calls'> | null
})[] | undefined
} & { generation: Entity<typeof schema, 'generations'> | null }
& { attachments: Entity<typeof schema, 'attachments'>[] }
export type Message = export type MessagePart = typeof schema.messageParts.$inferSelect & { toolCall: ToolCall | null }
MessageEntity & {
children: (MessageEntity | undefined)[]; export type MessageEntity = typeof schema.messages.$inferSelect & { parts: MessagePart[] | undefined } & { generation: typeof schema.generations.$inferSelect | null } & { attachments: (typeof schema.attachments.$inferSelect & { file: typeof schema.files.$inferSelect })[] }
}
export type Message = MessageEntity & { children: (MessageEntity | undefined)[] }
export enum ChatErrorType { export enum ChatErrorType {
NoModel = 0, NoModel = 0,
NoProvider, NoProvider,
NoAgent, NoAgent,
NoUser, NoUser,
NoTopic,
DatabaseOperationFailed, DatabaseOperationFailed,
FailedToDecryptProviderApiKey, FailedToDecryptProviderApiKey,
GenerationFailed, GenerationFailed,
@@ -37,194 +31,255 @@ export enum ChatErrorType {
Unimplemented, Unimplemented,
} }
export const useChat = (agentId: string) => { type Event = { type: string; payload: any; timestamp: number };
const triplit = useTriplitClient();
const createTopic = async () => { const TARGET_UPDATES_PER_SECOND = 24;
const { user } = useAuth();
if (!user.value) { export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
console.error('No user'); let data = useState<Topic & { messages: Message[] } | undefined>('useChat:data', () => undefined);
let sse: EventSource | undefined;
const textDeltaBuffer: Map<string, Map<string, string>> = new Map();
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
let flushCbs: Set<() => void> = new Set();
const flushTextDeltas = () => {
if (!data.value) return;
for (const [messageId, parts] of textDeltaBuffer) {
const msg = data.value.messages.find(m => m.id === messageId);
if (!msg) continue;
for (const [partId, content] of parts) {
console.log("content", content);
const part = msg.parts?.find(p => p.id === partId);
if (part) {
part.content += content;
}
}
}
textDeltaBuffer.clear();
flushTimeout = undefined;
for (const cb of flushCbs) {
cb();
}
flushCbs.clear();
};
const nextFlush = (cb: () => void) => {
if (flushTimeout) {
flushCbs.add(cb);
return; return;
} }
const newTopic = await triplit.insert('topics', { cb();
name: 'New Topic', }
userId: user.value.id,
agentId,
createdAt: new Date().toISOString(),
});
assert('flush' in triplit); const scheduleFlush = () => {
await triplit.flush(); if (flushTimeout) return;
flushTimeout = setTimeout(flushTextDeltas, 1000 / TARGET_UPDATES_PER_SECOND);
return newTopic;
}; };
const marshallMessages = (agent: Entity<typeof schema, 'agents'>, messages: Readonly<MessageEntity[]>): Result<ModelMessage[], string> => { const processEvent = (event: Event) => {
const marshalledMessages: ModelMessage[] = []; if (!data.value) return;
if (agent && agent.systemPrompt) { const { type, payload } = event;
marshalledMessages.push({
role: 'system',
content: agent.systemPrompt,
});
}
messages.forEach((message) => { switch (type) {
switch (message.role) { case 'MESSAGE_CREATED': {
case 'user': // Push the new message if it doesn't exist (prevents duplicates from HTTP vs SSE)
const attachments = message.attachments.map(attachment => { if (!data.value.messages.find(m => m.id === payload.id)) {
if (attachment.mimeType.startsWith('image/')) { data.value.messages.push({ ...payload, parts: [] });
return {
type: 'image',
image: attachment.url,
};
}
return {
type: 'file',
data: attachment.url,
filename: attachment.name,
mediaType: attachment.mimeType,
};
}) as (FilePart | ImagePart)[];
marshalledMessages.push({
role: 'user',
// TODO: when we have images or files, this is where we need to handle them
content: [
{
type: 'text',
text: message.content
},
...attachments,
],
});
break;
case 'assistant':
(message.parts || []).forEach((part) => {
if (!part) return Err('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) return Err('Tool call is null')
if (part.toolCall.status === 'pending') {
return Err('Marshalling tool call that is still pending. This is likely a UI bug if this happens.')
}
let inputValue: string = '';
switch (part.toolCall.input!.type) {
case 'text':
inputValue = part.toolCall.input!.value;
break;
case 'json':
inputValue = JSON.parse(part.toolCall.input!.value);
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 { } else {
switch (part.toolCall.error!.type) { // replace the existing data with the new data
case 'text': const index = data.value.messages.findIndex(m => m.id === payload.id);
failureType = 'error-text'; if (index !== -1) {
failureValue = part.toolCall.error!.value; data.value.messages[index] = { ...payload, parts: [] };
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; break;
} }
if (part.toolCall.status === 'completed') { case 'MESSAGE_UPDATED': {
marshalledMessages.push({ const msgIndex = data.value.messages.findIndex(m => m.id === payload.id);
role: 'tool', if (msgIndex !== -1) {
content: [ data.value.messages[msgIndex] = { ...data.value.messages[msgIndex], ...payload };
{ }
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;
} }
} break;
default: case 'MESSAGE_DELETED': {
return Err(`Unknown part type: ${part.type}`) data.value.messages = data.value.messages.filter(m => m.id !== payload.id);
}
});
break; break;
default: }
return Err(`Unknown message role: ${message.role}`)
case 'text-start': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg) {
if (!msg.parts) msg.parts = [];
msg.parts.push(payload.part);
}
break;
}
case 'text-delta': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (!msg) break;
if (!textDeltaBuffer.has(payload.messageId)) {
textDeltaBuffer.set(payload.messageId, new Map());
}
const messageBuffer = textDeltaBuffer.get(payload.messageId)!;
const existing = messageBuffer.get(payload.partId) || '';
messageBuffer.set(payload.partId, existing + payload.content);
console.log("messageBuffer", messageBuffer, existing + payload.content);
scheduleFlush();
break;
}
case 'text-end': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
const part = msg?.parts?.find(p => p.id === payload.partId);
if (part) {
nextFlush(() => {
part.content = payload.content;
part.finished = true;
});
}
break;
}
case 'tool-call-start': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg) {
if (!msg.parts) msg.parts = [];
msg.parts.push(payload.part);
}
break;
}
case 'tool-call-delta': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
const part = msg?.parts?.find(p => p.toolCallId === payload.toolCallId);
if (part?.toolCall) {
if (payload.input) part.toolCall.input = payload.input;
if (payload.output) part.toolCall.output = payload.output;
if (payload.error) part.toolCall.error = payload.error;
if (payload.status) part.toolCall.status = payload.status;
}
break;
}
case 'generation-complete': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg && msg.generation) {
msg.generation.status = 'completed';
msg.generation.tokens = payload.tokens;
}
break;
}
case 'generation-failed': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg && msg.generation) {
msg.generation.status = 'failed';
msg.generation.error = payload.error;
}
break;
}
case 'topic_updated': {
data.value = { ...data.value, ...payload };
break;
}
}
}
const connectSSE = async () => {
const { promise, resolve } = Promise.withResolvers<void>();
const id = unref(topicId);
const lastUpdate = data.value?.messages.at(-1)?.updatedAt || '0';
const msgCount = data.value?.messages.length || 0;
if (sse && sse.readyState !== EventSource.CLOSED) {
if (!(new URL(sse.url).pathname.startsWith(`/api/topic/${id}`))) {
sse.close();
} else {
return;
}
}
sse = new EventSource(`/api/topic/${id}?lastUpdate=${lastUpdate}&count=${msgCount}`);
sse.addEventListener("error", (e) => {
console.error('sse error', e);
setTimeout(connectSSE, 1500);
})
sse.addEventListener("message", (e) => {
const raw = JSON.parse(e.data);
const events: Event[] = Array.isArray(raw) ? raw : [raw];
for (const { type, payload, timestamp } of events) {
if (!type || !payload) continue;
if (type === 'initial_state') {
data.value = payload;
resolve()
continue;
}
processEvent({ type, payload, timestamp });
} }
}); });
return Ok(marshalledMessages); await promise;
}; }
if (connect) {
onBeforeUnmount(() => {
if (flushTimeout) {
clearTimeout(flushTimeout);
flushTextDeltas();
}
if (sse) {
console.log('[SSE] Closing connection');
sse.close();
sse = undefined;
}
});
if (import.meta.server) {
const id = unref(topicId);
const { data: ssrData } = await useFetch<Topic & { messages: Message[] }>(`/api/topic/${id}`);
data.value = ssrData.value;
} else {
// if (data.value === undefined) {
// const id = unref(topicId);
// const { data: ssrData } = await useFetch<Topic & { messages: Message[] }>(`/api/topic/${id}`);
// data.value = ssrData.value;
// }
await connectSSE();
}
}
const topic = computed(() => ({
...data.value,
messages: buildMessageTree(data.value?.messages || [])
}));
const getProviderAPIKey = async (provider: Provider): Promise<Result<string | undefined, ChatErrorType>> => {
if (provider.config.apiKey === undefined) {
return Ok(undefined);
}
const startGeneration = async (
messages: ModelMessage[],
args: Record<string, any>,
topic: Entity<typeof schema, 'topics'>,
provider: Entity<typeof schema, 'providers'>,
model: Entity<typeof schema, 'models'>,
parentMessageId: string | null = null
): Promise<Result<void, ChatErrorType>> => {
let providerApiKey: string | undefined = undefined;
if (provider.config.apiKey !== undefined) {
try { try {
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
"jwk", "jwk",
@@ -234,117 +289,88 @@ export const useChat = (agentId: string) => {
["encrypt", "decrypt"] ["encrypt", "decrypt"]
) )
providerApiKey = await decrypt( return Ok(await decrypt(
key, key,
base64ToUint8Array(provider.config.apiKey) base64ToUint8Array(provider.config.apiKey)
); ));
} catch (error) { } catch (error) {
console.error('Failed to decrypt provider API key:', error); console.error('Failed to decrypt provider API key:', error);
return Err(ChatErrorType.FailedToDecryptProviderApiKey); return Err(ChatErrorType.FailedToDecryptProviderApiKey);
} }
} }
const sendMessage = async (
baseMessage: BaseMessage,
onRequest?: () => void,
): Promise<Result<void, ChatErrorType>> => {
console.log("sendMessage", baseMessage);
const { user } = useAuth();
if (!user.value) return Err(ChatErrorType.NoUser);
try { try {
await $fetch('/api/chat/generate', { const message = {
id: nanoid(),
role: 'user',
content: baseMessage.content,
fileIds: baseMessage.fileIds,
}
console.log("sendMessage", message, baseMessage.content, baseMessage.fileIds);
await $fetch(`/api/topic/${unref(topicId)}/message`, {
method: 'POST', method: 'POST',
body: { body: {
messages, message
topicId: topic.id,
parentMessageId,
model: {
providerId: provider.id,
modelId: model.id,
args,
}, },
providerApiKey: providerApiKey, onRequest() {
if (data.value) {
data.value!.messages.push({
topicId: unref(topicId),
userId: user.value!.id,
// TODO
attachments: [],
parts: undefined,
generation: null,
parentMessageId: null,
generationId: null,
focusedIndex: null,
deleted: null,
createdAt: new Date(),
updatedAt: new Date(),
children: [],
...message
} as Message);
}
onRequest?.();
}, },
headers: { onResponseError() {
'Content-Type': 'application/json', data.value!.messages = data.value!.messages.filter(m => m.id !== message.id);
}, },
}); });
return Ok(undefined); return Ok(undefined);
} catch (error) { } catch (error) {
console.error('Failed to generate:', error); console.error('Failed to send message:', error);
return Err(ChatErrorType.GenerationFailed); return Err(ChatErrorType.GenerationFailed);
} }
}
const sendMessage = async (
message: BaseMessage,
topic: Entity<typeof schema, 'topics'>,
topicMessages: MessageEntity[],
agent: Entity<typeof schema, 'agents'>,
provider: Entity<typeof schema, 'providers'>,
model: Entity<typeof schema, 'models'>,
): Promise<Result<void, ChatErrorType>> => {
const { user } = useAuth();
if (!user.value) {
console.error('No user');
return Err(ChatErrorType.NoUser);
}
const messageId = nanoid();
const attachmentsPromise = message.fileIds.map(async fileId => {
const file = await triplit.fetchOne(triplit.query('files').Where('id', '=', fileId));
assert(file !== null);
return await triplit.insert('attachments', {
userId: user.value!.id,
topicId: topic.id,
messageId: messageId,
fileId: fileId,
name: file.name,
mimeType: file.mimeType,
url: file.url,
createdAt: file.createdAt,
})!;
});
const newMessage = await triplit.insert('messages', {
id: messageId,
userId: user.value.id,
topicId: topic.id,
createdAt: new Date().toISOString(),
content: message.content,
role: 'user',
}).catch(async error => {
console.error('Failed to insert message:', error);
await triplit.delete('messages', messageId);
return Err(ChatErrorType.DatabaseOperationFailed);
}) as Message;
const attachments = await Promise.all(attachmentsPromise);
newMessage.attachments = attachments;
const messages = marshallMessages(
agent,
topicMessages.concat(newMessage)
);
if (messages.ok === false) {
console.error('Failed to marshall messages:', messages.error);
return Err(ChatErrorType.MarshallFailed)
}
const args = {
temperature: 1,
max_tokens: 100,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
}; };
return startGeneration(messages.data, args, topic, provider, model).then(async res => { const startGeneration = async (model: ModelWithProvider) => {
if (res.ok === false) { console.log(model.provider);
console.error('Failed to start generation:', res.error); const providerApiKeyRes = await getProviderAPIKey(model.provider);
await triplit.delete('messages', messageId); if (providerApiKeyRes.ok === false) {
return providerApiKeyRes;
} }
const providerApiKey = providerApiKeyRes.data;
return res; await $fetch(`/api/topic/${unref(topicId)}/chat`, {
method: 'POST',
body: {
modelId: model.id,
providerApiKey,
},
}); });
}; }
/** /**
* *
@@ -364,11 +390,8 @@ export const useChat = (agentId: string) => {
*/ */
const regenerateMessage = async ( const regenerateMessage = async (
messageId: string, messageId: string,
topic: Entity<typeof schema, 'topics'>,
topicMessages: MessageEntity[], topicMessages: MessageEntity[],
agent: Entity<typeof schema, 'agents'>, model: ModelWithProvider
provider: Entity<typeof schema, 'providers'>,
model: Entity<typeof schema, 'models'>
): Promise<Result<void, ChatErrorType>> => { ): Promise<Result<void, ChatErrorType>> => {
const targetMessage = topicMessages.find(message => message.id === messageId); const targetMessage = topicMessages.find(message => message.id === messageId);
if (!targetMessage) { if (!targetMessage) {
@@ -376,16 +399,14 @@ export const useChat = (agentId: string) => {
} }
let targetMessageIndex = topicMessages.indexOf(targetMessage); let targetMessageIndex = topicMessages.indexOf(targetMessage);
const args = { const providerApiKeyRes = await getProviderAPIKey(model.provider);
temperature: 1, if (providerApiKeyRes.ok === false) {
max_tokens: 100, return providerApiKeyRes;
top_p: 1, }
frequency_penalty: 0, const providerApiKey = providerApiKeyRes.data;
presence_penalty: 0,
};
let parentMessageId = null; let parentMessageId = undefined;
let focusedMessages; let focusedMessages: MessageEntity[] | undefined;
if (targetMessage.role === 'user') { if (targetMessage.role === 'user') {
// we need to find the next agent message // we need to find the next agent message
while (targetMessageIndex < topicMessages.length) { while (targetMessageIndex < topicMessages.length) {
@@ -408,130 +429,31 @@ export const useChat = (agentId: string) => {
focusedMessages = topicMessages; focusedMessages = topicMessages;
} }
if (parentMessageId === null) { await $fetch(`/api/topic/${unref(topicId)}/chat`, {
const messages = marshallMessages(
agent,
topicMessages
);
if (messages.ok === false) {
console.error('Failed to marshall messages:', messages.error);
return Err(ChatErrorType.MarshallFailed)
}
return startGeneration(messages.data, args, topic, provider, model);
}
const focusedMessageIndex = topicMessages.findIndex(m => m.id === parentMessageId);
if (focusedMessageIndex !== topicMessages.length - 1) {
}
const messages = marshallMessages(
agent,
focusedMessages
);
if (messages.ok === false) {
console.error('Failed to marshall messages:', messages.error);
return Err(ChatErrorType.MarshallFailed)
}
return startGeneration(messages.data, args, topic, provider, model, parentMessageId);
}
enum AutoRenameError {
AutoRenameDisabled = 0,
NoModelSelected,
NoModelFound,
ModelDisabled,
DatabaseOperationFailed,
FailedToDecryptProviderApiKey,
FailedToGenerate,
}
const autoRename = async (topicId: string, prompt: string): Promise<Result<string, AutoRenameError>> => {
const { settings } = useUserSettings();
console.log(settings.value);
if (!settings.value.systemAssistants.rename.enabled) {
return Err(AutoRenameError.AutoRenameDisabled);
}
if (!settings.value.systemAssistants.rename.modelId) {
return Err(AutoRenameError.NoModelSelected);
}
const modelResult = await attempt(triplit.fetchOne(triplit.query('models').Where('id', '=', settings.value.systemAssistants.rename.modelId).Include('provider')));
if (modelResult.ok === false) {
return Err(AutoRenameError.DatabaseOperationFailed);
}
const model = modelResult.data;
if (!model) {
return Err(AutoRenameError.NoModelFound);
}
if (model.enabled === false || model.provider?.enabled === false) {
return Err(AutoRenameError.ModelDisabled)
}
let providerApiKey: string | undefined = undefined;
if (model.provider!.config.apiKey !== undefined) {
try {
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
providerApiKey = await decrypt(
key,
base64ToUint8Array(model.provider!.config.apiKey)
);
} catch (error) {
console.error('Failed to decrypt provider API key:', error);
return Err(AutoRenameError.FailedToDecryptProviderApiKey);
}
}
await triplit.update('topics', topicId, {
renaming: true
});
try {
// @ts-ignore - excessive stack depth
const res = await $fetch(`/api/auto-rename/${topicId}`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: {
modelId: model.id, modelId: model.id,
prompt,
providerApiKey, providerApiKey,
}), parentMessageId,
}) as { ok: true, renameId: string } | { ok: false, code: string }; },
if (!res.ok) {
console.error('Failed to auto-rename:', res.code);
return Err(AutoRenameError.FailedToGenerate);
}
return Ok(res.renameId);
} catch (error) {
triplit.update('topics', topicId, {
renaming: false,
}); });
console.error('Failed to auto-rename:', error); return Ok(undefined);
return Err(AutoRenameError.FailedToGenerate);
} }
const patchMessageLocally = (id: string, updates: Partial<Message>) => {
if (!data.value) return;
data.value.messages = data.value.messages.map(m =>
m.id === id ? { ...m, ...updates } : m
);
} }
return { return {
topic,
sendMessage, sendMessage,
AutoRenameError, startGeneration,
autoRename,
regenerateMessage, regenerateMessage,
createTopic, patchMessageLocally
}; };
} }
+142 -46
View File
@@ -1,54 +1,37 @@
import type { Entity } from '@triplit/client'; import { nanoid } from 'nanoid';
import type schema from '#triplit/schema'; import * as schema from '~~/drizzle/schema';
type Provider = Entity<typeof schema, 'providers'>; export type Model = typeof schema.models.$inferSelect;
type Model = Entity<typeof schema, 'models'>; export type Provider = typeof schema.providers.$inferSelect;
export interface ModelWithProvider extends Model { export type ProviderWithModels = Provider & {
provider: Provider;
}
export interface ProviderWithModels extends Provider {
models: Model[]; models: Model[];
} };
export const useModels = () => { export type ModelWithProvider = Model & {
const nuxtApp = useNuxtApp(); provider: Provider;
const triplit = useTriplitClient(); };
if (!nuxtApp._modelsState) { export const useModels = async () => {
nuxtApp._modelsState = { const providers = useState<ProviderWithModels[]>('models_state', () => []);
providers: shallowRef([]), const loaded = useState('models_loaded', () => false);
isReady: ref(false)
}; const { refresh } = await useFetch<ProviderWithModels[]>('/api/providers', {
key: 'models_request',
immediate: !loaded.value,
onRequest() {
loaded.value = true;
},
onResponse({ response }) {
if (response.ok) {
providers.value = response._data ?? [];
} }
const state = nuxtApp._modelsState as {
providers: Ref<ProviderWithModels[]>;
initPromise: Promise<void> | null;
};
const init = (): Promise<void> => {
if (state.initPromise) return state.initPromise;
state.initPromise = (async () => {
const query = triplit.query('providers').Include('models');
const { results } = await useQuery('providers', triplit, query)
watch(results, (newProviders) => {
if (newProviders && newProviders.length > 0) {
state.providers.value = newProviders as unknown as ProviderWithModels[];
} }
}, { immediate: true, flush: 'sync' }); });
})();
return state.initPromise;
};
const allModels = computed<ModelWithProvider[]>(() => { const allModels = computed<ModelWithProvider[]>(() => {
const result: ModelWithProvider[] = []; const result: ModelWithProvider[] = [];
for (const provider of state.providers.value) { for (const provider of providers.value) {
if (!provider.enabled) continue; if (!provider.enabled) continue;
for (const model of provider.models || []) { for (const model of provider.models || []) {
if (!model.enabled) continue; if (!model.enabled) continue;
@@ -63,19 +46,132 @@ export const useModels = () => {
}; };
const getProvider = (id: string): ProviderWithModels | undefined => { const getProvider = (id: string): ProviderWithModels | undefined => {
return state.providers.value.find((provider) => provider.id === id); return providers.value.find((provider) => provider.id === id);
}; };
const getFirstAvailableModel = () => { const getFirstAvailableModel = () => {
return allModels.value[0] ?? null; return allModels.value[0] ?? null;
}; };
const createModel = async (model: Model) => {
model.id = nanoid();
await $fetch(`/api/model`, {
method: 'POST',
body: model,
onRequest() {
providers.value = providers.value.map(p => ({
...p,
models: p.id === model.providerId ? [...p.models, model] : p.models
}));
},
onResponseError() {
providers.value = providers.value.map(p => ({
...p,
models: p.id === model.providerId ? p.models.filter(m => m.id !== model.id) : p.models
}));
},
});
}
const updateModel = async (id: string, updates: Partial<Model>) => {
const model = providers.value.find(p => p.models.find(m => m.id === id));
if (!model) return;
const original = model;
await $fetch(`/api/model/${id}`, {
method: 'PATCH',
body: updates,
onRequest() {
providers.value = providers.value.map(p => ({
...p,
models: p.models.map(m =>
m.id === id ? { ...m, ...updates } : m
)
}));
},
onRequestError() {
providers.value = providers.value.map(p => ({
...p,
models: p.models.map(m =>
m.id === id ? original : m
)
}) as ProviderWithModels);
},
onResponseError() {
providers.value = providers.value.map(p => ({
...p,
models: p.models.map(m =>
m.id === id ? original : m
)
}) as ProviderWithModels);
},
// async onResponse() {
// await refresh();
// }
});
};
const deleteModel = async (id: string) => {
const model = providers.value.find(p => p.models.find(m => m.id === id));
if (!model) return;
await $fetch(`/api/model/${id}`, {
method: 'DELETE',
onRequest() {
providers.value = providers.value.map(p => ({
...p,
models: p.models.filter(m => m.id !== id)
}));
},
onResponseError() {
providers.value.push(model);
},
async onResponse() {
await refresh();
}
});
}
const updateProvider = async (id: string, updates: Partial<Provider>) => {
const provider = providers.value.find(p => p.id === id);
if (!provider) return;
const original = provider;
await $fetch(`/api/provider/${id}`, {
method: 'PATCH',
body: updates,
onRequest() {
providers.value = providers.value.map(p =>
p.id === id ? { ...p, ...updates } : p
);
},
onRequestError() {
providers.value = providers.value.map(p =>
p.id === id ? original : p
);
},
onResponseError() {
providers.value = providers.value.map(p =>
p.id === id ? original : p
);
},
async onResponse() {
await refresh();
}
});
};
return { return {
init, providers,
providers: state.providers,
allModels, allModels,
getModel, getModel,
getProvider, getProvider,
getFirstAvailableModel getFirstAvailableModel,
createModel,
updateModel,
updateProvider,
deleteModel
}; };
}; }
+2 -3
View File
@@ -1,7 +1,6 @@
import type { Entity } from '@triplit/client'; import * as schema from '~~/drizzle/schema';
import type { schema } from '#triplit/schema';
type Generation = Entity<typeof schema, 'generations'>; type Generation = typeof schema.generations.$inferSelect;
export const useTokenDropdown = () => { export const useTokenDropdown = () => {
const isOpen = useState('token-dropdown:open', () => false); const isOpen = useState('token-dropdown:open', () => false);
+101
View File
@@ -0,0 +1,101 @@
import { Err, Ok, type Result } from "~~/types/result";
export const useTopic = () => {
enum AutoRenameError {
AutoRenameDisabled = 0,
NoModelSelected,
NoModelFound,
ModelDisabled,
DatabaseOperationFailed,
FailedToDecryptProviderApiKey,
FailedToGenerate,
}
const autoRename = async (topicId: string): Promise<Result<string, AutoRenameError>> => {
const { settings } = await useUserSettings();
if (!settings.value.systemAssistants.rename.enabled) {
return Err(AutoRenameError.AutoRenameDisabled);
}
if (!settings.value.systemAssistants.rename.modelId) {
return Err(AutoRenameError.NoModelSelected);
}
const { allModels } = await useModels();
const model = allModels.value.find(model => model.id === settings.value.systemAssistants.rename.modelId);
if (!model) {
return Err(AutoRenameError.NoModelFound);
}
if (model.enabled === false || model.provider?.enabled === false) {
return Err(AutoRenameError.ModelDisabled)
}
let providerApiKey: string | undefined = undefined;
if (model.provider!.config.apiKey !== undefined) {
try {
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
providerApiKey = await decrypt(
key,
base64ToUint8Array(model.provider!.config.apiKey)
);
} catch (error) {
console.error('Failed to decrypt provider API key:', error);
return Err(AutoRenameError.FailedToDecryptProviderApiKey);
}
}
const { agents } = await useAgents();
try {
// @ts-ignore - excessive stack depth
const res = await $fetch(`/api/topic/${topicId}/auto-rename`, {
method: 'POST',
body: JSON.stringify({
modelId: model.id,
providerApiKey,
}),
onRequest() {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(topic => topic.id === topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: true } : t) } : agent;
});
}
},
onRequestError() {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(topic => topic.id === topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: false } : t) } : agent;
});
}
},
}) as { ok: true, renameId: string } | { ok: false, code: string };
if (!res.ok) {
console.error('Failed to auto-rename:', res.code);
return Err(AutoRenameError.FailedToGenerate);
}
return Ok(res.renameId);
} catch (error) {
console.error('Failed to auto-rename:', error);
return Err(AutoRenameError.FailedToGenerate);
}
}
return {
autoRename
};
}
+190
View File
@@ -0,0 +1,190 @@
export const useUserEvents = () => {
const eventSource = ref<EventSource | null>(null);
const isConnected = useState('userEvents:connected', () => false);
const lastEventTimestamp = useState<number>('userEvents:lastTimestamp', () => 0);
const reconnectAttempts = ref(0);
const connectionStartTime = ref<number>(0);
const pendingReconnect = ref<NodeJS.Timeout | null>(null);
const BASE_DELAY = 1000;
const MAX_DELAY = 30000;
const MAX_RECONNECT_ATTEMPTS = 10;
const cleanup = () => {
if (pendingReconnect.value) {
clearTimeout(pendingReconnect.value);
}
if (eventSource.value) {
eventSource.value.close();
eventSource.value = null;
}
isConnected.value = false;
};
const getReconnectDelay = (attempt: number): number => {
const exponentialDelay = Math.min(
BASE_DELAY * Math.pow(2, attempt),
MAX_DELAY
);
const jitter = exponentialDelay * Math.random() * 0.25;
return exponentialDelay + jitter;
};
const scheduleReconnect = () => {
if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {
console.error('Max reconnection attempts reached');
return;
}
const delay = getReconnectDelay(reconnectAttempts.value);
console.log(`Scheduling reconnect in ${delay}ms (attempt ${reconnectAttempts.value + 1})`);
pendingReconnect.value = setTimeout(() => {
reconnectAttempts.value++;
connect();
}, delay);
};
const connect = async () => {
cleanup();
connectionStartTime.value = Date.now();
reconnectAttempts.value = 0;
const source = new EventSource('/api/events');
eventSource.value = source;
source.onopen = () => {
console.log('User events connected');
isConnected.value = true;
reconnectAttempts.value = 0;
};
source.onerror = () => {
isConnected.value = false;
if (source.readyState === EventSource.CLOSED) {
console.log('EventSource closed, scheduling reconnect');
scheduleReconnect();
}
};
source.addEventListener('message', async (event) => {
if (event.data === '') return;
try {
const data = JSON.parse(event.data);
if (data.timestamp && data.timestamp > lastEventTimestamp.value) {
lastEventTimestamp.value = data.timestamp;
}
handleUserEvent(data);
} catch (e) {
console.error('Failed to parse user event:', e);
}
});
};
const handleUserEvent = async (data: { entity: string; op: string; payload: any; timestamp?: number }) => {
const { agents } = await useAgents();
switch (data.entity) {
case 'topics': {
switch (data.op) {
case 'create': {
const existing = agents.value.find(a => a.id === data.payload.agentId);
if (existing) {
const topicExists = existing.topics.some(t => t.id === data.payload.id);
if (!topicExists) {
agents.value = agents.value.map(agent => {
return agent.id === data.payload.agentId
? { ...agent, topics: [data.payload, ...agent.topics] }
: agent;
});
}
}
break;
}
case 'update': {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(t => t.id === data.payload.topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId
? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...t, ...data.payload } : t) }
: agent;
});
}
break;
}
case 'delete': {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(t => t.id === data.payload.topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId
? { ...agent, topics: agent.topics.filter(t => t.id !== topic.id) }
: agent;
});
}
break;
}
}
break;
}
case 'agents': {
switch (data.op) {
case 'create': {
const existing = agents.value.find(a => a.id === data.payload.id);
if (existing) {
agents.value = agents.value.map(agent =>
agent.id === data.payload.id ? { ...agent, ...data.payload } : agent
);
} else {
agents.value = [...agents.value, data.payload];
}
break;
}
case 'update': {
agents.value = agents.value.map(agent =>
agent.id === data.payload.id ? { ...agent, ...data.payload } : agent
);
break;
}
case 'delete': {
agents.value = agents.value.filter(agent => agent.id !== data.payload.id);
break;
}
}
break;
}
}
};
const reconcile = async () => {
console.log('Reconciling user state...');
const { data } = await useFetch<AgentWithTopics[]>('/api/agents');
if (data.value) {
const { agents } = await useAgents();
agents.value = data.value;
}
};
onMounted(() => {
connect();
});
onBeforeUnmount(() => {
cleanup();
});
return {
eventSource,
isConnected,
connect,
cleanup,
reconcile,
};
};
+99 -166
View File
@@ -1,197 +1,130 @@
import { schema } from '#triplit/schema'; import * as schema from '~~/drizzle/schema';
import { type Entity } from '@triplit/client';
import { computed, watch, ref, type Ref } from 'vue';
type UserSettings = Entity<typeof schema, 'settings'>; type UserSettings = typeof schema.settings.$inferSelect;
type Appearance = UserSettings['appearance'];
type SystemAssistants = UserSettings['systemAssistants'];
interface UserSettingsState { const defaultAppearance: Appearance = {
accent: Ref<string>; colorScheme: 'system',
neutral: Ref<string>; accent: 'violet',
hinting: Ref<string>; neutral: 'zinc',
colorSchemePreference: Ref<'light' | 'dark' | 'system'>; hinting: 0,
colorSchemeClass: Ref<'light' | 'dark' | undefined>; fontSize: 'md',
remoteSettings: Ref<UserSettings | null>; };
initPromise: Promise<void> | null;
}
export const useUserSettings = () => { export const useUserSettings = async () => {
const nuxtApp = useNuxtApp(); const settings = useState<UserSettings | null>('settings_state', () => null);
const triplit = useTriplitClient(); const loaded = useState('settings_loaded', () => false);
const { user, loggedIn } = useAuth(); const syncing = useState('settings_syncing', () => false);
if (!nuxtApp._userSettingsState) { const localAppearance = useState<Appearance | null>('settings_local_appearance', () => null);
nuxtApp._userSettingsState = { const localSystemAssistants = useState<SystemAssistants | null>('settings_local_sa', () => null);
accent: ref('violet'),
neutral: ref('zinc'), const { refresh } = await useFetch<UserSettings>('/api/settings', {
hinting: ref('0'), key: 'settings_request',
colorSchemePreference: ref<'light' | 'dark' | 'system'>('system'), immediate: !loaded.value,
colorSchemeClass: ref<undefined | 'light' | 'dark'>(undefined), onRequest() {
remoteSettings: ref<UserSettings | null>(null), loaded.value = true;
initPromise: null, },
} as UserSettingsState; onResponse({ response }) {
if (response.ok && response._data) {
settings.value = response._data;
localAppearance.value = { ...defaultAppearance, ...response._data.appearance };
localSystemAssistants.value = response._data.systemAssistants;
} }
const state = nuxtApp._userSettingsState as UserSettingsState;
const init = (): Promise<void> => {
if (state.initPromise) return state.initPromise;
state.initPromise = (async () => {
const { results } = await useQuery('settings', triplit, triplit.query('settings'));
watch(results, (val) => {
if (val && val.length > 0) {
state.remoteSettings.value = val[0] as UserSettings;
} else {
state.remoteSettings.value = null;
} }
}, { immediate: true, deep: true }); });
watch(state.remoteSettings, (newSettings) => { const applyOptimisticUpdates = (updates: { appearance?: Partial<Appearance>; systemAssistants?: Partial<SystemAssistants> }) => {
if (!newSettings?.appearance) return; if (updates.appearance && localAppearance.value) {
const { appearance } = newSettings; localAppearance.value = { ...localAppearance.value, ...updates.appearance };
if (appearance.colorScheme) {
state.colorSchemePreference.value = appearance.colorScheme as 'light' | 'dark' | 'system';
} }
if (appearance.accent) { if (updates.systemAssistants && localSystemAssistants.value) {
state.accent.value = appearance.accent; localSystemAssistants.value = { ...localSystemAssistants.value, ...updates.systemAssistants };
} }
if (appearance.neutral) { if (updates.appearance && settings.value) {
state.neutral.value = appearance.neutral; settings.value = {
...settings.value,
appearance: { ...settings.value.appearance, ...updates.appearance }
};
} }
if (appearance.hinting !== undefined) { if (updates.systemAssistants && settings.value) {
state.hinting.value = String(appearance.hinting); settings.value = {
} ...settings.value,
}, { immediate: true, deep: true }); systemAssistants: { ...settings.value.systemAssistants, ...updates.systemAssistants }
};
if (import.meta.client) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const update = () => {
if (state.colorSchemePreference.value === 'system') {
state.colorSchemeClass.value = mediaQuery.matches ? 'dark' : 'light';
} else {
state.colorSchemeClass.value = state.colorSchemePreference.value as 'dark' | 'light';
} }
}; };
mediaQuery.addEventListener('change', update);
watch(state.colorSchemePreference, update, { immediate: true });
} else {
watch(state.colorSchemePreference, (pref) => {
state.colorSchemeClass.value = pref === 'system' ? 'dark' : (pref as 'dark' | 'light');
}, { immediate: true });
}
})();
return state.initPromise; const updateSettings = async (updates: { appearance?: Partial<Appearance>; systemAssistants?: Partial<SystemAssistants> }) => {
const original = settings.value;
const originalAppearance = localAppearance.value;
const originalSA = localSystemAssistants.value;
applyOptimisticUpdates(updates);
try {
syncing.value = true;
await $fetch(`/api/settings`, {
method: 'PATCH',
body: updates,
});
await refresh();
} catch (error) {
settings.value = original;
localAppearance.value = originalAppearance;
localSystemAssistants.value = originalSA;
throw error;
} finally {
syncing.value = false;
}
}; };
const effectiveAppearance = computed(() => {
return localAppearance.value || defaultAppearance;
});
const effectiveSystemAssistants = computed(() => {
return localSystemAssistants.value || {};
});
const colorSchemeValue = computed(() => { const colorSchemeValue = computed(() => {
if (state.colorSchemePreference.value === 'system') { if (effectiveAppearance.value.colorScheme === 'system') {
if (import.meta.client) { if (import.meta.client) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} }
return 'dark'; return 'dark';
} }
return state.colorSchemePreference.value as 'dark' | 'light'; return effectiveAppearance.value.colorScheme as 'dark' | 'light';
}); });
const settings = computed(() => { const effectiveSettings = computed(() => ({
const remote = state.remoteSettings.value; appearance: effectiveAppearance.value,
return { systemAssistants: effectiveSystemAssistants.value,
appearance: { }));
colorScheme: remote?.appearance?.colorScheme ?? state.colorSchemePreference.value,
accent: remote?.appearance?.accent ?? state.accent.value,
neutral: remote?.appearance?.neutral ?? state.neutral.value,
hinting: remote?.appearance?.hinting ?? Number(state.hinting.value),
fontSize: remote?.appearance?.fontSize ?? 'medium',
},
systemAssistants: {
rename: {
enabled: remote?.systemAssistants?.rename?.enabled ?? false,
prompt: remote?.systemAssistants?.rename?.prompt ?? null,
modelId: remote?.systemAssistants?.rename?.modelId ?? null,
}
}
};
});
const updateSettings = async (updates: {
appearance?: {
colorScheme?: 'light' | 'dark' | 'system';
accent?: string;
neutral?: string;
hinting?: number;
fontSize?: string;
};
systemAssistants?: {
rename?: {
enabled?: boolean;
prompt?: string | null;
modelId?: string | null;
};
};
}) => {
if (updates.appearance) {
if (updates.appearance.colorScheme) state.colorSchemePreference.value = updates.appearance.colorScheme;
if (updates.appearance.accent) state.accent.value = updates.appearance.accent;
if (updates.appearance.neutral) state.neutral.value = updates.appearance.neutral;
if (updates.appearance.hinting !== undefined) state.hinting.value = String(updates.appearance.hinting);
}
if (!loggedIn.value || !user.value?.id) return;
const current = state.remoteSettings.value;
if (!current) {
await triplit.insert('settings', {
userId: user.value.id,
appearance: {
colorScheme: state.colorSchemePreference.value,
accent: state.accent.value,
neutral: state.neutral.value,
hinting: Number(state.hinting.value),
...(updates.appearance || {})
},
systemAssistants: {
rename: {
enabled: updates.systemAssistants?.rename?.enabled ?? true,
prompt: updates.systemAssistants?.rename?.prompt ?? null,
modelId: updates.systemAssistants?.rename?.modelId ?? null,
}
}
});
return;
}
await triplit.update('settings', current.id, (s) => {
if (updates.appearance) {
s.appearance = {
...(s.appearance || {}),
...updates.appearance
};
}
if (updates.systemAssistants) {
// @ts-expect-error
s.systemAssistants = {
...(s.systemAssistants || {}),
...updates.systemAssistants
};
}
});
};
return { return {
init, settings: effectiveSettings,
settings, rawSettings: readonly(settings),
remoteSettings: state.remoteSettings,
updateSettings, updateSettings,
accent: state.accent, syncing: readonly(syncing),
neutral: state.neutral, loaded: readonly(loaded),
hinting: state.hinting, refresh,
accent: computed(() => effectiveAppearance.value.accent ?? 'violet'),
neutral: computed(() => effectiveAppearance.value.neutral ?? 'zinc'),
hinting: computed(() => effectiveAppearance.value.hinting ?? 0),
colorScheme: { colorScheme: {
preference: state.colorSchemePreference, preference: computed(() => effectiveAppearance.value.colorScheme ?? 'system'),
value: colorSchemeValue, value: colorSchemeValue,
class: state.colorSchemeClass class: computed(() => {
if (effectiveAppearance.value.colorScheme === 'system') {
if (import.meta.client) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'dark';
}
return effectiveAppearance.value.colorScheme as 'dark' | 'light';
}),
} }
}; };
}; };
+8 -2
View File
@@ -1,7 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
// TODO: when a user changes the color scheme and then signs in/up, the color // TODO: when a user changes the color scheme and then signs in/up, the color
// scheme will not be updated in the user's settings. // scheme will not be updated in the user's settings.
const { colorScheme } = useUserSettings(); // const { colorScheme } = useUserSettings();
const colorScheme = {
class: 'dark',
}
useHead({ useHead({
htmlAttrs: { htmlAttrs: {
@@ -13,12 +17,14 @@ preloadRouteComponents('/');
</script> </script>
<template> <template>
<div class="p-2 w-full h-full">
<main <main
class="p-2 bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden"> class="bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden">
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
<slot /> <slot />
</div> </div>
</main> </main>
</div>
</template> </template>
<style> <style>
+7 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Dialog from '~/components/Dialog/index.vue'; import Dialog from '~/components/Dialog/index.vue';
const { colorScheme } = useUserSettings(); const { colorScheme } = await useUserSettings();
const { toggle: toggleSidebar } = useSidebar(); const { toggle: toggleSidebar } = useSidebar();
const { addShortcut } = useKeyboardShortcuts(); const { addShortcut } = useKeyboardShortcuts();
@@ -16,11 +16,16 @@ useHead({
class: colorScheme.class class: colorScheme.class
} }
}); });
if (import.meta.client) {
const { connect } = useUserEvents();
connect();
}
</script> </script>
<template> <template>
<Sidenav /> <Sidenav />
<div class="p-2 w-full h-full"> <div class="p-2 w-full h-full max-w-full overflow-x-hidden">
<main <main
class="bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden"> class="bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden">
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
+19 -22
View File
@@ -2,16 +2,14 @@
import type { BaseMessage } from '~/composables/useChat'; import type { BaseMessage } from '~/composables/useChat';
import type { ModelWithProvider } from '~/composables/useModels'; import type { ModelWithProvider } from '~/composables/useModels';
const triplit = useTriplitClient();
const route = useRoute(); const route = useRoute();
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] }); const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
const pendingMessage = ref<Message | null>(null); const pendingMessage = ref<Message | null>(null);
const { open: sidebarOpen, openSidebar } = useSidebar(); const { open: sidebarOpen, openSidebar } = useSidebar();
const { createTopic, sendMessage, autoRename } = useChat(route.params.id as string); const { autoRename: autoRenameTopic } = useTopic();
const { getAgent } = useAgents(); const { getAgent, createTopic } = await useAgents();
const { providers } = useModels(); const { providers } = await useModels();
const agent = getAgent(route.params.id as string); const agent = getAgent(route.params.id as string);
@@ -31,10 +29,13 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
return; return;
} }
console.log("handleSubmit", message);
if (!message.content) return;
pendingMessage.value = { pendingMessage.value = {
id: '', id: '',
userId: user.value!.id, userId: user.value!.id,
topicId: null, topicId: '',
content: message.content, content: message.content,
role: 'user', role: 'user',
parts: [], parts: [],
@@ -46,37 +47,33 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
attachments: [], attachments: [],
deleted: false, deleted: false,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(),
} }
const topic = await createTopic(); const topic = await createTopic(agent.value!.id);
if (!topic) throw new Error('Failed to create topic'); if (!topic) throw new Error('Failed to create topic');
autoRename(topic.id, message.content); const { sendMessage, startGeneration } = await useChat(topic.id, false);
await navigateTo(`/agent/${route.params.id}/topic/${topic.id}`); sendMessage(message).then(async res => {
return sendMessage(message, topic, [], agent.value!, model.provider, model).then(async res => {
if (res.ok === false) { if (res.ok === false) {
console.error('Failed to send message:', res.error); console.error('Failed to send message:', res.error);
pendingMessage.value = null; pendingMessage.value = null;
await navigateTo(`/agent/${route.params.id}`); await navigateTo(`/agent/${route.params.id}`);
await triplit.delete('topics', topic.id);
// const chatInput = document.getElementById('chat') as HTMLInputElement;
// if (chatInput) {
// chatInput.value = message;
// chatInput.dispatchEvent(new Event('input'));
// nextTick(() => {
// chatInput.focus();
// });
// }
nextTick(() => { nextTick(() => {
inputValue.value = message; inputValue.value = message;
}); });
} }
}); });
await navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
autoRenameTopic(topic.id);
return startGeneration(model);
}; };
</script> </script>
@@ -86,10 +83,10 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
<div class="h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)]"> <div class="h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)]">
<div class="flex items-center gap-2 max-w-full"> <div class="flex items-center gap-2 max-w-full">
<button v-if="!sidebarOpen" @click="openSidebar" <button v-if="!sidebarOpen" @click="openSidebar"
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors"> class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-panel-left-open text-5"></span> <span class="i-mynaui-panel-left-open text-5"></span>
</button> </button>
<h4 class="text-lg text-ellipsis overflow-hidden whitespace-nowrap"> <h4 class="text-lg truncate">
{{ agent?.name }} {{ agent?.name }}
</h4> </h4>
</div> </div>
+43 -22
View File
@@ -1,35 +1,56 @@
<script setup lang="ts"> <script setup lang="ts">
const { getAgent } = useAgents(); const { getAgent, patchAgentLocally } = await useAgents();
const triplit = useTriplitClient();
const route = useRoute(); const route = useRoute();
const { open: sidebarOpen, openSidebar } = useSidebar(); const { open: sidebarOpen, openSidebar } = useSidebar();
const agent = getAgent(route.params.id as string); const agent = getAgent(route.params.id as string);
let serverAgent = agent.value;
const name = ref<string | null>(agent.value?.name ?? null);
const systemPrompt = ref<string | null>(agent.value?.systemPrompt ?? null);
if (agent.value === undefined) navigateTo('/'); if (agent.value === undefined) navigateTo('/');
const handleInput = async (e: Event) => { let debounceTimeout: NodeJS.Timeout | null = null;
const target = e.target as HTMLInputElement; const debouncedUpdate = async (updates: Partial<Agent>) => {
if (target.value.trimStart().length === 0) { if (debounceTimeout !== null) {
return; clearTimeout(debounceTimeout);
} }
await triplit.update('agents', agent.value!.id, { name: target.value }); patchAgentLocally(route.params.id as string, updates);
debounceTimeout = setTimeout(async () => {
debounceTimeout = null;
try {
await $fetch(`/api/agent/${route.params.id}`, {
method: 'PATCH',
body: updates,
});
} catch (error) {
console.error('Failed to update agent:', error);
if (serverAgent) {
patchAgentLocally(route.params.id as string, serverAgent);
name.value = serverAgent.name;
systemPrompt.value = serverAgent.systemPrompt;
}
}
}, 700);
};
const handleNameInput = async (e: Event) => {
const name = (e.target as HTMLInputElement).value;
if (!name.trim()) return;
debouncedUpdate({ name });
}; };
const changeSystemPrompt = async (e: Event) => { const changeSystemPrompt = async (e: Event) => {
const target = e.target as HTMLTextAreaElement; let systemPrompt = (e.target as HTMLTextAreaElement).value as string | null;
let value: string | undefined = target.value; if (!systemPrompt!.trim()) systemPrompt = null;
debouncedUpdate({ systemPrompt });
if (value.trimStart().length === 0) {
value = undefined;
}
await triplit.update('agents', agent.value!.id, {
systemPrompt: target.value,
});
}; };
</script> </script>
@@ -37,7 +58,7 @@ const changeSystemPrompt = async (e: Event) => {
<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="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors"> class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-panel-left-open text-5"></span> <span class="i-mynaui-panel-left-open text-5"></span>
</button> </button>
</div> </div>
@@ -49,15 +70,15 @@ const changeSystemPrompt = async (e: Event) => {
<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" />
<span class="text-16 i-mynaui-check-hexagon"></span> <span class="text-16 i-mynaui-check-hexagon"></span>
</div> </div>
<input @input="handleInput" placeholder="Agent Name..." <input v-model="name" @input="handleNameInput" placeholder="Agent Name..."
class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-border)] text-12 p-0" class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-border)] text-12 p-0"
type="text" :value="agent?.name" /> type="text" />
</div> </div>
<div class="flex flex-col gap-2 w-full h-full mb-14"> <div class="flex flex-col gap-2 w-full h-full mb-14">
<label class="text-sm text-[var(--text-secondary)]">System Message</label> <label class="text-sm text-[var(--text-secondary)]">System Message</label>
<textarea placeholder="You are a helpful assistant." <textarea v-model="systemPrompt" placeholder="You are a helpful assistant."
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-border)]" class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-border)]"
:value="agent?.systemPrompt" @input="changeSystemPrompt"></textarea> @input="changeSystemPrompt"></textarea>
</div> </div>
</div> </div>
</template> </template>
+52 -228
View File
@@ -1,131 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import type schema from '#triplit/schema'; import { type BaseMessage, type Message, ChatErrorType } from '~/composables/useChat';
import type { Entity } from '@triplit/client';
import type { BaseMessage } from '~/composables/useChat';
import type { ModelWithProvider } from '~/composables/useModels'; import type { ModelWithProvider } from '~/composables/useModels';
import { buildFocusedMessageTree } from '~~/utils/message';
const rootStart = Date.now(); const rootStart = Date.now();
const triplit = useTriplitClient();
const chatPaneWrapper = ref<HTMLElement | null>(null); const chatPaneWrapper = ref<HTMLElement | null>(null);
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] }); const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
const route = useRoute(); const route = useRoute();
const { sendMessage, regenerateMessage } = useChat(route.params.id as string); const { getAgent } = await useAgents();
const { getAgent } = useAgents();
const { open: sidebarOpen, openSidebar } = useSidebar(); const { open: sidebarOpen, openSidebar } = useSidebar();
const { providers, allModels } = useModels(); const { providers, allModels } = await useModels();
const { addShortcut } = useKeyboardShortcuts(); const { addShortcut } = useKeyboardShortcuts();
const agent = getAgent(route.params.id as string); const agent = getAgent(route.params.id as string);
const topicQuery = computed(() => const topicId = computed(() => route.params.topicId as string);
triplit const { topic, sendMessage, startGeneration, regenerateMessage, patchMessageLocally } = await useChat(topicId);
.query('topics')
.Where(['id', '=', route.params.topicId])
.Limit(1)
);
const messagesQuery = computed(() =>
triplit
.query('messages')
.Where(['topicId', '=', route.params.topicId])
.Order('createdAt', 'ASC')
);
const attachmentsQuery = computed(() =>
triplit
.query('attachments')
.Where(['topicId', '=', route.params.topicId])
.Order('createdAt', 'ASC')
);
const partsQuery = computed(() =>
triplit
.query('message_parts')
.Where(['topicId', '=', route.params.topicId])
.Order('createdAt', 'ASC')
.Include('toolCall')
);
const generationsQuery = computed(() =>
triplit
.query('generations')
.Where(['topicId', '=', route.params.topicId])
);
const [
{ results: rawTopic, unsubscribe: unsubscribeTopic },
{ results: rawMessages, unsubscribe: unsubscribeMessages },
{ results: rawAttachments, unsubscribe: unsubscribeAttachments },
{ results: rawParts, unsubscribe: unsubscribeParts },
{ results: rawGenerations, unsubscribe: unsubscribeGenerations }
] = await Promise.all([
useQuery('topic', triplit, topicQuery),
useQuery('messages', triplit, messagesQuery),
useQuery('attachments', triplit, attachmentsQuery),
useQuery('parts', triplit, partsQuery),
useQuery('generations', triplit, generationsQuery),
]);
const topic = computed(() => {
if (!rawMessages.value || !rawTopic.value?.[0]) return null;
const messagesMap = new Map();
const partsByMessage = new Map<string, Entity<typeof schema, 'message_parts'>[]>();
const attachmentsByMessage = new Map<string, Entity<typeof schema, 'attachments'>[]>();
// Group parts by message ID once
if (rawParts.value) {
for (const part of rawParts.value) {
if (!partsByMessage.has(part.messageId)) {
partsByMessage.set(part.messageId, []);
}
if (part.content !== '' || part.toolCall !== null) {
partsByMessage.get(part.messageId)!.push(part);
}
}
}
if (rawAttachments.value) {
for (const attachment of rawAttachments.value) {
if (!attachmentsByMessage.has(attachment.messageId)) {
attachmentsByMessage.set(attachment.messageId, []);
}
attachmentsByMessage.get(attachment.messageId)!.push(attachment);
}
}
const generationsMap = new Map(
rawGenerations.value?.map(g => [g.id, g]) ?? []
);
// Single pass to build messages
for (const msg of rawMessages.value) {
messagesMap.set(msg.id, {
...msg,
attachments: attachmentsByMessage.get(msg.id) ?? [],
parts: partsByMessage.get(msg.id) ?? [],
children: [],
generation: generationsMap.get(msg.generationId!) ?? null
});
}
// Build tree
const rootMessages: Message[] = [];
for (const msg of messagesMap.values()) {
if (msg.parentMessageId && messagesMap.has(msg.parentMessageId)) {
messagesMap.get(msg.parentMessageId)!.children.push(msg);
} else {
rootMessages.push(msg);
}
}
return {
...rawTopic.value[0],
messages: rootMessages as Message[],
generations: rawGenerations.value || []
};
});
watch(() => topic.value?.name, (newTopicName) => { watch(() => topic.value?.name, (newTopicName) => {
if (newTopicName !== undefined) { if (newTopicName !== undefined) {
@@ -139,7 +30,14 @@ const submitMessage = async (message: BaseMessage, model: ModelWithProvider | nu
return; return;
} }
const res = await sendMessage(message, topic.value!, focusedMessageTree.value, agent.value!, model.provider, model); inputValue.value = { content: '', fileIds: [] };
const res = await sendMessage(message, async () => {
await nextTick();
setTimeout(() => {
scrollToBottom('instant')
});
});
if (!res.ok) { if (!res.ok) {
console.error('Failed to send message:', res.error); console.error('Failed to send message:', res.error);
const chatInput = document.getElementById('chat') as HTMLInputElement | null; const chatInput = document.getElementById('chat') as HTMLInputElement | null;
@@ -153,33 +51,16 @@ const submitMessage = async (message: BaseMessage, model: ModelWithProvider | nu
return; return;
} }
scrollToBottom('instant'); startGeneration(model);
}; };
const focusedMessageTree = computed(() => {
const tree: Readonly<MessageEntity>[] = [];
for (const message of topic.value?.messages || []) {
if (message.focusedIndex !== undefined && message.focusedIndex !== null) {
if (message.focusedIndex === 0) {
tree.push(message);
continue;
}
tree.push(message.children[message.focusedIndex - 1]!);
} else {
tree.push(message);
}
}
return tree;
})
const handleRegenerate = async (message: Message) => { const handleRegenerate = async (message: Message) => {
if (!agent.value!.defaultModelId) { if (!agent.value!.defaultModelId) {
console.error('No model selected'); console.error('No model selected');
return; return;
} }
let messageId; let messageId: string;
if ( if (
(message.focusedIndex !== undefined && message.focusedIndex !== null) (message.focusedIndex !== undefined && message.focusedIndex !== null)
&& message.focusedIndex > 0 && message.focusedIndex > 0
@@ -191,90 +72,41 @@ const handleRegenerate = async (message: Message) => {
} }
const model = allModels.value.find(m => m.id === agent.value!.defaultModelId); const model = allModels.value.find(m => m.id === agent.value!.defaultModelId);
if (!model) { if (!model || !model.provider) {
console.error('Model not found'); console.error('Model not found or provider not found');
return; return;
} }
const res = await regenerateMessage(messageId, topic.value!, focusedMessageTree.value, agent.value!, model.provider, model); const res = await regenerateMessage(messageId, buildFocusedMessageTree(topic.value!.messages), model);
if (!res.ok) { if (!res.ok) {
console.error('Failed to regenerate message:', ChatErrorType[res.error]); console.error('Failed to regenerate message:', ChatErrorType[res.error]);
return; return;
} }
} }
const deeplyDeleteMessage = async (message: MessageEntity) => {
triplit.delete('messages', message.id);
if (message.generationId !== null && message.generationId !== undefined) {
triplit.delete('generations', message.generationId);
}
for (const part of message.parts || []) {
triplit.delete('message_parts', part.id);
}
if (topic.value?.messages.filter(m => m.id !== message.id).length === 0) {
triplit.delete('topics', topic.value!.id);
return navigateTo(`/agent/${route.params.id}/`);
}
}
const handleDelete = async (rootMessage: Message) => { const handleDelete = async (rootMessage: Message) => {
if (rootMessage.role === 'user') { await $fetch(`/api/messages/${rootMessage.id}`, {
deeplyDeleteMessage(rootMessage); method: 'DELETE',
return;
}
if (rootMessage.deleted === true) {
const message = rootMessage.children[rootMessage.focusedIndex!];
if (!message) {
console.error('Message not found');
return;
}
deeplyDeleteMessage(message);
if (rootMessage.children.filter(child => child!.id !== message.id).length === 0) {
deeplyDeleteMessage(rootMessage);
}
return;
}
// - If the message has children, check if they are all soft deleted
// - If they are all soft deleted, delete the message
// - If they are not all soft deleted, mark only this message as deleted
if (
(rootMessage.focusedIndex !== undefined && rootMessage.focusedIndex !== null)
&& rootMessage.focusedIndex > 0
&& rootMessage.children.length > 0
) {
// we are a child message
const message = rootMessage.children[rootMessage.focusedIndex - 1]!;
if (!message) {
console.error('Message not found');
return;
}
deeplyDeleteMessage(message);
return;
}
// we have no children
if (rootMessage.children.length === 0) {
deeplyDeleteMessage(rootMessage);
return;
}
// we are a root message and we have at least one living child, soft delete
await triplit.update('messages', rootMessage.id, {
deleted: true
}); });
} }
const flatMessages = computed(() => {
const messages: Message[] = [];
for (const message of topic.value?.messages ?? []) {
messages.push(message);
if (message.children.length > 0) {
messages.push(...message.children as Message[]);
}
}
return messages;
});
const activeGeneration = computed(() => { const activeGeneration = computed(() => {
if (topic.value === null) return null; if (topic.value === null) return null;
return topic.value?.generations?.find((generation) => generation.status === 'pending') ?? null; const generations = flatMessages.value.flatMap(message => message.generation);
return generations?.find((generation) => generation?.status === 'pending') ?? null;
}); });
const { scrollToBottom } = useAutoScroll(chatPaneWrapper); const { scrollToBottom } = useAutoScroll(chatPaneWrapper);
@@ -318,9 +150,8 @@ addShortcut(['alt', '['], async (event) => {
const lastMessage = topic.value?.messages?.at(-1); const lastMessage = topic.value?.messages?.at(-1);
if (lastMessage && lastMessage.children.length > 0) { if (lastMessage && lastMessage.children.length > 0) {
await triplit.update('messages', lastMessage.id, { const newIndex = Math.max(0, (lastMessage.focusedIndex || 0) - 1);
focusedIndex: Math.max(0, lastMessage.focusedIndex! - 1) patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
});
} }
}) })
@@ -329,10 +160,10 @@ addShortcut(['alt', ']'], async (event) => {
event.stopPropagation(); event.stopPropagation();
const lastMessage = topic.value?.messages?.at(-1); const lastMessage = topic.value?.messages?.at(-1);
const messageCount = lastMessage ? (lastMessage.deleted ? lastMessage.children.length : lastMessage.children.length + 1) : 0;
if (lastMessage && lastMessage.children.length > 0) { if (lastMessage && lastMessage.children.length > 0) {
await triplit.update('messages', lastMessage.id, { const newIndex = Math.min(messageCount - 1, (lastMessage.focusedIndex || 0) + 1);
focusedIndex: Math.min(lastMessage.children.length, lastMessage.focusedIndex! + 1) patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
});
} }
}) })
@@ -342,21 +173,20 @@ addShortcut(['ctrl', 'alt', 'arrowleft'], async (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
await triplit.update('messages', lastMessage.id, { const newIndex = Math.max(0, (lastMessage.focusedIndex || 0) - 1);
focusedIndex: Math.max(0, lastMessage.focusedIndex! - 1) patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
});
} }
}) })
addShortcut(['ctrl', 'alt', 'arrowright'], async (event) => { addShortcut(['ctrl', 'alt', 'arrowright'], async (event) => {
const lastMessage = topic.value?.messages?.at(-1); const lastMessage = topic.value?.messages?.at(-1);
const messageCount = lastMessage ? (lastMessage.deleted ? lastMessage.children.length : lastMessage.children.length + 1) : 0;
if (lastMessage && lastMessage.children.length > 0) { if (lastMessage && lastMessage.children.length > 0) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
await triplit.update('messages', lastMessage.id, { const newIndex = Math.min(messageCount - 1, (lastMessage.focusedIndex || 0) + 1);
focusedIndex: Math.min(lastMessage.children.length, lastMessage.focusedIndex! + 1) patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
});
} }
}) })
@@ -365,20 +195,13 @@ onMounted(() => {
}); });
const handleCancel = async () => { const handleCancel = async () => {
await $fetch(`/api/chat/cancel/${activeGeneration.value?.id}`, { // TODO: cancel generation
await $fetch(`/api/topic/${topicId.value}/chat/cancel/${activeGeneration.value?.id}`, {
method: 'POST', method: 'POST',
}); });
}; };
console.log("full page render took", Date.now() - rootStart); console.log("full page render took", Date.now() - rootStart);
onUnmounted(() => {
unsubscribeTopic?.();
unsubscribeMessages?.();
unsubscribeAttachments?.();
unsubscribeParts?.();
unsubscribeGenerations?.();
});
</script> </script>
<template> <template>
@@ -386,10 +209,10 @@ onUnmounted(() => {
<div class="h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)]"> <div class="h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)]">
<div class="flex items-center gap-2 max-w-full"> <div class="flex items-center gap-2 max-w-full">
<button v-if="!sidebarOpen" @click="openSidebar" <button v-if="!sidebarOpen" @click="openSidebar"
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors"> class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-panel-left-open text-5"></span> <span class="i-mynaui-panel-left-open text-5"></span>
</button> </button>
<h4 class="text-lg text-ellipsis overflow-hidden whitespace-nowrap"> <h4 class="text-lg truncate">
{{ topic?.name }} {{ topic?.name }}
</h4> </h4>
</div> </div>
@@ -403,7 +226,8 @@ onUnmounted(() => {
<Suspense> <Suspense>
<template v-if="Array.isArray(topic?.messages) && topic.messages.length > 0"> <template v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
<Message v-for="message in topic.messages" :key="message.id" :message="message" <Message v-for="message in topic.messages" :key="message.id" :message="message"
v-memo="[message.id, message.parts?.length, message.children, message.focusedIndex, message.content]" @edit="(value) => patchMessageLocally(message.id, { content: value })"
@patch="(updates) => patchMessageLocally(message.id, updates)"
@delete="handleDelete(message)" @regenerate="handleRegenerate(message)" /> @delete="handleDelete(message)" @regenerate="handleRegenerate(message)" />
</template> </template>
</Suspense> </Suspense>
+1 -4
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { deriveKey } from '~/utils/crypto'; import { deriveKey } from '~/utils/crypto';
import { initSettings } from '~/utils/settings';
const { client: authClient, signIn, session } = useAuth(); const { client: authClient, signIn, session } = useAuth();
@@ -101,8 +100,6 @@ const submit = async () => {
const key = await deriveKey(form.password, res.data.user.id); const key = await deriveKey(form.password, res.data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key)); localStorage.setItem('encryptionKey', JSON.stringify(key));
initSettings(res.data.user.id);
return navigateTo(to ?? '/'); return navigateTo(to ?? '/');
}; };
</script> </script>
@@ -152,7 +149,7 @@ const submit = async () => {
<p v-if="!config.public.disableSignup" class="text-sm text-center mt-3 text-[var(--text-secondary)]"> <p v-if="!config.public.disableSignup" class="text-sm text-center mt-3 text-[var(--text-secondary)]">
New to Veridian? New to Veridian?
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'" <NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
class="text-[var(--color-accent)] font-medium hover:underline"> class="text-[var(--color-accent)] font-medium @hover:underline">
Create an account Create an account
</NuxtLink> </NuxtLink>
</p> </p>
+1 -4
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { deriveKey } from '~/utils/crypto'; import { deriveKey } from '~/utils/crypto';
import { initSettings } from '~/utils/settings';
const { client: authClient, signUp, session } = useAuth(); const { client: authClient, signUp, session } = useAuth();
@@ -134,8 +133,6 @@ const submit = async () => {
const key = await deriveKey(form.password, res.data.user.id); const key = await deriveKey(form.password, res.data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key)); localStorage.setItem('encryptionKey', JSON.stringify(key));
initSettings(res.data.user.id);
return navigateTo(to ?? '/'); return navigateTo(to ?? '/');
}; };
</script> </script>
@@ -203,7 +200,7 @@ const submit = async () => {
<p class="text-sm text-center mt-3 text-[var(--text-secondary)]"> <p class="text-sm text-center mt-3 text-[var(--text-secondary)]">
Already have an account? Already have an account?
<NuxtLink :to="to ? `/auth/login?to=${to}` : '/auth/login'" <NuxtLink :to="to ? `/auth/login?to=${to}` : '/auth/login'"
class="text-[var(--color-accent)] font-medium hover:underline"> class="text-[var(--color-accent)] font-medium @hover:underline">
Log in here Log in here
</NuxtLink> </NuxtLink>
</p> </p>
+28 -44
View File
@@ -1,13 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { assert } from '~~/utils/assert';
import type { BaseMessage } from '~/composables/useChat'; import type { BaseMessage } from '~/composables/useChat';
import type { Agent } from '~/composables/useAgents';
const triplit = useTriplitClient();
const { open: sidebarOpen, openSidebar } = useSidebar(); const { open: sidebarOpen, openSidebar } = useSidebar();
const { agents, createAgent } = useAgents(); const { agents, createAgent, createTopic } = await useAgents();
const { providers, getFirstAvailableModel, allModels } = useModels(); const { providers } = await useModels();
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] }); const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
@@ -80,60 +76,48 @@ const agent = computed(() => {
return agents.value?.[0] ?? null; return agents.value?.[0] ?? null;
}); });
const { autoRename: autoRenameTopic } = useTopic();
const handleChatSubmit = async (message: BaseMessage, model: ModelWithProvider | null) => { const handleChatSubmit = async (message: BaseMessage, model: ModelWithProvider | null) => {
console.log('Message submitted:', message, agents);
let agent: Agent | null = agents.value?.[0] ?? null;
if (!agent) {
const triplit = useTriplitClient();
assert('flush' in triplit);
agent = await createAgent();
await triplit.flush();
}
if (!agent) throw new Error('Failed to find agent');
if (!model) {
if (agent.defaultModelId) {
model = allModels.value.find(m => m.id === agent.defaultModelId) ?? null;
} else {
model = getFirstAvailableModel();
}
}
if (!model) { if (!model) {
console.error('No model selected'); console.error('No model selected');
return; return;
} }
const { createTopic, autoRename, sendMessage } = useChat(agent.id); if (!message.content) return;
const topic = await createTopic(); let targetAgent = agents.value?.[0] ?? null;
if (!topic) throw new Error('Failed to create topic'); if (!targetAgent) {
targetAgent = await createAgent(false);
if (!targetAgent) {
console.error('Failed to create agent');
return;
}
}
await navigateTo(`/agent/${agent.id}/topic/${topic.id}`); const topic = await createTopic(targetAgent.id);
if (!topic) {
console.error('Failed to create topic');
return;
}
autoRename(topic.id, message.content); const { sendMessage, startGeneration } = await useChat(topic.id, false);
return sendMessage(message, topic, [], agent, model.provider, model).then(async res => { await sendMessage(message).then(async res => {
if (res.ok === false) { if (res.ok === false) {
console.error('Failed to send message:', res.error); console.error('Failed to send message:', res.error);
await navigateTo(`/agent/${agent.id}`); await navigateTo('/');
await triplit.delete('topics', topic.id);
// const chatInput = document.getElementById('chat') as HTMLInputElement;
// if (chatInput) {
// chatInput.value = message;
// chatInput.dispatchEvent(new Event('input'));
// nextTick(() => {
// chatInput.focus();
// });
// }
nextTick(() => { nextTick(() => {
inputValue.value = message; inputValue.value = message;
}); });
} }
}); });
await navigateTo(`/agent/${targetAgent.id}/topic/${topic.id}`);
autoRenameTopic(topic.id);
return startGeneration(model);
}; };
onMounted(() => { onMounted(() => {
@@ -166,7 +150,7 @@ onUnmounted(() => {
<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="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors"> class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-panel-left-open text-5"></span> <span class="i-mynaui-panel-left-open text-5"></span>
</button> </button>
</div> </div>
+1 -9
View File
@@ -1,11 +1,10 @@
import { assert } from "~~/utils/assert"; import { assert } from "~~/utils/assert";
export default defineNuxtPlugin({ export default defineNuxtPlugin({
name: 'better-auth-triplit', name: 'better-auth',
enforce: 'pre', enforce: 'pre',
async setup(nuxtApp) { async setup(nuxtApp) {
if (import.meta.client) { if (import.meta.client) {
const triplit = useTriplitClient();
const { session, fetchSession } = useAuth(); const { session, fetchSession } = useAuth();
nuxtApp.hook('app:mounted', async () => { nuxtApp.hook('app:mounted', async () => {
@@ -16,13 +15,6 @@ export default defineNuxtPlugin({
return; return;
} }
} }
if (!session.value) {
return;
}
assert('startSession' in triplit)
await triplit.startSession(session.value.token);
}); });
} }
}, },
-10
View File
@@ -4,8 +4,6 @@ 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) {
@@ -18,14 +16,6 @@ export default defineNuxtPlugin({
return; return;
} }
} }
if (!session.value) return;
assert('updateOptions' in triplit);
triplit.updateOptions({
serverUrl: process.env.NUXT_LOCAL_TRIPLIT_URL || process.env.NUXT_PUBLIC_TRIPLIT_URL,
token: session.value.token,
});
} }
}, },
}); });
-13
View File
@@ -1,13 +0,0 @@
export default defineNuxtPlugin({
name: 'global-data',
enforce: 'pre',
async setup() {
await Promise.all([
useAgents().init(),
useModels().init(),
useUserSettings().init()
]);
console.log('Global data ready');
}
});
+18 -4
View File
@@ -1,16 +1,30 @@
import { schema } from '#triplit/schema'; import * as schema from '~~/drizzle/schema';
import type { Entity } from '@triplit/client';
export const Providers = ['openrouter', 'ollama', 'cerebras', 'google', 'longcat', 'cohere'] as const; export const Providers = [
'openrouter',
'ollama',
'vllm',
'cerebras',
'google',
'longcat',
'cohere',
'inception',
'mistral',
'closedrouter',
] as const;
export const SupportedModalities = ['text', 'image', 'audio', 'video', 'pdf'] as const; export const SupportedModalities = ['text', 'image', 'audio', 'video', 'pdf'] as const;
export const providerBaseUrls = { export const providerBaseUrls = {
openrouter: 'https://openrouter.ai/api/v1', openrouter: 'https://openrouter.ai/api/v1',
vllm: '',
ollama: '', ollama: '',
cerebras: 'https://api.cerebras.ai/v1', cerebras: 'https://api.cerebras.ai/v1',
google: 'https://generativelanguage.googleapis.com/v1beta', google: 'https://generativelanguage.googleapis.com/v1beta',
longcat: 'https://api.longcat.chat/openai/v1', longcat: 'https://api.longcat.chat/openai/v1',
cohere: 'https://api.cohere.ai/v2', cohere: 'https://api.cohere.ai/v2',
inception: 'https://api.inceptionlabs.ai/v1',
mistral: 'https://api.mistral.ai/v1',
closedrouter: 'https://router.queef.in/v1',
}; };
export type Model = Entity<typeof schema, 'models'> & { provider: Entity<typeof schema, 'providers'> }; export type Model = typeof schema.models.$inferSelect;
+7
View File
@@ -50,6 +50,8 @@ import {
LogoAionLabs, LogoAionLabs,
LogoMicrosoft, LogoMicrosoft,
LogoInflection, LogoInflection,
LogoVllm,
LogoClosedRouter,
} from '#components'; } from '#components';
import { markRaw } from 'vue'; import { markRaw } from 'vue';
@@ -186,6 +188,7 @@ const MODEL_MAPPINGS: ModelConfig[] = [
/magistral/, /magistral/,
/devstral/, /devstral/,
/voxtral/, /voxtral/,
/leanstral/,
], ],
}, },
{ {
@@ -325,6 +328,10 @@ export const providerIcons: Record<string, any | null> = {
openrouter: markRaw(LogoOpenrouter), openrouter: markRaw(LogoOpenrouter),
cerebras: markRaw(LogoCerebras), cerebras: markRaw(LogoCerebras),
longcat: markRaw(LogoLongCat), longcat: markRaw(LogoLongCat),
inception: markRaw(LogoInception),
mistral: markRaw(LogoMistral),
vllm: markRaw(LogoVllm),
closedrouter: markRaw(LogoClosedRouter),
}; };
export function getModelConfig(modelId: string) { export function getModelConfig(modelId: string) {
-19
View File
@@ -1,19 +0,0 @@
export const initSettings = async (userId: string) => {
const triplit = useTriplitClient();
const settings = await triplit.fetchOne(triplit.query('settings').Where('userId', '=', userId));
if (!settings) {
console.log('no settings');
await triplit.insert('settings', {
userId: userId,
systemAssistants: {
rename: {
enabled: false,
prompt: null,
modelId: null,
}
}
});
}
}
+2
View File
@@ -1,4 +1,6 @@
export const sortByReleaseDate = (a: { releasedAt?: Date | null } & Record<string, unknown>, b: { releasedAt?: Date | null } & Record<string, unknown>) => { export const sortByReleaseDate = (a: { releasedAt?: Date | null } & Record<string, unknown>, b: { releasedAt?: Date | null } & Record<string, unknown>) => {
if (typeof a.releasedAt === 'string') a.releasedAt = new Date(a.releasedAt);
if (typeof b.releasedAt === 'string') b.releasedAt = new Date(b.releasedAt);
if (!a.releasedAt && !b.releasedAt) return 0; if (!a.releasedAt && !b.releasedAt) return 0;
if (!a.releasedAt) return 1; if (!a.releasedAt) return 1;
if (!b.releasedAt) return -1; if (!b.releasedAt) return -1;
+446 -315
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
services:
db:
image: pgvector/pgvector:pg17
container_name: veridian-db
ports:
- "5432:5432"
environment:
POSTGRES_DB: veridian
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: ['./drizzle/schema.ts', './drizzle/auth-schema.ts'],
out: './migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/veridian',
},
});
@@ -0,0 +1,2 @@
-- Custom SQL migration file, put your code below! --
CREATE EXTENSION IF NOT EXISTS vector;
+95
View File
@@ -0,0 +1,95 @@
import { defineRelations } from "drizzle-orm";
import * as schema from "./schema";
export const relations = defineRelations(schema, (r) => ({
users: {
sessions: r.many.sessions(),
accounts: r.many.accounts(),
},
sessions: {
users: r.one.users({
from: r.sessions.userId,
to: r.users.id
}),
},
accounts: {
users: r.one.users({
from: r.accounts.userId,
to: r.users.id
}),
},
agents: {
topics: r.many.topics({
from: r.agents.id,
to: r.topics.agentId
}),
},
topics: {
messages: r.many.messages({
from: r.topics.id,
to: r.messages.topicId
}),
generations: r.many.generations({
from: r.topics.id,
to: r.generations.topicId
}),
agent: r.one.agents({
from: r.topics.agentId,
to: r.agents.id,
optional: false,
}),
},
messages: {
topic: r.one.topics({
from: r.messages.topicId,
to: r.topics.id,
optional: false,
}),
parts: r.many.messageParts({
from: r.messages.id,
to: r.messageParts.messageId
}),
attachments: r.many.attachments({
from: r.messages.id,
to: r.attachments.messageId
}),
generation: r.one.generations({
from: r.messages.generationId,
to: r.generations.id
}),
parent: r.one.messages({
from: r.messages.parentMessageId,
to: r.messages.id
}),
children: r.many.messages({
from: r.messages.id,
to: r.messages.parentMessageId
}),
},
attachments: {
file: r.one.files({
from: r.attachments.fileId,
to: r.files.id,
optional: false,
}),
},
messageParts: {
toolCall: r.one.toolCalls({
from: r.messageParts.toolCallId,
to: r.toolCalls.id
}),
},
models: {
provider: r.one.providers({
from: r.models.providerId,
to: r.providers.id,
optional: false,
}),
},
providers: {
models: r.many.models({
from: r.providers.id,
to: r.models.providerId
}),
},
}))
+298
View File
@@ -0,0 +1,298 @@
import {
pgTable,
text,
timestamp,
boolean,
integer,
jsonb,
pgEnum,
vector,
index
} from 'drizzle-orm/pg-core';
import { nanoid } from 'nanoid';
export const roleEnum = pgEnum('role', ['user', 'assistant']);
export const partTypeEnum = pgEnum('part_type', ['reasoning', 'text', 'tool-call', 'file']);
export const statusEnum = pgEnum('status', ['pending', 'completed', 'failed', 'cancelled']);
export enum ToolCallType {
Text = 'text',
Json = 'json',
}
export const settings = pgTable('settings', {
id: text('id').primaryKey().$defaultFn(nanoid),
userId: text('user_id').notNull(),
systemAssistants: jsonb('system_assistants').$type<Record<string, any>>().notNull().default({}),
appearance: jsonb('appearance').$type<{
colorScheme?: 'light' | 'dark' | 'system';
accent?: string;
neutral?: string;
hinting?: number;
fontSize?: string;
}>(),
}, (table) => [
index('settings_userId_idx').on(table.userId),
]);
export const providers = pgTable('providers', {
id: text('id').primaryKey().$defaultFn(nanoid),
userId: text('user_id').notNull(),
type: text('type').notNull(),
name: text('name').notNull(),
enabled: boolean('enabled').notNull().default(true),
config: jsonb('config').notNull().$type<{
apiKey?: string;
apiProxyUrl?: string;
}>().default({}),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
}, (table) => [
index('providers_userId_idx').on(table.userId),
]);
export const models = pgTable('models', {
id: text('id').primaryKey().$defaultFn(nanoid),
userId: text('user_id').notNull(),
externalId: text('external_id').notNull(),
providerId: text('provider_id').notNull().references(() => providers.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
cost: jsonb('cost').notNull().$type<{
prompt?: string;
completion?: string;
request?: string;
image?: string;
imageTokens?: string;
imageOutput?: string;
audio?: string;
audioOutput?: string;
inputAudioCache?: string;
webSearch?: string;
internalReasoning?: string;
inputCacheRead?: string;
inputCacheWrite?: string;
discount?: string;
}>().default({}),
inputModalities: text('input_modalities').array().notNull().default([]),
outputModalities: text('output_modalities').array().notNull().default([]),
capabilities: text('capabilities').array().notNull().default([]),
contextWindow: integer('context_window'),
supportedParameters: text('supported_parameters').array(),
isCustom: boolean('is_custom').notNull().default(false),
enabled: boolean('enabled').notNull().default(true),
releasedAt: timestamp('released_at'),
}, (table) => [
index('models_providerId_idx').on(table.providerId),
index('models_userId_idx').on(table.userId),
]);
export const agents = pgTable('agents', {
id: text('id').primaryKey().$defaultFn(nanoid),
userId: text('user_id').notNull(),
name: text('name').notNull(),
systemPrompt: text('system_prompt'),
imageUrl: text('image_url'),
defaultModelId: text('default_model_id').references(() => models.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (table) => [
index('agents_userId_idx').on(table.userId),
]);
export const topics = pgTable('topics', {
id: text('id').primaryKey().$defaultFn(nanoid),
userId: text('user_id').notNull(),
agentId: text('agent_id').notNull().references(() => agents.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
renaming: boolean('renaming').default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (table) => [
index('topics_agentId_idx').on(table.agentId),
index('topics_userId_idx').on(table.userId),
]);
export const messages = pgTable('messages', {
id: text('id').primaryKey().$defaultFn(nanoid),
userId: text('user_id').notNull(),
topicId: text('topic_id').notNull().references(() => topics.id, { onDelete: 'cascade' }),
parentMessageId: text('parent_message_id'),
generationId: text('generation_id').references(() => generations.id, { onDelete: 'cascade' }),
role: roleEnum('role').notNull(),
content: text('content'),
focusedIndex: integer('focused_index'),
deleted: boolean('deleted').default(false),
updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => /* @__PURE__ */ new Date()),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (table) => [
index('messages_topicId_idx').on(table.topicId),
index('messages_parentMessageId_idx').on(table.parentMessageId),
index('messages_userId_idx').on(table.userId),
]);
export const messageParts = pgTable('message_parts', {
id: text('id').primaryKey().$defaultFn(nanoid),
userId: text('user_id').notNull(),
topicId: text('topic_id').notNull().references(() => topics.id, { onDelete: 'cascade' }),
messageId: text('message_id').notNull().references(() => messages.id, { onDelete: 'cascade' }),
toolCallId: text('tool_call_id').references(() => toolCalls.id, { onDelete: 'cascade' }),
type: partTypeEnum('type').notNull(),
content: text('content'),
providerOptions: jsonb('provider_options'),
finished: boolean('finished').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
lastUpdatedAt: timestamp('last_updated_at').notNull().defaultNow(),
}, (table) => [
index('message_parts_topicId_idx').on(table.topicId),
index('message_parts_messageId_idx').on(table.messageId),
index('message_parts_userId_idx').on(table.userId),
]);
export const toolCalls = pgTable('tool_calls', {
id: text('id').primaryKey().$defaultFn(nanoid),
userId: text('user_id').notNull(),
toolName: text('tool_name').notNull(),
status: statusEnum('status').notNull().default('pending'),
input: jsonb('input').$type<{
type: ToolCallType;
value: string;
}>(),
output: jsonb('output').$type<{
type: ToolCallType;
value: string;
}>(),
error: jsonb('error').$type<{
type: ToolCallType;
value: string;
}>(),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (table) => [
index('tool_calls_userId_idx').on(table.userId),
]);
export const generations = pgTable('generations', {
id: text('id').primaryKey().$defaultFn(nanoid),
userId: text('user_id').notNull(),
topicId: text('topic_id').notNull().references(() => topics.id, { onDelete: 'cascade' }),
modelId: text('model_id').notNull(),
status: statusEnum('status').notNull(),
tokens: jsonb('tokens').$type<{
input?: number;
cache?: {
read?: number;
write?: number;
};
output?: number;
thinking?: number;
ttft?: number;
tps?: number;
}>(),
error: text('error'),
}, (table) => [
index('generations_topicId_idx').on(table.topicId),
index('generations_userId_idx').on(table.userId),
]);
export const files = pgTable('files', {
id: text('id').primaryKey().$defaultFn(nanoid),
userId: text('user_id').notNull(),
name: text('name').notNull(),
mimeType: text('mime_type').notNull(),
url: text('url').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (table) => [
index('files_userId_idx').on(table.userId),
]);
export const embeddings = pgTable('embeddings', {
id: text('id').primaryKey().$defaultFn(nanoid),
fileId: text('file_id').notNull().references(() => files.id, { onDelete: 'cascade' }),
content: text('content').notNull(),
embedding: vector('embedding', { dimensions: 1536 }),
metadata: jsonb('metadata'),
}, (table) => [
index('embeddings_fileId_idx').on(table.fileId),
]);
export const attachments = pgTable('attachments', {
id: text('id').primaryKey().$defaultFn(nanoid),
userId: text('user_id').notNull(),
topicId: text('topic_id').notNull().references(() => topics.id),
messageId: text('message_id').notNull().references(() => messages.id, { onDelete: 'cascade' }),
fileId: text('file_id').notNull().references(() => files.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (table) => [
index('attachments_topicId_idx').on(table.topicId),
index('attachments_messageId_idx').on(table.messageId),
index('attachments_userId_idx').on(table.userId),
]);
export const users = pgTable("users", {
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 sessions = pgTable(
"sessions",
{
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(() => users.id, { onDelete: "cascade" }),
},
(table) => [index("sessions_userId_idx").on(table.userId)],
);
export const accounts = pgTable(
"accounts",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => users.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("accounts_userId_idx").on(table.userId)],
);
export const verifications = pgTable(
"verifications",
{
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("verifications_identifier_idx").on(table.identifier)],
);
-38
View File
@@ -1,38 +0,0 @@
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);
+7 -12
View File
@@ -1,18 +1,13 @@
import { triplitAdapter } from '@daveyplate/better-auth-triplit'; import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
import { HttpClient } from '@triplit/client'; import { db } from "../server/lib/db";
import { betterAuth } from 'better-auth/minimal'; import { betterAuth } from 'better-auth/minimal';
import { schema } from '../triplit/schema'; import * as schema from '../drizzle/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: triplitAdapter({ database: drizzleAdapter(db, {
httpClient, provider: "pg",
secretKey: process.env.BETTER_AUTH_SECRET, schema,
usePlural: true,
}), }),
session: { session: {
+1 -29
View File
@@ -69,14 +69,6 @@ export default defineNuxtConfig({
`, `,
tagPosition: 'bodyOpen', tagPosition: 'bodyOpen',
type: 'text/javascript', type: 'text/javascript',
},
{
src: 'https://cdn.jsdelivr.net/npm/eruda',
},
{
innerHTML: `
eruda.init();
`,
} }
] ]
}, },
@@ -115,8 +107,8 @@ export default defineNuxtConfig({
'@vue/devtools-core', '@vue/devtools-core',
'@vue/devtools-kit', '@vue/devtools-kit',
'@sentry/nuxt', '@sentry/nuxt',
'@floating-ui/vue',
'@nuxt/hints/runtime/hydration/component', '@nuxt/hints/runtime/hydration/component',
'@nuxt/hints/runtime/lazy-load/composables',
'@tanstack/vue-virtual', '@tanstack/vue-virtual',
'shiki', 'shiki',
'better-auth/vue', 'better-auth/vue',
@@ -127,10 +119,6 @@ export default defineNuxtConfig({
'remark-math', 'remark-math',
'rehype-katex', 'rehype-katex',
'unist-util-visit', 'unist-util-visit',
'@triplit/client',
'@triplit/db',
'sorted-btree',
'elen',
'comlink', 'comlink',
'nanoid' 'nanoid'
], ],
@@ -142,12 +130,6 @@ export default defineNuxtConfig({
}, },
}, },
nitro: {
experimental: {
websocket: true,
}
},
// bizarely, when I enable this, it adds an 8 second or so lag when I navigate to a different page occasionally // bizarely, when I enable this, it adds an 8 second or so lag when I navigate to a different page occasionally
// experimental: { // experimental: {
// viewTransition: true, // viewTransition: true,
@@ -156,20 +138,10 @@ export default defineNuxtConfig({
modules: [ modules: [
'@nuxt/hints', '@nuxt/hints',
'@unocss/nuxt', '@unocss/nuxt',
'triplit-nuxt',
// '@sentry/nuxt/module', // '@sentry/nuxt/module',
'@nuxt/fonts' '@nuxt/fonts'
], ],
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,
},
devtools: { devtools: {
enabled: true, enabled: true,
+7 -15
View File
@@ -4,7 +4,6 @@
"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",
@@ -14,10 +13,11 @@
"@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cerebras": "^2.0.34",
"@ai-sdk/cohere": "^3.0.21", "@ai-sdk/cohere": "^3.0.21",
"@ai-sdk/google": "^3.0.29", "@ai-sdk/google": "^3.0.29",
"@ai-sdk/mistral": "^3.0.24",
"@ai-sdk/openai-compatible": "^2.0.30", "@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-s3": "^3.1000.0", "@aws-sdk/client-s3": "^3.1000.0",
"@aws-sdk/s3-request-presigner": "^3.1000.0", "@aws-sdk/s3-request-presigner": "^3.1000.0",
"@daveyplate/better-auth-triplit": "^0.2.2", "@better-auth/drizzle-adapter": "https://pkg.pr.new/better-auth/better-auth/@better-auth/drizzle-adapter@6913",
"@floating-ui/vue": "^1.1.10", "@floating-ui/vue": "^1.1.10",
"@iconify-json/mynaui": "^1.2.17", "@iconify-json/mynaui": "^1.2.17",
"@nuxt/fonts": "0.14.0", "@nuxt/fonts": "0.14.0",
@@ -26,17 +26,16 @@
"@openrouter/ai-sdk-provider": "^2.2.3", "@openrouter/ai-sdk-provider": "^2.2.3",
"@sentry/nuxt": "^10.39.0", "@sentry/nuxt": "^10.39.0",
"@tanstack/vue-virtual": "^3.13.18", "@tanstack/vue-virtual": "^3.13.18",
"@triplit/client": "^1.0.50",
"@triplit/server": "^1.1.8",
"@types/big.js": "^6.2.2",
"ai": "^6.0.89", "ai": "^6.0.89",
"ai-sdk-ollama": "^3.7.1", "ai-sdk-ollama": "^3.7.1",
"better-auth": "^1.4.18", "better-auth": "^1.4.18",
"big.js": "^7.0.1",
"comlink": "^4.4.2", "comlink": "^4.4.2",
"drizzle-orm": "^1.0.0-beta.15-859cf75",
"glob": "^13.0.5", "glob": "^13.0.5",
"longcat-ai-sdk-provider": "^0.0.4",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"nuxt": "^4.3.1", "nuxt": "^4.3.1",
"pg": "^8.19.0",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@@ -44,7 +43,6 @@
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"shiki": "^3.22.0", "shiki": "^3.22.0",
"triplit-nuxt": "0.3.1-prerelease.5",
"unified": "^11.0.5", "unified": "^11.0.5",
"vue": "^3.5.28", "vue": "^3.5.28",
"vue-router": "^5.0.3", "vue-router": "^5.0.3",
@@ -54,12 +52,10 @@
"@iconify-json/logos": "^1.2.10", "@iconify-json/logos": "^1.2.10",
"@iconify-json/svg-spinners": "^1.2.4", "@iconify-json/svg-spinners": "^1.2.4",
"@iconify-json/tabler": "^1.2.26", "@iconify-json/tabler": "^1.2.26",
"@triplit/cli": "^1.0.61",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0", "@types/node": "^25.3.0",
"@types/pg": "^8.18.0",
"@unocss/nuxt": "^66.6.0", "@unocss/nuxt": "^66.6.0",
"jsonwebtoken": "^9.0.3", "drizzle-kit": "^1.0.0-beta.15-859cf75",
"knip": "^5.84.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"unocss": "^66.6.0" "unocss": "^66.6.0"
}, },
@@ -71,10 +67,6 @@
"unrs-resolver", "unrs-resolver",
"vue-demi" "vue-demi"
], ],
"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"
},
"overrides": { "overrides": {
"@vercel/nft": "^0.27.4", "@vercel/nft": "^0.27.4",
"vite": "8.0.0-beta.15" "vite": "8.0.0-beta.15"
-169
View File
@@ -1,169 +0,0 @@
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://') &&
-167
View File
@@ -1,167 +0,0 @@
diff --git a/node_modules/@triplit/db/.bun-tag-16a302de81445385 b/.bun-tag-16a302de81445385
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/node_modules/@triplit/db/.bun-tag-38646fa5cb007988 b/.bun-tag-38646fa5cb007988
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/node_modules/@triplit/db/.bun-tag-c381351c2a0f3c94 b/.bun-tag-c381351c2a0f3c94
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/dist/db.js b/dist/db.js
index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..0ce27b58838db11fd51fcf873a182d528d5823fb 100644
--- a/dist/db.js
+++ b/dist/db.js
@@ -17,7 +17,6 @@ import { Type } from './schema/data-types/index.js';
import { getCollectionPermissions } from './permissions.js';
import { QueryBuilder } from './query/query-builder.js';
import { validateSchema } from './schema/validation.js';
-import { tryPreloadingOptionalDeps } from './utils/optional-dep.js';
export class DB {
entityStore;
clock;
@@ -44,7 +43,7 @@ export class DB {
});
this.ivm =
options.ivm ??
- new IVM(
+ new IVM(
// @ts-expect-error - TODO: handle more generalized internal typings
this);
if (options.schema) {
@@ -73,9 +72,9 @@ export class DB {
getSchema() {
return this.schema;
}
- subscribe(query, onResults, onError,
- // TODO: will we need this?
- options = {}) {
+ subscribe(query, onResults, onError,
+ // TODO: will we need this?
+ options = {}) {
const preparedQuery = prepareQuery(query, this.schema?.collections, this.systemVars, this.session, {
applyPermission: options.skipRules ? undefined : 'read',
});
@@ -136,11 +135,11 @@ export class DB {
}
const timestamp = await this.entityStore.metadataStore.getTimestampForEntity(this.kv, collection, entityId);
if (
- // TODO: determine if timestamp can ever be undefined
- // I think the only case could be if the entity was optimistically inserted
- // on the client but never synced to the server
- // assuming that we don't delete metadata when we delete entities
- timestamp &&
+ // TODO: determine if timestamp can ever be undefined
+ // I think the only case could be if the entity was optimistically inserted
+ // on the client but never synced to the server
+ // assuming that we don't delete metadata when we delete entities
+ timestamp &&
HybridLogicalClock.compare(timestamp, options.queryState.timestamp) < 0) {
if (!entitiesThatHaveNotChanged[collection]) {
entitiesThatHaveNotChanged[collection] = new Set();
@@ -192,9 +191,9 @@ export class DB {
onResults(relevantChanges, options.queryKey);
isInitialResponse = false;
};
- return this.ivm.subscribe(preparedQuery,
- // @ts-expect-error - Ignoring because method is deprecated
- callback, options.errorCallback);
+ return this.ivm.subscribe(preparedQuery,
+ // @ts-expect-error - Ignoring because method is deprecated
+ callback, options.errorCallback);
}
async fetch(query, options) {
const preparedQuery = prepareQuery(query, this.schema?.collections, this.systemVars, this.session, {
@@ -307,9 +306,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) {
@@ -609,7 +606,6 @@ export async function createDB(options) {
if (options.kv) {
savedSchema = await DB.getSchemaFromStorage(options.kv);
}
- await tryPreloadingOptionalDeps();
db = new DB({ ...options, schema: savedSchema });
let schemaChange = undefined;
// A schema is provided, attempt to apply it
diff --git a/dist/schema/data-types/type.js b/dist/schema/data-types/type.js
index 2e1281c5577b48ffe609a08da933b5f342a485fa..e88487852f6c49185144a9057ffb11d93a3b6cc8 100644
--- a/dist/schema/data-types/type.js
+++ b/dist/schema/data-types/type.js
@@ -3,7 +3,6 @@ import { DBDeserializationError, DBSerializationError, JSONDeserializationError,
import { prefixOperations, SET_OP_PREFIX, SUPPORTED_OPERATIONS, } from './operations.js';
import { DEFAULTABLE_TYPE_KEYS_SET, PRIMITIVE_TYPE_KEYS_SET } from './index.js';
import { hasNoValue, isDefaultFunction } from '../../utils/value.js';
-import { getOptionalDep } from '../../utils/optional-dep.js';
/**
* Returns an empty object for the given type
* If the type is not a record, it returns undefined
@@ -151,8 +150,8 @@ export function encode(type, input) {
throw new DBSerializationError(`set<${type.items.type}>`, input);
}
throw new UnrecognizedAttributeTypeError(
- // @ts-expect-error If this has an error, it means we are missing a case above
- type.type, 'Failed to encode value');
+ // @ts-expect-error If this has an error, it means we are missing a case above
+ type.type, 'Failed to encode value');
}
export function validateEncoded(type, encoded, options) {
switch (type.type) {
@@ -219,8 +218,8 @@ export function validateEncoded(type, encoded, options) {
};
}
throw new UnrecognizedAttributeTypeError(
- // @ts-expect-error If this has an error, it means we are missing a case above
- type.type, 'Failed to validate value');
+ // @ts-expect-error If this has an error, it means we are missing a case above
+ type.type, 'Failed to validate value');
}
export function decode(type, encoded) {
switch (type.type) {
@@ -274,8 +273,8 @@ export function decode(type, encoded) {
throw new DBDeserializationError(`set<${type.items.type}>`, encoded);
}
throw new UnrecognizedAttributeTypeError(
- // @ts-expect-error If this has an error, it means we are missing a case above
- type.type, 'Failed to decode value');
+ // @ts-expect-error If this has an error, it means we are missing a case above
+ type.type, 'Failed to decode value');
}
// FOR SET KEYS
// Must encode to string
@@ -407,8 +406,8 @@ export function supportedOperations(type) {
if (type.type === 'string')
return SUPPORTED_OPERATIONS.string;
throw new UnrecognizedAttributeTypeError(
- // @ts-expect-error If this has an error, it means we are missing a case above
- type.type, 'Failed to get supported operations');
+ // @ts-expect-error If this has an error, it means we are missing a case above
+ type.type, 'Failed to get supported operations');
}
/**
* Checks if the type has a default value that can be configured
@@ -440,9 +439,6 @@ function calcDefaultValue(config) {
else if (func === 'uuidv4') {
return crypto.randomUUID();
}
- else if (func === 'uuidv7') {
- return getOptionalDep('uuidv7').uuidv7();
- }
else if (func === 'now') {
return new Date().toISOString();
}
diff --git a/dist/utils/optional-dep.d.ts b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/utils/optional-dep.d.ts
deleted file mode 100644
index aa726389af2582fdb764fe313691423ffde89c4c..0000000000000000000000000000000000000000
diff --git a/dist/utils/optional-dep.js b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/utils/optional-dep.js
deleted file mode 100644
index 850317d787608b58f4083aa91e282c3598e32fa3..0000000000000000000000000000000000000000
diff --git a/dist/utils/optional-dep.js.map b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/utils/optional-dep.js.map
deleted file mode 100644
index d39488cfa8b65ac4d7e8b9909c82b7af8cc838e1..0000000000000000000000000000000000000000
+205
View File
@@ -0,0 +1,205 @@
import { db } from '../server/lib/db';
import * as drizzleSchema from '../drizzle/schema';
import { HttpClient } from '@triplit/client';
import { schema } from '../triplit/schema';
import fs from 'node:fs/promises';
export const httpClient = new HttpClient({
schema,
serverUrl: process.env.NUXT_LOCAL_TRIPLIT_URL || process.env.NUXT_PUBLIC_TRIPLIT_URL,
token: process.env.TRIPLIT_SERVICE_TOKEN,
});
/**
* Simple Argument Parser
* Use: --skip=users,accounts --topics-file=ids.txt
*/
const args = process.argv.slice(2);
const skipList = args.find(a => a.startsWith('--skip='))?.split('=')[1].split(',') || [];
const topicsFilePath = args.find(a => a.startsWith('--topics-file='))?.split('=')[1];
async function migrate() {
console.log('🚀 Starting Complex Relational Migration...');
// Load allowed topic IDs if a file was provided
let allowedTopicIds: Set<string> | null = null;
if (topicsFilePath) {
try {
const fileContent = await fs.readFile(topicsFilePath, 'utf-8');
allowedTopicIds = new Set(fileContent.split('\n').map(id => id.trim()).filter(Boolean));
console.log(`📂 Loaded ${allowedTopicIds.size} topic IDs from filter file.`);
} catch (e) {
console.error(`❌ Failed to read topics file: ${topicsFilePath}`);
process.exit(1);
}
}
const shouldSkip = (name: string) => skipList.includes(name);
if (!shouldSkip('users')) {
console.log('📦 Migrating Users...');
const users = await httpClient.fetch(httpClient.query('users'));
if (users.length) {
await db.insert(drizzleSchema.users).values(users).onConflictDoNothing();
console.log(` - Migrated ${users.length} users`);
}
}
if (!shouldSkip('accounts')) {
console.log('📦 Migrating Accounts...');
const accounts = await httpClient.fetch(httpClient.query('accounts'));
if (accounts.length) {
await db.insert(drizzleSchema.accounts).values(accounts).onConflictDoNothing();
console.log(` - Migrated ${accounts.length} accounts`);
}
}
if (!shouldSkip('sessions')) {
console.log('📦 Migrating Sessions...');
const sessions = await httpClient.fetch(httpClient.query('sessions'));
if (sessions.length) {
await db.insert(drizzleSchema.sessions).values(sessions).onConflictDoNothing();
console.log(` - Migrated ${sessions.length} sessions`);
}
}
if (!shouldSkip('verifications')) {
console.log('📦 Migrating Verifications...');
const verifications = await httpClient.fetch(httpClient.query('verifications'));
if (verifications.length) {
await db.insert(drizzleSchema.verifications).values(verifications).onConflictDoNothing();
console.log(` - Migrated ${verifications.length} verifications`);
}
}
if (!shouldSkip('settings')) {
console.log('📦 Migrating Settings...');
const settings = await httpClient.fetch(httpClient.query('settings'));
if (settings.length) {
await db.insert(drizzleSchema.settings).values(settings).onConflictDoNothing();
console.log(` - Migrated ${settings.length} settings`);
}
}
// Providers and Models are usually needed for Topics/Agents, so skip with caution
let providers = await httpClient.fetch(httpClient.query('providers'));
if (!shouldSkip('providers') && providers.length) {
console.log('📦 Migrating Providers...');
const cleanedProviders = providers.map(p => ({
...p,
config: typeof p.config === 'string' ? JSON.parse(p.config) : p.config
}));
await db.insert(drizzleSchema.providers).values(cleanedProviders).onConflictDoNothing();
console.log(` - Migrated ${providers.length} providers`);
}
const rawModels = await httpClient.fetch(httpClient.query('models'));
const models = rawModels.map(m => ({
...m,
cost: typeof m.cost === 'string' ? JSON.parse(m.cost) : m.cost,
attributes: typeof m.attributes === 'string' ? JSON.parse(m.attributes) : m.attributes
}));
if (!shouldSkip('models') && models.length) {
console.log('📦 Migrating Models...');
for (const model of models) {
const { attributes, ...rest } = model;
let drizzleModel = {
inputModalities: Array.from(attributes.inputModalities || ['text']),
outputModalities: Array.from(attributes.outputModalities || ['text']),
capabilities: Array.from(attributes.capabilities || []),
contextWindow: attributes.contextWindow,
supportedParameters: Array.from(attributes.supported_parameters || []),
...rest,
};
await db.insert(drizzleSchema.models).values(drizzleModel).onConflictDoNothing();
}
console.log(` - Migrated ${models.length} models`);
}
let agents = await httpClient.fetch(httpClient.query('agents'));
if (!shouldSkip('agents') && agents.length) {
console.log('📦 Migrating Agents...');
await db.insert(drizzleSchema.agents).values(agents).onConflictDoNothing();
console.log(` - Migrated ${agents.length} agents`);
}
console.log('📦 Migrating Topics...');
let topics = await httpClient.fetch(httpClient.query('topics'));
// Filter by Topic ID file if provided
if (allowedTopicIds) {
topics = topics.filter(t => allowedTopicIds!.has(t.id));
}
if (topics.length) {
await db.insert(drizzleSchema.topics).values(topics).onConflictDoNothing();
console.log(` - Migrated ${topics.length} topics`);
}
// Relational Filtering Helper
const isTopicAllowed = (topicId: string) => topics.some(t => t.id === topicId);
let generations = await httpClient.fetch(httpClient.query('generations'));
generations = generations.filter(g => isTopicAllowed(g.topicId));
if (generations.length) {
console.log('📦 Migrating Generations...');
await db.insert(drizzleSchema.generations).values(generations).onConflictDoNothing();
console.log(` - Migrated ${generations.length} generations`);
}
let messages = await httpClient.fetch(httpClient.query('messages').Order('createdAt', 'ASC'));
messages = messages.filter(m => isTopicAllowed(m.topicId));
if (messages.length) {
console.log('📦 Migrating Messages...');
for (const msg of messages) {
await db.insert(drizzleSchema.messages).values(msg).onConflictDoNothing();
}
console.log(` - Migrated ${messages.length} messages`);
}
if (!shouldSkip('tool_calls')) {
console.log('📦 Migrating Tool Calls...');
let toolCalls = await httpClient.fetch(httpClient.query('tool_calls'));
if (toolCalls.length) {
const cleaned = toolCalls.map(tc => ({
...tc,
input: typeof tc.input === 'string' ? JSON.parse(tc.input) : tc.input,
output: typeof tc.output === 'string' ? JSON.parse(tc.output) : tc.output,
error: typeof tc.error === 'string' ? JSON.parse(tc.error) : tc.error,
}));
await db.insert(drizzleSchema.toolCalls).values(cleaned).onConflictDoNothing();
console.log(` - Migrated ${toolCalls.length} tool calls`);
}
}
let parts = await httpClient.fetch(httpClient.query('message_parts').Order('createdAt', 'ASC'));
parts = parts.filter(p => isTopicAllowed(p.topicId));
if (parts.length) {
console.log('📦 Migrating Message Parts...');
for (const part of parts) {
await db.insert(drizzleSchema.messageParts).values(part).onConflictDoNothing();
}
console.log(` - Migrated ${parts.length} parts`);
}
if (!shouldSkip('files')) {
console.log('📦 Migrating Files...');
let files = await httpClient.fetch(httpClient.query('files'));
if (files.length) {
await db.insert(drizzleSchema.files).values(files).onConflictDoNothing();
console.log(` - Migrated ${files.length} files`);
}
}
console.log('📦 Migrating Attachments...');
let attachments = await httpClient.fetch(httpClient.query('attachments'));
attachments = attachments.filter(a => isTopicAllowed(a.topicId));
if (attachments.length) {
await db.insert(drizzleSchema.attachments).values(attachments).onConflictDoNothing();
console.log(` - Migrated ${attachments.length} attachments`);
}
console.log('✅ Relational migration finished.');
process.exit(0);
}
migrate();
+34
View File
@@ -0,0 +1,34 @@
import { and, eq } from "drizzle-orm";
import { agents } from "~~/drizzle/schema";
import { db } from "~~/server/lib/db";
import { userEvents } from "~~/server/utils/events";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const agentId = getRouterParam(event, 'id')!;
const res = await db.delete(agents).where(
and(
eq(agents.id, agentId),
eq(agents.userId, userId),
)
);
if (res.rowCount === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid agent',
});
}
userEvents.emit(userId, 'agents', {
op: 'delete',
payload: {
id: agentId,
},
});
return { ok: true };
});
+66
View File
@@ -0,0 +1,66 @@
import { and, eq } from 'drizzle-orm';
import * as z from 'zod';
import { agents } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
import { userEvents } from '~~/server/utils/events';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const result = await readValidatedBody(event, (body) =>
z
.object({
name: z.string().optional(),
systemPrompt: z.string().nullable().optional(),
imageUrl: z.string().optional(),
defaultModelId: z.string().optional(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const agentId = getRouterParam(event, 'id')!;
const { name, systemPrompt, imageUrl, defaultModelId } = result.data;
const res = await db.update(agents)
.set({
name,
systemPrompt,
imageUrl,
defaultModelId,
})
.where(
and(
eq(agents.id, agentId),
eq(agents.userId, userId),
)
);
if (res.rowCount === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid agent',
});
}
userEvents.emit(userId, 'agents', {
op: 'update',
payload: {
id: agentId,
name,
systemPrompt,
imageUrl,
defaultModelId,
},
});
return { ok: true };
});
+44
View File
@@ -0,0 +1,44 @@
import { createInsertSchema } from 'drizzle-orm/zod';
import { db } from '~~/server/lib/db';
import { agents } from '~~/drizzle/schema';
import { userEvents } from '~~/server/utils/events';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const result = await readValidatedBody(event,
createInsertSchema(agents).safeParse
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
if (!result.data.userId) {
result.data.userId = userId;
}
if (result.data.userId !== userId) {
throw createError({
statusCode: 403,
statusMessage: 'Forbidden',
data: {
code: 'FORBIDDEN',
ok: false,
}
});
}
await db.insert(agents).values(result.data).onConflictDoNothing();
userEvents.emit(userId, 'agents', {
op: 'create',
payload: result.data,
});
return { ok: true };
})
+37
View File
@@ -0,0 +1,37 @@
import { db } from "~~/server/lib/db";
import * as z from 'zod';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const result = await getValidatedQuery(event, z.object({
page: z.number().optional(),
limit: z.number().optional(),
}).safeParse)
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const agents = await db.query.agents.findMany({
where: {
userId,
},
orderBy: {
createdAt: 'asc',
},
with: {
topics: {
orderBy: {
createdAt: 'desc',
},
}
}
});
return agents;
})
-142
View File
@@ -1,142 +0,0 @@
import * as z from 'zod';
import { httpClient } from '~~/server/lib/triplit';
import { renamePrompt } from '~~/prompts';
import { schema } from '~~/triplit/schema';
import { type Entity } from '@triplit/client';
import { generateText } from 'ai';
import { GatewayFetchError, getProviderDetails, type ModelGateway } from '~~/server/utils/ai-provider';
import { addPendingRename } from '~~/server/utils/renames';
import { assert } from '~~/utils/assert';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const topicId = getRouterParam(event, 'topicId')!;
const result = await readValidatedBody(event, (body) =>
z
.object({
modelId: z.string(),
prompt: z.string(),
providerApiKey: z.string().optional(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
data: {
code: 'INVALID_BODY',
ok: false,
}
});
}
const { modelId, prompt, providerApiKey } = result.data;
const model = await httpClient.fetchOne(httpClient.query('models').Where('id', '=', modelId).Include('provider'));
if (model === null || model.providerId !== model.providerId || model.userId !== userId || model.provider === null) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid model',
data: {
code: 'INVALID_MODEL',
ok: false,
}
});
}
const providerDetails = await getProviderDetails(model.provider, providerApiKey, model);
if (!providerDetails.ok) {
switch (providerDetails.error) {
case GatewayFetchError.NoProviderApiKey: {
setResponseStatus(event, 400, "No provider API key");
throw createError({
statusCode: 400,
statusMessage: `${model.provider.type} provider requires an API key`,
data: {
code: 'NO_PROVIDER_API_KEY',
ok: false,
}
});
}
case GatewayFetchError.NoProviderBaseUrl: {
throw createError({
statusCode: 400,
statusMessage: 'Invalid provider URL',
data: {
code: 'BAD_PROVIDER_URL',
ok: false,
}
});
}
}
}
const { gateway } = providerDetails.data;
if (gateway === null) {
throw createError({
statusCode: 500,
statusMessage: 'Invalid gateway',
data: {
code: 'INVALID_GATEWAY',
ok: false,
}
});
}
const [renameId, pendingRename] = addPendingRename(topicId);
event.waitUntil(autoRename(topicId, renameId, pendingRename.abortController, { gateway: gateway.gateway, model }, gateway.textTransformer, prompt));
return {
renameId,
ok: true,
};
});
const autoRename = async (
topicId: string,
renameId: string,
abortController: AbortController,
model: {
gateway: ModelGateway,
model: Entity<typeof schema, 'models'>,
},
textTransformer: ((text: string) => string) | ((text: string) => string)[] | undefined,
prompt: string,
) => {
try {
const response = await generateText({
model: model.gateway(model.model.externalId),
system: renamePrompt,
prompt,
timeout: 90 * 1000,
abortSignal: abortController.signal,
})
let text = response.text;
if (textTransformer !== undefined) {
if (Array.isArray(textTransformer)) {
for (const transformer of textTransformer) {
text = transformer(text);
}
} else {
text = textTransformer(text);
}
}
await httpClient.update('topics', topicId, {
name: text,
});
} catch (error) {
console.error('Failed to auto-rename:', error);
} finally {
completeRename(renameId);
await httpClient.update('topics', topicId, {
renaming: false
});
}
}
-42
View File
@@ -1,42 +0,0 @@
import * as z from 'zod';
import { httpClient } from '~~/server/lib/triplit';
import { cancelPendingRename } from '~~/server/utils/renames';
import { assert } from '~~/utils/assert';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const body = await readValidatedBody(event, (body) =>
z
.object({
renameId: z.string(),
})
.safeParse(body),
);
if (!body.success) {
throw createError({
statusCode: 400,
message: 'Invalid rename ID',
});
}
const { renameId } = body.data;
assert(renameId);
const [success, pendingRename] = cancelPendingRename(renameId);
if (success) {
const topic = await httpClient.fetchOne(httpClient.query('topics').Where('id', '=', pendingRename!.topicId));
if (topic !== null && topic.renaming) {
await httpClient.update('topics', topic.id, {
renaming: false,
});
}
return {
ok: true,
};
}
return {
ok: false,
};
});
@@ -1,26 +0,0 @@
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!);
const generation = await httpClient.fetchOne(httpClient.query('generations').Where('id', '=', generationId!));
if (generation !== null && generation.status === 'pending') {
await httpClient.update('generations', generationId!, {
status: 'cancelled',
});
}
if (!success) {
throw createError({
statusCode: 400,
message: 'Generation not found or already completed',
});
}
return 'ok';
});
-919
View File
@@ -1,919 +0,0 @@
import type { Entity } from '@triplit/client';
import { type ModelMessage, modelMessageSchema, streamText, type StreamTextTransform, 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';
import { spawn } from 'child_process';
import { GatewayFetchError, getProviderDetails, type ModelGateway } from '~~/server/utils/ai-provider';
import { assert } from '~~/utils/assert';
export const messagesSchema = z.array(modelMessageSchema);
export default defineEventHandler(async (event) => {
await protectRoute(event);
const result = await readValidatedBody(event, (body) =>
z
.object({
messages: messagesSchema.min(1),
topicId: z.string(),
parentMessageId: z.string().nullable(),
model: z.object({
providerId: z.string(),
modelId: z.string(),
args: z.record(z.string(), z.any()),
}),
providerApiKey: z.string().optional(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const userId = event.context.user!.id;
const { messages, topicId, parentMessageId, model: { modelId, providerId, args }, providerApiKey } = result.data;
const [provider, model, existingPendingGenerations] = await Promise.all([
httpClient.fetchOne(httpClient.query('providers').Where('id', '=', providerId)),
httpClient.fetchOne(httpClient.query('models').Where('id', '=', modelId)),
httpClient.fetchOne(
httpClient.query('generations').Where('topicId', '=', topicId).Where('status', '=', 'pending'),
),
]);
if (provider === null || provider.userId !== userId) {
throw createError({
statusCode: 400,
message: 'Invalid provider',
});
}
if (model === null || model.providerId !== model.providerId || model.userId !== userId) {
throw createError({
statusCode: 400,
message: 'Invalid model',
});
}
if (existingPendingGenerations !== null) {
throw createError({
statusCode: 400,
message: 'There cannot be more than one active generation per topic',
});
}
const providerDetails = await getProviderDetails(provider, providerApiKey, model);
if (!providerDetails.ok) {
switch (providerDetails.error) {
case GatewayFetchError.NoProviderApiKey: {
throw createError({
statusCode: 400,
message: `${provider.type} provider requires an API key`,
});
}
case GatewayFetchError.NoProviderBaseUrl: {
throw createError({
statusCode: 400,
message: 'Invalid provider URL',
});
}
}
}
const { gateway } = providerDetails.data;
if (gateway === null) {
throw createError({
statusCode: 500,
statusMessage: 'Invalid gateway',
data: {
code: 'INVALID_GATEWAY',
ok: false,
}
});
}
const generationId = nanoid();
// TODO: do these inserts on the client so that the feedback is instant
const message = await httpClient.insert('messages', {
topicId,
userId,
focusedIndex: parentMessageId ? null : 0,
generationId,
parentMessageId,
content: '',
role: 'assistant',
});
await httpClient.insert('generations', {
id: generationId,
userId,
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) {
if (process.env.LOG_DIR) {
await fs.mkdir(process.env.LOG_DIR!, { recursive: true });
logFile = await fs.open(path.join(process.env.LOG_DIR!, `${Date.now()}-${generationId}.log`), 'w');
logMessage = (message: string) => {
logFile!.write(message + '\n');
};
} else {
console.warn('Generation debug logging is enabled but GENERATION_DEBUG is not set');
}
}
event.waitUntil(
generateResponse(message, { gateway: gateway.gateway, model, parameters: args }, generationId, userId, topicId, messages, gateway.streamTransformer, logMessage, logFile),
);
return {
generationId,
messageId: message.id,
};
});
const INTERNAL_ERROR = 'An internal error occurred';
// todo message takes in variadics like console.log
const todo = (...args: any[]) => {
console.error('TODO', ...args);
throw new Error('TODO');
};
const evalPython = async (code: string) => {
// This is the wrapper logic from above, minified or stored as a string
// Or you can save the wrapper script to a file and call that.
const wrapper = `
import ast
import sys
code = sys.stdin.read()
tree = ast.parse(code)
last_node = tree.body[-1] if tree.body else None
namespace = {}
if len(tree.body) > 1:
exec(compile(ast.Module(tree.body[:-1], []), "<ast>", "exec"), namespace)
if isinstance(last_node, ast.Expr):
res = eval(compile(ast.Expression(last_node.value), "<ast>", "eval"), namespace)
if res is not None: print(res)
elif last_node:
exec(compile(ast.Module([last_node], []), "<ast>", "exec"), namespace)
`.trim();
return new Promise<string>((resolve, reject) => {
const child = spawn('python3', ['-c', wrapper]);
let output = '';
let errorOutput = '';
child.stdout.on('data', (data) => {
output += data.toString();
});
child.stderr.on('data', (data) => {
errorOutput += data.toString();
});
child.on('close', (exitCode) => {
if (exitCode !== 0) {
reject(errorOutput || `Exit code ${exitCode}`);
} else {
resolve(output.trim());
}
});
// Send the agent's code to the wrapper via stdin
child.stdin.write(code);
child.stdin.end();
});
};
// TODO: obviously come up with a better way for the user to define their own tools
const { listDirectoryTool, globTool, readFileTool, readFilesTool, fetchUrlTool, pythonTool, bashTool } = {
listDirectoryTool: tool({
description: 'Lists the contents of a directory',
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,
};
},
}),
globTool: tool({
description: 'Lists files matching a glob pattern',
inputSchema: z.object({
pattern: z.string(),
}),
outputSchema: z.object({
files: z.array(z.object({ path: z.string(), type: z.string() })),
}),
execute: async ({ pattern }) => {
const rawFiles = await glob(pattern, { withFileTypes: true });
const files = rawFiles.map((file) => ({
path: file.parentPath + '/' + file.name,
type: file.isFile() ? 'file' : 'directory',
}));
return {
files,
};
},
}),
readFileTool: tool({
description: 'Reads the contents of a file',
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(),
};
},
}),
readFilesTool: tool({
description: 'Reads the contents of multiple files',
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,
};
},
}),
fetchUrlTool: tool({
description: 'Fetches the content of a URL',
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,
};
},
}),
pythonTool: tool({
description: 'Executes a Python code snippet',
inputSchema: z.object({
code: z.string(),
}),
outputSchema: z.object({
output: z.string(),
}),
execute: async ({ code }) => {
const output = await evalPython(code);
return {
output,
};
},
}),
bashTool: tool({
description: 'Executes a Bash command',
inputSchema: z.object({
code: z.string(),
}),
outputSchema: z.object({
output: z.string(),
}),
execute: async ({ code }) => {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
async function runCommand(command: string) {
const { stdout, stderr } = await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
return stderr;
} else {
return stdout;
}
}
const output = await runCommand(code);
return {
output,
};
},
}),
}
async function generateResponse(
message: Entity<typeof schema, 'messages'>,
model: {
gateway: ModelGateway,
model: Entity<typeof schema, 'models'>,
parameters: Record<string, any>,
},
generationId: string,
userId: string,
topicId: string,
messages: ModelMessage[],
streamTransoforms: StreamTextTransform<{}> | StreamTextTransform<{}>[] | undefined,
log?: (message: string) => void,
logFile?: fs.FileHandle,
) {
const controller = new AbortController();
addPendingGeneration(generationId, controller);
let requestStart = undefined;
let ttft = undefined;
const activeParts = new Map<string, { id: string; accumulatedContent: string; providerOptions?: any }>();
const activeToolCalls = new Map<string, void>();
// TODO: somehow let the user turn on and off tools
const 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: listDirectoryTool,
glob: globTool,
readFile: readFileTool,
readFiles: readFilesTool,
// fetchUrl: fetchUrlTool,
python: pythonTool,
bash: bashTool,
};
const response = streamText({
model: model.gateway(model.model.externalId),
messages,
providerOptions: {
openrouter: {
debug: {
echo_upstream_body: true,
},
user: userId,
}
},
experimental_transform: streamTransoforms,
// 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: [...model.model.attributes.capabilities].includes('tools') ? tools : undefined,
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 toolCallId of activeToolCalls.keys()) {
await httpClient.update('tool_calls', toolCallId, {
status: 'failed',
error: {
type: 'text',
value: 'An unknown error occurred',
},
});
activeToolCalls.delete(toolCallId);
}
let errValue = error.message || error;
if (typeof errValue === 'object') {
errValue = JSON.stringify(errValue, null, 2);
}
await httpClient.update('generations', generationId, {
status: 'failed',
error: errValue,
});
},
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': {
requestStart = performance.now();
} break;
case 'start-step': {
curStepIdx++;
} break;
case 'tool-input-start': {
key = `tool-call-${curStepIdx}`;
const toolCallId = token.id;
part = await httpClient.insert('message_parts', {
topicId,
messageId: message.id,
userId,
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(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', {
topicId,
messageId: message.id,
userId,
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': {
if (ttft === undefined) {
ttft = performance.now() - requestStart!;
}
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);
break;
default:
console.error('Unknown input type', token.input);
break;
}
if (activeToolCalls.has(token.toolCallId)) {
await httpClient.update('tool_calls', token.toolCallId, {
status: 'pending',
input: {
type: inputType,
value: inputValue,
},
});
} else {
key = `tool-call-${curStepIdx}`;
const toolCallId = token.toolCallId;
part = await httpClient.insert('message_parts', {
topicId,
messageId: message.id,
userId,
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: {
type: inputType,
value: inputValue,
},
output: null,
error: null,
createdAt: new Date(),
});
activeToolCalls.set(toolCallId);
activeParts.set(key, { id: part.id, accumulatedContent: '' });
}
} break;
case 'tool-result': {
let outputType: 'text' | 'json' = 'text';
let outputValue: string = '';
switch (typeof token.output) {
case 'string':
outputType = 'text';
outputValue = token.output;
break;
case 'object':
outputType = 'json';
outputValue = JSON.stringify(token.output);
break;
default:
console.error('Unknown output type', token.output);
await httpClient.update('tool_calls', token.toolCallId, {
status: 'failed',
error: {
type: 'text',
value: 'Tool returned invalid output',
},
});
activeToolCalls.delete(token.toolCallId);
break;
}
await httpClient.update('tool_calls', token.toolCallId, {
status: 'completed',
output: {
type: outputType,
value: outputValue,
},
});
activeToolCalls.delete(token.toolCallId);
} break;
case 'tool-error': {
console.error('Tool error:', token);
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);
break;
default:
console.error('Unknown error type', token.error);
outputType = 'text';
outputValue = 'Tool returned invalid output';
break;
}
if (activeToolCalls.has(token.toolCallId)) {
await httpClient.update('tool_calls', token.toolCallId, {
status: 'failed',
error: {
type: outputType,
value: outputValue,
},
});
} else {
key = `tool-call-${curStepIdx}`;
const toolCallId = token.toolCallId;
part = await httpClient.insert('message_parts', {
topicId,
messageId: message.id,
userId,
toolCallId: token.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: 'failed',
input: null,
output: null,
error: {
type: outputType,
value: outputValue,
},
createdAt: new Date(),
});
activeParts.set(key, { id: part.id, accumulatedContent: '' });
}
activeToolCalls.delete(token.toolCallId);
} break;
case 'error': {
console.error('Error:', token);
for (const activePart of activeParts.values()) {
await httpClient.update('message_parts', activePart.id, {
finished: true,
lastUpdatedAt: new Date(),
});
}
let error = INTERNAL_ERROR;
if (typeof token.error === 'string') {
error = token.error;
} else if (typeof token.error === 'object') {
error = JSON.stringify(token.error);
}
await httpClient.update('generations', generationId, {
status: 'failed',
error,
});
} break;
case 'finish': {
let tps;
if (ttft !== undefined && token.totalUsage.outputTokens !== undefined) {
const tokenStreamStart = requestStart! + ttft;
// this is the *real* request duration, excluding the
// TTFT
const requestDuration = performance.now() - tokenStreamStart;
tps = token.totalUsage.outputTokens / (requestDuration / 1000);
}
for (const activePart of activeParts.values()) {
await httpClient.update('message_parts', activePart.id, {
finished: true,
lastUpdatedAt: new Date(),
});
}
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;
}
const generation = await httpClient.fetchOne(httpClient.query('generations').Where('id', '=', generationId));
if (generation === null || generation.status === 'failed') return;
await httpClient.update('generations', generationId, {
status: 'completed',
tokens: {
input: token.totalUsage.inputTokens,
cache: {
read: token.totalUsage.inputTokenDetails.cacheReadTokens,
write: token.totalUsage.inputTokenDetails.cacheWriteTokens,
},
output: token.totalUsage.outputTokens,
thinking: token.totalUsage.outputTokenDetails.reasoningTokens,
ttft,
tps,
},
});
// I hate you switch fallthroughs
} break;
case 'abort': {
for (const activePart of activeParts.values()) {
await httpClient.update('message_parts', activePart.id, {
finished: true,
lastUpdatedAt: new Date(),
});
}
for (const toolCallId of activeToolCalls.keys()) {
await httpClient.update('tool_calls', toolCallId, {
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;
// typescript thinks this is not a real token type?
// case 'tool-output-denied':
// todo('tool-output-denied token type', token);
// break;
case 'finish-step':
case 'tool-input-delta':
case 'tool-input-end':
// 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 toolCallId of activeToolCalls.keys()) {
await httpClient.update('tool_calls', toolCallId, {
status: 'failed',
error: {
type: 'text',
value: 'An unknown error occurred',
},
});
activeToolCalls.delete(toolCallId);
}
await httpClient.update('generations', generationId, {
status: 'failed',
error: error.message,
});
} finally {
completeGeneration(generationId);
if (logFile !== undefined) logFile.close();
}
}
+47
View File
@@ -0,0 +1,47 @@
import { userEvents } from "~~/server/utils/events";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user.id;
let controller: ReadableStreamDefaultController;
let pingInterval: NodeJS.Timeout;
const stream = new ReadableStream({
start(c) {
controller = c;
// Send initial connection message
try {
controller.enqueue(':connected\n\n');
} catch (e) {
console.error('Failed to send initial connection:', e);
return;
}
userEvents.subscribe(userId, controller);
pingInterval = setInterval(() => {
try {
controller.enqueue(':heartbeat\n\n');
} catch (e) {
clearInterval(pingInterval);
}
}, 15000);
},
cancel() {
console.log(`User ${userId} event stream cancelled`);
clearInterval(pingInterval);
userEvents.unsubscribe(userId, controller!);
}
});
setHeaders(event, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
return sendStream(event, stream);
});
+23
View File
@@ -0,0 +1,23 @@
import { and, eq } from "drizzle-orm";
import { files } from "~~/drizzle/schema";
import { db } from "~~/server/lib/db";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const id = getRouterParam(event, 'id')!;
const file = await db.delete(files).where(and(eq(files.id, id), eq(files.userId, userId))).returning();
if (!file) {
throw createError({
statusCode: 404,
statusMessage: 'Not Found',
message: 'File not found',
});
}
return { ok: true };
});
+47
View File
@@ -0,0 +1,47 @@
import { files } from "~~/drizzle/schema";
import { db } from "~~/server/lib/db";
import * as z from 'zod';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const result = await readValidatedBody(event, (body) =>
z
.object({
id: z.string(),
name: z.string(),
mimeType: z.string(),
url: z.string(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const { id, name, mimeType, url } = result.data;
const file = await db.insert(files).values({
id,
userId,
name,
mimeType,
url,
createdAt: new Date(),
}).returning();
if (!file) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to insert file',
message: 'Failed to insert file',
});
}
return { ok: true };
});
@@ -0,0 +1,146 @@
import { sql } from "drizzle-orm";
import { messages } from "~~/drizzle/schema";
import { db } from "~~/server/lib/db";
import { topicEvents } from "~~/server/utils/events";
async function deeplyDeleteMessage(messageId: string, topicId: string, userId: string) {
const children = await db.query.messages.findMany({
where: {
parentMessageId: messageId,
userId,
}
} as any);
for (const child of children) {
await deeplyDeleteMessage(child.id, topicId, userId);
}
await db.delete(messages).where(sql`${messages.id} = ${messageId} AND ${messages.userId} = ${userId}`);
topicEvents.emit(topicId, {
type: 'MESSAGE_DELETED',
payload: { id: messageId },
});
}
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const messageId = getRouterParam(event, 'messageId')!;
const message = await db.query.messages.findFirst({
where: {
id: messageId,
userId,
}
} as any);
if (!message) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid message',
});
}
const topicId = message.topicId;
const allTopicMessages = await db.query.messages.findMany({
where: {
topicId,
userId,
},
orderBy: {
createdAt: 'asc',
},
});
const messagesMap = new Map(allTopicMessages.map(m => [m.id, { ...m, children: [] as typeof m[] }]));
for (const msg of messagesMap.values()) {
if (msg.parentMessageId && messagesMap.has(msg.parentMessageId)) {
messagesMap.get(msg.parentMessageId)!.children.push(msg);
}
}
const rootMessage = messagesMap.get(messageId);
if (!rootMessage) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid message',
});
}
if (rootMessage.role === 'user') {
await deeplyDeleteMessage(rootMessage.id, topicId, userId);
return { ok: true };
}
if (rootMessage.deleted === true) {
const childMessage = rootMessage.children[rootMessage.focusedIndex!];
if (!childMessage) {
console.error('Message not found');
return { ok: false };
}
await deeplyDeleteMessage(childMessage.id, topicId, userId);
const remainingChildren = rootMessage.children.filter(child => child!.id !== childMessage.id);
if (remainingChildren.length === 0) {
await deeplyDeleteMessage(rootMessage.id, topicId, userId);
} else {
const newFocusedIndex = rootMessage.focusedIndex && rootMessage.focusedIndex > 0
? Math.min(rootMessage.focusedIndex - 1, remainingChildren.length - 1)
: 0;
await db.update(messages).set({ focusedIndex: newFocusedIndex }).where(sql`${messages.id} = ${rootMessage.id} AND ${messages.userId} = ${userId}`);
topicEvents.emit(topicId, {
type: 'MESSAGE_UPDATED',
payload: { ...rootMessage, focusedIndex: newFocusedIndex },
});
}
return { ok: true };
}
if (
(rootMessage.focusedIndex !== undefined && rootMessage.focusedIndex !== null)
&& rootMessage.focusedIndex > 0
&& rootMessage.children.length > 0
) {
const childMessage = rootMessage.children[rootMessage.focusedIndex - 1];
if (!childMessage) {
console.error('Message not found');
return { ok: false };
}
await deeplyDeleteMessage(childMessage.id, topicId, userId);
const remainingChildren = rootMessage.children.filter(child => child!.id !== childMessage.id);
const newFocusedIndex = remainingChildren.length > 0
? Math.min(rootMessage.focusedIndex - 1, remainingChildren.length - 1)
: 0;
await db.update(messages).set({ focusedIndex: newFocusedIndex }).where(sql`${messages.id} = ${rootMessage.id} AND ${messages.userId} = ${userId}`);
topicEvents.emit(topicId, {
type: 'MESSAGE_UPDATED',
payload: { ...rootMessage, focusedIndex: newFocusedIndex },
});
return { ok: true };
}
if (rootMessage.children.length === 0) {
await deeplyDeleteMessage(rootMessage.id, topicId, userId);
return { ok: true };
}
await db.update(messages).set({ deleted: true }).where(sql`${messages.id} = ${rootMessage.id} AND ${messages.userId} = ${userId}`);
topicEvents.emit(topicId, {
type: 'MESSAGE_UPDATED',
payload: { ...rootMessage, deleted: true },
});
return { ok: true };
});
@@ -0,0 +1,30 @@
import { db } from "~~/server/lib/db";
import { messages } from "~~/drizzle/schema";
import { createUpdateSchema } from "drizzle-orm/zod";
import { and, eq } from "drizzle-orm";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const messageId = getRouterParam(event, 'messageId');
const result = await readValidatedBody(event, createUpdateSchema(messages).safeParse);
if (!result.success) {
throw createError({
statusCode: 400,
statusMessage: 'Bad Request',
message: result.error.issues.map(issue => issue.message).join(', '),
});
}
const res = await db.update(messages).set(result.data).where(and(eq(messages.id, messageId!), eq(messages.userId, event.context.user!.id)));
if (res.rowCount === 0) {
throw createError({
statusCode: 404,
statusMessage: 'Not Found',
message: 'Message not found',
});
}
return { ok: true };
});
@@ -0,0 +1,26 @@
import { and, eq } from "drizzle-orm";
import { models } from "~~/drizzle/schema";
import { db } from "~~/server/lib/db";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const modelId = getRouterParam(event, 'modelId')!;
const res = await db.delete(models).where(
and(
eq(models.id, modelId),
eq(models.userId, userId),
)
);
if (res.rowCount === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid model',
});
}
return { ok: true };
});
+95
View File
@@ -0,0 +1,95 @@
import { and, eq } from "drizzle-orm";
import { models } from "~~/drizzle/schema";
import { db } from "~~/server/lib/db";
import * as z from 'zod';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const result = await readValidatedBody(event, (body) =>
z
.object({
name: z.string().optional(),
cost: z.object({
prompt: z.string().optional(),
completion: z.string().optional(),
request: z.string().optional(),
image: z.string().optional(),
imageTokens: z.string().optional(),
imageOutput: z.string().optional(),
audio: z.string().optional(),
audioOutput: z.string().optional(),
inputAudioCache: z.string().optional(),
webSearch: z.string().optional(),
internalReasoning: z.string().optional(),
inputCacheRead: z.string().optional(),
inputCacheWrite: z.string().optional(),
discount: z.string().optional(),
}).optional(),
inputModalities: z.array(z.string()).optional(),
outputModalities: z.array(z.string()).optional(),
capabilities: z.array(z.string()).optional(),
contextWindow: z.number().optional(),
supportedParameters: z.array(z.string()).optional(),
isCustom: z.boolean().optional(),
enabled: z.boolean().optional(),
releasedAt: z.string().optional(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const modelId = getRouterParam(event, 'modelId')!;
const { name, cost, inputModalities, outputModalities, capabilities, contextWindow, supportedParameters, isCustom, enabled, releasedAt } = result.data;
const existing = await db.query.models.findFirst({
where: {
id: modelId,
userId,
},
});
if (!existing) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid model',
});
}
const res = await db.update(models)
.set({
name,
cost,
inputModalities,
outputModalities,
capabilities,
contextWindow,
supportedParameters,
isCustom,
enabled,
releasedAt: releasedAt ? new Date(releasedAt) : existing.releasedAt ?? null,
})
.where(
and(
eq(models.id, modelId),
eq(models.userId, userId),
)
);
if (res.rowCount === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid model',
});
}
return { ok: true };
});
+86
View File
@@ -0,0 +1,86 @@
import * as z from 'zod';
import { models } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const result = await readValidatedBody(event, (body) =>
z.object({
id: z.string(),
providerId: z.string(),
externalId: z.string(),
name: z.string(),
cost: z.object({
prompt: z.string().optional(),
completion: z.string().optional(),
request: z.string().optional(),
image: z.string().optional(),
imageTokens: z.string().optional(),
imageOutput: z.string().optional(),
audio: z.string().optional(),
audioOutput: z.string().optional(),
inputAudioCache: z.string().optional(),
webSearch: z.string().optional(),
internalReasoning: z.string().optional(),
inputCacheRead: z.string().optional(),
inputCacheWrite: z.string().optional(),
discount: z.string().optional(),
}).optional(),
inputModalities: z.array(z.string()).optional(),
outputModalities: z.array(z.string()).optional(),
capabilities: z.array(z.string()).optional(),
contextWindow: z.number().nullable().optional(),
supportedParameters: z.array(z.string()).optional(),
isCustom: z.boolean().optional(),
enabled: z.boolean().optional(),
releasedAt: z.date().nullable().optional(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
...result.error
});
}
const provider = await db.query.providers.findFirst({
where: {
id: result.data.providerId,
userId: event.context.user!.id,
},
with: {
models: true,
}
});
if (!provider) {
throw createError({
statusCode: 404,
message: 'Provider not found',
});
}
const model = await db.insert(models).values({
id: result.data.id,
userId: event.context.user!.id,
providerId: result.data.providerId,
externalId: result.data.externalId,
name: result.data.name,
cost: result.data.cost,
inputModalities: result.data.inputModalities,
outputModalities: result.data.outputModalities,
capabilities: result.data.capabilities,
contextWindow: result.data.contextWindow,
supportedParameters: result.data.supportedParameters,
isCustom: result.data.isCustom,
enabled: result.data.enabled,
releasedAt: result.data.releasedAt,
}).onConflictDoNothing().returning();
return {
model,
};
});
@@ -0,0 +1,68 @@
import { and, eq } from "drizzle-orm";
import { providers } from "~~/drizzle/schema";
import { db } from "~~/server/lib/db";
import * as z from 'zod';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id;
const result = await readValidatedBody(event, (body) =>
z
.object({
config: z.object({
apiKey: z.string().optional(),
apiProxyUrl: z.string().optional(),
}).optional(),
enabled: z.boolean().optional(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const providerId = getRouterParam(event, 'providerId')!;
const { config, enabled } = result.data;
const existing = await db.query.providers.findFirst({
where: {
id: providerId,
userId,
},
});
if (!existing) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid provider',
});
}
const res = await db.update(providers)
.set({
config,
enabled,
})
.where(
and(
eq(providers.id, providerId),
eq(providers.userId, userId),
)
);
if (res.rowCount === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid provider',
});
}
return { ok: true };
});
@@ -0,0 +1,28 @@
// delete all models for a provider
import { and, eq } from "drizzle-orm";
import { models } from "~~/drizzle/schema";
import { db } from "~~/server/lib/db";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const providerId = getRouterParam(event, 'providerId');
if (!providerId) throw createError({ statusCode: 400, message: 'Invalid provider' });
const provider = await db.query.providers.findFirst({
where: {
id: providerId,
userId,
},
});
if (!provider) throw createError({ statusCode: 404, message: 'Provider not found' });
await db.delete(models).where(and(eq(models.providerId, providerId), eq(models.userId, userId)));
return {
ok: true,
};
});
@@ -0,0 +1,38 @@
import { and, eq } from 'drizzle-orm';
import * as z from 'zod';
import { models } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const provider = await db.query.providers.findFirst({
where: {
id: getRouterParam(event, 'providerId'),
userId,
},
});
if (!provider) throw createError({ statusCode: 404, message: 'Provider not found' });
const result = await readValidatedBody(event, (body) =>
z.object({ enabled: z.boolean() }).safeParse(body)
);
if (!result.success) {
throw createError({ statusCode: 400, message: result.error.issues[0]!.message });
}
const { enabled } = result.data;
const res = await db.update(models).set({
enabled
}).where(and(
eq(models.userId, userId),
eq(models.providerId, provider.id),
));
return {
ok: res.rowCount ?? 0 > 0,
};
});
+200 -68
View File
@@ -1,48 +1,44 @@
import { type Entity } from '@triplit/client';
import * as z from 'zod'; import * as z from 'zod';
import { SupportedModalities } from '~/types/model'; import { SupportedModalities } from '~/types/model';
import { Providers } from '~/types/model'; import { Providers } from '~/types/model';
import { httpClient } from '~~/server/lib/triplit';
import { GatewayFetchError, getProviderDetails } from '~~/server/utils/ai-provider'; import { GatewayFetchError, getProviderDetails } from '~~/server/utils/ai-provider';
import { getModelsDevData } from '~~/server/utils/models-dev'; import { getModelsDevData } from '~~/server/utils/models-dev';
import { schema } from '~~/triplit/schema';
import { Err, Ok, type Result } from '~~/types/result'; import { Err, Ok, type Result } from '~~/types/result';
import { db } from '~~/server/lib/db';
import { type Provider } from '~/composables/useModels';
import { models } from '~~/drizzle/schema';
import { and, eq, notInArray } from 'drizzle-orm';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await protectRoute(event); await protectRoute(event);
const userId = event.context.user!.id; const userId = event.context.user!.id as string;
const result = await readValidatedBody(event, (body) => const result = await readValidatedBody(event, (body) =>
z z.object({ providerApiKey: z.string().optional() }).safeParse(body)
.object({
providerApiKey: z.string().optional(),
})
.safeParse(body),
); );
if (!result.success) { if (!result.success) {
throw createError({ throw createError({ statusCode: 400, message: result.error.issues[0]!.message });
statusCode: 400,
message: result.error.issues[0]!.message,
});
} }
const providerId = getRouterParam(event, 'providerId') const providerId = getRouterParam(event, 'providerId');
if (!providerId) throw createError({ statusCode: 400, message: 'Invalid provider' });
const provider = await httpClient.fetchOne(httpClient.query('providers').Where('id', '=', providerId)); const provider = await db.query.providers.findFirst({
if (provider === null || provider.userId !== userId) { where: {
throw createError({ id: providerId,
statusCode: 400, userId,
message: 'Invalid provider', },
with: { models: true }
}); });
}
console.log("apiKey", result.data.providerApiKey); if (!provider) throw createError({ statusCode: 404, message: 'Provider not found' });
const [providerModelsRes, modelsDevRes] = await Promise.all([ const [providerModelsRes, modelsDevRes] = await Promise.all([
fetchProviderModels(provider, result.data.providerApiKey), fetchProviderModels(provider, result.data.providerApiKey),
getModelsDevData(), getModelsDevData(),
]); ]);
if (!providerModelsRes.ok) { if (!providerModelsRes.ok) {
switch (providerModelsRes.error) { switch (providerModelsRes.error) {
case ProviderFetchError.NoProviderApiKey: { case ProviderFetchError.NoProviderApiKey: {
@@ -60,17 +56,75 @@ export default defineEventHandler(async (event) => {
} }
} }
console.log("providerModelsRes.data", providerModelsRes.data);
const normalizedModels = await normalizeResponse( const normalizedModels = await normalizeResponse(
providerModelsRes.data.data, providerModelsRes.data.data,
provider.type, provider.type as typeof Providers[number],
providerModelsRes.data.baseURL, providerModelsRes.data.baseURL,
modelsDevRes modelsDevRes
); );
const existingModels = provider.models || [];
const apiModelExternalIds = normalizedModels.map((m: any) => m.id);
console.log({ existingModels, apiModelExternalIds, normalizedModels });
await db.transaction(async (tx) => {
if (existingModels.length > 0) {
await tx.delete(models)
.where(
and(
eq(models.providerId, providerId),
eq(models.isCustom, false),
notInArray(models.externalId, apiModelExternalIds)
)
);
}
for (const model of normalizedModels) {
const existing = existingModels.find(m => m.externalId === model.id);
if (existing) {
await tx.update(models)
.set({
name: model.name || existing.name,
cost: model.cost,
inputModalities: model.attributes.inputModalities,
outputModalities: model.attributes.outputModalities,
capabilities: model.attributes.capabilities,
contextWindow: model.attributes.contextWindow,
releasedAt: model.releasedAt ? new Date(model.releasedAt) : existing.releasedAt,
})
.where(eq(models.id, existing.id));
} else {
await tx.insert(models).values({
userId,
providerId,
externalId: model.id,
name: model.name || model.id,
cost: model.cost || {},
inputModalities: model.attributes.inputModalities,
outputModalities: model.attributes.outputModalities,
capabilities: model.attributes.capabilities,
contextWindow: model.attributes.contextWindow,
isCustom: false,
enabled: false,
releasedAt: model.releasedAt ? new Date(model.releasedAt) : null,
});
}
}
});
const updatedModels = await db.query.models.findMany({
where: {
providerId,
},
orderBy: {
releasedAt: 'desc'
}
});
return { return {
models: normalizedModels, models: updatedModels
}; };
}); });
@@ -79,22 +133,14 @@ enum ProviderFetchError {
NoProviderBaseUrl, NoProviderBaseUrl,
} }
const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>, providerApiKey: string | undefined): Promise<Result<{ data: Record<string, any>, baseURL: string }, ProviderFetchError>> => { const fetchProviderModels = async (provider: Provider, providerApiKey: string | undefined): Promise<Result<{ data: Record<string, any>, baseURL: string }, ProviderFetchError>> => {
const providerDetails = await getProviderDetails(provider, providerApiKey); const providerDetails = await getProviderDetails(provider, providerApiKey);
if (!providerDetails.ok) { if (!providerDetails.ok) {
switch (providerDetails.error) { switch (providerDetails.error) {
case GatewayFetchError.NoProviderApiKey: { case GatewayFetchError.NoProviderApiKey: {
// throw createError({
// statusCode: 400,
// message: `${provider.type} provider requires an API key`,
// });
return Err(ProviderFetchError.NoProviderApiKey); return Err(ProviderFetchError.NoProviderApiKey);
} }
case GatewayFetchError.NoProviderBaseUrl: { case GatewayFetchError.NoProviderBaseUrl: {
// throw createError({
// statusCode: 400,
// message: 'Invalid provider URL',
// });
return Err(ProviderFetchError.NoProviderBaseUrl); return Err(ProviderFetchError.NoProviderBaseUrl);
} }
} }
@@ -107,7 +153,7 @@ const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>,
// longcat doesn't have a model list endpoint so we just hardcode them here, // longcat doesn't have a model list endpoint so we just hardcode them here,
// sry. I talked to Meituan and this is what they said: // sry. I talked to Meituan and this is what they said:
// 后续如果我们新增了这样的接口会及时同步您。 // 后续如果我们新增了这样的接口会及时同步您。
// en (approx): If we add an interface like this in the future, we will promptly update you // en (approx): If we add an interface like this in the future, we will keep you in sync
return Ok({ return Ok({
data: { data: {
models: [ models: [
@@ -122,8 +168,8 @@ const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>,
} }
}, },
{ {
id: "LongCat-Flash-Thinking", id: "LongCat-Flash-Thinking-2601",
name: "LongCat Flash Thinking", name: "LongCat Flash Thinking (2601)",
attributes: { attributes: {
inputModalities: ['text'], inputModalities: ['text'],
outputModalities: ['text'], outputModalities: ['text'],
@@ -132,12 +178,12 @@ const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>,
} }
}, },
{ {
id: "LongCat-Flash-Thinking-2601", id: "LongCat-Flash-Omni-2603",
name: "LongCat Flash Thinking (2601)", name: "LongCat Flash Omni",
attributes: { attributes: {
inputModalities: ['text'], inputModalities: ['text', 'image', 'audio', 'video'],
outputModalities: ['text'], outputModalities: ['text', 'audio'],
capabilities: ['reasoning', 'tools'], capabilities: ['tools'],
contextWindow: 256_000, contextWindow: 256_000,
} }
}, },
@@ -155,6 +201,24 @@ const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>,
}, },
baseURL baseURL
}); });
case 'inception':
return Ok({
data: {
models: [
{
id: "mercury-2",
name: "Mercury 2",
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
capabilities: ['tools', 'reasoning'],
contextWindow: 128_000,
}
},
],
},
baseURL
});
} }
let res; let res;
@@ -189,7 +253,6 @@ function mergeSets(setA: Set<string>, setB: Set<string>): string[] {
const getModelData = (modelId: string, providerId: string, modelsDevData: any) => { const getModelData = (modelId: string, providerId: string, modelsDevData: any) => {
const modelData = modelsDevData[providerId]?.models[modelId]; const modelData = modelsDevData[providerId]?.models[modelId];
console.log("modelData", modelData);
if (modelData === undefined) return { if (modelData === undefined) return {
cost: {}, cost: {},
@@ -200,13 +263,13 @@ const getModelData = (modelId: string, providerId: string, modelsDevData: any) =
} }
}; };
const capabilities = new Set<string>(); const capabilities = new Array<string>();
if (modelData.reasoning) { if (modelData.reasoning) {
capabilities.add('reasoning'); capabilities.push('reasoning');
} }
if (modelData.tool_call) { if (modelData.tool_call) {
capabilities.add('tools'); capabilities.push('tools');
} }
let inputModalities = modelData.modalities.input.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m)); let inputModalities = modelData.modalities.input.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m));
@@ -236,7 +299,7 @@ const getModelData = (modelId: string, providerId: string, modelsDevData: any) =
attributes: { attributes: {
inputModalities, inputModalities,
outputModalities, outputModalities,
capabilities: modelData.capabilities || [], capabilities: capabilities || [],
contextWindow, contextWindow,
supported_parameters: modelData.supportedParameters, supported_parameters: modelData.supportedParameters,
}, },
@@ -245,15 +308,6 @@ const getModelData = (modelId: string, providerId: string, modelsDevData: any) =
} }
} }
// const formatBig = (bigValue: Big) => {
// let str = bigValue.toString();
// if (!str.includes('.')) return str + '.00';
// if (str.split('.')[1]!.length === 1) return str + '0';
// return str;
// };
// this function multiplies a string in the format of 'D.DD' by 1_000_000 // this function multiplies a string in the format of 'D.DD' by 1_000_000
// it does this by finding the first digit that is not a zero, and then // it does this by finding the first digit that is not a zero, and then
// left shifting it in decimal by 3 places // left shifting it in decimal by 3 places
@@ -320,7 +374,7 @@ const formatMoney = (value: string) => {
return `${integerPart}.${fractionalPart}`; return `${integerPart}.${fractionalPart}`;
} }
const normalizeResponse = async (response: Record<string, any>, provider: typeof Providers[number], baseUrl: string, modelsDevData: any) => { const normalizeResponse = async (response: Record<string, any>, provider: typeof Providers[number], baseUrl: string, modelsDevData: any): Promise<any[]> => {
switch (provider) { switch (provider) {
case 'cerebras': { case 'cerebras': {
console.log("response.data", response.data); console.log("response.data", response.data);
@@ -397,14 +451,6 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
name: model.name as string, name: model.name as string,
cost: pricing, cost: pricing,
attributes: { attributes: {
// inputModalities: mergeSets(
// new Set(modelData.inputModalities || []),
// new Set(model.architecture.input_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality))),
// ),
// outputModalities: mergeSets(
// new Set(modelData.outputModalities || []),
// new Set(model.architecture.output_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality))),
// ),
inputModalities: model.architecture.input_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)), inputModalities: model.architecture.input_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)),
outputModalities: model.architecture.output_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)), outputModalities: model.architecture.output_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)),
capabilities: Array.from(capabilities), capabilities: Array.from(capabilities),
@@ -417,6 +463,33 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
return models; return models;
} }
case 'closedrouter': {
return response.data.map((model: any) => {
const capabilities = new Array<string>();
if (model.capabilities) {
if (model.capabilities.reasoning) {
capabilities.push('reasoning');
}
if (model.capabilities.tool_call) {
capabilities.push('tools');
}
}
return {
id: model.id,
name: model.name,
attributes: {
inputModalities: model.modalities?.input || ['text'],
outputModalities: model.modalities?.output || ['text'],
capabilities: capabilities,
contextWindow: model.context_window,
supported_parameters: [],
},
}
});
}
case 'ollama': { case 'ollama': {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -503,12 +576,21 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
return Array.from(models.values()); return Array.from(models.values());
} }
case 'vllm': {
return response.data.map((model: any) => {
return {
id: model.id,
attributes: {
inputModalities: ['text'],
outputModalities: ['text'],
contextWindow: model.max_model_len,
}
}
})
}
case 'google': { case 'google': {
return response.models.map((model: any) => ({ ...getModelData(model.name.replace('models/', ''), provider, modelsDevData), id: model.name.replace('models/', ''), name: model.displayName })); return response.models.map((model: any) => ({ ...getModelData(model.name.replace('models/', ''), provider, modelsDevData), id: model.name.replace('models/', ''), name: model.displayName }));
} }
case 'longcat': {
return response.models;
}
case 'cohere': { case 'cohere': {
const models = []; const models = [];
for (const model of response.models) { for (const model of response.models) {
@@ -551,5 +633,55 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
return models; return models;
} }
case 'mistral': {
const models = [];
for (const model of response.data) {
const inputModalities = new Set<string>();
const capabilities = new Set<string>();
for (const capability of Object.keys(model.capabilities)) {
if (model.capabilities[capability] === false) continue;
switch (capability) {
case 'function_calling': {
capabilities.add('tools');
} break;
case 'completion_chat': {
inputModalities.add('text');
capabilities.add('completion');
} break;
case 'vision': {
inputModalities.add('image');
} break;
}
}
const modelData = getModelData(model.id, provider, modelsDevData);
models.push({
...modelData,
id: model.id,
name: modelData.name || model.name || model.id,
attributes: {
...modelData.attributes,
inputModalities: mergeSets(
inputModalities,
new Set(modelData.attributes?.inputModalities || [])
),
capabilities: mergeSets(
capabilities,
new Set(modelData.attributes?.capabilities || [])
),
contextWindow: model.max_context_length || modelData.attributes?.contextWindow,
},
});
}
return models;
}
case 'inception':
case 'longcat': {
return response.models;
}
} }
} }
+39
View File
@@ -0,0 +1,39 @@
import { nanoid } from "nanoid";
import { Providers } from "~/types/model";
import { db } from "~~/server/lib/db";
import { providers as providersSchema } from "~~/drizzle/schema";
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const providers = await db.query.providers.findMany({
where: {
userId,
},
with: {
models: true,
},
});
Providers.forEach(async p => {
if (!providers.find(provider => provider.type === p)) {
const [created] = await db.insert(providersSchema).values({
id: nanoid(),
userId,
type: p,
name: p,
enabled: false,
config: {},
createdAt: new Date(),
updatedAt: new Date(),
}).returning() as typeof providers;
if (created) {
created.models = [];
providers.push(created);
}
}
});
return providers;
})
+34
View File
@@ -0,0 +1,34 @@
import { and, eq } from 'drizzle-orm';
import { settings } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
const defaultAppearance = {
colorScheme: 'system' as const,
accent: 'violet',
neutral: 'zinc',
hinting: 0,
fontSize: 'md',
};
export default defineEventHandler(async (event) => {
await protectRoute(event);
const userId = event.context.user!.id as string;
const existing = await db.query.settings.findFirst({
where: { userId },
});
if (existing) {
return existing;
}
const [created] = await db.insert(settings)
.values({
userId,
systemAssistants: {},
appearance: defaultAppearance,
})
.returning();
return created;
});

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