feat: ditch triplit, move to postgresql + drizzle orm
This commit is contained in:
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)]" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
}">
|
}">
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,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,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
@@ -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,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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -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)],
|
||||||
|
);
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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://') &&
|
|
||||||
@@ -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
|
|
||||||
@@ -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();
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
|
})
|
||||||
@@ -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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
})
|
||||||
@@ -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
Reference in New Issue
Block a user