merge: drizzle rewrite into main

Complete migration from Triplit to PostgreSQL + Drizzle ORM with all subsequent feature work and fixes.
This commit is contained in:
Zoe
2026-05-11 17:44:22 -05:00
163 changed files with 16554 additions and 5563 deletions
+22 -6
View File
@@ -1,6 +1,22 @@
TRIPLIT_SERVICE_TOKEN="your-triplit-service-token" # given to you on startup, or by running `tsx generate-tokens.ts` # Database
NUXT_TRIPLIT_ANON_TOKEN="your-anonymous-triplit-token" # also given to you on startup, or by running `tsx generate-tokens.ts` POSTGRES_DB=veridian
BETTER_AUTH_SECRET="super-secret" # openssl rand -base64 32 POSTGRES_USER=postgres
TRIPLIT_JWT_SECRET="super-secret" POSTGRES_PASSWORD=changeme
EXTERNAL_JWT_SECRET=${BETTER_AUTH_SECRET} # no need to change this DATABASE_URL=postgresql://postgres:changeme@db:5432/veridian
NUXT_PUBLIC_TRIPLIT_URL="http://localhost:6543" # your triplit server url
# Auth
BETTER_AUTH_SECRET=changeme
# App URL (your domain, used for CORS and auth redirects)
NUXT_PUBLIC_URL=https://veridian.example.com
# S3-compatible storage (optional, for file uploads)
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET_NAME=
S3_REGION=
S3_ENDPOINT=
# Auth controls (optional)
DISABLE_SIGNUP=false
DISABLE_LOCAL_AUTH=false
+27 -50
View File
@@ -1,6 +1,6 @@
# Veridian AGENTS.md # Veridian AGENTS.md
**Nuxt 4 AI chat platform with Triplit database + better-auth** **Nuxt 4 AI chat platform with Drizzle + PostgreSQL + better-auth**
## Architecture Overview ## Architecture Overview
@@ -21,9 +21,10 @@ server/ # Server-side API routes
├── chat/ # Generation, cancel endpoints ├── chat/ # Generation, cancel endpoints
└── provider/ # Model fetching from providers └── provider/ # Model fetching from providers
triplit/ # Database drizzle/ # Database (migrated from Triplit)
├── schema.ts # Full schema with auth + app collections ├── schema.ts # Full schema with auth + app tables
└── auth-schema.ts # Auth collections (users, sessions, accounts) ├── relations.ts # Table relationships
└── migrations/ # SQL migration files
lib/ # Shared utilities lib/ # Shared utilities
├── auth.ts # Better-auth server config ├── auth.ts # Better-auth server config
@@ -39,7 +40,9 @@ database commands.
### Database Commands ### Database Commands
- `bunx triplit schema push` - Push schema changes to database - `bunx drizzle-kit push` - Push schema changes to database
- `bunx drizzle-kit generate` - Generate migration from schema changes
- `bunx drizzle-kit migrate` - Run pending migrations
## Code Style ## Code Style
@@ -57,15 +60,12 @@ database commands.
Group imports in this order (alphabetical within groups): Group imports in this order (alphabetical within groups):
1. Type imports (`types/`) 1. Type imports (`types/`)
2. Dependency imports (npm packages) 2. Dependency imports (npm packages)
3. Triplit imports (`#triplit/`, `@triplit/`) 3. Vue/Nuxt imports (`vue`, `#app`, `~~/`, `~/`, `@/`)
4. Vue/Nuxt imports (`vue`, `#app`, `~~/`, `~/`, `@/`) 4. Local imports (`lib/`, `server/`)
5. Local imports (`lib/`, `server/`)
```typescript ```typescript
// Example import order // Example import order
import type { Result, Ok, Err } from '~~/types/result'; import type { Result, Ok, Err } from '~~/types/result';
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
import type { Foo } from 'vue'; import type { Foo } from 'vue';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useFeature } from '~/composables/useFeature'; import { useFeature } from '~/composables/useFeature';
@@ -128,25 +128,30 @@ 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`, `app/plugins/auth.*.ts`, `app/middleware/auth.global.ts`, `app/composables/useAuth.ts`
## Styling System ## Styling System
@@ -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
@@ -176,37 +179,11 @@ await httpClient.fetchOne(httpClient.query('collection').Where('id', '=', provid
| `useTheme()` | Accent/neutral theme cookies | | `useTheme()` | Accent/neutral theme cookies |
| `useSettings()` | Settings dialog state | | `useSettings()` | Settings dialog state |
## Key Routes
| Route | File |
|-------|------|
| `/` | `app/pages/index.vue` |
| `/auth/login` | `app/pages/auth/login.vue` |
| `/auth/register` | `app/pages/auth/register.vue` |
| `/agent/:id` | `app/pages/agent/[id]/index.vue` |
| `/agent/:id/topic/:topicId` | `app/pages/agent/[id]/topic/[topicId].vue` |
| `/agent/:id/profile` | `app/pages/agent/[id]/profile.vue` |
## Server API Routes
| Endpoint | File |
|----------|------|
| `POST /api/chat/generate` | `server/api/chat/generate.post.ts` |
| `POST /api/chat/cancel/:id` | `server/api/chat/cancel/[generationId].post.ts` |
| `POST /api/provider/:id/models` | `server/api/provider/[providerId]/models.post.ts` |
| `* /api/auth/*` | `server/api/auth/[...all].ts` |
## Known Issues (from BUGS.md)
1. Triplit occasionally makes duplicate connections (race condition)
2. Sidebar hover animation occasionally glitches on agent routes
3. Theme switcher + sidebar interaction bug
## 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
+186
View File
@@ -0,0 +1,186 @@
# Deploying Veridian
Self-hosted deployment guide using Docker Compose on a VPS.
## Prerequisites
- A VPS with Docker and Docker Compose installed
- A domain name pointed at your VPS IP address
- An external reverse proxy (Caddy, Nginx, etc.) for HTTPS termination
## First Deploy
### 1. Clone the repository
```bash
git clone https://gitea.wildcardproject.com/zoeissleeping/veridian.git
cd veridian
```
### 2. Configure environment variables
```bash
cp .env.example .env
```
Edit `.env` and set the required values:
```bash
# Generate a secure auth secret
openssl rand -base64 32
# Set these in .env
POSTGRES_PASSWORD=<your-secure-db-password>
BETTER_AUTH_SECRET=<output from openssl>
NUXT_PUBLIC_URL=https://your-domain.com
```
### 3. Build and start
```bash
docker compose up --build -d
```
This will:
1. Build the Nuxt application
2. Start PostgreSQL and wait for it to be healthy
3. Run all pending database migrations automatically
4. Start the application server
### 4. Verify
```bash
# Check container status
docker compose ps
# Check app logs (should show migration + server start)
docker compose logs app
# Check for errors
docker compose logs app | grep -i error
```
### 5. Set up your reverse proxy
Point your reverse proxy at `127.0.0.1:3000`. Example Caddy config:
```
your-domain.com {
reverse_proxy 127.0.0.1:3000
}
```
## Updating
When you pull new changes:
```bash
git pull
# Rebuild and restart (migrations run automatically on startup)
docker compose up --build -d
```
The app container will:
1. Rebuild with the latest code
2. Run any new migrations that were added
3. Restart the server
## Database Migrations
Migrations run automatically every time the app container starts, via [`scripts/start.ts`](scripts/start.ts). This uses Drizzle ORM's programmatic migration API (`drizzle-orm/node-postgres/migrator`).
- **First deploy**: All migrations run, creating all tables
- **Updates**: Only new/pending migrations run
- **No changes**: Migrations are a no-op
If a migration fails, the app will not start and the container will exit with an error. Check logs with:
```bash
docker compose logs app
```
### Creating new migrations
After modifying [`drizzle/schema.ts`](drizzle/schema.ts):
```bash
bunx drizzle-kit generate
```
This creates a new SQL file in `drizzle/migrations/`. Commit it to git. It will be applied automatically on next deploy.
## Database Backups
### Manual backup
```bash
docker compose exec db pg_dump -U postgres veridian > backup_$(date +%Y%m%d).sql
```
### Restore from backup
```bash
cat backup_20250101.sql | docker compose exec -T db psql -U postgres veridian
```
### Automated backups (optional)
Add a cron job on the host:
```bash
# Daily backup at 3 AM
0 3 * * * cd /path/to/veridian && docker compose exec -T db pg_dump -U postgres veridian | gzip > /var/backups/veridian/veridian_$(date +\%Y\%m\%d).sql.gz
```
## Troubleshooting
### App won't start
```bash
# Check logs
docker compose logs app
# Common issues:
# - Missing .env file or required variables not set
# - Database not reachable (check db container is healthy)
# - Migration errors (schema conflicts)
```
### Database connection refused
```bash
# Check DB is healthy
docker compose ps db
# Check DB logs
docker compose logs db
# Test connection
docker compose exec db psql -U postgres veridian -c "SELECT 1"
```
### Reset database (destructive)
```bash
docker compose down -v # Removes volumes!
docker compose up --build -d
```
## Environment Variables Reference
| Variable | Required | Description |
|----------|----------|-------------|
| `POSTGRES_DB` | No | Database name (default: `veridian`) |
| `POSTGRES_USER` | No | Database user (default: `postgres`) |
| `POSTGRES_PASSWORD` | **Yes** | Database password |
| `DATABASE_URL` | Auto | Built from the above vars by docker-compose |
| `BETTER_AUTH_SECRET` | **Yes** | Auth encryption secret (`openssl rand -base64 32`) |
| `NUXT_PUBLIC_URL` | **Yes** | Public URL (e.g., `https://veridian.example.com`) |
| `S3_ACCESS_KEY_ID` | No | S3-compatible storage access key |
| `S3_SECRET_ACCESS_KEY` | No | S3-compatible storage secret key |
| `S3_BUCKET_NAME` | No | S3 bucket name |
| `S3_REGION` | No | S3 region |
| `S3_ENDPOINT` | No | S3 endpoint URL |
| `DISABLE_SIGNUP` | No | Set to `true` to disable new user signups |
| `DISABLE_LOCAL_AUTH` | No | Set to `true` to disable email/password auth |
+14 -1
View File
@@ -20,6 +20,19 @@ WORKDIR /app
# Only `.output` folder is needed from the build stage # Only `.output` folder is needed from the build stage
COPY --from=build /app/.output /app COPY --from=build /app/.output /app
# Copy migration SQL files for runtime migrations
COPY --from=build /app/drizzle/migrations /app/drizzle/migrations
# Copy entrypoint script
COPY --from=build /app/scripts/start.ts /app/scripts/start.ts
# Copy package.json and lockfile for production dependency install
COPY --from=build /app/package.json /app/package.json
COPY --from=build /app/bun.lock* ./
# Install production dependencies only (drizzle-orm, pg, etc.)
RUN bun install --frozen-lockfile --production --ignore-scripts
# run the app # run the app
EXPOSE 3000/tcp EXPOSE 3000/tcp
ENTRYPOINT [ "bun", "--bun", "run", "/app/server/index.mjs" ] ENTRYPOINT [ "bun", "run", "/app/scripts/start.ts" ]
+5 -4
View File
@@ -2,10 +2,11 @@
import '~/assets/css/reset.css'; import '~/assets/css/reset.css';
import '~/assets/css/base.css'; import '~/assets/css/base.css';
const { accent, neutral, hinting } = useUserSettings(); const { user } = useAuth();
const { accent, neutral, hinting, refresh: refreshSettings } = await useUserSettings();
// by default, disable hinting watch(user, () => {
if (Number.isNaN(Number(hinting.value))) hinting.value = '0'; refreshSettings();
})
watchEffect(() => { watchEffect(() => {
useHead({ useHead({
+46 -3
View File
@@ -186,6 +186,10 @@ body {
height: 100%; height: 100%;
} }
a {
color: currentColor;
}
button { button {
color: inherit; color: inherit;
display: flex; display: flex;
@@ -220,6 +224,45 @@ button.accent:hover {
animation: blink 1s step-end infinite; animation: blink 1s step-end infinite;
} }
.animate-pulse {
background: linear-gradient(90deg,
currentColor 0%,
currentColor 35%,
rgba(255, 255, 255, 0.65) 50%,
currentColor 65%,
currentColor 100%);
background-size: 300% 100%;
background-repeat: no-repeat;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation:
pulse-shimmer 2s linear infinite,
pulse-opacity 2s ease-in-out infinite;
}
@keyframes pulse-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: 0% 0;
}
}
@keyframes pulse-opacity {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes blink { @keyframes blink {
0%, 0%,
@@ -273,15 +316,15 @@ button.accent:hover {
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='18' height='18'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='18' height='18'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0'/%3E%3C/svg%3E");
} }
.reasoning-contaizner.middle { .reasoning-container.middle {
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent) mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent)
} }
.reasoning-contaizner.top { .reasoning-container.top {
mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent) mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent)
} }
.reasoning-contaizner.bottom { .reasoning-container.bottom {
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000) mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000)
} }
-4
View File
@@ -67,10 +67,6 @@ h6 {
text-wrap: balance; text-wrap: balance;
} }
a {
color: #fff;
}
a:hover { a:hover {
text-decoration: none; text-decoration: none;
} }
+21 -8
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import ImageViewer from '~/components/ImageViewer.vue';
const props = defineProps<{ const props = defineProps<{
file: { file: {
id: string; id: string;
@@ -7,7 +9,7 @@ const props = defineProps<{
status?: 'uploading' | 'uploaded' | 'error'; status?: 'uploading' | 'uploaded' | 'error';
url: string; url: string;
progress?: number; progress?: number;
} };
}>(); }>();
const isImage = computed(() => props.file.mimeType.startsWith('image/')); const isImage = computed(() => props.file.mimeType.startsWith('image/'));
@@ -15,6 +17,10 @@ const isVideo = computed(() => props.file.mimeType.startsWith('video/'));
const isAudio = computed(() => props.file.mimeType.startsWith('audio/')); const isAudio = computed(() => props.file.mimeType.startsWith('audio/'));
const isPdf = computed(() => props.file.mimeType === 'application/pdf'); const isPdf = computed(() => props.file.mimeType === 'application/pdf');
const imageViewerOpen = ref(false);
const imageRef = ref<HTMLImageElement | null>(null);
const imageViewerOriginRect = ref<DOMRect | null>(null);
const fileIcon = computed(() => { const fileIcon = computed(() => {
if (isImage.value) return 'i-mynaui-image'; if (isImage.value) return 'i-mynaui-image';
if (isVideo.value) return 'i-mynaui-video'; if (isVideo.value) return 'i-mynaui-video';
@@ -29,11 +35,23 @@ const fileExtension = computed(() => {
const parts = props.file.name.split('.'); const parts = props.file.name.split('.');
return parts.length > 1 ? parts.pop()?.toUpperCase() : 'FILE'; return parts.length > 1 ? parts.pop()?.toUpperCase() : 'FILE';
}); });
function openImageViewer() {
imageViewerOriginRect.value = imageRef.value?.getBoundingClientRect() ?? null;
imageViewerOpen.value = true;
}
</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" /> <div v-if="isImage">
<img ref="imageRef" :src="props.file.url"
class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover cursor-zoom-in hover:opacity-90 transition-opacity"
@click="openImageViewer" />
<ImageViewer v-if="imageViewerOpen" :src="props.file.url" :alt="props.file.name"
:origin-rect="imageViewerOriginRect" :origin-element="imageRef"
@close="imageViewerOpen = false" />
</div>
<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" />
<audio v-else-if="isAudio" controls :src="props.file.url" class="rounded-lg h-full w-full max-h-36 max-w-64" /> <audio v-else-if="isAudio" controls :src="props.file.url" class="rounded-lg h-full w-full max-h-36 max-w-64" />
@@ -53,10 +71,5 @@ const fileExtension = computed(() => {
<span class="text-xs text-white">{{ file.progress || 0 }}%</span> <span class="text-xs text-white">{{ file.progress || 0 }}%</span>
</div> </div>
</div> </div>
<button @click="$emit('delete')"
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 flex items-center justify-center w-4 h-4 rounded-full bg-[var(--bg-base)] border border-[var(--color-border)] text-xs text-[#ff3b3b]">
<span class="i-mynaui-x text-3"></span>
</button>
</div> </div>
</template> </template>
+1 -1
View File
@@ -21,7 +21,7 @@ const handleDelete = async () => {
</script> </script>
<template> <template>
<div class="relative h-full w-fit"> <div class="relative h-full w-fit flex">
<Display :file="props.file" /> <Display :file="props.file" />
<button @click="handleDelete" <button @click="handleDelete"
+141 -30
View File
@@ -1,14 +1,12 @@
<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 { updateAgent, patchAgentLocally } = await useAgents();
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,19 +28,48 @@ 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];
cancel: []; cancel: [];
resize: [];
}>(); }>();
const props = defineProps<{ const props = defineProps<{
loading?: boolean; loading?: boolean;
agent: Agent | null; agent: Readonly<Agent> | null;
providers?: ProviderWithModels[]; providers?: ProviderWithModels[];
}>(); }>();
const searchConfig = ref({
enabled: props.agent?.config?.search?.enabled ?? false,
maxResults: props.agent?.config?.search?.maxResults ?? 10,
rerank: props.agent?.config?.search?.rerank ?? false,
});
watch(() => props.agent?.config?.search, (val) => {
searchConfig.value = {
enabled: val?.enabled ?? false,
maxResults: val?.maxResults ?? 10,
rerank: val?.rerank ?? false,
};
}, { deep: true });
const saveSearchConfig = async () => {
if (!props.agent) return;
const currentConfig = props.agent.config ?? {};
const newConfig = {
...currentConfig,
search: {
enabled: searchConfig.value.enabled,
maxResults: searchConfig.value.maxResults,
rerank: searchConfig.value.rerank,
},
};
patchAgentLocally(props.agent.id, { config: newConfig });
updateAgent(props.agent.id, { config: newConfig });
};
const selectedModel = ref<ModelWithProvider | null>(null); const selectedModel = ref<ModelWithProvider | null>(null);
const handlePaste = async (event: ClipboardEvent) => { const handlePaste = async (event: ClipboardEvent) => {
@@ -82,8 +109,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,15 +144,54 @@ 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 };
if (inputRef.value) inputRef.value.style.height = 'auto';
const toggleMarkdown = (marker: '*' | '**') => {
const textarea = inputRef.value;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
if (start === end) return;
const text = textAreaValue.value;
const len = marker.length;
let countBefore = 0;
for (let i = start - 1; i >= 0 && text[i] === '*'; i--) countBefore++;
let countAfter = 0;
for (let i = end; i < text.length && text[i] === '*'; i++) countAfter++;
const shouldRemove = len === 1
? countBefore >= 1 && countAfter >= 1 && countBefore % 2 === 1 && countAfter % 2 === 1
: countBefore >= 2 && countAfter >= 2;
if (shouldRemove) {
textAreaValue.value = text.slice(0, start - len) + text.slice(start, end) + text.slice(end + len);
nextTick(() => textarea.setSelectionRange(start - len, end - len));
} else {
textAreaValue.value = text.slice(0, start) + marker + text.slice(start, end) + marker + text.slice(end);
nextTick(() => textarea.setSelectionRange(start + len, end + len));
}
}; };
const handleKeyDown = async (event: KeyboardEvent) => { const handleKeyDown = async (event: KeyboardEvent) => {
if (event.ctrlKey && event.key.toLowerCase() === 'i') {
event.preventDefault();
event.stopPropagation();
toggleMarkdown('*');
return;
}
if (event.ctrlKey && event.key.toLowerCase() === 'b') {
event.preventDefault();
event.stopPropagation();
toggleMarkdown('**');
return;
}
if (event.key === 'Enter') { if (event.key === 'Enter') {
if (event.shiftKey) return; if (event.shiftKey) return;
if (event.ctrlKey || event.metaKey) { if (event.ctrlKey || event.metaKey) {
@@ -166,25 +235,48 @@ const handleWindowKeyDown = async (event: KeyboardEvent) => {
} }
}; };
watch(textAreaValue, async () => { const mirrorRef = ref<HTMLDivElement | null>(null);
const resizeTextArea = () => {
const textarea = inputRef.value; const textarea = inputRef.value;
if (!textarea) return; const mirror = mirrorRef.value;
if (!textarea || !mirror) return;
inputHeight.value = 'auto'; emit('resize');
await nextTick(); nextTick().then(() => {
mirror.textContent = (textarea.value || '\u200b') + '\n';
const lineHeight = 24; const textareaStyles = getComputedStyle(textarea);
const maxLines = 10; mirror.style.width = textareaStyles.width;
const maxHeight = maxLines * lineHeight; mirror.style.fontFamily = textareaStyles.fontFamily;
mirror.style.fontSize = textareaStyles.fontSize;
mirror.style.lineHeight = textareaStyles.lineHeight;
mirror.style.letterSpacing = textareaStyles.letterSpacing;
mirror.style.wordSpacing = textareaStyles.wordSpacing;
mirror.style.textAlign = textareaStyles.textAlign;
mirror.style.paddingTop = textareaStyles.paddingTop;
mirror.style.paddingBottom = textareaStyles.paddingBottom;
mirror.style.paddingLeft = textareaStyles.paddingLeft;
mirror.style.paddingRight = textareaStyles.paddingRight;
mirror.style.borderTopWidth = textareaStyles.borderTopWidth;
mirror.style.borderBottomWidth = textareaStyles.borderBottomWidth;
mirror.style.borderLeftWidth = textareaStyles.borderLeftWidth;
mirror.style.borderRightWidth = textareaStyles.borderRightWidth;
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.wordWrap = 'break-word';
mirror.style.overflow = 'hidden';
const newHeight = textarea.scrollHeight; const lineHeight = 24;
const minLines = 2;
const maxLines = 10;
const minHeight = minLines * lineHeight;
const maxHeight = maxLines * lineHeight;
const height = Math.min(Math.max(mirror.scrollHeight, minHeight), maxHeight);
inputHeight.value = `${height}px`;
});
};
if (newHeight > maxHeight) { watch(textAreaValue, resizeTextArea, { immediate: true });
inputHeight.value = `${maxHeight}px`;
} else {
inputHeight.value = `${newHeight}px`;
}
}, { immediate: true });
let hasCommandKey = false; let hasCommandKey = false;
if (import.meta.server) { if (import.meta.server) {
@@ -198,20 +290,34 @@ onBeforeMount(() => {
tempInput = (document.getElementById('chat') as HTMLInputElement)?.value ?? ''; tempInput = (document.getElementById('chat') as HTMLInputElement)?.value ?? '';
}); });
let resizeRafId: number | undefined;
const handleWindowResize = () => {
if (resizeRafId) return;
resizeRafId = requestAnimationFrame(() => {
resizeRafId = undefined;
resizeTextArea();
});
}
onMounted(() => { onMounted(() => {
textAreaValue.value = tempInput; textAreaValue.value = tempInput;
document.addEventListener('keydown', handleWindowKeyDown); document.addEventListener('keydown', handleWindowKeyDown);
window.addEventListener('resize', handleWindowResize);
inputRef.value?.addEventListener('paste', handlePaste); inputRef.value?.addEventListener('paste', handlePaste);
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('keydown', handleWindowKeyDown); document.removeEventListener('keydown', handleWindowKeyDown);
window.removeEventListener('resize', handleWindowResize);
if (resizeRafId !== undefined) cancelAnimationFrame(resizeRafId);
inputRef.value?.removeEventListener('paste', handlePaste); inputRef.value?.removeEventListener('paste', handlePaste);
}); });
</script> </script>
<template> <template>
<div :class="['w-full flex max-h-full', $attrs.class]"> <div :class="['w-full flex max-h-full', $attrs.class]">
<div ref="mirrorRef" aria-hidden="true"
class="absolute top-0 left-0 pointer-events-none invisible overflow-hidden h-0"></div>
<div class="relative w-full flex flex-shrink-1 flex-col gap-3 p-2 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--bg-container)] <div class="relative w-full flex flex-shrink-1 flex-col gap-3 p-2 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--bg-container)]
border-[var(--color-border)] focus-within:border-[var(--color-border-active)]"> border-[var(--color-border)] focus-within:border-[var(--color-border-active)]">
<div v-if="files.length > 0" class="flex-1 flex gap-2 pt-2 px-2 pb-1 overflow-x-auto flex-wrap"> <div v-if="files.length > 0" class="flex-1 flex gap-2 pt-2 px-2 pb-1 overflow-x-auto flex-wrap">
@@ -225,23 +331,28 @@ 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" />
<SearchSelector v-if="selectedModel?.capabilities.includes('tools')" :enabled="searchConfig.enabled"
:max-results="searchConfig.maxResults" :rerank="searchConfig.rerank"
@update:enabled="(v: boolean) => { searchConfig.enabled = v; saveSearchConfig() }"
@update:max-results="(v: number) => { searchConfig.maxResults = v; saveSearchConfig() }"
@update:rerank="(v: boolean) => { searchConfig.rerank = v; saveSearchConfig() }" />
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files" /> <FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files" />
</div> </div>
<button aria-label="Send message" @click="handleSubmit" <button aria-label="Send message" @click="handleSubmit"
: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>
+10 -5
View File
@@ -2,7 +2,7 @@
import { useAgents } from '~/composables/useAgents'; import { useAgents } from '~/composables/useAgents';
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue'; import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
const { agents } = useAgents(); const { agents } = await useAgents();
const route = useRoute(); const route = useRoute();
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
@@ -116,14 +116,19 @@ onMounted(() => { inputRef.value?.focus(); });
<template> <template>
<div <div
class="flex flex-col w-[100vw] max-w-4xl max-h-[60vh] bg-[var(--color-bg-elevated)] rounded-xl shadow-2xl border border-[var(--color-border)] overflow-hidden"> class="flex flex-col w-[100vw] max-w-4xl md:max-h-[60vh] bg-[var(--color-bg-elevated)] md:rounded-xl shadow-2xl border border-[var(--color-border)] overflow-hidden">
<!-- Search --> <!-- Search -->
<div class="p-4 border-b border-[var(--color-border)]"> <div class="flex p-4 w-full border-b border-[var(--color-border)]">
<input ref="inputRef" v-model="query" type="text" role="combobox" aria-autocomplete="list" <input ref="inputRef" v-model="query" type="text" role="combobox" aria-autocomplete="list"
aria-haspopup="listbox" :aria-expanded="flattenedResults.length > 0" :aria-controls="listboxId" aria-haspopup="listbox" :aria-expanded="flattenedResults.length > 0" :aria-controls="listboxId"
:aria-activedescendant="flattenedResults[selectedIndex]?.uiId" placeholder="Search agents and topics..." :aria-activedescendant="flattenedResults[selectedIndex]?.uiId" placeholder="Search agents and topics..."
class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent border-none outline-none text-lg text-[var(--text-primary)]" class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent border-none outline-none text-lg text-[var(--text-primary)]"
@keydown="handleKeyDown" /> @keydown="handleKeyDown" />
<button
class="p-2 flex items-center justify-center md:hidden rounded hover:bg-[var(--color-hover)] text-[var(--text-secondary)] transition-colors"
@click="emit('close')">
<span class="block i-mynaui-x-solid"></span>
</button>
</div> </div>
<div ref="navRef" <div ref="navRef"
@@ -134,7 +139,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 +153,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>
+48 -13
View File
@@ -6,7 +6,7 @@ import SystemAssistants from '~/components/Settings/SystemAssistants.vue';
import AppearanceSettings from '~/components/Settings/AppearanceSettings.vue'; import AppearanceSettings from '~/components/Settings/AppearanceSettings.vue';
import AIServiceProvider from '~/components/Settings/AIServiceProvider.vue'; import AIServiceProvider from '~/components/Settings/AIServiceProvider.vue';
const { providers } = useModels(); const { providers } = await useModels();
const PAGES_CONFIG = { const PAGES_CONFIG = {
general: { general: {
@@ -39,6 +39,21 @@ const props = defineProps<{
const { close, setOptions } = useDialog(); const { close, setOptions } = useDialog();
const mobileNavOpen = ref(false);
onMounted(() => {
window.addEventListener('resize', () => {
if (window.innerWidth >= 768) {
mobileNavOpen.value = false;
}
})
});
const handleNavigate = (page: string, params?: string) => {
setOptions({ page: page as keyof typeof PAGES_CONFIG, params });
mobileNavOpen.value = false;
};
const runtimePage = computed(() => { const runtimePage = computed(() => {
// 1. Get the base config (e.g., 'providers' or 'general') // 1. Get the base config (e.g., 'providers' or 'general')
const config = PAGES_CONFIG[props.page as keyof typeof PAGES_CONFIG] || PAGES_CONFIG.general; const config = PAGES_CONFIG[props.page as keyof typeof PAGES_CONFIG] || PAGES_CONFIG.general;
@@ -70,14 +85,27 @@ const runtimePage = computed(() => {
</script> </script>
<template> <template>
<div class="p-2 flex h-[70vh] h-[70dvh] w-[85vw]"> <div class="p-2 flex flex-col md:flex-row h-screen md:h-[70vh] md:h-[70dvh] w-screen md:w-[85vw]">
<nav class="w-64 flex flex-col gap-1 mr-2 overflow-y-auto"> <nav :class="[
<!-- If the page has a custom sidebar (for nested lists), show it; otherwise show default nav --> 'w-full md:w-64 flex-col gap-1 mr-2 overflow-y-auto text-xl md:text-base',
<component v-if="runtimePage?.sidebar" :is="runtimePage.sidebar" // Mobile: hidden by default, shown as fixed overlay
@navigate="(p: string, params?: string) => setOptions({ page: p, params })" :params="props.params" /> 'hidden md:flex',
mobileNavOpen ? '!flex fixed inset-y-0 left-0 z-15 bg-[var(--bg-base)] p-2 shadow-xl' : ''
]">
<!-- Mobile close button -->
<button
class="md:hidden flex items-center justify-center h-9 w-full rounded-lg @hover:bg-[var(--color-hover)] text-[var(--text-secondary)]"
@click="mobileNavOpen = false">
<span class="i-mynaui-x-solid mr-2"></span>
Close
</button>
<button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="setOptions({ page: id })" <!-- If the page has a custom sidebar (for nested lists), show it; otherwise show default nav -->
: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']"> <component v-if="runtimePage?.sidebar" :is="runtimePage.sidebar" @navigate="handleNavigate"
:params="props.params" />
<button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="handleNavigate(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']">
<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 }}
@@ -87,17 +115,24 @@ const runtimePage = computed(() => {
<!-- DYNAMIC CONTENT --> <!-- DYNAMIC CONTENT -->
<main <main
class="flex-1 flex flex-col overflow-y-hidden max-h-full h-full px-3 bg-[var(--bg-surface)] border rounded-lg border-[var(--color-border)]"> class="flex-1 flex flex-col overflow-y-hidden max-h-full h-full px-3 bg-[var(--bg-surface)] border rounded-none md:rounded-lg border-[var(--color-border)]">
<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> <div class="flex items-center gap-2">
<!-- Mobile menu button -->
<button
class="md:hidden flex items-center justify-center h-8 w-8 rounded-lg @hover:bg-[var(--color-hover)]"
@click="mobileNavOpen = true">
<span class="i-mynaui-menu text-5"></span>
</button>
<h2 class="text-lg font-semibold m-0 case-capital">{{ runtimePage.label }}</h2>
</div>
<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>
</header> </header>
<component @navigate="(p: string, params?: string) => setOptions({ page: p, params })" <component @navigate="handleNavigate" :is="runtimePage.component" :params="props.params" />
:is="runtimePage.component" :params="props.params" />
</main> </main>
</div> </div>
</template> </template>
+2 -2
View File
@@ -14,10 +14,10 @@ const text = ref(props.initialValue || '');
<textarea v-model="text" class="bg-[var(--bg-surface)] resize-none h-full w-full p-2 rounded" /> <textarea v-model="text" class="bg-[var(--bg-surface)] resize-none h-full w-full p-2 rounded" />
<div class="flex justify-end gap-2 m-2"> <div class="flex justify-end gap-2 m-2">
<button <button
class="px-2 py-1 rounded-lg border border-[var(--color-border)] hover:bg-[var(--color-hover)] transition-[background-color] duration-200" class="px-2 py-1 rounded-lg border border-[var(--color-border)] @hover:bg-[var(--color-hover)] transition-[background-color] duration-200"
@click="emit('cancel')">Cancel</button> @click="emit('cancel')">Cancel</button>
<button <button
class="px-2 py-1 rounded-lg bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)] transition-[background-color] duration-200" class="px-2 py-1 rounded-lg bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)] transition-[background-color] duration-200"
@click="emit('confirm', text)">Confirm</button> @click="emit('confirm', text)">Confirm</button>
</div> </div>
</div> </div>
+2 -2
View File
@@ -53,9 +53,9 @@ onUnmounted(() => {
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" <Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95 translate-y-2" leave-from-class="opacity-100 scale-100 translate-y-0" enter-from-class="opacity-0 scale-95 translate-y-2" leave-from-class="opacity-100 scale-100 translate-y-0"
enter-to-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 -translate-y-2"> enter-to-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 -translate-y-2">
<div v-if="open" class="z-50 fixed top-1/2 left-1/2 -translate-x-1/2 flex items-center justify-center"> <div v-if="open" class="z-50 fixed inset-0 md:inset-auto md:top-1/2 md:left-1/2 md:-translate-x-1/2 md:-translate-y-1/2 flex items-center justify-center">
<div <div
class="absolute max-w-6xl bg-[var(--bg-base)] rounded-2xl shadow-2xl border border-[var(--color-border)] flex"> class="absolute w-full h-full md:w-auto md:h-auto max-w-6xl bg-[var(--bg-base)] rounded-none md:rounded-2xl shadow-2xl border border-[var(--color-border)] flex">
<KeepAlive> <KeepAlive>
<Settings v-if="page === DialogType.Settings" :page="data.page" :params="data.params" /> <Settings v-if="page === DialogType.Settings" :page="data.page" :params="data.params" />
+10 -6
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useFloating, offset, flip, shift, autoUpdate, type Placement } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, type Placement, hide } from '@floating-ui/vue';
const props = defineProps<{ const props = defineProps<{
placement?: Placement; placement?: Placement;
@@ -8,7 +8,7 @@ const props = defineProps<{
}>(); }>();
const isOpen = ref(false); const isOpen = ref(false);
const triggerRef = ref(null); const triggerRef = ref<HTMLElement | null>(null);
const dropdownRef = ref(null); const dropdownRef = ref(null);
const setTriggerRef = (el: any) => { const setTriggerRef = (el: any) => {
@@ -18,10 +18,10 @@ const setTriggerRef = (el: any) => {
} }
}; };
const { floatingStyles, placement } = useFloating(triggerRef, dropdownRef, { const { floatingStyles, placement, middlewareData } = useFloating(triggerRef, dropdownRef, {
placement: props.placement ?? 'bottom-start', placement: props.placement ?? 'bottom-start',
whileElementsMounted: autoUpdate, whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 10 })], middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
transform: false, transform: false,
}); });
@@ -55,8 +55,12 @@ defineExpose({ close });
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100" enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95"> leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
<div v-if="isOpen" ref="dropdownRef" :style="floatingStyles" class="fixed z-9999" <div v-if="isOpen" ref="dropdownRef" :style="{
:class="[dropdownClass, transformOrigin]"> ...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}" class="fixed z-9999" :class="[dropdownClass, transformOrigin]">
<div v-click-outside="close" <div v-click-outside="close"
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1"> class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1">
<slot name="dropdown" :close="close" /> <slot name="dropdown" :close="close" />
+16 -23
View File
@@ -8,8 +8,6 @@ const props = defineProps<{
const imageInputRef = ref<HTMLInputElement | null>(null); const imageInputRef = ref<HTMLInputElement | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null); const fileInputRef = ref<HTMLInputElement | null>(null);
const { user } = useAuth();
const files = defineModel<{ const files = defineModel<{
id: string; id: string;
name: string; name: string;
@@ -20,8 +18,6 @@ const files = defineModel<{
}[]>({ required: false, default: [] }); }[]>({ required: false, default: [] });
const rawFiles = ref<File[]>([]); const rawFiles = ref<File[]>([]);
const triplit = useTriplitClient();
const activeUploads = ref<Map<string, XMLHttpRequest>>(new Map()); const activeUploads = ref<Map<string, XMLHttpRequest>>(new Map());
const uploadWithProgress = (file: File, url: string, id: string) => { const uploadWithProgress = (file: File, url: string, id: string) => {
@@ -78,23 +74,26 @@ 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', {
id, method: 'POST',
userId: user.value!.id, body: {
name: fileName, id,
mimeType: fileType, name: fileName,
url: assetUrl, mimeType: fileType,
}); size: file.size,
url: assetUrl,
},
})
files.value = files.value.map(f => { files.value = files.value.map(f => {
if (f.name === file.name) { if (f.name === file.name) {
@@ -148,12 +147,6 @@ watch(files, async (newFiles, oldFiles) => {
} }
if (removedFile.url.startsWith('blob:')) { if (removedFile.url.startsWith('blob:')) {
// we knpw that if the url starts with blob: it was the first time we uploaded it
// so we can just delete it
if (removedFile.status === 'uploaded') {
await triplit.delete('files', removedFile.id);
}
URL.revokeObjectURL(removedFile.url); URL.revokeObjectURL(removedFile.url);
} }
} }
@@ -185,21 +178,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>
+438
View File
@@ -0,0 +1,438 @@
<script setup lang="ts">
import { assert } from '~~/utils/assert';
type ViewerRect = {
top: number;
left: number;
width: number;
height: number;
};
type Phase = 'entering' | 'open' | 'leaving';
const props = defineProps<{
src: string;
alt?: string;
initialScale?: number;
originRect?: ViewerRect | null;
originElement?: HTMLImageElement | null;
}>();
const emit = defineEmits<{
close: [];
}>();
const MIN_SCALE = 0.5;
const MAX_SCALE = 12;
const ZOOM_STEP = 0.15;
const rootRef = ref<HTMLDivElement | null>(null);
const containerRef = ref<HTMLDivElement | null>(null);
const imageRef = ref<HTMLImageElement | null>(null);
const transitionImageRef = ref<HTMLImageElement | null>(null);
const phase = ref<Phase>(props.originRect ? 'entering' : 'open');
const transitionImageStyle = ref<Record<string, string>>({
position: 'fixed',
objectFit: 'contain',
pointerEvents: 'none',
});
const scale = ref(props.initialScale ?? 1);
const translateX = ref(0);
const translateY = ref(0);
const isDragging = ref(false);
const dragStart = ref({ x: 0, y: 0 });
const dragTranslateStart = ref({ x: 0, y: 0 });
const pointerDownTarget = ref<HTMLElement | null>(null);
const hasMoved = ref(false);
const imageStyle = computed(() => {
const { x, y } = clampTranslate(translateX.value, translateY.value);
return {
transform: `translate(${x}px, ${y}px) scale(${scale.value})`,
cursor: isDragging.value ? 'grabbing' : scale.value > 1 ? 'grab' : 'zoom-in',
};
});
// --- Geometry helpers ---
function getTranslateBounds() {
const image = imageRef.value;
if (!image) return { maxX: Infinity, maxY: Infinity };
const maxX = (image.offsetWidth * scale.value) / 2;
const maxY = (image.offsetHeight * scale.value) / 2;
return { maxX, maxY };
}
function clampTranslate(x: number, y: number) {
const { maxX, maxY } = getTranslateBounds();
return {
x: Math.min(maxX, Math.max(-maxX, x)),
y: Math.min(maxY, Math.max(-maxY, y)),
};
}
function zoomAroundPoint(newScale: number, x: number, y: number) {
if (phase.value !== 'open') return;
const clamped = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newScale));
if (clamped === scale.value) return;
const rect = containerRef.value!.getBoundingClientRect();
const mx = x - rect.left - rect.width / 2;
const my = y - rect.top - rect.height / 2;
const old = scale.value;
const diff = clamped - old;
translateX.value -= ((mx - translateX.value) / old) * diff;
translateY.value -= ((my - translateY.value) / old) * diff;
scale.value = clamped;
}
function applyPan(rawX: number, rawY: number) {
const clamped = clampTranslate(rawX, rawY);
translateX.value = clamped.x;
translateY.value = clamped.y;
}
function resetView() {
if (phase.value !== 'open') return;
scale.value = 1;
translateX.value = 0;
translateY.value = 0;
}
// --- Transition animations ---
function getValidRect(rect?: ViewerRect | DOMRect | null) {
if (!rect || rect.width <= 0 || rect.height <= 0) return null;
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
}
function getOriginRect() {
const live = props.originElement?.getBoundingClientRect();
return getValidRect(live) ?? getValidRect(props.originRect);
}
async function runTransition(
from: ViewerRect | DOMRect,
to: ViewerRect | DOMRect,
opts: { duration: number; easing: string; backdropFrom: number; backdropTo: number; borderRadiusFrom: string; borderRadiusTo: string },
) {
const transitionImage = transitionImageRef.value;
if (!transitionImage) return;
const imageAnim = transitionImage.animate([
{ top: `${from.top}px`, left: `${from.left}px`, width: `${from.width}px`, height: `${from.height}px`, borderRadius: opts.borderRadiusFrom },
{ top: `${to.top}px`, left: `${to.left}px`, width: `${to.width}px`, height: `${to.height}px`, borderRadius: opts.borderRadiusTo },
], { duration: opts.duration, easing: opts.easing, fill: 'forwards' });
const backdropAnim = rootRef.value?.animate(
[{ opacity: opts.backdropFrom }, { opacity: opts.backdropTo }],
{ duration: opts.duration - 20, easing: opts.backdropFrom === 0 ? 'ease-out' : 'ease-in', fill: 'forwards' },
);
await Promise.all([
imageAnim.finished.catch(() => undefined),
backdropAnim?.finished.catch(() => undefined),
]);
}
async function runOpenAnimation() {
const start = getValidRect(props.originRect);
if (!start || !imageRef.value) {
phase.value = 'open';
return;
}
transitionImageStyle.value = {
...transitionImageStyle.value,
top: `${start.top}px`, left: `${start.left}px`,
width: `${start.width}px`, height: `${start.height}px`,
borderRadius: '0.5rem',
};
await nextTick();
const end = getValidRect(imageRef.value.getBoundingClientRect());
if (!end) {
phase.value = 'open';
return;
}
await runTransition(start, end, {
duration: 240,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
backdropFrom: 0, backdropTo: 1,
borderRadiusFrom: '0.5rem', borderRadiusTo: '0rem',
});
phase.value = 'open';
}
async function requestClose() {
if (phase.value !== 'open') return;
isDragging.value = false;
hasMoved.value = false;
const start = imageRef.value ? getValidRect(imageRef.value.getBoundingClientRect()) : null;
const end = getOriginRect();
if (!start || !end) {
emit('close');
return;
}
phase.value = 'leaving';
transitionImageStyle.value = {
...transitionImageStyle.value,
top: `${start.top}px`, left: `${start.left}px`,
width: `${start.width}px`, height: `${start.height}px`,
borderRadius: '0rem',
};
await nextTick();
await runTransition(start, end, {
duration: 210,
easing: 'cubic-bezier(0.4, 0, 1, 1)',
backdropFrom: 1, backdropTo: 0,
borderRadiusFrom: '0rem', borderRadiusTo: '0.5rem',
});
emit('close');
}
// --- Interaction handlers ---
function handleWheel(e: WheelEvent) {
if (phase.value !== 'open') return;
const factor = e.deltaY > 0 ? (1 - ZOOM_STEP) : (1 + ZOOM_STEP);
zoomAroundPoint(scale.value * factor, e.clientX, e.clientY);
}
function startDrag(x: number, y: number) {
isDragging.value = true;
dragStart.value = { x, y };
dragTranslateStart.value = { x: translateX.value, y: translateY.value };
hasMoved.value = false;
}
function moveDrag(x: number, y: number) {
if (!isDragging.value) return;
const dx = x - dragStart.value.x;
const dy = y - dragStart.value.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) hasMoved.value = true;
applyPan(dragTranslateStart.value.x + dx, dragTranslateStart.value.y + dy);
}
function endDrag(upX: number, upY: number, upTarget: HTMLElement | null) {
if (phase.value !== 'open') return;
isDragging.value = false;
if (hasMoved.value) {
hasMoved.value = false;
pointerDownTarget.value = null;
return;
}
const startedOnImage = pointerDownTarget.value === imageRef.value;
const endedOnImage = upTarget === imageRef.value;
pointerDownTarget.value = null;
if (!startedOnImage && !endedOnImage) {
void requestClose();
return;
}
if (startedOnImage && endedOnImage) {
const dx = Math.abs(dragStart.value.x - upX);
const dy = Math.abs(dragStart.value.y - upY);
if (dx < 10 && dy < 10) {
if (scale.value <= 1) {
scale.value = scale.value === 1 ? 2 : 1;
translateX.value = 0;
translateY.value = 0;
}
}
}
}
function handlePointerDown(e: PointerEvent) {
if (phase.value !== 'open') return;
pointerDownTarget.value = e.target as HTMLElement;
startDrag(e.clientX, e.clientY);
e.preventDefault();
}
function handlePointerMove(e: PointerEvent) {
if (phase.value !== 'open') return;
moveDrag(e.clientX, e.clientY);
}
function handlePointerUp(e: PointerEvent) {
endDrag(e.clientX, e.clientY, e.target as HTMLElement);
}
function handlePointerLeave() {
if (isDragging.value) {
isDragging.value = false;
hasMoved.value = false;
pointerDownTarget.value = null;
}
}
function handleDoubleClick() {
if (phase.value !== 'open') return;
resetView();
}
// --- Touch (pinch zoom) ---
let lastTouchDistance = 0;
let lastTouchCenter = { x: 0, y: 0 };
let touchStartTime: number | null = null;
let touchTarget: HTMLElement | null = null;
function getTouchDistance(touches: TouchList) {
assert(touches.length >= 2);
const dx = touches[0]!.clientX - touches[1]!.clientX;
const dy = touches[0]!.clientY - touches[1]!.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function getTouchCenter(touches: TouchList) {
assert(touches.length >= 2);
return {
x: (touches[0]!.clientX + touches[1]!.clientX) / 2,
y: (touches[0]!.clientY + touches[1]!.clientY) / 2,
};
}
function handleTouchStart(e: TouchEvent) {
if (phase.value !== 'open') return;
touchTarget = e.target as HTMLElement;
touchStartTime = Date.now();
if (e.touches.length === 2) {
touchStartTime = null;
lastTouchDistance = getTouchDistance(e.touches);
lastTouchCenter = getTouchCenter(e.touches);
} else if (e.touches.length === 1 && scale.value > 1) {
startDrag(e.touches[0]!.clientX, e.touches[0]!.clientY);
}
}
function handleTouchMove(e: TouchEvent) {
if (phase.value !== 'open') return;
e.preventDefault();
if (e.touches.length === 2) {
const distance = getTouchDistance(e.touches);
const center = getTouchCenter(e.touches);
const factor = distance / lastTouchDistance;
zoomAroundPoint(scale.value * factor, center.x, center.y);
const pan = clampTranslate(
translateX.value + center.x - lastTouchCenter.x,
translateY.value + center.y - lastTouchCenter.y,
);
translateX.value = pan.x;
translateY.value = pan.y;
lastTouchDistance = distance;
lastTouchCenter = center;
} else if (e.touches.length === 1 && isDragging.value) {
moveDrag(e.touches[0]!.clientX, e.touches[0]!.clientY);
}
}
function handleTouchEnd(e: TouchEvent) {
if (phase.value !== 'open') return;
if (touchStartTime !== null && Date.now() - touchStartTime < 200) {
if (touchTarget !== imageRef.value && e.target !== imageRef.value) {
void requestClose();
return;
}
}
isDragging.value = false;
lastTouchDistance = 0;
}
// --- Keyboard ---
function zoomIn() { zoomAroundPoint(scale.value * (1 + ZOOM_STEP), window.innerWidth / 2, window.innerHeight / 2); }
function zoomOut() { zoomAroundPoint(scale.value * (1 - ZOOM_STEP), window.innerWidth / 2, window.innerHeight / 2); }
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { void requestClose(); e.preventDefault(); }
else if (e.key === '+' || e.key === '=') { zoomIn(); e.preventDefault(); }
else if (e.key === '-') { zoomOut(); e.preventDefault(); }
else if (e.key === '0') { resetView(); e.preventDefault(); }
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown);
void runOpenAnimation();
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown);
});
</script>
<template>
<Teleport to="body">
<div ref="rootRef" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 backdrop-blur-sm"
:style="{ opacity: phase === 'entering' ? 0 : 1 }">
<div ref="containerRef" class="relative w-full h-full overflow-hidden flex items-center justify-center"
@wheel.prevent="handleWheel" @pointerdown="handlePointerDown" @pointermove="handlePointerMove"
@pointerup="handlePointerUp" @pointerleave="handlePointerLeave" @dblclick="handleDoubleClick"
@touchstart.prevent="handleTouchStart" @touchmove.prevent="handleTouchMove" @touchend="handleTouchEnd">
<img ref="imageRef" :src="src" :alt="alt ?? 'Image'" :style="imageStyle"
class="max-w-full max-h-full select-none"
:class="phase === 'open' ? 'opacity-100' : 'opacity-0 pointer-events-none'" draggable="false" />
<img v-if="phase === 'entering' || phase === 'leaving'" ref="transitionImageRef" :src="src"
:alt="alt ?? 'Image'" class="select-none" :style="transitionImageStyle" draggable="false" />
</div>
<div
class="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-black/60 backdrop-blur-md rounded-full px-3 py-1.5 text-white text-sm">
<button @click="zoomOut"
class="w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/20 transition-colors"
title="Zoom out (-)">
<span class="i-mynaui-minus-solid text-4"></span>
</button>
<span class="w-14 text-center tabular-nums">{{ Math.round(scale * 100) }}%</span>
<button @click="zoomIn"
class="w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/20 transition-colors"
title="Zoom in (+)">
<span class="i-mynaui-plus-solid text-4"></span>
</button>
<div class="w-px h-5 bg-white/20 mx-1"></div>
<button @click="resetView"
class="w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/20 transition-colors"
title="Reset (0)">
<span class="i-tabler-zoom-reset text-4"></span>
</button>
</div>
<button @click="requestClose"
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full bg-black/60 backdrop-blur-md hover:bg-white/20 transition-colors text-white"
title="Close (Esc)">
<span class="i-mynaui-x-solid text-5"></span>
</button>
</div>
</Teleport>
</template>
+4 -3
View File
@@ -7,20 +7,21 @@ defineProps<{
const TITLE = 'Cerebras'; const TITLE = 'Cerebras';
const BACKGROUND_COLOR = "#F15A29"; const BACKGROUND_COLOR = "#FFF";
const AVATAR_SCALE = 0.8; const AVATAR_SCALE = 0.8;
</script> </script>
<template> <template>
<div class="inline-flex items-center justify-center" <div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']"> :style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
<svg fill="currentColor" fill-rule="evenodd" :height="size" style="flex: none; line-height: 1;" <svg :fill="avatar ? '#000' : 'currentColor'" fill-rule="evenodd" :height="size"
style="flex: none; line-height: 1;"
:style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']" :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
viewBox="0 0 24 24" :width="size" xmlns="http://www.w3.org/2000/svg"> viewBox="0 0 24 24" :width="size" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title> <title>{{ TITLE }}</title>
<path clipRule="evenodd" <path clipRule="evenodd"
d="M14.121 2.701a9.299 9.299 0 000 18.598V22.7c-5.91 0-10.7-4.791-10.7-10.701S8.21 1.299 14.12 1.299V2.7zm4.752 3.677A7.353 7.353 0 109.42 17.643l-.901 1.074a8.754 8.754 0 01-1.08-12.334 8.755 8.755 0 0112.335-1.08l-.901 1.075zm-2.255.844a5.407 5.407 0 00-5.048 9.563l-.656 1.24a6.81 6.81 0 016.358-12.043l-.654 1.24zM14.12 8.539a3.46 3.46 0 100 6.922v1.402a4.863 4.863 0 010-9.726v1.402z" d="M14.121 2.701a9.299 9.299 0 000 18.598V22.7c-5.91 0-10.7-4.791-10.7-10.701S8.21 1.299 14.12 1.299V2.7zm4.752 3.677A7.353 7.353 0 109.42 17.643l-.901 1.074a8.754 8.754 0 01-1.08-12.334 8.755 8.755 0 0112.335-1.08l-.901 1.075zm-2.255.844a5.407 5.407 0 00-5.048 9.563l-.656 1.24a6.81 6.81 0 016.358-12.043l-.654 1.24zM14.12 8.539a3.46 3.46 0 100 6.922v1.402a4.863 4.863 0 010-9.726v1.402z"
:fill="!avatar && color ? '#F15A29' : ''" :fillRule="!avatar && color ? 'evenodd' : ''" /> :fill="avatar || color ? '#F15A29' : ''" :fillRule="avatar || color ? 'evenodd' : ''" />
<path <path
d="M15.407 10.836a2.24 2.24 0 00-.51-.409 1.084 1.084 0 00-.544-.152c-.255 0-.483.047-.684.14a1.58 1.58 0 00-.84.912c-.074.203-.11.416-.11.631 0 .218.036.43.11.631a1.594 1.594 0 00.84.913c.2.093.43.14.684.14.216 0 .417-.046.602-.135.188-.09.35-.225.475-.392l.928 1.006c-.14.14-.3.261-.482.363a3.367 3.367 0 01-1.083.38c-.17.026-.317.04-.44.04a3.315 3.315 0 01-1.182-.21 2.825 2.825 0 01-.961-.597 2.816 2.816 0 01-.644-.929 2.987 2.987 0 01-.238-1.21c0-.444.08-.847.238-1.21.15-.35.368-.666.643-.929.278-.261.605-.464.962-.596a3.315 3.315 0 011.182-.21c.355 0 .712.068 1.072.204.361.138.685.36.944.649l-.962.97z" /> d="M15.407 10.836a2.24 2.24 0 00-.51-.409 1.084 1.084 0 00-.544-.152c-.255 0-.483.047-.684.14a1.58 1.58 0 00-.84.912c-.074.203-.11.416-.11.631 0 .218.036.43.11.631a1.594 1.594 0 00.84.913c.2.093.43.14.684.14.216 0 .417-.046.602-.135.188-.09.35-.225.475-.392l.928 1.006c-.14.14-.3.261-.482.363a3.367 3.367 0 01-1.083.38c-.17.026-.317.04-.44.04a3.315 3.315 0 01-1.182-.21 2.825 2.825 0 01-.961-.597 2.816 2.816 0 01-.644-.929 2.987 2.987 0 01-.238-1.21c0-.444.08-.847.238-1.21.15-.35.368-.666.643-.929.278-.261.605-.464.962-.596a3.315 3.315 0 011.182-.21c.355 0 .712.068 1.072.204.361.138.685.36.944.649l-.962.97z" />
</svg> </svg>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>();
const TITLE = 'ClosedRouter';
const BACKGROUND_COLOR = "#050816";
const AVATAR_SCALE = 0.7;
</script>
<template>
<div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
<svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title>
<g transform="translate(-10.909 -10)" stroke-width="2.1818">
<path
d="m16.364 14.364v6.5455c0 2.41 1.9537 4.3636 4.3636 4.3636h4.3636c2.41 0 4.3636 1.9537 4.3636 4.3636v0"
:stroke="color ? '#00e87a' : 'currentColor'" stroke-linecap="round" stroke-linejoin="round" />
<g :fill="BACKGROUND_COLOR">
<circle cx="16.364" cy="14.364" r="3.2727" :stroke="color ? '#00e87a' : 'currentColor'" />
<circle cx="29.455" cy="29.636" r="3.2727" :stroke="color ? '#00e87a' : 'currentColor'" />
<circle cx="29.455" cy="14.364" r="3.2727" :stroke="color ? '#7aa2ff' : 'currentColor'" />
</g>
<path d="m20.727 14.364h5.4545" :stroke="color ? '#7aa2ff' : 'currentColor'" stroke-linecap="round" />
</g>
</svg>
</div>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>();
const TITLE = 'HuggingFace';
const BACKGROUND_COLOR = "#FFF";
const AVATAR_SCALE = 0.75;
</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 v-if="color && !avatar"
: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="M2.25 11.535c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0c2.997 1.704 4.844 4.851 4.844 8.258 0 5.266-4.337 9.535-9.687 9.535S2.25 16.8 2.25 11.535z"
fill="#FF9D0B" />
<path
d="M11.938 20.086c4.797 0 8.687-3.829 8.687-8.551 0-4.722-3.89-8.55-8.687-8.55-4.798 0-8.688 3.828-8.688 8.55 0 4.722 3.89 8.55 8.688 8.55z"
fill="#FFD21E" />
<path
d="M11.875 15.113c2.457 0 3.25-2.156 3.25-3.263 0-.576-.393-.394-1.023-.089-.582.283-1.365.675-2.224.675-1.798 0-3.25-1.693-3.25-.586 0 1.107.79 3.263 3.25 3.263h-.003z"
fill="#FF323D" />
<path
d="M14.76 9.21c.32.108.445.753.767.585.447-.233.707-.708.659-1.204a1.235 1.235 0 00-.879-1.059 1.262 1.262 0 00-1.33.394c-.322.384-.377.92-.14 1.36.153.283.638-.177.925-.079l-.002.003zm-5.887 0c-.32.108-.448.753-.768.585a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003zm1.12 5.34a2.166 2.166 0 011.325-1.106c.07-.02.144.06.219.171l.192.306c.069.1.139.175.209.175.074 0 .15-.074.223-.172l.205-.302c.08-.11.157-.188.234-.165.537.168.986.536 1.25 1.026.932-.724 1.275-1.905 1.275-2.633 0-.508-.306-.426-.81-.19l-.616.296c-.52.24-1.148.48-1.824.48-.676 0-1.302-.24-1.823-.48l-.589-.283c-.52-.248-.838-.342-.838.177 0 .703.32 1.831 1.187 2.56l.18.14z"
fill="#3A3B45" />
<path
d="M17.812 10.366a.806.806 0 00.813-.8c0-.441-.364-.8-.813-.8a.806.806 0 00-.812.8c0 .442.364.8.812.8zm-11.624 0a.806.806 0 00.812-.8c0-.441-.364-.8-.812-.8a.806.806 0 00-.813.8c0 .442.364.8.813.8zM4.515 13.073c-.405 0-.765.162-1.017.46a1.455 1.455 0 00-.333.925 1.801 1.801 0 00-.485-.074c-.387 0-.737.146-.985.409a1.41 1.41 0 00-.2 1.722 1.302 1.302 0 00-.447.694c-.06.222-.12.69.2 1.166a1.267 1.267 0 00-.093 1.236c.238.533.81.958 1.89 1.405l.24.096c.768.3 1.473.492 1.478.494.89.243 1.808.375 2.732.394 1.465 0 2.513-.443 3.115-1.314.93-1.342.842-2.575-.274-3.763l-.151-.154c-.692-.684-1.155-1.69-1.25-1.912-.195-.655-.71-1.383-1.562-1.383-.46.007-.889.233-1.15.605-.25-.31-.495-.553-.715-.694a1.87 1.87 0 00-.993-.312zm14.97 0c.405 0 .767.162 1.017.46.216.262.333.588.333.925.158-.047.322-.071.487-.074.388 0 .738.146.985.409a1.41 1.41 0 01.2 1.722c.22.178.377.422.445.694.06.222.12.69-.2 1.166.244.37.279.836.093 1.236-.238.533-.81.958-1.889 1.405l-.239.096c-.77.3-1.475.492-1.48.494-.89.243-1.808.375-2.732.394-1.465 0-2.513-.443-3.115-1.314-.93-1.342-.842-2.575.274-3.763l.151-.154c.695-.684 1.157-1.69 1.252-1.912.195-.655.708-1.383 1.56-1.383.46.007.889.233 1.15.605.25-.31.495-.553.718-.694.244-.162.523-.265.814-.3l.176-.012z"
fill="#FF9D0B" />
<path
d="M9.785 20.132c.688-.994.638-1.74-.305-2.667-.945-.928-1.495-2.288-1.495-2.288s-.205-.788-.672-.714c-.468.074-.81 1.25.17 1.971.977.721-.195 1.21-.573.534-.375-.677-1.405-2.416-1.94-2.751-.532-.332-.907-.148-.782.541.125.687 2.357 2.35 2.14 2.707-.218.362-.983-.42-.983-.42S2.953 14.9 2.43 15.46c-.52.558.398 1.026 1.7 1.803 1.308.778 1.41.985 1.225 1.28-.187.295-3.07-2.1-3.34-1.083-.27 1.011 2.943 1.304 2.745 2.006-.2.7-2.265-1.324-2.685-.537-.425.79 2.913 1.718 2.94 1.725 1.075.276 3.813.859 4.77-.522zm4.432 0c-.687-.994-.64-1.74.305-2.667.943-.928 1.493-2.288 1.493-2.288s.205-.788.675-.714c.465.074.807 1.25-.17 1.971-.98.721.195 1.21.57.534.377-.677 1.407-2.416 1.94-2.751.532-.332.91-.148.782.541-.125.687-2.355 2.35-2.137 2.707.215.362.98-.42.98-.42S21.05 14.9 21.57 15.46c.52.558-.395 1.026-1.7 1.803-1.308.778-1.408.985-1.225 1.28.187.295 3.07-2.1 3.34-1.083.27 1.011-2.94 1.304-2.743 2.006.2.7 2.263-1.324 2.685-.537.423.79-2.912 1.718-2.94 1.725-1.077.276-3.815.859-4.77-.522z"
fill="#FFD21E" />
</svg>
<svg v-else fill="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});` : '']"
viewBox="0 0 24 24" :width="size" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title>
<path
d="M16.781 3.277c2.997 1.704 4.844 4.851 4.844 8.258 0 .995-.155 1.955-.443 2.857a1.332 1.332 0 011.125.4 1.41 1.41 0 01.2 1.723c.204.165.352.385.428.632l.017.062c.06.222.12.69-.2 1.166.244.37.279.836.093 1.236-.255.57-.893 1.018-2.128 1.5l-.202.078-.131.048c-.478.173-.89.295-1.061.345l-.086.024c-.89.243-1.808.375-2.732.394-1.32 0-2.3-.36-2.923-1.067a9.852 9.852 0 01-3.18.018C9.778 21.647 8.802 22 7.494 22a11.249 11.249 0 01-2.541-.343l-.221-.06-.273-.08a16.574 16.574 0 01-1.175-.405c-1.237-.483-1.875-.93-2.13-1.501-.186-.4-.151-.867.093-1.236a1.42 1.42 0 01-.2-1.166c.069-.273.226-.516.447-.694a1.41 1.41 0 01.2-1.722c.233-.248.557-.391.917-.407l.078-.001a9.385 9.385 0 01-.44-2.85c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0zM4.188 14.758c.125.687 2.357 2.35 2.14 2.707-.19.315-.796-.239-.948-.386l-.041-.04-.168-.147c-.561-.479-2.304-1.9-2.74-1.432-.43.46.119.859 1.055 1.42l.784.467.136.083c1.045.643 1.12.84.95 1.113-.188.295-3.07-2.1-3.34-1.083-.27 1.011 2.942 1.304 2.744 2.006-.2.7-2.265-1.324-2.685-.537-.425.79 2.913 1.718 2.94 1.725l.16.04.175.042c1.227.284 3.565.65 4.435-.604.673-.973.64-1.709-.248-2.61l-.057-.057c-.945-.928-1.495-2.288-1.495-2.288l-.017-.058-.025-.072c-.082-.22-.284-.639-.63-.584-.46.073-.798 1.21.12 1.933l.05.038c.977.721-.195 1.21-.573.534l-.058-.104-.143-.25c-.463-.799-1.282-2.111-1.739-2.397-.532-.332-.907-.148-.782.541zm14.842-.541c-.533.335-1.563 2.074-1.94 2.751a.613.613 0 01-.687.302.436.436 0 01-.176-.098.303.303 0 01-.049-.06l-.014-.028-.008-.02-.007-.019-.003-.013-.003-.017a.289.289 0 01-.004-.048c0-.12.071-.266.25-.427.026-.024.054-.047.084-.07l.047-.036c.022-.016.043-.032.063-.049.883-.71.573-1.81.131-1.917l-.031-.006-.056-.004a.368.368 0 00-.062.006l-.028.005-.042.014-.039.017-.028.015-.028.019-.036.027-.023.02c-.173.158-.273.428-.31.542l-.016.054s-.53 1.309-1.439 2.234l-.054.054c-.365.358-.596.69-.702 1.018-.143.437-.066.868.21 1.353.055.097.117.195.187.296.882 1.275 3.282.876 4.494.59l.286-.07.25-.074c.276-.084.736-.233 1.2-.42l.188-.077.065-.028.064-.028.124-.056.081-.038c.529-.252.964-.543.994-.827l.001-.036a.299.299 0 00-.037-.139c-.094-.176-.271-.212-.491-.168l-.045.01c-.044.01-.09.024-.136.04l-.097.035-.054.022c-.559.23-1.238.705-1.607.745h.006a.452.452 0 01-.05.003h-.024l-.024-.003-.023-.005c-.068-.016-.116-.06-.14-.142a.22.22 0 01-.005-.1c.062-.345.958-.595 1.713-.91l.066-.028c.528-.224.97-.483.985-.832v-.04a.47.47 0 00-.016-.098c-.048-.18-.175-.251-.36-.251-.785 0-2.55 1.36-2.92 1.36-.025 0-.048-.007-.058-.024a.6.6 0 01-.046-.088c-.1-.238.068-.462 1.06-1.066l.209-.126c.538-.32 1.01-.588 1.341-.831.29-.212.475-.406.503-.6l.003-.028c.008-.113-.038-.227-.147-.344a.266.266 0 00-.07-.054l-.034-.015-.013-.005a.403.403 0 00-.13-.02c-.162 0-.369.07-.595.18-.637.313-1.431.952-1.826 1.285l-.249.215-.033.033c-.08.078-.288.27-.493.386l-.071.037-.041.019a.535.535 0 01-.122.036h.005a.346.346 0 01-.031.003l.01-.001-.013.001c-.079.005-.145-.021-.19-.095a.113.113 0 01-.014-.065c.027-.465 2.034-1.991 2.152-2.642l.009-.048c.1-.65-.271-.817-.791-.493zM11.938 2.984c-4.798 0-8.688 3.829-8.688 8.55 0 .692.083 1.364.24 2.008l.008-.009c.252-.298.612-.46 1.017-.46.355.008.699.117.993.312.22.14.465.384.715.694.261-.372.69-.598 1.15-.605.852 0 1.367.728 1.562 1.383l.047.105.06.127c.192.396.595 1.139 1.143 1.68 1.06 1.04 1.324 2.115.8 3.266a8.865 8.865 0 002.024-.014c-.505-1.12-.26-2.17.74-3.186l.066-.066c.695-.684 1.157-1.69 1.252-1.912.195-.655.708-1.383 1.56-1.383.46.007.889.233 1.15.605.25-.31.495-.553.718-.694a1.87 1.87 0 01.99-.312c.357 0 .682.126.925.36.14-.61.215-1.245.215-1.898 0-4.722-3.89-8.55-8.687-8.55zm1.857 8.926l.439-.212c.553-.264.89-.383.89.152 0 1.093-.771 3.208-3.155 3.262h-.184c-2.325-.052-3.116-2.06-3.156-3.175l-.001-.087c0-1.107 1.452.586 3.25.586.716 0 1.379-.272 1.917-.526zm4.017-3.143c.45 0 .813.358.813.8 0 .441-.364.8-.813.8a.806.806 0 01-.812-.8c0-.442.364-.8.812-.8zm-11.624 0c.448 0 .812.358.812.8 0 .441-.364.8-.812.8a.806.806 0 01-.813-.8c0-.442.364-.8.813-.8zm7.79-.841c.32-.384.846-.54 1.33-.394.483.146.83.564.878 1.06.048.495-.212.97-.659 1.203-.322.168-.447-.477-.767-.585l.002-.003c-.287-.098-.772.362-.925.079a1.215 1.215 0 01.14-1.36zm-4.323 0c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003c-.108.036-.194.134-.273.24l-.118.165c-.11.15-.22.262-.377.18a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394z" />
</svg>
</div>
</template>
+2 -2
View File
@@ -15,8 +15,8 @@ const BACKGROUND_COLOR = "#fff";
<div class="inline-flex items-center justify-center" <div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']"> :style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
<svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']" <svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
fill="currentColor" fill-rule="evenodd" style="flex: none; line-height: 1;" viewBox="0 0 24 24" :fill="avatar ? '#000' : 'currentColor'" fill-rule="evenodd" style="flex: none; line-height: 1;"
xmlns="http://www.w3.org/2000/svg"> viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title> <title>{{ TITLE }}</title>
<path <path
d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z" /> d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z" />
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>();
const TITLE = 'Poolside';
const BACKGROUND_COLOR = "#4137FF";
const AVATAR_SCALE = 0.7;
const [a, b, c, d] = useFillIds(TITLE, 4);
</script>
<template>
<div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
<!-- TODO: scale to 24px -->
<svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
style="flex: none; line-height: 1;" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title>
<mask :id="d!.id" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="32" height="32">
<rect width="32" height="32" :fill="a!.fill" style="" />
<rect width="32" height="32" :fill="b!.fill" style="" />
<rect width="32" height="32" :fill="c!.fill" style="" />
</mask>
<g :mask="d!.fill">
<path
d="M8.98976 30.3815C6.02045 28.9329 3.60852 26.6447 2.01266 23.7643C0.453682 20.9498 -0.219858 17.775 0.0629571 14.5827C0.126988 13.8674 0.757216 13.3392 1.47289 13.4024C2.18722 13.466 2.71622 14.0968 2.65309 14.8126C2.41613 17.4865 2.98128 20.1467 4.28757 22.5053C5.41219 24.536 7.023 26.2153 8.9891 27.4192L15.1043 14.8784C12.7106 14.0812 10.7169 14.4282 10.5375 14.4631C10.5112 14.4692 10.4858 14.4735 10.4599 14.4786C9.86386 14.5784 9.28735 14.2527 9.05527 13.7111C8.73499 13.1131 7.77341 11.6515 6.60959 11.0837C5.44577 10.516 3.65213 10.6991 3.07471 10.8246C2.58789 10.931 2.08122 10.7494 1.77371 10.358C1.4662 9.9665 1.40726 9.43259 1.62554 8.98496C5.48998 1.05529 15.087 -2.24965 23.0153 1.61816C30.9437 5.48598 34.2453 15.0756 30.3875 23.003C30.3836 23.0111 30.3796 23.0192 30.3752 23.0282C26.504 30.949 16.9145 34.2476 8.98976 30.3815ZM17.44 16.0179L11.3253 28.5578C17.4968 30.8631 24.5196 28.3019 27.731 22.4803C27.2796 21.7817 26.4875 20.78 25.5709 20.3328C24.3864 19.7549 22.6509 19.9442 22.0547 20.0695C21.9566 20.0928 21.8588 20.1041 21.7604 20.105C21.5842 20.1059 21.4054 20.071 21.2339 19.9962C21.056 19.9183 20.8952 19.8009 20.7654 19.6497C20.6912 19.5623 20.6299 19.4667 20.5806 19.3648C20.5422 19.2882 19.5939 17.4481 17.4391 16.0175L17.44 16.0179ZM7.74737 8.74582C8.74313 9.2316 9.5548 10.0037 10.1608 10.7321C12.0348 7.86545 14.578 5.70023 16.5425 4.3042C17.2726 3.78579 18.0284 3.30111 18.7563 2.88291C13.6429 1.80923 8.28375 3.83577 5.1764 8.10245C5.99889 8.15876 6.90528 8.33501 7.74737 8.74582ZM24.6415 5.75507C24.7602 6.58628 24.8441 7.47954 24.8847 8.37503C24.9943 10.7762 24.8555 14.1022 23.7588 17.3369C24.6735 17.3614 25.7372 17.5232 26.7105 17.998C27.5795 18.4219 28.3081 19.0633 28.8814 19.7035C30.3453 14.6184 28.6441 9.13194 24.6411 5.75597L24.6415 5.75507ZM17.4489 13.0336C19.2472 13.9109 20.5471 15.078 21.4282 16.0842C22.8614 11.427 22.2366 6.21527 21.7149 4.28506C19.873 5.06221 15.3815 7.77795 12.5957 11.7753C13.9322 11.8499 15.6515 12.1568 17.4489 13.0336Z"
:fill="!avatar && color ? '#4137FF' : 'currentColor'" style="fill-opacity:1;" />
</g>
<defs>
<linearGradient :id="a!.id" x1="9" y1="27.4" x2="7.5" y2="27.4" gradientUnits="userSpaceOnUse">
<stop style="stop-color:black;stop-opacity:1;" />
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;" />
</linearGradient>
<linearGradient :id="b!.id" x1="9.1" y1="15.8" x2="9.6" y2="14.85" gradientUnits="userSpaceOnUse">
<stop stop-opacity="0" style="stop-color:none;stop-opacity:0;" />
<stop offset="1" style="stop-color:black;stop-opacity:1;" />
</linearGradient>
<linearGradient :id="c!.id" x1="1.3" y1="27.35" x2="1.3" y2="14.55" gradientUnits="userSpaceOnUse">
<stop style="stop-color:black;stop-opacity:1;" />
<stop offset="0.105" stop-opacity="0.9" style="stop-color:black;stop-opacity:0.9;" />
<stop offset="0.903766" stop-opacity="0.04" style="stop-color:black;stop-opacity:0.04;" />
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;" />
</linearGradient>
</defs>
</svg>
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>();
const TITLE = 'Tencent';
const BACKGROUND_COLOR = "#0052D9";
const AVATAR_SCALE = 0.75;
</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="M9.976 1L24 9.8l-10.587.015L10.723 23H5.489L8.18 9.8H3.244L1 5.4h8.077L9.976 1z"
:fill="color && !avatar ? '#0052D9' : 'currentColor'" fillRule="evenodd" />
</svg>
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>();
const TITLE = 'vLLM';
const BACKGROUND_COLOR = "#fff";
const AVATAR_SCALE = 0.6;
</script>
<template>
<div class="inline-flex items-center justify-center"
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
<svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title>
<path d="M0 4.973h9.324V23L0 4.973z" :fill="color ? '#FDB515' : ''" />
<path d="M13.986 4.351L22.378 0l-6.216 23H9.324l4.662-18.649z" :fill="color ? '#30A2FF' : ''" />
</svg>
</div>
</template>
+27 -4
View File
@@ -5,16 +5,31 @@ const props = defineProps<{ code: string; language: string }>();
const start = Date.now(); const start = Date.now();
const MAX_LENGTH = 15000;
const renderId = `${useId()}-${hashSync(props.code + props.language)}`; const renderId = `${useId()}-${hashSync(props.code + props.language)}`;
const copied = ref(false); const copied = ref(false);
const collapsed = ref(false); const collapsed = ref(false);
const showFull = ref(false);
const isTruncated = computed(() => props.code.length > MAX_LENGTH);
const displayCode = computed(() => isTruncated.value && !showFull.value ? props.code.slice(0, MAX_LENGTH) : props.code);
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}kB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
}
const fullSizeFormatted = computed(() => formatSize(props.code.length));
const { $shiki } = useNuxtApp(); const { $shiki } = useNuxtApp();
const { data: parsed, clear } = await useAsyncData(`shiki-${renderId}`, const { data: parsed, clear } = await useAsyncData(`shiki-${renderId}`,
() => parseCode(props.code, props.language.toLowerCase()), () => parseCode(displayCode.value, props.language.toLowerCase()),
{ {
watch: [() => props.code], watch: [displayCode],
dedupe: 'defer' dedupe: 'defer'
} }
); );
@@ -72,15 +87,23 @@ console.log("shiki codeblock rendered in", Date.now() - start);
{{ parsed?.displayLang }} {{ parsed?.displayLang }}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button v-if="isTruncated && !showFull" @click="showFull = true"
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)]">
Show all ({{ fullSizeFormatted }})
</button>
<button v-else-if="isTruncated" @click="showFull = false"
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)]">
Collapse
</button>
<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>
+34 -9
View File
@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Entity } from '@triplit/client'; import type { MessagePart } from '~/composables/useChat';
import type schema from '#triplit/schema';
const props = defineProps<{ const props = defineProps<{
part: Readonly<Entity<typeof schema, 'message_parts'>>; part: Readonly<MessagePart>;
}>(); }>();
const reasoningOpen = ref(!props.part.finished); const reasoningOpen = ref(!props.part.finished);
@@ -30,6 +29,12 @@ const handleScroll = () => {
const topThreshold = 0.02 * clientHeight; const topThreshold = 0.02 * clientHeight;
if (scrollHeight <= clientHeight) {
// the container does not have enough content to scroll
scrollState.value = '';
return;
}
if (scrollTop <= topThreshold) { if (scrollTop <= topThreshold) {
scrollState.value = 'top'; scrollState.value = 'top';
} else if (scrollTop + 100 >= scrollHeight - clientHeight) { } else if (scrollTop + 100 >= scrollHeight - clientHeight) {
@@ -48,28 +53,48 @@ const toggleReasoning = async () => {
scrollToBottom('instant'); scrollToBottom('instant');
handleScroll(); handleScroll();
}; };
const formatDuration = (ms: number) => {
const seconds = ms / 1000;
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${(seconds % 60).toFixed(1)}s`;
} else if (minutes > 0) {
return `${minutes}m ${(seconds % 60).toFixed(1)}s`;
} else {
return `${seconds.toFixed(1)}s`;
}
}
</script> </script>
<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">
<span <span
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center"> class="w-6 h-6 flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
<span class="i-mynaui-atom text-2.5 text-[var(--reasoning-accent)]"></span> <span class="i-mynaui-atom text-3 text-[var(--reasoning-accent)]"></span>
</span>
<span v-if="!part.finished" class="animate-pulse">
Deep Thinking
</span>
<span v-else>
Deeply Thought (in {{ formatDuration(new Date(part.lastUpdatedAt).getTime() - new
Date(part.createdAt).getTime()) }})
</span> </span>
Deep Thinking
</span> </span>
<span class="i-mynaui-chevron-down w-4 h-4 text-[var(--text-secondary)]" <span class="i-mynaui-chevron-down w-4 h-4 text-[var(--text-secondary)]"
:class="reasoningOpen ? '' : '-rotate-90'"></span> :class="reasoningOpen ? '' : '-rotate-90'"></span>
</button> </button>
<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-container max-h-[min(40vh,320px)] overflow-y-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]', scrollState]">
<div class="p-2"> <div class="p-2">
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" /> <MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content!" />
</div> </div>
</div> </div>
</div> </div>
+3 -4
View File
@@ -1,12 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Entity } from '@triplit/client'; import type { MessagePart } from '~/composables/useChat';
import type schema from '#triplit/schema';
const props = defineProps<{ const props = defineProps<{
part: Readonly<Entity<typeof schema, 'message_parts'>>; part: Readonly<MessagePart>;
}>(); }>();
</script> </script>
<template> <template>
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" /> <MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content!" />
</template> </template>
+2 -3
View File
@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Entity } from '@triplit/client'; import type { ToolCall } from '~/composables/useChat';
import type schema from '#triplit/schema';
const props = defineProps<{ const props = defineProps<{
toolCall: Readonly<Entity<typeof schema, 'tool_calls'>>; toolCall: Readonly<ToolCall>;
}>(); }>();
const activeTab = ref('input'); const activeTab = ref('input');
+4 -5
View File
@@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Entity } from '@triplit/client'; import type { ToolCall } from '~/composables/useChat';
import type schema from '#triplit/schema';
import Debug from './Debug.vue'; import Debug from './Debug.vue';
const props = defineProps<{ const props = defineProps<{
toolCall: Readonly<Entity<typeof schema, 'tool_calls'>>; toolCall: Readonly<ToolCall>;
}>(); }>();
const deubgToolCallOpen = ref(false); const deubgToolCallOpen = ref(false);
@@ -17,7 +16,7 @@ const toggleDebugToolCall = () => {
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div <div
class="select-none w-full hover:bg-[var(--color-hover)] group rounded-lg p-1 flex justify-between items-center text-[--text-tertiary] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="select-none w-full @hover:bg-[var(--color-hover)] group rounded-lg p-1 flex justify-between items-center text-[--text-tertiary] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div <div
@@ -34,7 +33,7 @@ const toggleDebugToolCall = () => {
<div <div
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<button @click="toggleDebugToolCall" <button @click="toggleDebugToolCall"
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden hover:bg-[var(--color-hover)] flex items-center justify-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden @hover:bg-[var(--color-hover)] flex items-center justify-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-search w-3 h-3 text-[var(--text-secondary)]"></span> <span class="i-mynaui-search w-3 h-3 text-[var(--text-secondary)]"></span>
</button> </button>
</div> </div>
+2 -2
View File
@@ -25,7 +25,7 @@ const toggleTokenDropdown = () => {
<template> <template>
<div class="flex flex-col w-full gap-2"> <div class="flex flex-col w-full gap-2">
<div v-for="part in message.parts" :key="part.id"> <div v-for="part in message.parts?.filter(p => p.content?.trim() !== '' || p.toolCall)" :key="part.id">
<Reasoning v-if="part.type === 'reasoning'" :part="part" /> <Reasoning v-if="part.type === 'reasoning'" :part="part" />
<Text v-else-if="part.type === 'text'" :part="part" /> <Text v-else-if="part.type === 'text'" :part="part" />
<Tool v-else-if="part.type === 'tool-call'" :toolCall="part.toolCall!" /> <Tool v-else-if="part.type === 'tool-call'" :toolCall="part.toolCall!" />
@@ -54,7 +54,7 @@ const toggleTokenDropdown = () => {
</div> </div>
<button ref="triggerRef" @click="toggleTokenDropdown" <button ref="triggerRef" @click="toggleTokenDropdown"
class="flex gap-2 hover:bg-[var(--color-hover)] px-1 rounded transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="flex gap-2 @hover:bg-[var(--color-hover)] px-1 rounded transition-colors duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="flex gap-1 items-center" v-if="message.generation.tokens?.output"> <span class="flex gap-1 items-center" v-if="message.generation.tokens?.output">
<span class="i-tabler-coins"></span> <span class="i-tabler-coins"></span>
{{ message.generation?.tokens?.output }} {{ message.generation?.tokens?.output }}
@@ -1,16 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
const tokenDetailsDropdownRef = ref<HTMLElement | null>(null); const tokenDetailsDropdownRef = ref<HTMLElement | null>(null);
const { triggerElement, activeGeneration: generation, close, isOpen } = useTokenDropdown(); const { triggerElement, activeGeneration: generation, close, isOpen } = useTokenDropdown();
const { floatingStyles } = useFloating(triggerElement, tokenDetailsDropdownRef, { const { floatingStyles, middlewareData } = useFloating(triggerElement, tokenDetailsDropdownRef, {
placement: 'bottom-end', placement: 'bottom-end',
whileElementsMounted: autoUpdate, whileElementsMounted: autoUpdate,
middleware: [ middleware: [
offset(4), offset(4),
flip(), flip(),
shift({ padding: 10 }), shift({ padding: 10 }),
hide(),
], ],
transform: false, transform: false,
}); });
@@ -57,7 +58,12 @@ const formatNumber = (num: number | null | undefined) => {
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95"> leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
<div v-if="isOpen && generation && generation.tokens" v-click-outside="close" ref="tokenDetailsDropdownRef" <div v-if="isOpen && generation && generation.tokens" v-click-outside="close" ref="tokenDetailsDropdownRef"
:style="floatingStyles" :style="{
...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}"
class="absolute z-50 flex flex-col min-w-[280px] bg-[var(--bg-surface)] rounded-md border border-[var(--color-border)] shadow-2xl text-[var(--text-secondary)] font-sans"> class="absolute z-50 flex flex-col min-w-[280px] bg-[var(--bg-surface)] rounded-md border border-[var(--color-border)] shadow-2xl text-[var(--text-secondary)] font-sans">
<!-- Output Details Section --> <!-- Output Details Section -->
+5 -6
View File
@@ -1,21 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Entity } from '@triplit/client'; import * as schema from '~~/drizzle/schema';
import type schema from '#triplit/schema';
defineProps<{ defineProps<{
message: Readonly<Entity<typeof schema, 'messages'> & { attachments: Entity<typeof schema, 'attachments'>[] }>; message: Readonly<typeof schema.messages.$inferSelect & { attachments: (typeof schema.attachments.$inferSelect & { file: typeof schema.files.$inferSelect })[] }>;
}>(); }>();
</script> </script>
<template> <template>
<div class="flex flex-col gap-2 max-w-full bg-[var(--bg-container)] py-2 px-3 rounded-xl"> <div class="flex flex-col gap-2 max-w-full bg-[var(--bg-container)] py-2 px-3 rounded-xl">
<MarkdownRenderer :finished="true" :content="message.content!" :id="message.id" /> <MarkdownRenderer v-if="message.content" :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>
+94 -63
View File
@@ -1,70 +1,73 @@
<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, { }>();
focusedIndex: newValue
}); let reqAbortController: AbortController | null = null;
watch(() => message.activeChildId, async (newValue) => {
if (reqAbortController) {
reqAbortController.abort();
reqAbortController = null;
} }
reqAbortController = new AbortController();
await $fetch(`/api/messages/${message.id}`, {
method: 'PATCH',
body: {
activeChildId: newValue
},
signal: reqAbortController.signal,
});
}); });
watch(() => message.children.length, (newCount, oldCount) => { watch(() => message.children.length, (newCount, oldCount) => {
if (newCount === oldCount) return; if (newCount === oldCount) return;
// if we are deleting children, only move the focus index if its no longer valid if (newCount === 0) {
// e.g. if we we have 4 children, and are focused on the 2nd, if we delete it, emit('patch', { activeChildId: null });
// we want to keep the focus on the 2nd index. It just makes me feel better return;
}
if (oldCount > newCount) { if (oldCount > newCount) {
if (message.deleted === true && focusedIndex.value === newCount) { const activeChild = message.children.find(c => c.id === message.activeChildId);
focusedIndex.value = Math.max(0, newCount - 1); if (!activeChild) {
return; emit('patch', { activeChildId: message.children[newCount - 1]?.id ?? null });
} }
if (focusedIndex.value > newCount) {
focusedIndex.value = newCount;
}
return; return;
} }
if (message.deleted === true) { const newChild = message.children[newCount - 1];
focusedIndex.value = newCount - 1; if (newChild) {
return; emit('patch', { activeChildId: newChild.id });
} }
focusedIndex.value = newCount;
}); });
const activeMessage = computed(() => { const activeMessage = computed(() => {
if (message.children.length === 0 || (focusedIndex.value === 0 && !message.deleted)) { if (message.activeChildId && message.children.length > 0) {
return message; const child = message.children.find(c => c.id === message.activeChildId);
if (child) return child;
} }
if (message.deleted === true) { if (message.deleted === true && message.children.length > 0) {
return message.children[Math.min(focusedIndex.value, message.children.length - 1)]; return message.children[0];
} }
return message.children[focusedIndex.value - 1]; return message;
}); });
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 +94,40 @@ 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}`, {
content: res.data method: 'PATCH',
body: {
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) { emit('delete');
focusedIndex.value = Math.max(0, focusedIndex.value - 1);
}
nextTick(() => {
emit('delete');
});
}; };
const messageCount = computed(() => { const messageCount = computed(() => {
@@ -127,6 +137,27 @@ const messageCount = computed(() => {
return message.children.length + 1; return message.children.length + 1;
}); });
const currentChildIndex = computed(() => {
if (!message.activeChildId) return 0;
const idx = message.children.findIndex(c => c.id === message.activeChildId);
return idx >= 0 ? idx + 1 : 0;
});
const navigateChild = (direction: -1 | 1) => {
const allItems = message.deleted === true
? message.children
: [null, ...message.children];
const currentIdx = allItems.findIndex(item =>
item === null ? !message.activeChildId : item.id === message.activeChildId
);
const newIdx = Math.max(0, Math.min(allItems.length - 1, currentIdx + direction));
const newItem = allItems[newIdx];
emit('patch', { activeChildId: newItem?.id ?? null });
};
</script> </script>
<template> <template>
@@ -138,21 +169,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="currentChildIndex === 0" :hotkey="['alt', '[']">
<button @click="focusedIndex = focusedIndex! - 1" <button @click="navigateChild(-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="currentChildIndex === 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 }} {{ currentChildIndex + 1 }} / {{ messageCount }}
</span> </span>
<Tooltip :inert="focusedIndex + 1 === messageCount" :hotkey="['alt', ']']"> <Tooltip :inert="currentChildIndex + 1 === messageCount" :hotkey="['alt', ']']">
<button @click="focusedIndex = focusedIndex + 1" <button @click="navigateChild(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="currentChildIndex + 1 === messageCount ? 'opacity-0' : ''"></span>
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
@@ -162,21 +193,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>
+25 -25
View File
@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Model } from '~/types/model'; import { type Model } from '~/composables/useModels';
const { updateModel, deleteModel } = await useModels();
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
model: Model; model: Model;
@@ -22,8 +24,6 @@ const emit = defineEmits<{
edit: [model: Model]; edit: [model: Model];
}>(); }>();
const triplit = useTriplitClient();
const formatContextWindow = (window: number | null | undefined): string => { const formatContextWindow = (window: number | null | undefined): string => {
if (!window) return ''; if (!window) return '';
if (window >= 1000000) return `${(window / 1000000).toFixed(0)}M`; if (window >= 1000000) return `${(window / 1000000).toFixed(0)}M`;
@@ -31,28 +31,16 @@ const formatContextWindow = (window: number | null | undefined): string => {
return window.toString(); return window.toString();
}; };
const hasCapability = (capability: string): boolean => {
return props.model.attributes.capabilities.has(capability);
};
const hasInputModality = (modality: string): boolean => {
return (props.model.attributes.inputModalities as ReadonlySet<string>).has(modality);
};
const showCost = computed(() => { const showCost = computed(() => {
return props.showCost && (props.model.cost.prompt || props.model.cost.completion || props.model.cost.request); return props.showCost && (props.model.cost.prompt || props.model.cost.completion || props.model.cost.request);
}); });
const toggleModel = async (id: string) => { const toggleModel = async (id: string) => {
await triplit.update('models', id, { await updateModel(id, {
enabled: !props.model.enabled, enabled: !props.model.enabled,
}); });
}; };
const deleteModel = async () => {
await triplit.delete('models', props.model.id);
};
const handleEdit = () => { const handleEdit = () => {
emit('edit', props.model); emit('edit', props.model);
}; };
@@ -79,11 +67,11 @@ const handleEdit = () => {
<div v-if="showEdit" class="flex items-center gap-2"> <div v-if="showEdit" class="flex items-center gap-2">
<!-- Edit button - only for custom models --> <!-- Edit button - only for custom models -->
<button v-if="model.isCustom" @click="handleEdit" <button v-if="model.isCustom" @click="handleEdit"
class="rounded h-5 w-5 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-[var(--color-accent)]/30 text-[var(--color-accent)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="rounded h-5 w-5 flex items-center justify-center opacity-0 group-hover:opacity-100 @hover:bg-[var(--color-accent)]/30 text-[var(--color-accent)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-pencil text-3.5"></span> <span class="i-mynaui-pencil text-3.5"></span>
</button> </button>
<button @click="deleteModel" <button @click="deleteModel(model.id)"
class="rounded h-5 w-5 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-red-500/30 text-red-500 transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="rounded h-5 w-5 flex items-center justify-center opacity-0 group-hover:opacity-100 @hover:bg-red-500/30 text-red-500 transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-trash text-3.5"></span> <span class="i-mynaui-trash text-3.5"></span>
</button> </button>
</div> </div>
@@ -91,7 +79,11 @@ const handleEdit = () => {
<div v-if="details" class="flex items-center gap-1.5 flex-wrap text-[var(--text-tertiary)]"> <div v-if="details" class="flex items-center gap-1.5 flex-wrap text-[var(--text-tertiary)]">
<span v-if="showReleaseDate && model.releasedAt" class="text-xs whitespace-nowrap"> <span v-if="showReleaseDate && model.releasedAt" class="text-xs whitespace-nowrap">
Released on {{ model.releasedAt.toISOString().split('T')[0] }} Released on {{
typeof model.releasedAt === 'string'
? (model.releasedAt as string).split('T')[0]
: model.releasedAt.toISOString().split('T')[0]
}}
</span> </span>
<template v-if="showCost"> <template v-if="showCost">
<span v-if="model.cost.prompt" class="text-xs whitespace-nowrap flex items-center"> <span v-if="model.cost.prompt" class="text-xs whitespace-nowrap flex items-center">
@@ -114,23 +106,31 @@ 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.inputModalities.includes('video')" title="Video Input"
class="w-4.5 h-4.5 bg-pink/10 rounded flex items-center justify-center">
<span class="i-mynaui-video text-2.5 text-pink"></span>
</div>
<div v-if="model.inputModalities.includes('audio')" title="Audio Input"
class="w-4.5 h-4.5 bg-orange/10 rounded flex items-center justify-center">
<span class="i-mynaui-volume-high text-2.5 text-orange"></span>
</div>
<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)" />
+21 -17
View File
@@ -1,14 +1,13 @@
<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 { DialogType } from '~/composables/useDialog';
import type schema from '#triplit/schema'; import type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels';
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 +27,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 +42,7 @@ const { floatingStyles, placement } = useFloating(dropdownButton, dropdownConten
}, },
padding: 10, padding: 10,
}), }),
hide()
], ],
transform: false, transform: false,
}); });
@@ -65,8 +65,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 +102,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 +212,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 w-fit 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 +236,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 +263,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 +279,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 +294,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>
+13 -22
View File
@@ -21,17 +21,6 @@ const updateScrollMargin = () => {
} }
}; };
const resizeObserver = new ResizeObserver(updateScrollMargin);
onMounted(() => {
resizeObserver.observe(containerRef.value!);
updateScrollMargin();
});
onUnmounted(() => {
resizeObserver.disconnect();
});
const rowVirtualizer = useVirtualizer(computed(() => ({ const rowVirtualizer = useVirtualizer(computed(() => ({
count: props.items.length, count: props.items.length,
getScrollElement: () => props.scrollElement, getScrollElement: () => props.scrollElement,
@@ -39,22 +28,25 @@ const rowVirtualizer = useVirtualizer(computed(() => ({
overscan: props.overscan, overscan: props.overscan,
scrollMargin: scrollMargin.value, scrollMargin: scrollMargin.value,
getItemKey: (index: number) => props.keyField ? props.items[index]?.[props.keyField] || index : index, 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 virtualRows = computed(() => rowVirtualizer.value.getVirtualItems());
const totalSize = computed(() => rowVirtualizer.value.getTotalSize()); const totalSize = computed(() => rowVirtualizer.value.getTotalSize());
const measureElement = (el: Element) => { onMounted(() => {
if (!el) { window.addEventListener('resize', updateScrollMargin);
return updateScrollMargin();
} });
rowVirtualizer.value.measureElement(el) onUnmounted(() => {
} window.removeEventListener('resize', updateScrollMargin);
});
const measureElement = (el: any) => {
if (el) {
rowVirtualizer.value.measureElement(el);
}
};
watch(() => props.items, () => { watch(() => props.items, () => {
rowVirtualizer.value.measure(); rowVirtualizer.value.measure();
@@ -69,7 +61,6 @@ watch(() => props.items, () => {
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start - scrollMargin}px)`, transform: `translateY(${virtualRow.start - scrollMargin}px)`,
}"> }">
<slot :item="props.items[virtualRow.index]" :index="virtualRow.index" /> <slot :item="props.items[virtualRow.index]" :index="virtualRow.index" />
+1 -1
View File
@@ -2,7 +2,7 @@
import { useVirtualizer } from '@tanstack/vue-virtual'; import { useVirtualizer } from '@tanstack/vue-virtual';
const props = defineProps<{ const props = defineProps<{
items: any[]; items: readonly any[] | any[];
keyField?: string; keyField?: string;
scrollElement: HTMLElement | null; scrollElement: HTMLElement | null;
itemSize: number; itemSize: number;
+140
View File
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { DialogType } from '~/composables/useDialog';
const { openDialog } = await useDialog();
const props = defineProps<{
enabled: boolean;
maxResults: number;
rerank: boolean;
}>();
const emit = defineEmits<{
'update:enabled': [value: boolean];
'update:maxResults': [value: number];
'update:rerank': [value: boolean];
}>();
const adjustMaxResults = (delta: number) => {
const next = Math.min(50, Math.max(1, props.maxResults + delta));
emit('update:maxResults', next);
};
const { settings } = await useUserSettings();
const systemAssistantsRerank = computed(() => {
return settings.value.systemAssistants?.rerank ?? null;
});
const isRerankConfigured = computed(() => {
const sa = systemAssistantsRerank.value;
return sa?.enabled === true && sa?.modelId != null && sa.modelId !== '';
});
const openSettings = () => {
openDialog(DialogType.Settings, undefined, { page: 'systemAssistants' });
};
</script>
<template>
<Dropdown dropdownClass="text-sm" placement="top">
<template #default="{ toggle, setRef }">
<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)]">
<span class="pointer-events-none i-tabler-world text-5 transition-colors duration-200"
:class="enabled ? 'text-[var(--color-accent)]' : 'text-[var(--text-secondary)]'"></span>
</button>
</template>
<template #dropdown="{ close }">
<div class="flex p-1 gap-4">
<!-- Left: mode toggle group -->
<div class="flex flex-col gap-1">
<button @click="emit('update:enabled', false)"
class="flex items-start gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-150"
:class="!enabled
? 'bg-[var(--color-active)]'
: '@hover:bg-[var(--color-hover)]'">
<span class="i-tabler-world-off text-5 mt-0.5 shrink-0"
:class="!enabled ? 'text-[var(--text-primary)]' : 'text-[var(--text-dim)]'"></span>
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium"
:class="!enabled ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
Off
</span>
<span class="text-xs leading-snug"
:class="!enabled ? 'text-[var(--text-secondary)]' : 'text-[var(--text-dim)]'">
Disable web access
</span>
</div>
</button>
<button @click="emit('update:enabled', true)"
class="flex items-start gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-150"
:class="enabled
? 'bg-[var(--color-active)]'
: '@hover:bg-[var(--color-hover)]'">
<span class="i-tabler-world text-5 mt-0.5 shrink-0"
:class="enabled ? 'text-[var(--color-accent)]' : 'text-[var(--text-dim)]'"></span>
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium"
:class="enabled ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
Auto
</span>
<span class="text-xs leading-snug"
:class="enabled ? 'text-[var(--text-secondary)]' : 'text-[var(--text-dim)]'">
Search the web automatically when needed
</span>
</div>
</button>
</div>
<!-- Right: settings (only when enabled) -->
<div v-if="enabled" class="flex flex-col gap-3 border-l border-[var(--color-border)] pl-4">
<!-- Max results stepper -->
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
Max results
</span>
<div class="flex items-center gap-2">
<button @click="adjustMaxResults(-1)"
class="h-7 w-7 flex items-center justify-center rounded-lg @hover:bg-[var(--color-hover)] text-[var(--text-secondary)] @hover:text-[var(--text-primary)] transition-colors duration-150">
<span class="text-sm i-mynaui-minus"></span>
</button>
<span class="w-8 text-center text-sm font-medium tabular-nums text-[var(--text-primary)]">
{{ maxResults }}
</span>
<button @click="adjustMaxResults(1)"
class="h-7 w-7 flex items-center justify-center rounded-lg @hover:bg-[var(--color-hover)] text-[var(--text-secondary)] @hover:text-[var(--text-primary)] transition-colors duration-150">
<span class="text-sm i-mynaui-plus-solid"></span>
</button>
</div>
</div>
<!-- Rerank toggle -->
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-[var(--color-tertiary)] uppercase tracking-wider">
Rerank
</span>
<div v-if="!isRerankConfigured"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)]">
<button @click="() => { openSettings(); close() }"
class="flex items-center gap-2 text-xs text-[var(--text-dim)] hover:text-[var(--text-primary)] transition-colors duration-150">
<span class="i-mynaui-sparkles text-3.5"></span>
<span>Setup reranking</span>
<span class="i-mynaui-arrow-right text-3 ml-auto"></span>
</button>
</div>
<label v-else
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] cursor-pointer @hover:border-[var(--color-hover)] transition-colors duration-150">
<input type="checkbox" :checked="props.rerank"
@change="emit('update:rerank', ($event.target as HTMLInputElement).checked)"
class="w-4 h-4 rounded border-[var(--color-border)] bg-transparent text-[var(--color-accent)] focus:ring-[var(--color-accent)] focus:ring-offset-0 cursor-pointer" />
<span class="text-xs text-[var(--text-secondary)]">Rerank results</span>
</label>
</div>
</div>
</div>
</template>
</Dropdown>
</template>
+148 -114
View File
@@ -1,16 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { sortByReleaseDate } from '~/utils/sort'; import { sortByReleaseDate } from '~/utils/sort';
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto'; import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
import { providerBaseUrls, type Model } from '~/types/model'; import { 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);
@@ -32,10 +31,10 @@ const modelSearch = ref('');
watch(() => props.params, () => { watch(() => props.params, () => {
if (props.params === undefined) return; if (props.params === undefined) return;
console.log(scrollContainerRef.value);
apiKey.value = provider.value?.config.apiKey ?? ''; apiKey.value = provider.value?.config.apiKey ?? '';
apiProxyUrl.value = provider.value?.config.apiProxyUrl ?? ''; apiProxyUrl.value = provider.value?.config.apiProxyUrl ?? '';
modelSearch.value = ''; modelSearch.value = '';
apiKeyVisible.value = false;
nextTick(() => { nextTick(() => {
if (scrollContainerRef.value) { if (scrollContainerRef.value) {
@@ -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) {
config: { clearTimeout(apiKeyTimeout);
...provider.value.config, }
apiKey: uint8ArrayToBase64(encypted),
}, apiKeyTimeout = setTimeout(async () => {
}); await updateProvider(provider.value!.id, {
config: {
...provider.value!.config,
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) {
config: { clearTimeout(proxyUrlTimeout);
...provider.value.config, }
apiProxyUrl: value,
}, proxyUrlTimeout = setTimeout(async () => {
}); await updateProvider(provider.value!.id, {
config: {
...provider.value!.config,
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;
enabled: true
}))); await $fetch(`/api/provider/${provider.value.id}/models`, {
method: 'PATCH',
body: {
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;
enabled: false
}))); await $fetch(`/api/provider/${provider.value.id}/models`, {
method: 'PATCH',
body: {
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: formData.value.capabilities.filter(c => c !== 'vision') as any,
capabilities: new Set(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 as Model);
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.defaultBaseUrl || ''"
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)" />
@@ -410,11 +422,33 @@ defineEmits(['navigate']);
<h4 class="whitespace-nowrap m-0 flex gap-x-2 items-start"> <h4 class="whitespace-nowrap m-0 flex gap-x-2 items-start">
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
class="p-0.5 hover:bg-[var(--color-hover)] rounded transition-colors duration-200" <Dropdown placement="bottom">
@click="deleteModels"> <template #default="{ toggle, setRef }">
<span class="i-mynaui-x-solid"></span> <button :ref="setRef" @click="toggle"
</button> class="p-0.5 @hover:bg-[var(--color-hover)] rounded transition-colors duration-200">
<span class="i-mynaui-x-solid"></span>
</button>
</template>
<template #dropdown="{ close }">
<div class="px-3 py-2.5 w-64 flex flex-col gap-3">
<p class="text-sm text-[var(--text-primary)] m-0 leading-snug text-center">
Are you sure you want to delete all models? This will remove
<strong>all</strong> fetched models for this provider
</p>
<div class="flex gap-2 justify-end">
<button @click="close()"
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 border border-[var(--color-border)] @hover:border-transparent">
Cancel
</button>
<button @click="deleteModels(); close()"
class="px-3 py-1.5 text-sm bg-red-500 text-white rounded-md @hover:bg-red-600 transition-colors duration-200">
Delete
</button>
</div>
</div>
</template>
</Dropdown>
</span> </span>
</h4> </h4>
@@ -423,40 +457,40 @@ 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-solid text-5"></span>
</button> </button>
<div class="relative"> <div class="relative">
<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 +515,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 +552,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 +599,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 +629,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 +643,7 @@ defineEmits(['navigate']);
</span> </span>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<RowVirtualizerDynamic :items="disabledModels" key-field="id" <RowVirtualizerDynamic :items="disabledModels" key-field="id"
:scroll-element="scrollContainerRef" :min-item-size="64" :overscan="20"> :scroll-element="scrollContainerRef" :min-item-size="68" :overscan="20">
<template v-slot="{ item: model }"> <template v-slot="{ item: model }">
<ModelItem :model="model" @edit="openEditPanel" /> <ModelItem :model="model" @edit="openEditPanel" />
</template> </template>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const { colorScheme, settings, updateSettings } = useUserSettings(); const { colorScheme, settings, updateSettings } = await useUserSettings();
const accents = ['violet', 'volcano', 'lime', 'sky', 'coral', 'emerald', 'amber', 'rose', 'cyan', 'indigo', 'magenta']; const accents = ['violet', 'volcano', 'lime', 'sky', 'coral', 'emerald', 'amber', 'rose', 'cyan', 'indigo', 'magenta'];
const neutrals = ['zinc', 'slate', 'obsidian']; const neutrals = ['zinc', 'slate', 'obsidian'];
@@ -27,19 +27,19 @@ defineEmits(['navigate']);
<h4 class="font-medium">Theme</h4> <h4 class="font-medium">Theme</h4>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="updateSettings({ appearance: { colorScheme: 'system' } })" <button @click="updateSettings({ appearance: { colorScheme: 'system' } })"
class="flex items-center gap-1 px-1 rounded-md hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" class="flex items-center gap-1 px-1 rounded-md @hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="colorScheme.preference.value === 'system' ? 'bg-[var(--color-hover)]' : ''"> :class="colorScheme.preference.value === 'system' ? 'bg-[var(--color-hover)]' : ''">
<span class="i-tabler-device-desktop text-4"></span> <span class="i-tabler-device-desktop text-4"></span>
<span>System</span> <span>System</span>
</button> </button>
<button @click="updateSettings({ appearance: { colorScheme: 'dark' } })" <button @click="updateSettings({ appearance: { colorScheme: 'dark' } })"
class="flex items-center gap-1 px-1 rounded-md hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" class="flex items-center gap-1 px-1 rounded-md @hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="colorScheme.preference.value === 'dark' ? 'bg-[var(--color-hover)]' : ''"> :class="colorScheme.preference.value === 'dark' ? 'bg-[var(--color-hover)]' : ''">
<span class="i-mynaui-moon text-4"></span> <span class="i-mynaui-moon text-4"></span>
<span>Dark</span> <span>Dark</span>
</button> </button>
<button @click="updateSettings({ appearance: { colorScheme: 'light' } })" <button @click="updateSettings({ appearance: { colorScheme: 'light' } })"
class="flex items-center gap-1 px-1 rounded-md hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" class="flex items-center gap-1 px-1 rounded-md @hover:bg-[var(--color-hover)] transition-[background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="colorScheme.preference.value === 'light' ? 'bg-[var(--color-hover)]' : ''"> :class="colorScheme.preference.value === 'light' ? 'bg-[var(--color-hover)]' : ''">
<span class="i-mynaui-sun text-4"></span> <span class="i-mynaui-sun text-4"></span>
<span>Light</span> <span>Light</span>
@@ -51,7 +51,7 @@ defineEmits(['navigate']);
<h4 class="text-sm font-medium">Accent Color</h4> <h4 class="text-sm font-medium">Accent Color</h4>
<div class="grid grid-cols-5 gap-2"> <div class="grid grid-cols-5 gap-2">
<button v-for="accent in accents" :key="accent" @click="updateAccent(accent)" <button v-for="accent in accents" :key="accent" @click="updateAccent(accent)"
class="h-8 rounded border-2 hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]" class="h-8 rounded border-2 @hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]"
:class="[ :class="[
settings.appearance.accent === accent ? 'dark:border-white/70 border-black/70' : 'border-transparent' settings.appearance.accent === accent ? 'dark:border-white/70 border-black/70' : 'border-transparent'
]" :style="`background-color: var(--accent-${accent})`" :title="accent" /> ]" :style="`background-color: var(--accent-${accent})`" :title="accent" />
@@ -62,9 +62,9 @@ defineEmits(['navigate']);
<h4 class="text-sm font-medium">Neutral Color</h4> <h4 class="text-sm font-medium">Neutral Color</h4>
<div class="grid grid-cols-5 gap-2"> <div class="grid grid-cols-5 gap-2">
<button v-for="neutral in neutrals" :key="neutral" @click="updateNeutral(neutral)" <button v-for="neutral in neutrals" :key="neutral" @click="updateNeutral(neutral)"
class="h-8 rounded border-2 hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]" class="h-8 rounded border-2 @hover:scale-105 active:scale-95 transition-all duration-200 ease-[cubic-bezier(0.33,_1,_0.68,_1)]"
:class="[ :class="[
settings.appearance.neutral === neutral ? 'border-[var(--color-accent)]' : 'border-transparent hover:border-zinc-500' settings.appearance.neutral === neutral ? 'border-[var(--color-accent)]' : 'border-transparent @hover:border-zinc-500'
]" :style="`background-color: var(--palette-${neutral}-200)`" :title="neutral" /> ]" :style="`background-color: var(--palette-${neutral}-200)`" :title="neutral" />
</div> </div>
</div> </div>
+1 -1
View File
@@ -14,7 +14,7 @@ const handleEdit = (model: Model) => {
<template> <template>
<div <div
class="p-3 text-white flex max-w-full items-center justify-between gap-2 group hover:bg-[var(--color-hover)] transition duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="min-h-17 p-3 text-white flex max-w-full items-center justify-between gap-2 group @hover:bg-[var(--color-hover)] transition duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<ModelInfo :details="true" :model="model" :show-edit="true" :show-cost="true" :show-external-id="true" <ModelInfo :details="true" :model="model" :show-edit="true" :show-cost="true" :show-external-id="true"
:show-release-date="true" @edit="handleEdit" /> :show-release-date="true" @edit="handleEdit" />
</div> </div>
+4 -31
View File
@@ -1,9 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Providers } from '~/types/model';
import { providerIcons } from '~/utils/model-mapping'; import { providerIcons } from '~/utils/model-mapping';
const triplit = useTriplitClient(); const { providers, updateProvider } = await useModels();
const { providers } = useModels();
const props = defineProps<{ const props = defineProps<{
params?: string; params?: string;
@@ -11,32 +9,11 @@ const props = defineProps<{
if (providers.value === undefined) throw new Error('Providers not loaded'); if (providers.value === undefined) throw new Error('Providers not loaded');
// TODO: sometimes this code can create duplicate providers
const { user } = useAuth();
for (const provider of Providers) {
if (!providers.value?.find(p => p.type === provider)) {
// create a new provider
await triplit.insert('providers', {
name: provider,
userId: user.value!.id,
type: provider,
enabled: false,
config: {},
});
}
}
for (const provider of providers.value) {
if (!Providers.includes(provider.type)) {
await triplit.delete('providers', provider.id);
}
}
const toggleProvider = async (id: string) => { const toggleProvider = async (id: string) => {
const provider = providers.value!.find(p => p.id === id); const provider = providers.value!.find(p => p.id === id);
if (!provider) return; if (!provider) return;
await triplit.update('providers', provider.id, { await updateProvider(provider.id, {
enabled: !provider.enabled, enabled: !provider.enabled,
}); });
}; };
@@ -56,7 +33,7 @@ defineEmits(['navigate']);
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]"> class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => p.enabled)" <button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => p.enabled)"
:key="p.id" :key="p.id"
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-active)]"> class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]">
<div class="flex flex-col flex-grow"> <div class="flex flex-col flex-grow">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]" <component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
@@ -66,8 +43,6 @@ defineEmits(['navigate']);
<hr class="border-t border-[var(--color-border)]" /> <hr class="border-t border-[var(--color-border)]" />
</div> </div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<!-- <input type="checkbox"
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-border)]" /> -->
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" /> <Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
</div> </div>
</button> </button>
@@ -82,7 +57,7 @@ defineEmits(['navigate']);
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]"> class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => !p.enabled)" <button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => !p.enabled)"
:key="p.id" :key="p.id"
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-active)]"> class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]">
<div class="flex flex-col flex-grow"> <div class="flex flex-col flex-grow">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]" <component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
@@ -92,8 +67,6 @@ defineEmits(['navigate']);
<hr class="border-t border-[var(--color-border)]" /> <hr class="border-t border-[var(--color-border)]" />
</div> </div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<!-- <input type="checkbox"
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-border)]" /> -->
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" /> <Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
</div> </div>
</button> </button>
+11 -11
View File
@@ -4,7 +4,7 @@ import { providerIcons } from '~/utils/model-mapping';
defineProps<{ defineProps<{
params?: string; params?: string;
}>(); }>();
const { providers } = useModels(); const { providers } = await useModels();
defineEmits(['navigate']); defineEmits(['navigate']);
</script> </script>
@@ -12,34 +12,34 @@ 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 @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 md: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 @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 md: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 md:text-xs text-sm 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="h-6 w-6 md:w-4 md:h-4 text-[var(--text-primary)]" />
<span>{{ p.name }}</span> <span>{{ p.name }}</span>
</div> </div>
</button> </button>
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Disabled Providers</div> <div class="px-2 py-4 font-bold md:text-xs text-sm uppercase opacity-50">Disabled Providers</div>
<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="h-6 w-6 md:w-4 md:h-4 h-full text-[var(--text-primary)]" />
<span>{{ p.name }}</span> <span>{{ p.name }}</span>
</div> </div>
</button> </button>
+5 -5
View File
@@ -5,8 +5,8 @@ const props = defineProps<{
params?: string; params?: string;
}>(); }>();
const { providers, allModels } = useModels(); const { providers, allModels } = await useModels();
const { settings, updateSettings } = useUserSettings(); const { settings, updateSettings } = await useUserSettings();
const toggle = async (key: string) => { const toggle = async (key: string) => {
const current = (settings.value.systemAssistants as any)[key]; const current = (settings.value.systemAssistants as any)[key];
@@ -39,7 +39,6 @@ const getModel = (id: string | null | undefined) => {
} }
defineEmits(['navigate']); defineEmits(['navigate']);
</script> </script>
<template> <template>
@@ -48,10 +47,11 @@ defineEmits(['navigate']);
<div class="flex flex-col" v-for="(systemAssistant, key) in settings.systemAssistants" :key="key"> <div class="flex flex-col" v-for="(systemAssistant, key) in settings.systemAssistants" :key="key">
<label :for="`slider-${key}`" class="flex justify-between gap-4 items-center"> <label :for="`slider-${key}`" class="flex justify-between gap-4 items-center">
<h4 class="case-capital">{{ key }}</h4> <h4 class="case-capital">{{ key }}</h4>
<Slider :id="`slider-${key}`" :checked="systemAssistant.enabled" @click="toggle(String(key))" /> <Slider :id="`slider-${key}`" :checked="systemAssistant?.enabled ?? false"
@click="toggle(String(key))" />
</label> </label>
<div class="flex-1 justify-between gap-4 items-center"> <div class="flex-1 justify-between gap-4 items-center">
<ModelSelector :providers="providers" :model-value="getModel(systemAssistant.modelId)" <ModelSelector :providers="providers" :model-value="getModel(systemAssistant?.modelId)"
@update:model-value="(model) => updateModel(String(key), model)" /> @update:model-value="(model) => updateModel(String(key), model)" />
</div> </div>
</div> </div>
+10 -12
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { assert } from '~~/utils/assert'; import { DialogType } from '~/composables/useDialog';
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 +22,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,20 +30,21 @@ 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-9 h-9 md:w-7 md:h-7 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" />
<span v-else class="i-mynaui-user text-4 text-[var(--text-secondary)]"></span> <span v-else class="w-5.5 h-5.5 md:h-4 md:w-4 i-mynaui-user text-[var(--text-secondary)]"></span>
</div> </div>
<span <span
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap"> class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
{{ cachedUser?.name }} {{ cachedUser?.name }}
</span> </span>
<div :class="['transform-origin-left-center flex-shrink-0 w-4 h-4 text-[var(--text-secondary)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left', <div :class="['transform-origin-left-center flex-shrink-0 w-5 h-5 md:w-4 md:h-4 text-[var(--text-secondary)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left',
isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-40' isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-40'
]"> ]">
<span class="i-mynaui-chevron-down inline-block text-4 transition-transform duration-250 ease-in-out" <span
class="i-mynaui-chevron-down inline-block text-5 md:text-4 transition-transform duration-250 ease-in-out"
:class="dropdownOpen ? 'rotate-180' : ''"></span> :class="dropdownOpen ? 'rotate-180' : ''"></span>
</div> </div>
</button> </button>
@@ -58,13 +56,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>
+16 -12
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { navigateTo } from '#app';
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);
@@ -10,32 +11,35 @@ const { isHovered } = useSidenavContext();
<template> <template>
<header class="flex items-center rounded-lg overflow-hidden flex-1 min-w-0"> <header class="flex items-center rounded-lg overflow-hidden flex-1 min-w-0">
<div :style="isHovered ? 'width: 32px;' : 'width: 0px;'" <div :class="[
: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']"> 'w-10 flex flex-shrink-0 opacity-100 scale-100 transform-origin-left-center items-center overflow-hidden transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
isHovered ? 'md:w-8' : 'md:w-0 md:opacity-0 md: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-5.5 md:text-4.5"></span>
</NuxtLink> </NuxtLink>
</div> </div>
<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-9 h-9 md:w-7 md:h-7 flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-border)]']">
<img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl" <img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl"
class="w-full h-full object-cover" /> class="w-full h-full object-cover" />
<span v-else class="h-4 w-4 i-mynaui-check-hexagon text-[var(--color-accent)]"></span> <span v-else
class="w-5.5 h-5.5 md:h-4 md:w-4 i-mynaui-check-hexagon text-[var(--color-accent)]"></span>
</div> </div>
<span <span
class="min-w-0 text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap"> class="min-w-0 md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
{{ activeAgent?.name }} {{ activeAgent?.name }}
</span> </span>
<div class="shrink-0 w-4 h-4 text-[var(--text-secondary)]"> <div class="shrink-0 w-5 h-5 md:w-4 md:h-4 text-[var(--text-secondary)]">
<span <span
class="i-mynaui-chevron-up-down inline-block text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"></span> class="i-mynaui-chevron-up-down inline-block text-5 md:text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"></span>
</div> </div>
</div> </div>
</button> </button>
@@ -43,7 +47,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>
@@ -0,0 +1,22 @@
<script setup lang="ts">
const { isHovered } = useSidenavContext();
</script>
<template>
<header class="flex items-center rounded-lg overflow-hidden flex-1 min-w-0">
<div class="w-10 md:w-8 flex flex-shrink-0 items-center">
<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">
<span class="i-mynaui-home text-5.5 md:text-4.5"></span>
</NuxtLink>
</div>
<div class="flex items-center gap-1 pr-1">
<span class="i-mynaui-chevron-right inline-block text-5 md:text-4"></span>
<span
class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-secondary)] whitespace-nowrap">
Resources
</span>
</div>
</header>
</template>
+8 -8
View File
@@ -13,14 +13,14 @@ 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-9 w-9 md:h-7 md:w-7 flex items-center justify-center">
<span class="text-4.5" :class="props.icon"></span> <span class="text-5.5 md:text-4.5" :class="props.icon"></span>
</div> </div>
<div class="flex justify-between items-center w-full"> <div class="flex justify-between items-center w-full">
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name <span class="md:text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
}}</span> }}</span>
<slot /> <slot />
</div> </div>
@@ -31,14 +31,14 @@ 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-9 w-9 md:h-7 md:w-7 flex items-center justify-center">
<span class="text-4.5" :class="props.icon"></span> <span class="text-5.5 md:text-4.5" :class="props.icon"></span>
</div> </div>
<div class="flex justify-between items-center w-full"> <div class="flex justify-between items-center w-full">
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name <span class="md:text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
}}</span> }}</span>
<slot /> <slot />
</div> </div>
+39 -73
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { assert } from '~~/utils/assert';
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue'; import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
const dropdownOpen = ref(false); const dropdownOpen = ref(false);
const dropdownTrigger = ref<HTMLElement | null>(null); const dropdownTrigger = ref<HTMLElement | null>(null);
@@ -9,9 +8,8 @@ const dropdownContent = ref(null);
const activeMenuTopicId = ref<string | null>(null); const activeMenuTopicId = ref<string | null>(null);
const route = useRoute(); const route = useRoute();
const { getAgent } = useAgents(); const { getAgent, patchTopicLocally, deleteTopic: deleteAgentTopic } = await useAgents();
const { autoRename } = useTopic();
const triplit = useTriplitClient();
const navRef = ref<HTMLElement | null>(null); const navRef = ref<HTMLElement | null>(null);
const agentId = computed(() => route.params.id as string); const agentId = computed(() => route.params.id as string);
@@ -27,10 +25,10 @@ const topics = computed(() => {
return activeAgent.value?.topics || []; return activeAgent.value?.topics || [];
}) })
const { floatingStyles, placement } = useFloating(dropdownTrigger, dropdownContent, { const { floatingStyles, placement, middlewareData } = useFloating(dropdownTrigger, dropdownContent, {
placement: 'bottom-end', placement: 'bottom-end',
whileElementsMounted: autoUpdate, whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 10 })], middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
transform: false, transform: false,
}); });
@@ -52,59 +50,16 @@ const menuTopic = computed(() =>
); );
const topicsOpen = ref(true); const topicsOpen = ref(true);
let activeAutoRenames = reactive(new Map<string, string>());
const autoRenameTopic = async (topicId: string) => { const autoRenameTopic = async (topicId: string) => {
const { setPage } = useSettings(); autoRename(topicId);
const { autoRename, AutoRenameError } = useChat(agentId.value);
const firstMessage = await triplit.fetchOne(triplit.query('messages').Where('topicId', '=', topicId).Order('createdAt', 'ASC').Limit(1));
if (!firstMessage) return;
const res = await autoRename(topicId, firstMessage.content);
if (res.ok) {
activeAutoRenames.set(topicId, res.data);
return;
}
switch (res.error) {
case AutoRenameError.NoModelSelected:
case AutoRenameError.ModelDisabled:
case AutoRenameError.AutoRenameDisabled: {
setPage('systemAssistants');
} break;
case AutoRenameError.NoModelFound:
case AutoRenameError.DatabaseOperationFailed:
case AutoRenameError.FailedToGenerate:
case AutoRenameError.FailedToDecryptProviderApiKey: {
console.error('Failed to auto-rename:', res.error);
await triplit.update('topics', topicId, {
renaming: false,
});
assert('flush' in triplit);
await triplit.flush();
} break;
}
} }
const cancelAutoRename = async (topicId: string) => { const cancelAutoRename = async (topicId: string) => {
await triplit.update('topics', topicId, { // TODO: make this more optimistic
renaming: false, await $fetch(`/api/topic/${topicId}/auto-rename/cancel`, {
});
const renameId = activeAutoRenames.get(topicId);
if (!renameId) return;
await $fetch(`/api/auto-rename/cancel`, {
body: {
renameId,
},
method: 'POST', method: 'POST',
}); });
activeAutoRenames.delete(topicId);
} }
const renameTopicId = ref<string | null>(null); const renameTopicId = ref<string | null>(null);
@@ -123,10 +78,16 @@ const startRename = (topicId: string, currentName: string) => {
const saveRename = async () => { const saveRename = async () => {
if (renameTopicId.value && newTopicName.value.trim()) { if (renameTopicId.value && newTopicName.value.trim()) {
await triplit.update('topics', renameTopicId.value, { patchTopicLocally(renameTopicId.value, { name: newTopicName.value.trim() });
name: newTopicName.value.trim()
$fetch(`/api/topic/${renameTopicId.value}`, {
method: 'PATCH',
body: {
name: newTopicName.value.trim(),
},
}); });
} }
cancelRename(); cancelRename();
}; };
@@ -143,9 +104,8 @@ const deleteTopic = async (topicId: string) => {
await navigateTo('/'); await navigateTo('/');
} }
} }
await triplit.delete('topics', topicId);
// TODO: deeply delete all messages, generations, and message_parts in the topic deleteAgentTopic(agentId.value!, topicId);
}; };
const handleNavClick = (e: MouseEvent) => { const handleNavClick = (e: MouseEvent) => {
@@ -193,10 +153,10 @@ 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="md: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-5 md:text-4 transition-transform duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="topicsOpen ? '' : '-rotate-90'"></span> :class="topicsOpen ? '' : '-rotate-90'"></span>
</button> </button>
@@ -207,22 +167,23 @@ 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="group mt-1 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"
@blur="saveRename" @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 md:text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" />
<div v-else-if="topic.renaming" class="flex w-full"> <div v-else-if="topic.renaming" class="flex w-full">
<span class="i-svg-spinners-3-dots-fade text-6"></span> <span class="i-svg-spinners-3-dots-fade text-7 md:text-6"></span>
</div> </div>
<span v-else class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap"> <span v-else class="md:text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
{{ topic.name }} {{ topic.name }}
</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 w-0 max-w-0 opacity-0 overflow-hidden p-1 max-md:w-auto max-md:max-w-none max-md:opacity-100 flex items-center justify-center rounded-md @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]"
<span class="pointer-events-none h-4.5 w-4.5 i-tabler-dots"></span> :class="(route.params.topicId === topic.id || (dropdownOpen && activeMenuTopicId === topic.id)) ? 'w-auto max-w-none opacity-100' : 'group-hover:w-auto group-hover:max-w-none group-hover:opacity-100'">
<span class="pointer-events-none h-6 w-6 md:h-4.5 md:w-4.5 i-tabler-dots"></span>
</div> </div>
</a> </a>
</template> </template>
@@ -237,32 +198,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-20" :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>
@@ -271,4 +237,4 @@ onMounted(() => {
</Transition> </Transition>
</Teleport> </Teleport>
</nav> </nav>
</template> </template>
+42 -43
View File
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
import type { DialogType } from '~/composables/useDialog'; import { 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,16 +105,26 @@ 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">
<SidenavItem to="/" name="Home" icon="i-mynaui-home" /> <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" active />
<SidenavItem to="/resources" name="Resources" icon="i-tabler-books" />
<!-- 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="md:text-sm">Agents</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-5 md:text-4 transition-transform duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="{ '-rotate-90': !agentsOpen }"></span> :class="{ '-rotate-90': !agentsOpen }"></span>
</button> </button>
@@ -138,10 +132,10 @@ 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 md: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-9 w-9 md:h-7 md: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-5 md:text-4"></span>
<span v-else class="i-mynaui-plus text-4.5"></span> <span v-else class="i-mynaui-plus-solid text-5 md:text-4"></span>
</div> </div>
<span>New Agent</span> <span>New Agent</span>
</button> </button>
@@ -152,18 +146,19 @@ 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"
class="flex-1 bg-transparent border-none outline-none text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" /> @blur="saveRename"
class="flex-1 bg-transparent border-none outline-none md: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="md:text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
{{ agent.name }} {{ agent.name }}
</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 md: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,background-color] 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-6 w-6 md:h-4.5 md:w-4.5 i-tabler-dots"></span>
</div> </div>
</a> </a>
</template> </template>
@@ -179,21 +174,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>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ResourceCategory } from '~/composables/useResources';
const { category, setCategory, categoryCount } = await useResources();
const categories: { key: ResourceCategory; label: string; icon: string }[] = [
{ key: 'all', label: 'All', icon: 'i-mynaui-folder' },
{ key: 'documents', label: 'Documents', icon: 'i-mynaui-file-text' },
{ key: 'images', label: 'Images', icon: 'i-mynaui-image' },
{ key: 'audio', label: 'Audio', icon: 'i-mynaui-music' },
{ key: 'videos', label: 'Videos', icon: 'i-mynaui-video' },
];
</script>
<template>
<nav class="flex flex-col gap-1 overflow-auto">
<button v-for="cat in categories" :key="cat.key" @click="setCategory(cat.key)"
class="flex items-center justify-between gap-2 px-2 h-9 shrink-0 rounded-lg transition-colors w-full"
:class="category === cat.key
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
: 'text-[var(--text-secondary)] @hover:bg-[var(--color-hover)]'">
<div class="flex items-center gap-2">
<div class="h-9 w-9 md:h-7 md:w-7 flex items-center justify-center">
<span class="text-5 md:text-4.5" :class="cat.icon"></span>
</div>
<span class="md:text-sm font-medium">{{ cat.label }}</span>
</div>
<span class="text-xs tabular-nums text-[var(--text-tertiary)]">
{{ categoryCount(cat.key) }}
</span>
</button>
</nav>
</template>
+38 -16
View File
@@ -96,6 +96,7 @@ onUnmounted(() => {
const navKind = computed(() => { const navKind = computed(() => {
if (route.path === '/') return 'home'; if (route.path === '/') return 'home';
if (route.path.startsWith('/agent/')) return 'agent'; if (route.path.startsWith('/agent/')) return 'agent';
if (route.path === '/resources') return 'resources';
return null; return null;
}); });
</script> </script>
@@ -103,39 +104,50 @@ const navKind = computed(() => {
<template> <template>
<div class="relative"> <div class="relative">
<aside ref="sidenavRef" :class="[ <aside ref="sidenavRef" :class="[
'h-full max-w-fit background-[var(--bg-base)] overflow-hidden will-change-width text-[var(--text-secondary)] select-none transition-[width,margin] duration-250 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', 'mobile-full-width pr-2 md:pr-0 bg-[var(--bg-base)] h-full background-[var(--bg-base)] overflow-hidden will-change-width text-[var(--text-secondary)] select-none transition-[width,margin,transform] duration-250 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
open ? 'w-full mr-2' : 'w-0 mr-0', open ? 'w-full' : 'w-0',
isResizing ? 'transition-none' : '' isResizing ? 'transition-none' : '',
// Mobile styles
'md:static md:z-auto',
open ? 'md:translate-x-0' : 'md:translate-x-0',
// Mobile overlay styles
'fixed inset-0 z-15 h-screen md:h-full',
open ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
'md:max-w-xs',
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="isMouseOver = true" ]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="isMouseOver = true"
@mouseleave="isMouseOver = false" @focusin="isFocused = true" @focusout="onFocusOut"> @mouseleave="isMouseOver = false" @focusin="isFocused = true" @focusout="onFocusOut">
<div :style="{ minWidth: `${sidebarWidth}px` }" class="pl-2 flex flex-col h-full justify-between"> <div :style="{ width: `${sidebarWidth}px` }"
class="mobile-full-width pl-2 flex flex-col h-full justify-between">
<div class="flex flex-col h-full max-h-full overflow-y-hidden"> <div class="flex flex-col h-full max-h-full overflow-y-hidden">
<!-- Header --> <!-- Header -->
<div <div
class="z-25 bg-[var(--bg-base)] relative flex flex-row gap-2 justify-between items-center shrink-0 pb-1.5 h-11 pt-2"> class="z-25 bg-[var(--bg-base)] relative flex flex-row gap-2 justify-between items-center shrink-0 pb-1.5 h-13 md:h-11 pt-2">
<SidenavHeader v-if="navKind === 'home'" /> <SidenavHeader v-if="navKind === 'home'" />
<SidenavHeaderResources v-else-if="navKind === 'resources'" />
<SidenavHeaderAgent v-else-if="navKind === 'agent'" /> <SidenavHeaderAgent v-else-if="navKind === 'agent'" />
<div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5"> <div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
<div :style="isHovering ? 'width: 32px;' : 'width: 0px;'" <div :class="[
: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']"> 'w-10 flex-shrink-0 overflow-hidden rounded-lg transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center-right md:flex',
isHovering ? 'md:w-8' : 'md:w-0',
]">
<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 h-10 w-10 md:h-8 md: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 opacity-100 scale-100 text-6 md:text-5 transition-all transform-origin-right-center"
:class="isHovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95'"></span> :class="isHovering ? '' : 'md:opacity-0 md:scale-95'"></span>
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg"> <div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
<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 h-10 w-10 md:h-8 md: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-6 md:text-5"></span>
</NuxtLink> </NuxtLink>
</Tooltip> </Tooltip>
</div> </div>
@@ -145,14 +157,15 @@ const navKind = computed(() => {
<!-- Main Menu --> <!-- Main Menu -->
<SidenavNavHome v-if="navKind === 'home'" /> <SidenavNavHome v-if="navKind === 'home'" />
<SidenavNavAgent v-else-if="navKind === 'agent'" /> <SidenavNavAgent v-else-if="navKind === 'agent'" />
<SidenavNavResources v-else-if="navKind === 'resources'" />
</div> </div>
<div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2"> <div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2">
<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-9 w-9 md:h-7 md: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-6 md:text-5"></span>
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
@@ -164,8 +177,17 @@ const navKind = computed(() => {
</div> </div>
</aside> </aside>
<!-- resize handle --> <!-- resize handle - hidden on mobile -->
<div @mousedown="onResizeStart" class="absolute top-0 right-0 bottom-0 p-1 cursor-ew-resize"> <div @mousedown="onResizeStart" class="md:flex hidden absolute top-0 left-full bottom-0 p-1 cursor-ew-resize">
</div> </div>
</div> </div>
</template> </template>
<style>
@media (max-width: 767px) {
.mobile-full-width {
width: 100% !important;
min-width: 100% !important;
}
}
</style>
+5 -1
View File
@@ -26,12 +26,16 @@ watch(() => props.checked, (newValue) => {
<template> <template>
<button :id="id" role="switch" <button :id="id" role="switch"
:class="{
'checked': active,
}"
class="vl-toggle-switch flex items-center w-[2.5em] h-[1.4em] rounded-[100px] bg-[var(--bg-container)] py-0.5 px-1 transition-[background-color] duration-300 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" class="vl-toggle-switch flex items-center w-[2.5em] h-[1.4em] rounded-[100px] bg-[var(--bg-container)] py-0.5 px-1 transition-[background-color] duration-300 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:aria-disabled="(props.disabled === true) ? 'true' : 'false'" :aria-label="label" :aria-labelledby="id" :aria-disabled="(props.disabled === true) ? 'true' : 'false'" :aria-label="label" :aria-labelledby="id"
:tabindex="(disabled) ? '-1' : '0'" @click="(e) => $emit('click', e)" :aria-checked="active" :tabindex="(disabled) ? '-1' : '0'" @click="(e) => $emit('click', e)" :aria-checked="active"
:data-state="(active) ? 'checked' : 'unchecked'"> :data-state="(active) ? 'checked' : 'unchecked'">
<div <div
class="transform-origin-center-left [will-change:tranform] relative left-0 w-[1em] h-[1em] bg-gray-100 rounded-full pointer-events-none transition-all duration-300"> :class="active ? 'bg-gray-100' : 'bg-gray-500 dark:bg-gray-100'"
class="transform-origin-center-left [will-change:tranform] relative left-0 w-[1em] h-[1em] rounded-full pointer-events-none transition-all duration-300">
</div> </div>
</button> </button>
</template> </template>
+5 -5
View File
@@ -7,7 +7,7 @@ const props = defineProps<{
type Theme = 'light' | 'dark' | 'system'; type Theme = 'light' | 'dark' | 'system';
const { updateSettings, settings } = useUserSettings(); const { updateSettings, settings } = await useUserSettings();
const selectTheme = (colorScheme: Theme) => { const selectTheme = (colorScheme: Theme) => {
updateSettings({ appearance: { colorScheme } }); updateSettings({ appearance: { colorScheme } });
@@ -20,7 +20,7 @@ const themeOptions: DropdownItem[] = [
]; ];
const currentOption = computed( const currentOption = computed(
() => themeOptions.find((option) => option.id === settings.value.appearance.colorScheme) || themeOptions[2], () => themeOptions.find((option) => option.id === settings.value?.appearance?.colorScheme) || themeOptions[2],
); );
</script> </script>
@@ -28,9 +28,9 @@ 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-9 w-9 md:h-7 md:w-7 md:text-5 text-6': size === 'small',
'h-9 w-9 text-6': size === 'medium', 'h-9 w-9 text-6': size === 'medium',
'h-11 w-11 text-7': size === 'large', 'h-11 w-11 text-7': size === 'large',
}"> }">
@@ -40,7 +40,7 @@ const currentOption = computed(
<template #dropdown="{ close }"> <template #dropdown="{ close }">
<button v-for="option in themeOptions" :key="option.id" @click="option.onClick!(); close()" <button v-for="option in themeOptions" :key="option.id" @click="option.onClick!(); close()"
class="text-left px-3 py-1.5 items-center gap-1 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150" class="text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"
:class="{ :class="{
'bg-[var(--color-hover)]': option.id === currentOption?.id, 'bg-[var(--color-hover)]': option.id === currentOption?.id,
}"> }">
+7 -1
View File
@@ -6,8 +6,14 @@ const props = defineProps<{
const { show, hide } = useTooltip(); const { show, hide } = useTooltip();
const triggerRef = ref<HTMLElement | null>(null); const triggerRef = ref<HTMLElement | null>(null);
const hasCursor = ref(false);
onMounted(() => {
hasCursor.value = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
});
const onMouseEnter = () => { const onMouseEnter = () => {
if (triggerRef.value) { if (triggerRef.value && hasCursor.value) {
show(triggerRef.value, props.hotkey); show(triggerRef.value, props.hotkey);
} }
}; };
+9 -10
View File
@@ -1,18 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
const { activeElement, activeHotkey, isVisible } = useTooltip(); const { activeElement, activeHotkey, isVisible } = useTooltip();
watch(activeElement, () => {
console.log('activeElement', activeElement.value);
});
const tooltipRef = ref<HTMLElement | null>(null); const tooltipRef = ref<HTMLElement | null>(null);
const { floatingStyles, placement } = useFloating(activeElement, tooltipRef, { const { floatingStyles, placement, middlewareData } = useFloating(activeElement, tooltipRef, {
placement: 'top', placement: 'top',
whileElementsMounted: autoUpdate, whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 8 })], middleware: [offset(6), flip(), shift({ padding: 8 }), hide()],
transform: false, transform: false,
}); });
@@ -29,8 +25,8 @@ watch(activeElement, (newEl, oldEl) => {
}); });
// Also reset isMoving when the tooltip fully closes // Also reset isMoving when the tooltip fully closes
watch(isVisible, (visible) => { watch([isVisible, middlewareData], ([visible, middlewareData]) => {
if (!visible) isMoving.value = false; if (!visible || middlewareData.hide?.referenceHidden) isMoving.value = false;
}); });
const isMac = import.meta.client ? navigator.userAgent.toUpperCase().indexOf('MAC') >= 0 : false; const isMac = import.meta.client ? navigator.userAgent.toUpperCase().indexOf('MAC') >= 0 : false;
@@ -58,7 +54,10 @@ const transformOrigin = computed(() => placement.value.includes('top') ? 'transf
? 'top, left, right, bottom, opacity, transform' ? 'top, left, right, bottom, opacity, transform'
: 'opacity, transform', : 'opacity, transform',
transitionDuration: '150ms', transitionDuration: '150ms',
transitionTimingFunction: 'cubic-bezier(0.5, 1, 0.89, 1)' transitionTimingFunction: 'cubic-bezier(0.5, 1, 0.89, 1)',
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
} }
]" :class="transformOrigin" ]" :class="transformOrigin"
class="pointer-events-none fixed z-35 flex flex-col bg-[var(--bg-surface)] rounded-lg shadow-xl px-1.5 py-1 text-xs"> class="pointer-events-none fixed z-35 flex flex-col bg-[var(--bg-surface)] rounded-lg shadow-xl px-1.5 py-1 text-xs">
+196 -58
View File
@@ -1,75 +1,213 @@
import type { Entity } from "@triplit/client";
import type schema from "#triplit/schema";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { assert } from "~~/utils/assert"; import { attempt } from "~~/types/result";
import * as schema from '~~/drizzle/schema';
export type Agent = Readonly<Entity<typeof schema, 'agents'> & { topics: Readonly<Entity<typeof schema, 'topics'>>[] }>; export type Topic = typeof schema.topics.$inferSelect;
export type Agent = typeof schema.agents.$inferSelect;
export type AgentWithTopics = Agent & { topics: Topic[] };
export const useAgents = () => { export const useAgents = async () => {
const nuxtApp = useNuxtApp(); const agents = useState<AgentWithTopics[]>('agents_state', () => []);
const triplit = useTriplitClient(); const loaded = useState('agents_loaded', () => false);
// dont leaking between different users/requests const { refresh } = await useFetch<AgentWithTopics[]>('/api/agents', {
if (!nuxtApp._agentsState) { key: 'agents_request',
nuxtApp._agentsState = { immediate: !loaded.value,
list: ref<Agent[]>([]), onRequest() {
initPromise: null as Promise<void> | null, loaded.value = true;
}; },
} onResponse({ response }) {
if (response.ok) {
agents.value = response._data ?? [];
}
}
});
const state = nuxtApp._agentsState as { const createAgent = async (navigate: boolean = true) => {
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 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] } as AgentWithTopics : 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
}; };
}; }
+8 -33
View File
@@ -30,11 +30,9 @@ export const useAuth = () => {
return sessionPromise; return sessionPromise;
} }
let finish: (value: Result<sessionData, AuthError>) => void;
sessionFetching.value = true; sessionFetching.value = true;
sessionPromise = new Promise(async (resolve, reject) => { const { promise, resolve } = Promise.withResolvers<Result<sessionData, AuthError>>();
finish = resolve; sessionPromise = promise;
});
let data: { let data: {
session: InferSessionFromClient<BetterAuthClientOptions>; session: InferSessionFromClient<BetterAuthClientOptions>;
user: InferUserFromClient<BetterAuthClientOptions>; user: InferUserFromClient<BetterAuthClientOptions>;
@@ -52,18 +50,17 @@ export const useAuth = () => {
} else { } else {
data = (await authClient.getSession()).data; data = (await authClient.getSession()).data;
} }
session.value = data?.session || null;
user.value = data?.user || null;
resolve(Ok({ session: data?.session || null, user: data?.user || null }));
} catch (error) { } catch (error) {
console.error('Failed to fetch session:', error); console.error('Failed to fetch session:', error);
resolve(Err(AuthError.NetworkError));
} finally {
sessionFetching.value = false; sessionFetching.value = false;
finish!(Err(AuthError.NetworkError));
return sessionPromise;
} }
session.value = data?.session || null;
user.value = data?.user || null;
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);
+381 -411
View File
@@ -1,33 +1,27 @@
import type schema from "#triplit/schema"; import { nanoid } from 'nanoid';
import type { Entity } from "@triplit/client"; import * as schema from '~~/drizzle/schema';
import type { FilePart, ImagePart, ModelMessage } from "ai"; import { Err, Ok, type Result } from '~~/types/result';
import { nanoid } from "nanoid"; import { buildMessageTree } from '~~/utils/message';
import { decrypt, base64ToUint8Array } from "~/utils/crypto";
import { type Result, Ok, Err, attempt } from "~~/types/result";
import { assert } from "~~/utils/assert";
export type BaseMessage = { export type BaseMessage = {
content: string; content: string;
fileIds: string[]; fileIds: string[];
} }
export type MessageEntity = Entity<typeof schema, 'messages'> & { export type ToolCall = typeof schema.toolCalls.$inferSelect
parts: (Entity<typeof schema, 'message_parts'> & {
toolCall: Entity<typeof schema, 'tool_calls'> | null
})[] | undefined
} & { generation: Entity<typeof schema, 'generations'> | null }
& { attachments: Entity<typeof schema, 'attachments'>[] }
export type Message = export type MessagePart = typeof schema.messageParts.$inferSelect & { toolCall: ToolCall | null }
MessageEntity & {
children: (MessageEntity | undefined)[]; export type MessageEntity = typeof schema.messages.$inferSelect & { parts: MessagePart[] | undefined } & { generation: typeof schema.generations.$inferSelect | null } & { attachments: (typeof schema.attachments.$inferSelect & { file: typeof schema.files.$inferSelect })[] }
}
export type Message = MessageEntity & { children: (MessageEntity | undefined)[] }
export enum ChatErrorType { export enum ChatErrorType {
NoModel = 0, NoModel = 0,
NoProvider, NoProvider,
NoAgent, NoAgent,
NoUser, NoUser,
NoTopic,
DatabaseOperationFailed, DatabaseOperationFailed,
FailedToDecryptProviderApiKey, FailedToDecryptProviderApiKey,
GenerationFailed, GenerationFailed,
@@ -37,315 +31,373 @@ 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, {
content: string;
lastUpdatedAt: 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) {
const part = msg.parts?.find(p => p.id === partId);
if (part) {
part.content += content.content;
part.lastUpdatedAt = new Date(content.lastUpdatedAt);
}
}
}
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', switch (type) {
content: agent.systemPrompt, case 'MESSAGE_CREATED': {
}); // Push the new message if it doesn't exist (prevents duplicates from HTTP vs SSE)
if (!data.value.messages.find(m => m.id === payload.id)) {
data.value.messages.push({ ...payload, parts: [] });
} else {
// replace the existing data with the new data
const index = data.value.messages.findIndex(m => m.id === payload.id);
if (index !== -1) {
data.value.messages[index] = { ...payload, parts: [] };
}
}
break;
}
case 'MESSAGE_UPDATED': {
const msgIndex = data.value.messages.findIndex(m => m.id === payload.id);
if (msgIndex !== -1) {
data.value.messages[msgIndex] = { ...data.value.messages[msgIndex], ...payload };
}
break;
}
case 'MESSAGE_DELETED': {
data.value.messages = data.value.messages.filter(m => m.id !== payload.id);
break;
}
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)?.content || '';
messageBuffer.set(payload.partId, { content: existing + payload.content, lastUpdatedAt: payload.lastUpdatedAt });
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.lastUpdatedAt = payload.lastUpdatedAt;
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;
}
} }
messages.forEach((message) => { sse = new EventSource(`/api/topic/${id}?lastUpdate=${lastUpdate}&count=${msgCount}`);
switch (message.role) {
case 'user':
const attachments = message.attachments.map(attachment => {
if (attachment.mimeType.startsWith('image/')) {
return {
type: 'image',
image: attachment.url,
};
}
return { sse.addEventListener("error", (e) => {
type: 'file', console.error('sse error', e);
data: attachment.url, setTimeout(connectSSE, 1500);
filename: attachment.name, })
mediaType: attachment.mimeType,
};
}) as (FilePart | ImagePart)[];
marshalledMessages.push({ sse.addEventListener("message", (e) => {
role: 'user', const raw = JSON.parse(e.data);
// TODO: when we have images or files, this is where we need to handle them const events: Event[] = Array.isArray(raw) ? raw : [raw];
content: [
{
type: 'text',
text: message.content
},
...attachments,
],
});
break;
case 'assistant':
(message.parts || []).forEach((part) => {
if (!part) return Err('Part is undefined')
switch (part.type) { for (const { type, payload, timestamp } of events) {
case 'text': if (!type || !payload) continue;
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') { if (type === 'initial_state') {
return Err('Marshalling tool call that is still pending. This is likely a UI bug if this happens.') data.value = payload;
} resolve()
continue;
}
let inputValue: string = ''; processEvent({ type, payload, timestamp });
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 {
switch (part.toolCall.error!.type) {
case 'text':
failureType = 'error-text';
failureValue = part.toolCall.error!.value;
break;
case 'json':
failureType = 'error-json';
failureValue = JSON.stringify(part.toolCall.error!.value, null, 2);
break;
}
}
marshalledMessages.push({
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: part.toolCall.id,
toolName: part.toolCall.toolName,
output: {
type: failureType,
value: failureValue,
},
},
],
providerOptions: part.providerOptions,
});
break;
}
if (part.toolCall.status === 'completed') {
marshalledMessages.push({
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: part.toolCall.id,
toolName: part.toolCall.toolName,
output: {
type: 'json',
value: JSON.stringify(part.toolCall.output!.value, null, 2),
},
},
],
providerOptions: part.providerOptions,
});
break;
}
} break;
default:
return Err(`Unknown part type: ${part.type}`)
}
});
break;
default:
return Err(`Unknown message role: ${message.role}`)
} }
}); });
return Ok(marshalledMessages); await promise;
}; }
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 {
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
providerApiKey = await decrypt( if (connect) {
key, onBeforeUnmount(() => {
base64ToUint8Array(provider.config.apiKey) if (flushTimeout) {
); clearTimeout(flushTimeout);
} catch (error) { flushTextDeltas();
console.error('Failed to decrypt provider API key:', error);
return Err(ChatErrorType.FailedToDecryptProviderApiKey);
} }
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);
} }
try { try {
await $fetch('/api/chat/generate', { const key = await crypto.subtle.importKey(
method: 'POST', "jwk",
body: { JSON.parse(window.localStorage.getItem("encryptionKey")!),
messages, "AES-GCM",
topicId: topic.id, false,
parentMessageId, ["encrypt", "decrypt"]
model: { )
providerId: provider.id,
modelId: model.id,
args,
},
providerApiKey: providerApiKey,
},
headers: {
'Content-Type': 'application/json',
},
});
return Ok(undefined); return Ok(await decrypt(
key,
base64ToUint8Array(provider.config.apiKey)
));
} catch (error) { } catch (error) {
console.error('Failed to generate:', error); console.error('Failed to decrypt provider API key:', error);
return Err(ChatErrorType.GenerationFailed); return Err(ChatErrorType.FailedToDecryptProviderApiKey);
} }
} }
const sendMessage = async ( const sendMessage = async (
message: BaseMessage, baseMessage: BaseMessage,
topic: Entity<typeof schema, 'topics'>, onRequest?: () => void,
topicMessages: MessageEntity[],
agent: Entity<typeof schema, 'agents'>,
provider: Entity<typeof schema, 'providers'>,
model: Entity<typeof schema, 'models'>,
): Promise<Result<void, ChatErrorType>> => { ): Promise<Result<void, ChatErrorType>> => {
const { user } = useAuth(); const { user } = useAuth();
if (!user.value) { if (!user.value) return Err(ChatErrorType.NoUser);
console.error('No user');
return Err(ChatErrorType.NoUser);
}
const messageId = nanoid(); try {
const attachmentsPromise = message.fileIds.map(async fileId => { const message = {
const file = await triplit.fetchOne(triplit.query('files').Where('id', '=', fileId)); id: nanoid(),
assert(file !== null); role: 'user',
content: baseMessage.content,
return await triplit.insert('attachments', { fileIds: baseMessage.fileIds,
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 => {
if (res.ok === false) {
console.error('Failed to start generation:', res.error);
await triplit.delete('messages', messageId);
} }
return res; await $fetch(`/api/topic/${unref(topicId)}/message`, {
}); method: 'POST',
body: {
message
},
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,
activeChildId: null,
deleted: null,
createdAt: new Date(),
updatedAt: new Date(),
children: [],
...message
} as Message);
}
onRequest?.();
},
onResponseError() {
data.value!.messages = data.value!.messages.filter(m => m.id !== message.id);
},
});
return Ok(undefined);
} catch (error) {
console.error('Failed to send message:', error);
return Err(ChatErrorType.GenerationFailed);
}
}; };
const startGeneration = async (model: ModelWithProvider, parentMessageId?: string, agentIdOverride?: string) => {
const providerApiKeyRes = await getProviderAPIKey(model.provider);
if (providerApiKeyRes.ok === false) {
return providerApiKeyRes;
}
const providerApiKey = providerApiKeyRes.data;
const { agents } = await useAgents();
const agent = agents.value.find(agent => agent.id === (agentIdOverride || topic.value?.agentId));
if (!agent) {
return Err(ChatErrorType.NoAgent);
}
const { settings } = await useUserSettings();
let rerank = undefined;
if (agent.config?.search?.rerank && settings.value.systemAssistants.rerank.enabled) {
const rerankModel = await useModels().then(m => m.allModels.value.find(m => m.id === settings.value.systemAssistants.rerank.modelId));
if (rerankModel) {
const rerankProviderApiKeyRes = await getProviderAPIKey(rerankModel.provider);
if (rerankProviderApiKeyRes.ok === false) {
return rerankProviderApiKeyRes;
}
const rerankProviderApiKey = rerankProviderApiKeyRes.data;
rerank = {
modelId: rerankModel.id,
providerApiKey: rerankProviderApiKey,
};
}
}
await $fetch(`/api/topic/${unref(topicId)}/chat`, {
method: 'POST',
body: {
modelId: model.id,
providerApiKey,
rerank,
parentMessageId,
},
});
}
/** /**
* *
* @param messageId The ID of the message we wish to regenerate *for*, this * @param messageId The ID of the message we wish to regenerate *for*, this
@@ -364,11 +416,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 +425,8 @@ export const useChat = (agentId: string) => {
} }
let targetMessageIndex = topicMessages.indexOf(targetMessage); let targetMessageIndex = topicMessages.indexOf(targetMessage);
const args = { let parentMessageId = undefined;
temperature: 1, let focusedMessages: MessageEntity[] | undefined;
max_tokens: 100,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
};
let parentMessageId = null;
let focusedMessages;
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 +449,59 @@ export const useChat = (agentId: string) => {
focusedMessages = topicMessages; focusedMessages = topicMessages;
} }
if (parentMessageId === null) { startGeneration(model, parentMessageId);
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); return Ok(undefined);
} }
const focusedMessageIndex = topicMessages.findIndex(m => m.id === parentMessageId); const patchMessageLocally = (id: string, updates: Partial<Message>) => {
if (focusedMessageIndex !== topicMessages.length - 1) { if (!data.value) return;
} data.value.messages = data.value.messages.map(m =>
m.id === id ? { ...m, ...updates } : m
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 { const deleteMessagesFocusedDescendant = async (messageId: string): Promise<Result<void, Error>> => {
AutoRenameDisabled = 0, if (!data.value) return Err(new Error('No data'));
NoModelSelected,
NoModelFound,
ModelDisabled,
DatabaseOperationFailed,
FailedToDecryptProviderApiKey,
FailedToGenerate,
}
const autoRename = async (topicId: string, prompt: string): Promise<Result<string, AutoRenameError>> => {
const { settings } = useUserSettings();
console.log(settings.value); const msg = data.value.messages.find(m => m.id === messageId);
if (!msg) return Err(new Error('Message not found'));
if (!settings.value.systemAssistants.rename.enabled) { const previousMessages = data.value!.messages;
return Err(AutoRenameError.AutoRenameDisabled);
}
if (!settings.value.systemAssistants.rename.modelId) { const res = await $fetch(`/api/messages/${messageId}`, {
return Err(AutoRenameError.NoModelSelected); method: 'DELETE',
} onRequest() {
if (!msg.activeChildId && (msg.parentMessageId === null || msg.parentMessageId === undefined)) {
msg.deleted = true;
return;
}
const modelResult = await attempt(triplit.fetchOne(triplit.query('models').Where('id', '=', settings.value.systemAssistants.rename.modelId).Include('provider'))); const parent = data.value!.messages.find(m => m.id === msg.parentMessageId);
data.value!.messages = data.value!.messages.filter(m => m.id !== msg.id);
if (modelResult.ok === false) { if (parent?.deleted) {
return Err(AutoRenameError.DatabaseOperationFailed); data.value!.messages = data.value!.messages.filter(m => m.id !== parent.id);
} }
},
const model = modelResult.data; onResponseError() {
data.value!.messages = previousMessages;
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 { if (!res.ok) {
// @ts-ignore - excessive stack depth return Err(new Error('Failed to delete message'));
const res = await $fetch(`/api/auto-rename/${topicId}`, {
method: 'POST',
body: JSON.stringify({
modelId: model.id,
prompt,
providerApiKey,
}),
}) 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 Err(AutoRenameError.FailedToGenerate);
} }
return Ok(undefined);
} }
return { return {
topic,
sendMessage, sendMessage,
AutoRenameError, startGeneration,
autoRename,
regenerateMessage, regenerateMessage,
createTopic, patchMessageLocally,
deleteMessagesFocusedDescendant
}; };
} }
+144 -47
View File
@@ -1,54 +1,38 @@
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[];
} defaultBaseUrl?: string;
};
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', {
const state = nuxtApp._modelsState as { key: 'models_request',
providers: Ref<ProviderWithModels[]>; immediate: !loaded.value,
initPromise: Promise<void> | null; onRequest() {
}; loaded.value = true;
},
const init = (): Promise<void> => { onResponse({ response }) {
if (state.initPromise) return state.initPromise; if (response.ok) {
providers.value = response._data ?? [];
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 +47,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
}; };
}; }
+186
View File
@@ -0,0 +1,186 @@
import { attempt } from "~~/types/result";
import * as schema from '~~/drizzle/schema';
export type ResourceFile = typeof schema.files.$inferSelect;
export type ResourceCategory = 'all' | 'documents' | 'images' | 'audio' | 'videos';
const categoryMimeMap: Record<ResourceCategory, (mime: string) => boolean> = {
all: () => true,
documents: (mime) =>
mime === 'application/pdf' ||
mime.startsWith('text/') ||
mime.includes('document') ||
mime.includes('spreadsheet') ||
mime.includes('presentation'),
images: (mime) => mime.startsWith('image/'),
audio: (mime) => mime.startsWith('audio/'),
videos: (mime) => mime.startsWith('video/'),
};
export const useResources = async () => {
const files = useState<ResourceFile[]>('resources:files', () => []);
const loaded = useState('resources:loaded', () => false);
const selectedIds = useState<Set<string>>('resources:selected', () => new Set());
const category = useState<ResourceCategory>('resources:category', () => 'all');
const { refresh } = await useFetch<ResourceFile[]>('/api/files', {
key: 'resources_files_request',
immediate: !loaded.value,
onRequest() {
loaded.value = true;
},
onResponse({ response }) {
if (response.ok) {
files.value = response._data ?? [];
}
},
});
const filteredFiles = computed(() => {
const filter = categoryMimeMap[category.value];
return files.value.filter(f => filter(f.mimeType));
});
const setCategory = (cat: ResourceCategory) => {
category.value = cat;
clearSelection();
};
const categoryCount = (cat: ResourceCategory): number => {
const filter = categoryMimeMap[cat];
return files.value.filter(f => filter(f.mimeType)).length;
};
const toggleSelect = (id: string) => {
const next = new Set(selectedIds.value);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
selectedIds.value = next;
};
const selectAll = () => {
selectedIds.value = new Set(filteredFiles.value.map(f => f.id));
};
const clearSelection = () => {
selectedIds.value = new Set();
};
const isAllSelected = computed(() =>
filteredFiles.value.length > 0 && selectedIds.value.size === filteredFiles.value.length
);
const hasSelection = computed(() => selectedIds.value.size > 0);
const selectedCount = computed(() => selectedIds.value.size);
const deleteFile = async (id: string) => {
const file = files.value.find(f => f.id === id);
if (!file) return;
const res = await attempt($fetch(`/api/file/${id}`, {
method: 'DELETE',
onRequest() {
files.value = files.value.filter(f => f.id !== id);
const next = new Set(selectedIds.value);
next.delete(id);
selectedIds.value = next;
},
onRequestError() {
files.value = [...files.value, file].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
},
onResponseError() {
files.value = [...files.value, file].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
},
}));
return res;
};
const deleteSelected = async () => {
const ids = Array.from(selectedIds.value);
clearSelection();
const results = await Promise.allSettled(
ids.map(id => deleteFile(id))
);
await refresh();
return results;
};
const copyLink = async (file: ResourceFile) => {
const url = file.url;
await navigator.clipboard.writeText(url);
};
const downloadFile = (file: ResourceFile) => {
const url = file.url;
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const getFileIcon = (mimeType: string): string => {
if (mimeType.startsWith('image/')) return 'i-mynaui-image';
if (mimeType === 'application/pdf') return 'i-tabler-file-type-pdf';
if (mimeType.startsWith('text/')) return 'i-mynaui-file-text';
if (mimeType.startsWith('video/')) return 'i-mynaui-video';
if (mimeType.startsWith('audio/')) return 'i-mynaui-music';
return 'i-mynaui-file-text';
};
const isImage = (mimeType: string): boolean => mimeType.startsWith('image/');
const formatSize = (bytes: number): string => {
if (bytes === 0) return '-';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
const formatDate = (date: Date | string): string => {
const d = new Date(date);
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
return {
files,
filteredFiles,
selectedIds,
selectedCount,
hasSelection,
isAllSelected,
category,
setCategory,
categoryCount,
toggleSelect,
selectAll,
clearSelection,
deleteFile,
deleteSelected,
copyLink,
downloadFile,
getFileIcon,
isImage,
formatSize,
formatDate,
refresh,
};
};
+12
View File
@@ -37,6 +37,18 @@ export const useSidebar = () => {
useCookie('sidebar:width').value = sidebarWidth.value.toString(); useCookie('sidebar:width').value = sidebarWidth.value.toString();
}; };
// Auto-close sidebar on mobile when navigating
if (import.meta.client) {
const mobileQuery = window.matchMedia('(max-width: 767px)');
const route = useRoute();
watch(() => route.path, () => {
if (mobileQuery.matches && open.value) {
open.value = false;
}
});
}
return { return {
open: readonly(open), open: readonly(open),
toggle, toggle,
+2 -3
View File
@@ -1,7 +1,6 @@
import type { Entity } from '@triplit/client'; import * as schema from '~~/drizzle/schema';
import type { schema } from '#triplit/schema';
type Generation = Entity<typeof schema, 'generations'>; type Generation = typeof schema.generations.$inferSelect;
export const useTokenDropdown = () => { export const useTokenDropdown = () => {
const isOpen = useState('token-dropdown:open', () => false); const isOpen = useState('token-dropdown:open', () => false);
+101
View File
@@ -0,0 +1,101 @@
import { Err, Ok, type Result } from "~~/types/result";
export const useTopic = () => {
enum AutoRenameError {
AutoRenameDisabled = 0,
NoModelSelected,
NoModelFound,
ModelDisabled,
DatabaseOperationFailed,
FailedToDecryptProviderApiKey,
FailedToGenerate,
}
const autoRename = async (topicId: string): Promise<Result<string, AutoRenameError>> => {
const { settings } = await useUserSettings();
if (!settings.value.systemAssistants.rename.enabled) {
return Err(AutoRenameError.AutoRenameDisabled);
}
if (!settings.value.systemAssistants.rename.modelId) {
return Err(AutoRenameError.NoModelSelected);
}
const { allModels } = await useModels();
const model = allModels.value.find(model => model.id === settings.value.systemAssistants.rename.modelId);
if (!model) {
return Err(AutoRenameError.NoModelFound);
}
if (model.enabled === false || model.provider?.enabled === false) {
return Err(AutoRenameError.ModelDisabled)
}
let providerApiKey: string | undefined = undefined;
if (model.provider!.config.apiKey !== undefined) {
try {
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
providerApiKey = await decrypt(
key,
base64ToUint8Array(model.provider!.config.apiKey)
);
} catch (error) {
console.error('Failed to decrypt provider API key:', error);
return Err(AutoRenameError.FailedToDecryptProviderApiKey);
}
}
const { agents } = await useAgents();
try {
// @ts-ignore - excessive stack depth
const res = await $fetch(`/api/topic/${topicId}/auto-rename`, {
method: 'POST',
body: JSON.stringify({
modelId: model.id,
providerApiKey,
}),
onRequest() {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(topic => topic.id === topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: true } : t) } : agent;
});
}
},
onRequestError() {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(topic => topic.id === topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: false } : t) } : agent;
});
}
},
}) as { ok: true, renameId: string } | { ok: false, code: string };
if (!res.ok) {
console.error('Failed to auto-rename:', res.code);
return Err(AutoRenameError.FailedToGenerate);
}
return Ok(res.renameId);
} catch (error) {
console.error('Failed to auto-rename:', error);
return Err(AutoRenameError.FailedToGenerate);
}
}
return {
autoRename
};
}
+190
View File
@@ -0,0 +1,190 @@
export const useUserEvents = () => {
const eventSource = ref<EventSource | null>(null);
const isConnected = useState('userEvents:connected', () => false);
const lastEventTimestamp = useState<number>('userEvents:lastTimestamp', () => 0);
const reconnectAttempts = ref(0);
const connectionStartTime = ref<number>(0);
const pendingReconnect = ref<NodeJS.Timeout | null>(null);
const BASE_DELAY = 1000;
const MAX_DELAY = 30000;
const MAX_RECONNECT_ATTEMPTS = 10;
const cleanup = () => {
if (pendingReconnect.value) {
clearTimeout(pendingReconnect.value);
}
if (eventSource.value) {
eventSource.value.close();
eventSource.value = null;
}
isConnected.value = false;
};
const getReconnectDelay = (attempt: number): number => {
const exponentialDelay = Math.min(
BASE_DELAY * Math.pow(2, attempt),
MAX_DELAY
);
const jitter = exponentialDelay * Math.random() * 0.25;
return exponentialDelay + jitter;
};
const scheduleReconnect = () => {
if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {
console.error('Max reconnection attempts reached');
return;
}
const delay = getReconnectDelay(reconnectAttempts.value);
console.log(`Scheduling reconnect in ${delay}ms (attempt ${reconnectAttempts.value + 1})`);
pendingReconnect.value = setTimeout(() => {
reconnectAttempts.value++;
connect();
}, delay);
};
const connect = async () => {
cleanup();
connectionStartTime.value = Date.now();
reconnectAttempts.value = 0;
const source = new EventSource('/api/events');
eventSource.value = source;
source.onopen = () => {
console.log('User events connected');
isConnected.value = true;
reconnectAttempts.value = 0;
};
source.onerror = () => {
isConnected.value = false;
if (source.readyState === EventSource.CLOSED) {
console.log('EventSource closed, scheduling reconnect');
scheduleReconnect();
}
};
source.addEventListener('message', async (event) => {
if (event.data === '') return;
try {
const data = JSON.parse(event.data);
if (data.timestamp && data.timestamp > lastEventTimestamp.value) {
lastEventTimestamp.value = data.timestamp;
}
handleUserEvent(data);
} catch (e) {
console.error('Failed to parse user event:', e);
}
});
};
const handleUserEvent = async (data: { entity: string; op: string; payload: any; timestamp?: number }) => {
const { agents } = await useAgents();
switch (data.entity) {
case 'topics': {
switch (data.op) {
case 'create': {
const existing = agents.value.find(a => a.id === data.payload.agentId);
if (existing) {
const topicExists = existing.topics.some(t => t.id === data.payload.id);
if (!topicExists) {
agents.value = agents.value.map(agent => {
return agent.id === data.payload.agentId
? { ...agent, topics: [data.payload, ...agent.topics] }
: agent;
});
}
}
break;
}
case 'update': {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(t => t.id === data.payload.topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId
? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...t, ...data.payload } : t) }
: agent;
});
}
break;
}
case 'delete': {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(t => t.id === data.payload.topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId
? { ...agent, topics: agent.topics.filter(t => t.id !== topic.id) }
: agent;
});
}
break;
}
}
break;
}
case 'agents': {
switch (data.op) {
case 'create': {
const existing = agents.value.find(a => a.id === data.payload.id);
if (existing) {
agents.value = agents.value.map(agent =>
agent.id === data.payload.id ? { ...agent, ...data.payload } : agent
);
} else {
agents.value = [...agents.value, data.payload];
}
break;
}
case 'update': {
agents.value = agents.value.map(agent =>
agent.id === data.payload.id ? { ...agent, ...data.payload } : agent
);
break;
}
case 'delete': {
agents.value = agents.value.filter(agent => agent.id !== data.payload.id);
break;
}
}
break;
}
}
};
const reconcile = async () => {
console.log('Reconciling user state...');
const { data } = await useFetch<AgentWithTopics[]>('/api/agents');
if (data.value) {
const { agents } = await useAgents();
agents.value = data.value;
}
};
onMounted(() => {
connect();
});
onBeforeUnmount(() => {
cleanup();
});
return {
eventSource,
isConnected,
connect,
cleanup,
reconcile,
};
};
+105 -172
View File
@@ -1,197 +1,130 @@
import { schema } from '#triplit/schema'; import * as schema from '~~/drizzle/schema';
import { type Entity } from '@triplit/client';
import { computed, watch, ref, type Ref } from 'vue';
type UserSettings = Entity<typeof schema, 'settings'>; type UserSettings = typeof schema.settings.$inferSelect;
type Appearance = UserSettings['appearance'];
type SystemAssistants = UserSettings['systemAssistants'];
interface UserSettingsState { const defaultAppearance: Appearance = {
accent: Ref<string>; colorScheme: 'system',
neutral: Ref<string>; accent: 'violet',
hinting: Ref<string>; neutral: 'zinc',
colorSchemePreference: Ref<'light' | 'dark' | 'system'>; hinting: 0,
colorSchemeClass: Ref<'light' | 'dark' | undefined>; fontSize: 'md',
remoteSettings: Ref<UserSettings | null>; };
initPromise: Promise<void> | null;
}
export const useUserSettings = () => { export const useUserSettings = async () => {
const nuxtApp = useNuxtApp(); const settings = useState<UserSettings | null>('settings_state', () => null);
const triplit = useTriplitClient(); const loaded = useState('settings_loaded', () => false);
const { user, loggedIn } = useAuth(); const syncing = useState('settings_syncing', () => false);
if (!nuxtApp._userSettingsState) { const localAppearance = useState<Appearance | null>('settings_local_appearance', () => null);
nuxtApp._userSettingsState = { const localSystemAssistants = useState<SystemAssistants | null>('settings_local_sa', () => null);
accent: ref('violet'),
neutral: ref('zinc'),
hinting: ref('0'),
colorSchemePreference: ref<'light' | 'dark' | 'system'>('system'),
colorSchemeClass: ref<undefined | 'light' | 'dark'>(undefined),
remoteSettings: ref<UserSettings | null>(null),
initPromise: null,
} as UserSettingsState;
}
const state = nuxtApp._userSettingsState as UserSettingsState; const { refresh } = await useFetch<UserSettings>('/api/settings', {
key: 'settings_request',
const init = (): Promise<void> => { immediate: !loaded.value,
if (state.initPromise) return state.initPromise; onRequest() {
loaded.value = true;
state.initPromise = (async () => { },
const { results } = await useQuery('settings', triplit, triplit.query('settings')); onResponse({ response }) {
if (response.ok && response._data) {
watch(results, (val) => { settings.value = response._data;
if (val && val.length > 0) { localAppearance.value = { ...defaultAppearance, ...response._data.appearance };
state.remoteSettings.value = val[0] as UserSettings; localSystemAssistants.value = response._data.systemAssistants;
} else {
state.remoteSettings.value = null;
}
}, { immediate: true, deep: true });
watch(state.remoteSettings, (newSettings) => {
if (!newSettings?.appearance) return;
const { appearance } = newSettings;
if (appearance.colorScheme) {
state.colorSchemePreference.value = appearance.colorScheme as 'light' | 'dark' | 'system';
}
if (appearance.accent) {
state.accent.value = appearance.accent;
}
if (appearance.neutral) {
state.neutral.value = appearance.neutral;
}
if (appearance.hinting !== undefined) {
state.hinting.value = String(appearance.hinting);
}
}, { immediate: true, deep: true });
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 applyOptimisticUpdates = (updates: { appearance?: Partial<Appearance>; systemAssistants?: Partial<SystemAssistants> }) => {
if (updates.appearance && localAppearance.value) {
localAppearance.value = { ...localAppearance.value, ...updates.appearance };
}
if (updates.systemAssistants && localSystemAssistants.value) {
localSystemAssistants.value = { ...localSystemAssistants.value, ...updates.systemAssistants };
}
if (updates.appearance && settings.value) {
settings.value = {
...settings.value,
appearance: { ...settings.value.appearance, ...updates.appearance }
};
}
if (updates.systemAssistants && settings.value) {
settings.value = {
...settings.value,
systemAssistants: { ...settings.value.systemAssistants, ...updates.systemAssistants }
};
}
}; };
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 || {} as SystemAssistants;
});
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';
}),
} }
}; };
}; };
+13 -7
View File
@@ -1,7 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
// TODO: when a user changes the color scheme and then signs in/up, the color // TODO: when a user changes the color scheme and then signs in/up, the color
// scheme will not be updated in the user's settings. // scheme will not be updated in the user's settings.
const { colorScheme } = useUserSettings(); // const { colorScheme } = useUserSettings();
const colorScheme = {
class: 'dark',
}
useHead({ useHead({
htmlAttrs: { htmlAttrs: {
@@ -13,12 +17,14 @@ preloadRouteComponents('/');
</script> </script>
<template> <template>
<main <div class="p-2 w-full h-full">
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"> <main
<div class="flex-1 overflow-y-auto"> class="bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden">
<slot /> <div class="flex-1 overflow-y-auto">
</div> <slot />
</main> </div>
</main>
</div>
</template> </template>
<style> <style>
+7 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Dialog from '~/components/Dialog/index.vue'; import Dialog from '~/components/Dialog/index.vue';
const { colorScheme } = useUserSettings(); const { colorScheme } = await useUserSettings();
const { toggle: toggleSidebar } = useSidebar(); const { toggle: toggleSidebar } = useSidebar();
const { addShortcut } = useKeyboardShortcuts(); const { addShortcut } = useKeyboardShortcuts();
@@ -16,11 +16,16 @@ useHead({
class: colorScheme.class class: colorScheme.class
} }
}); });
if (import.meta.client) {
const { connect } = useUserEvents();
connect();
}
</script> </script>
<template> <template>
<Sidenav /> <Sidenav />
<div class="p-2 w-full h-full"> <div class="p-2 w-full h-full max-w-full overflow-x-hidden">
<main <main
class="bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden"> class="bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden">
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
+29 -32
View File
@@ -2,16 +2,14 @@
import type { BaseMessage } from '~/composables/useChat'; import type { BaseMessage } from '~/composables/useChat';
import type { ModelWithProvider } from '~/composables/useModels'; import type { ModelWithProvider } from '~/composables/useModels';
const triplit = useTriplitClient();
const route = useRoute(); const route = useRoute();
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] }); const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
const pendingMessage = ref<Message | null>(null); const pendingMessage = ref<Message | null>(null);
const { open: sidebarOpen, openSidebar } = useSidebar(); const { open: sidebarOpen, openSidebar } = useSidebar();
const { createTopic, sendMessage, autoRename } = useChat(route.params.id as string); const { autoRename: autoRenameTopic } = useTopic();
const { getAgent } = useAgents(); const { getAgent, createTopic } = await useAgents();
const { providers } = useModels(); const { providers } = await useModels();
const agent = getAgent(route.params.id as string); const agent = getAgent(route.params.id as string);
@@ -31,10 +29,12 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
return; return;
} }
console.log("handleSubmit", message);
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: [],
@@ -42,41 +42,38 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
parentMessageId: null, parentMessageId: null,
children: [], children: [],
generationId: null, generationId: null,
focusedIndex: null, activeChildId: null,
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);
const res = await sendMessage(message);
if (res.ok === false) {
console.error('Failed to send message:', res.error);
pendingMessage.value = null;
await navigateTo(`/agent/${route.params.id}`);
nextTick(() => {
inputValue.value = message;
});
return;
}
await navigateTo(`/agent/${route.params.id}/topic/${topic.id}`); await navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
return sendMessage(message, topic, [], agent.value!, model.provider, model).then(async res => { autoRenameTopic(topic.id);
if (res.ok === false) {
console.error('Failed to send message:', res.error);
pendingMessage.value = null; return startGeneration(model, undefined, route.params.id as string);
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(() => {
inputValue.value = message;
});
}
});
}; };
</script> </script>
@@ -86,10 +83,10 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
<div class="h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)]"> <div class="h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)]">
<div class="flex items-center gap-2 max-w-full"> <div class="flex items-center gap-2 max-w-full">
<button v-if="!sidebarOpen" @click="openSidebar" <button v-if="!sidebarOpen" @click="openSidebar"
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors"> class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-panel-left-open text-5"></span> <span class="i-mynaui-panel-left-open text-5"></span>
</button> </button>
<h4 class="text-lg text-ellipsis overflow-hidden whitespace-nowrap"> <h4 class="text-lg truncate">
{{ agent?.name }} {{ agent?.name }}
</h4> </h4>
</div> </div>
+43 -22
View File
@@ -1,35 +1,56 @@
<script setup lang="ts"> <script setup lang="ts">
const { getAgent } = useAgents(); const { getAgent, patchAgentLocally } = await useAgents();
const triplit = useTriplitClient();
const route = useRoute(); const route = useRoute();
const { open: sidebarOpen, openSidebar } = useSidebar(); const { open: sidebarOpen, openSidebar } = useSidebar();
const agent = getAgent(route.params.id as string); const agent = getAgent(route.params.id as string);
let serverAgent = agent.value;
const name = ref<string | null>(agent.value?.name ?? null);
const systemPrompt = ref<string | null>(agent.value?.systemPrompt ?? null);
if (agent.value === undefined) navigateTo('/'); if (agent.value === undefined) navigateTo('/');
const handleInput = async (e: Event) => { let debounceTimeout: NodeJS.Timeout | null = null;
const target = e.target as HTMLInputElement; const debouncedUpdate = async (updates: Partial<Agent>) => {
if (target.value.trimStart().length === 0) { if (debounceTimeout !== null) {
return; clearTimeout(debounceTimeout);
} }
await triplit.update('agents', agent.value!.id, { name: target.value }); patchAgentLocally(route.params.id as string, updates);
debounceTimeout = setTimeout(async () => {
debounceTimeout = null;
try {
await $fetch(`/api/agent/${route.params.id}`, {
method: 'PATCH',
body: updates,
});
} catch (error) {
console.error('Failed to update agent:', error);
if (serverAgent) {
patchAgentLocally(route.params.id as string, serverAgent);
name.value = serverAgent.name;
systemPrompt.value = serverAgent.systemPrompt;
}
}
}, 700);
};
const handleNameInput = async (e: Event) => {
const name = (e.target as HTMLInputElement).value;
if (!name.trim()) return;
debouncedUpdate({ name });
}; };
const changeSystemPrompt = async (e: Event) => { const changeSystemPrompt = async (e: Event) => {
const target = e.target as HTMLTextAreaElement; let systemPrompt = (e.target as HTMLTextAreaElement).value as string | null;
let value: string | undefined = target.value; if (!systemPrompt!.trim()) systemPrompt = null;
debouncedUpdate({ systemPrompt });
if (value.trimStart().length === 0) {
value = undefined;
}
await triplit.update('agents', agent.value!.id, {
systemPrompt: target.value,
});
}; };
</script> </script>
@@ -37,7 +58,7 @@ const changeSystemPrompt = async (e: Event) => {
<div class="h-14 flex items-center justify-between px-4"> <div class="h-14 flex items-center justify-between px-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button v-if="!sidebarOpen" @click="openSidebar" <button v-if="!sidebarOpen" @click="openSidebar"
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors"> class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-panel-left-open text-5"></span> <span class="i-mynaui-panel-left-open text-5"></span>
</button> </button>
</div> </div>
@@ -49,15 +70,15 @@ const changeSystemPrompt = async (e: Event) => {
<img v-if="agent?.imageUrl" :src="agent.imageUrl" class="w-16 h-16 rounded-full object-cover" /> <img v-if="agent?.imageUrl" :src="agent.imageUrl" class="w-16 h-16 rounded-full object-cover" />
<span class="text-16 i-mynaui-check-hexagon"></span> <span class="text-16 i-mynaui-check-hexagon"></span>
</div> </div>
<input @input="handleInput" placeholder="Agent Name..." <input v-model="name" @input="handleNameInput" placeholder="Agent Name..."
class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-border)] text-12 p-0" class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-border)] text-12 p-0"
type="text" :value="agent?.name" /> type="text" />
</div> </div>
<div class="flex flex-col gap-2 w-full h-full mb-14"> <div class="flex flex-col gap-2 w-full h-full mb-14">
<label class="text-sm text-[var(--text-secondary)]">System Message</label> <label class="text-sm text-[var(--text-secondary)]">System Message</label>
<textarea placeholder="You are a helpful assistant." <textarea v-model="systemPrompt" placeholder="You are a helpful assistant."
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-border)]" class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-border)]"
:value="agent?.systemPrompt" @input="changeSystemPrompt"></textarea> @input="changeSystemPrompt"></textarea>
</div> </div>
</div> </div>
</template> </template>
+108 -226
View File
@@ -1,131 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import type schema from '#triplit/schema'; import { type BaseMessage, type Message, ChatErrorType } from '~/composables/useChat';
import type { Entity } from '@triplit/client';
import type { BaseMessage } from '~/composables/useChat';
import type { ModelWithProvider } from '~/composables/useModels'; import type { ModelWithProvider } from '~/composables/useModels';
import { buildFocusedMessageTree } from '~~/utils/message';
const rootStart = Date.now(); const rootStart = Date.now();
const triplit = useTriplitClient();
const chatPaneWrapper = ref<HTMLElement | null>(null); const chatPaneWrapper = ref<HTMLElement | null>(null);
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] }); const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
const route = useRoute(); const route = useRoute();
const { sendMessage, regenerateMessage } = useChat(route.params.id as string); const { getAgent, deleteTopic } = 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, deleteMessagesFocusedDescendant } = 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,128 +51,86 @@ 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.activeChildId && message.children.length > 0) {
(message.focusedIndex !== undefined && message.focusedIndex !== null) messageId = message.activeChildId;
&& message.focusedIndex > 0
&& message.children.length > 0
) {
messageId = message.children[message.focusedIndex - 1]!.id;
} else { } else {
messageId = message.id; messageId = message.id;
} }
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) => { const flatMessages = computed(() => {
triplit.delete('messages', message.id); const messages: Message[] = [];
if (message.generationId !== null && message.generationId !== undefined) { for (const message of topic.value?.messages ?? []) {
triplit.delete('generations', message.generationId); messages.push(message);
if (message.children.length > 0) {
messages.push(...message.children as Message[]);
}
} }
for (const part of message.parts || []) { return messages;
triplit.delete('message_parts', part.id); });
const handleDelete = async (rootMessage: Message) => {
let messageId: string;
if (rootMessage.activeChildId && rootMessage.children.length > 0) {
messageId = rootMessage.activeChildId;
} else {
messageId = rootMessage.id;
} }
if (topic.value?.messages.filter(m => m.id !== message.id).length === 0) { const result = await deleteMessagesFocusedDescendant(messageId);
triplit.delete('topics', topic.value!.id); if (!result.ok) {
return navigateTo(`/agent/${route.params.id}/`); console.error('Failed to delete message:', result.error);
return;
}
if (flatMessages.value.length === 0) {
await navigateTo(`/agent/${route.params.id}`);
deleteTopic(route.params.id as string, topicId.value);
} }
} }
const handleDelete = async (rootMessage: Message) => { const handleResize = () => {
if (rootMessage.role === 'user') { const el = chatPaneWrapper.value;
deeplyDeleteMessage(rootMessage); if (!el) return;
return;
}
if (rootMessage.deleted === true) { const atBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) <= 80;
const message = rootMessage.children[rootMessage.focusedIndex!]; if (!atBottom) return;
if (!message) {
console.error('Message not found');
return;
}
deeplyDeleteMessage(message); nextTick().then(() => {
requestAnimationFrame(() => {
if (rootMessage.children.filter(child => child!.id !== message.id).length === 0) { scrollToBottom('instant');
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 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 +174,11 @@ 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 currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
focusedIndex: Math.max(0, lastMessage.focusedIndex! - 1) const prevIdx = Math.max(0, currentIdx);
}); if (currentIdx > 0) {
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[prevIdx - 1]!.id });
}
} }
}) })
@@ -330,9 +188,19 @@ 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 currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
focusedIndex: Math.min(lastMessage.children.length, lastMessage.focusedIndex! + 1) const maxIdx = lastMessage.deleted ? lastMessage.children.length - 1 : lastMessage.children.length;
}); const nextIdx = Math.min(maxIdx, currentIdx + 1);
if (lastMessage.deleted) {
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx]?.id ?? null });
} else {
if (nextIdx === 0) {
patchMessageLocally(lastMessage.id, { activeChildId: null });
} else {
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx - 1]?.id ?? null });
}
}
} }
}) })
@@ -342,9 +210,10 @@ addShortcut(['ctrl', 'alt', 'arrowleft'], async (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
await triplit.update('messages', lastMessage.id, { const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
focusedIndex: Math.max(0, lastMessage.focusedIndex! - 1) if (currentIdx > 0) {
}); patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[currentIdx - 1]!.id });
}
} }
}) })
@@ -354,31 +223,42 @@ addShortcut(['ctrl', 'alt', 'arrowright'], async (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
await triplit.update('messages', lastMessage.id, { const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
focusedIndex: Math.min(lastMessage.children.length, lastMessage.focusedIndex! + 1) const maxIdx = lastMessage.deleted ? lastMessage.children.length - 1 : lastMessage.children.length;
}); const nextIdx = Math.min(maxIdx, currentIdx + 1);
if (lastMessage.deleted) {
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx]?.id ?? null });
} else {
if (nextIdx === 0) {
patchMessageLocally(lastMessage.id, { activeChildId: null });
} else {
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx - 1]?.id ?? null });
}
}
} }
}) })
addShortcut(['ctrl', 'alt', 'backspace'], async (event) => {
event.preventDefault();
event.stopPropagation();
await navigateTo(`/agent/${route.params.id}`);
deleteTopic(route.params.id as string, topicId.value);
})
onMounted(() => { onMounted(() => {
scrollToBottom('instant'); scrollToBottom('instant');
}); });
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 +266,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 +283,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>
@@ -412,7 +293,8 @@ onUnmounted(() => {
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl"> <div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out" <ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
:loading="activeGeneration !== null" :agent="agent" :loading="activeGeneration !== null" :agent="agent"
:providers="providers?.filter(p => p.enabled)" @submit="submitMessage" @cancel="handleCancel" /> :providers="providers?.filter(p => p.enabled)" @submit="submitMessage" @cancel="handleCancel"
@resize="handleResize" />
</div> </div>
</div> </div>
</div> </div>
+1 -4
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { deriveKey } from '~/utils/crypto'; import { deriveKey } from '~/utils/crypto';
import { initSettings } from '~/utils/settings';
const { client: authClient, signIn, session } = useAuth(); const { client: authClient, signIn, session } = useAuth();
@@ -101,8 +100,6 @@ const submit = async () => {
const key = await deriveKey(form.password, res.data.user.id); const key = await deriveKey(form.password, res.data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key)); localStorage.setItem('encryptionKey', JSON.stringify(key));
initSettings(res.data.user.id);
return navigateTo(to ?? '/'); return navigateTo(to ?? '/');
}; };
</script> </script>
@@ -152,7 +149,7 @@ const submit = async () => {
<p v-if="!config.public.disableSignup" class="text-sm text-center mt-3 text-[var(--text-secondary)]"> <p v-if="!config.public.disableSignup" class="text-sm text-center mt-3 text-[var(--text-secondary)]">
New to Veridian? New to Veridian?
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'" <NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
class="text-[var(--color-accent)] font-medium hover:underline"> class="text-[var(--color-accent)] font-medium @hover:underline">
Create an account Create an account
</NuxtLink> </NuxtLink>
</p> </p>
+1 -4
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { deriveKey } from '~/utils/crypto'; import { deriveKey } from '~/utils/crypto';
import { initSettings } from '~/utils/settings';
const { client: authClient, signUp, session } = useAuth(); const { client: authClient, signUp, session } = useAuth();
@@ -134,8 +133,6 @@ const submit = async () => {
const key = await deriveKey(form.password, res.data.user.id); const key = await deriveKey(form.password, res.data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key)); localStorage.setItem('encryptionKey', JSON.stringify(key));
initSettings(res.data.user.id);
return navigateTo(to ?? '/'); return navigateTo(to ?? '/');
}; };
</script> </script>
@@ -203,7 +200,7 @@ const submit = async () => {
<p class="text-sm text-center mt-3 text-[var(--text-secondary)]"> <p class="text-sm text-center mt-3 text-[var(--text-secondary)]">
Already have an account? Already have an account?
<NuxtLink :to="to ? `/auth/login?to=${to}` : '/auth/login'" <NuxtLink :to="to ? `/auth/login?to=${to}` : '/auth/login'"
class="text-[var(--color-accent)] font-medium hover:underline"> class="text-[var(--color-accent)] font-medium @hover:underline">
Log in here Log in here
</NuxtLink> </NuxtLink>
</p> </p>
+28 -44
View File
@@ -1,13 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { assert } from '~~/utils/assert';
import type { BaseMessage } from '~/composables/useChat'; import type { BaseMessage } from '~/composables/useChat';
import type { Agent } from '~/composables/useAgents';
const triplit = useTriplitClient();
const { open: sidebarOpen, openSidebar } = useSidebar(); const { open: sidebarOpen, openSidebar } = useSidebar();
const { agents, createAgent } = useAgents(); const { agents, createAgent, createTopic } = await useAgents();
const { providers, getFirstAvailableModel, allModels } = useModels(); const { providers } = await useModels();
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] }); const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
@@ -80,60 +76,48 @@ const agent = computed(() => {
return agents.value?.[0] ?? null; return agents.value?.[0] ?? null;
}); });
const { autoRename: autoRenameTopic } = useTopic();
const handleChatSubmit = async (message: BaseMessage, model: ModelWithProvider | null) => { const handleChatSubmit = async (message: BaseMessage, model: ModelWithProvider | null) => {
console.log('Message submitted:', message, agents);
let agent: Agent | null = agents.value?.[0] ?? null;
if (!agent) {
const triplit = useTriplitClient();
assert('flush' in triplit);
agent = await createAgent();
await triplit.flush();
}
if (!agent) throw new Error('Failed to find agent');
if (!model) {
if (agent.defaultModelId) {
model = allModels.value.find(m => m.id === agent.defaultModelId) ?? null;
} else {
model = getFirstAvailableModel();
}
}
if (!model) { if (!model) {
console.error('No model selected'); console.error('No model selected');
return; return;
} }
const { createTopic, autoRename, sendMessage } = useChat(agent.id); if (!message.content) return;
const topic = await createTopic(); let targetAgent = agents.value?.[0] ?? null;
if (!topic) throw new Error('Failed to create topic'); if (!targetAgent) {
targetAgent = await createAgent(false);
if (!targetAgent) {
console.error('Failed to create agent');
return;
}
}
await navigateTo(`/agent/${agent.id}/topic/${topic.id}`); const topic = await createTopic(targetAgent.id);
if (!topic) {
console.error('Failed to create topic');
return;
}
autoRename(topic.id, message.content); const { sendMessage, startGeneration } = await useChat(topic.id, false);
return sendMessage(message, topic, [], agent, model.provider, model).then(async res => { await sendMessage(message).then(async res => {
if (res.ok === false) { if (res.ok === false) {
console.error('Failed to send message:', res.error); console.error('Failed to send message:', res.error);
await navigateTo(`/agent/${agent.id}`); await navigateTo('/');
await triplit.delete('topics', topic.id);
// const chatInput = document.getElementById('chat') as HTMLInputElement;
// if (chatInput) {
// chatInput.value = message;
// chatInput.dispatchEvent(new Event('input'));
// nextTick(() => {
// chatInput.focus();
// });
// }
nextTick(() => { nextTick(() => {
inputValue.value = message; inputValue.value = message;
}); });
} }
}); });
await navigateTo(`/agent/${targetAgent.id}/topic/${topic.id}`);
autoRenameTopic(topic.id);
return startGeneration(model, undefined, targetAgent.id);
}; };
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>
+264
View File
@@ -0,0 +1,264 @@
<script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
const { open: sidebarOpen, openSidebar } = useSidebar();
const {
filteredFiles,
selectedIds,
selectedCount,
hasSelection,
isAllSelected,
toggleSelect,
selectAll,
clearSelection,
deleteFile,
deleteSelected,
copyLink,
downloadFile,
getFileIcon,
isImage,
formatSize,
formatDate,
} = await useResources();
const scrollRef = ref<HTMLElement | null>(null);
const dropdownOpen = ref(false);
const dropdownTrigger = ref<HTMLElement | null>(null);
const dropdownContent = ref(null);
const activeMenuFileId = ref<string | null>(null);
const previewSrc = ref<string | null>(null);
const previewOrigin = ref<DOMRect | null>(null);
const { floatingStyles, placement, middlewareData } = useFloating(dropdownTrigger, dropdownContent, {
placement: 'bottom-end',
whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
transform: false,
});
const transformOrigin = computed(() =>
placement.value.startsWith('top')
? 'transform-origin-bottom-center'
: 'transform-origin-top-center'
);
const closeDropdown = () => {
dropdownOpen.value = false;
activeMenuFileId.value = null;
dropdownTrigger.value = null;
};
const menuFile = computed(() =>
filteredFiles.value.find(f => f.id === activeMenuFileId.value)
);
const handleRowClick = (e: MouseEvent) => {
const trigger = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
if (!trigger) return;
const fileId = trigger.dataset.fileId || (trigger.closest('[data-file-id]') as HTMLElement | null)?.dataset.fileId;
if (!fileId) return;
const action = trigger.dataset.action;
switch (action) {
case 'toggle-dropdown':
e.preventDefault();
e.stopPropagation();
if (activeMenuFileId.value === fileId && dropdownOpen.value) {
closeDropdown();
return;
}
activeMenuFileId.value = fileId;
dropdownTrigger.value = trigger;
dropdownOpen.value = true;
break;
case 'toggle-select':
e.preventDefault();
e.stopPropagation();
toggleSelect(fileId);
break;
case 'open':
e.preventDefault();
e.stopPropagation();
const file = filteredFiles.value.find(f => f.id === fileId);
if (file && isImage(file.mimeType)) {
const row = trigger.closest('[data-file-id]') as HTMLElement;
previewOrigin.value = row?.getBoundingClientRect() ?? null;
previewSrc.value = file.url;
}
break;
}
};
const handleSelectAll = () => {
if (isAllSelected.value) {
clearSelection();
} else {
selectAll();
}
};
const handleDeleteSelected = async () => {
await deleteSelected();
};
const closePreview = () => {
previewSrc.value = null;
previewOrigin.value = null;
};
</script>
<template>
<div class="h-full w-full flex flex-col">
<div class="h-14 flex items-center justify-between px-4 shrink-0">
<div class="flex items-center gap-2">
<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">
<span class="i-mynaui-panel-left-open text-5"></span>
</button>
<h1 class="text-lg font-semibold text-[var(--text-primary)]">Resources</h1>
</div>
</div>
<div ref="scrollRef" class="flex-1 overflow-auto pb-4">
<div v-if="filteredFiles.length === 0"
class="flex flex-col items-center justify-center h-full gap-2 text-[var(--text-tertiary)]">
<span class="i-tabler-books text-10"></span>
<span class="text-sm">No files found</span>
</div>
<div v-else @click="handleRowClick" class="flex flex-col">
<div
class="sticky top-0 z-5 bg-[var(--bg-surface)] flex items-center gap-3 px-3 h-10 shrink-0 text-[var(--text-tertiary)] text-xs font-medium uppercase tracking-wider border-b border-[var(--color-border)]">
<button @click="handleSelectAll"
class="flex items-center justify-center h-4 w-4 rounded border transition-colors shrink-0"
:class="isAllSelected
? 'bg-[var(--color-accent)] border-[var(--color-accent)]'
: 'border-[var(--color-border)] @hover:border-[var(--color-border-active)]'">
<span v-if="isAllSelected" class="i-mynaui-check text-3 text-[var(--color-accent-text)]"></span>
</button>
<span class="flex-1 min-w-0">Name</span>
<span class="w-20 text-right shrink-0 hidden md:block">Size</span>
<span class="w-24 text-right shrink-0 hidden md:block">Date</span>
<div class="w-8 shrink-0"></div>
</div>
<RowVirtualizerFixed :scroll-element="scrollRef" key-field="id" :items="filteredFiles" :item-size="48"
:overscan="20" :prerender="50">
<template v-slot="{ item: file }">
<div :data-file-id="file.id" class="group flex items-center gap-3 px-3 cursor-default h-12"
:class="selectedIds.has(file.id)
? 'bg-[var(--color-active)]'
: '@hover:bg-[var(--color-hover)]'">
<button data-action="toggle-select" :data-file-id="file.id"
class="flex items-center justify-center h-4 w-4 rounded border shrink-0 transition-colors"
:class="selectedIds.has(file.id)
? 'bg-[var(--color-accent)] border-[var(--color-accent)]'
: 'border-[var(--color-border)] @hover:border-[var(--color-border-active)]'">
<span v-if="selectedIds.has(file.id)"
class="i-mynaui-check text-3 text-[var(--color-accent-text)]"></span>
</button>
<div data-action="open" :data-file-id="file.id"
class="flex items-center gap-2 flex-1 min-w-0"
:class="isImage(file.mimeType) ? 'cursor-pointer' : ''">
<span class="text-6 text-[var(--text-tertiary)] shrink-0"
:class="getFileIcon(file.mimeType)"></span>
<span
class="md:text-sm font-medium text-[var(--text-primary)] overflow-hidden text-ellipsis whitespace-nowrap">
{{ file.name }}
</span>
</div>
<span
class="w-20 text-right text-xs text-[var(--text-tertiary)] tabular-nums shrink-0 hidden md:block">
{{ formatSize(file.size) }}
</span>
<span
class="w-24 text-right text-xs text-[var(--text-tertiary)] tabular-nums shrink-0 hidden md:block">
{{ formatDate(file.createdAt) }}
</span>
<div data-action="toggle-dropdown" :data-file-id="file.id"
class="text-[var(--text-secondary)] shrink-0 md:opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md @hover:bg-[var(--color-hover)] transition-[opacity,background-color] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="pointer-events-none h-6 w-6 md:h-4.5 md:w-4.5 i-tabler-dots"></span>
</div>
</div>
</template>
</RowVirtualizerFixed>
</div>
</div>
<Transition
enter-active-class="transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 translate-y-4" enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-4">
<div v-if="hasSelection"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-full px-4 py-2 shadow-xl">
<span class="text-sm font-medium text-[var(--text-primary)]">
{{ selectedCount }} selected
</span>
<div class="w-px h-4 bg-[var(--color-border)]"></div>
<button @click="handleDeleteSelected"
class="flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium text-red-500 @hover:bg-red-500/10 transition-colors">
<span class="i-mynaui-trash text-4"></span>
Delete
</button>
<button @click="clearSelection"
class="flex items-center justify-center p-1 rounded-full text-[var(--text-tertiary)] @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-x text-4"></span>
</button>
</div>
</Transition>
<Teleport to="body">
<Transition
enter-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-1">
<div v-if="dropdownOpen" ref="dropdownContent" :style="{
...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}" class="fixed z-15" :class="transformOrigin">
<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">
<template v-if="menuFile">
<button @click="copyLink(menuFile); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors flex items-center gap-2">
<span class="i-mynaui-copy text-4 text-[var(--text-tertiary)]"></span>
Copy Link
</button>
<button @click="downloadFile(menuFile); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors flex items-center gap-2">
<span class="i-mynaui-download text-4 text-[var(--text-tertiary)]"></span>
Download
</button>
<div class="h-px bg-[var(--color-border)] my-1" />
<button @click="deleteFile(menuFile.id); closeDropdown()"
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors text-red-500 flex items-center gap-2">
<span class="i-mynaui-trash text-4"></span>
Delete
</button>
</template>
</div>
</div>
</Transition>
</Teleport>
<ImageViewer v-if="previewSrc" :src="previewSrc" :origin-rect="previewOrigin" @close="closePreview" />
</div>
</template>
+1 -9
View File
@@ -1,11 +1,10 @@
import { assert } from "~~/utils/assert"; import { assert } from "~~/utils/assert";
export default defineNuxtPlugin({ export default defineNuxtPlugin({
name: 'better-auth-triplit', name: 'better-auth',
enforce: 'pre', enforce: 'pre',
async setup(nuxtApp) { async setup(nuxtApp) {
if (import.meta.client) { if (import.meta.client) {
const triplit = useTriplitClient();
const { session, fetchSession } = useAuth(); const { session, fetchSession } = useAuth();
nuxtApp.hook('app:mounted', async () => { nuxtApp.hook('app:mounted', async () => {
@@ -16,13 +15,6 @@ export default defineNuxtPlugin({
return; return;
} }
} }
if (!session.value) {
return;
}
assert('startSession' in triplit)
await triplit.startSession(session.value.token);
}); });
} }
}, },
-10
View File
@@ -4,8 +4,6 @@ export default defineNuxtPlugin({
name: 'better-auth-fetch-plugin', name: 'better-auth-fetch-plugin',
enforce: 'pre', enforce: 'pre',
async setup(nuxtApp) { async setup(nuxtApp) {
const triplit = useTriplitClient();
// Flag if request is cached // Flag if request is cached
nuxtApp.payload.isCached = Boolean(useRequestEvent()?.context.cache); nuxtApp.payload.isCached = Boolean(useRequestEvent()?.context.cache);
if (nuxtApp.payload.serverRendered && !nuxtApp.payload.prerenderedAt && !nuxtApp.payload.isCached) { if (nuxtApp.payload.serverRendered && !nuxtApp.payload.prerenderedAt && !nuxtApp.payload.isCached) {
@@ -18,14 +16,6 @@ export default defineNuxtPlugin({
return; return;
} }
} }
if (!session.value) return;
assert('updateOptions' in triplit);
triplit.updateOptions({
serverUrl: process.env.NUXT_LOCAL_TRIPLIT_URL || process.env.NUXT_PUBLIC_TRIPLIT_URL,
token: session.value.token,
});
} }
}, },
}); });
-13
View File
@@ -1,13 +0,0 @@
export default defineNuxtPlugin({
name: 'global-data',
enforce: 'pre',
async setup() {
await Promise.all([
useAgents().init(),
useModels().init(),
useUserSettings().init()
]);
console.log('Global data ready');
}
});
+43
View File
@@ -1,16 +1,59 @@
import { unified } from 'unified'; import { unified } from 'unified';
import { visit } from 'unist-util-visit';
import remarkParse from 'remark-parse'; import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype'; import remarkRehype from 'remark-rehype';
import remarkBreaks from 'remark-breaks';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
function remarkSplitBlocks() {
return (tree: any) => {
visit(tree, 'paragraph', (node, index, parent) => {
const breakIndex = node.children.findIndex((child: any) => child.type === 'break');
if (breakIndex !== -1) {
const beforeBreak = node.children.slice(0, breakIndex);
const afterBreak = node.children.slice(breakIndex + 1);
node.children = beforeBreak;
const newParagraph = {
type: 'paragraph',
children: afterBreak,
};
let rootChildIndex = -1;
if (parent.type === 'root') {
rootChildIndex = index!;
} else {
rootChildIndex = tree.children.findIndex((child: any) =>
child === parent || (child.children && child.children.includes(node))
);
if (rootChildIndex === -1) {
rootChildIndex = tree.children.indexOf(parent);
}
}
if (rootChildIndex !== -1) {
tree.children.splice(rootChildIndex + 1, 0, newParagraph);
}
return index! + 1;
}
});
};
}
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
const remark = const remark =
unified() unified()
.use(remarkParse) .use(remarkParse)
.use(remarkGfm) .use(remarkGfm)
.use(remarkMath) .use(remarkMath)
.use(remarkBreaks)
.use(remarkSplitBlocks)
.use(remarkRehype, { allowDangerousHtml: true }) .use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeKatex, { output: 'mathml' }); .use(rehypeKatex, { output: 'mathml' });
+2 -13
View File
@@ -1,16 +1,5 @@
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 SupportedModalities = ['text', 'image', 'audio', 'video', 'pdf'] as const; export const SupportedModalities = ['text', 'image', 'audio', 'video', 'pdf'] as const;
export const providerBaseUrls = { export type Model = typeof schema.models.$inferSelect;
openrouter: 'https://openrouter.ai/api/v1',
ollama: '',
cerebras: 'https://api.cerebras.ai/v1',
google: 'https://generativelanguage.googleapis.com/v1beta',
longcat: 'https://api.longcat.chat/openai/v1',
cohere: 'https://api.cohere.ai/v2',
};
export type Model = Entity<typeof schema, 'models'> & { provider: Entity<typeof schema, 'providers'> };
+23 -3
View File
@@ -50,6 +50,11 @@ import {
LogoAionLabs, LogoAionLabs,
LogoMicrosoft, LogoMicrosoft,
LogoInflection, LogoInflection,
LogoVllm,
LogoClosedRouter,
LogoPoolside,
LogoTencent,
LogoHuggingFace,
} from '#components'; } from '#components';
import { markRaw } from 'vue'; import { markRaw } from 'vue';
@@ -159,7 +164,7 @@ const MODEL_MAPPINGS: ModelConfig[] = [
}, },
{ {
Icon: markRaw(LogoZAI), Icon: markRaw(LogoZAI),
keywords: [/^glm-4/, /\/glm-4/, /zai-/, /-glm-4/, /glm-\d/] keywords: [/zai-/, /(^|\/|-)?glm-?\d/]
}, },
{ {
Icon: markRaw(LogoGLMV), Icon: markRaw(LogoGLMV),
@@ -186,6 +191,7 @@ const MODEL_MAPPINGS: ModelConfig[] = [
/magistral/, /magistral/,
/devstral/, /devstral/,
/voxtral/, /voxtral/,
/leanstral/,
], ],
}, },
{ {
@@ -311,6 +317,14 @@ const MODEL_MAPPINGS: ModelConfig[] = [
{ {
Icon: markRaw(LogoInflection), Icon: markRaw(LogoInflection),
keywords: [/inflection-/, /^inflection\//] keywords: [/inflection-/, /^inflection\//]
},
{
Icon: markRaw(LogoPoolside),
keywords: [/poolside\//, /laguna/]
},
{
Icon: markRaw(LogoTencent),
keywords: [/tencent\//, /hy/],
} }
]; ];
@@ -319,12 +333,18 @@ export const providerIcons: Record<string, any | null> = {
google: markRaw(LogoGoogle), google: markRaw(LogoGoogle),
openai: markRaw(LogoOpenAI), openai: markRaw(LogoOpenAI),
azure: null, azure: null,
anthropic: null, anthropic: markRaw(LogoAnthropic),
cohere: markRaw(LogoCohere), cohere: markRaw(LogoCohere),
huggingface: null, huggingface: markRaw(LogoHuggingFace),
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),
nvidia: markRaw(LogoNvidia),
xiaomi: markRaw(LogoXiaomiMiMo),
}; };
export function getModelConfig(modelId: string) { export function getModelConfig(modelId: string) {
-19
View File
@@ -1,19 +0,0 @@
export const initSettings = async (userId: string) => {
const triplit = useTriplitClient();
const settings = await triplit.fetchOne(triplit.query('settings').Where('userId', '=', userId));
if (!settings) {
console.log('no settings');
await triplit.insert('settings', {
userId: userId,
systemAssistants: {
rename: {
enabled: false,
prompt: null,
modelId: null,
}
}
});
}
}
+2
View File
@@ -1,4 +1,6 @@
export const sortByReleaseDate = (a: { releasedAt?: Date | null } & Record<string, unknown>, b: { releasedAt?: Date | null } & Record<string, unknown>) => { export const sortByReleaseDate = (a: { releasedAt?: Date | null } & Record<string, unknown>, b: { releasedAt?: Date | null } & Record<string, unknown>) => {
if (typeof a.releasedAt === 'string') a.releasedAt = new Date(a.releasedAt);
if (typeof b.releasedAt === 'string') b.releasedAt = new Date(b.releasedAt);
if (!a.releasedAt && !b.releasedAt) return 0; if (!a.releasedAt && !b.releasedAt) return 0;
if (!a.releasedAt) return 1; if (!a.releasedAt) return 1;
if (!b.releasedAt) return -1; if (!b.releasedAt) return -1;
+1258 -797
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
services:
db:
image: pgvector/pgvector:pg17
container_name: veridian-db
restart: unless-stopped
ports:
- "127.0.0.1:5432:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB:-veridian}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-veridian}"]
interval: 5s
timeout: 5s
retries: 5
app:
build: .
container_name: veridian-app
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-veridian}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?Set BETTER_AUTH_SECRET in .env}
NUXT_PUBLIC_URL: ${NUXT_PUBLIC_URL:?Set NUXT_PUBLIC_URL in .env}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
S3_REGION: ${S3_REGION:-}
S3_ENDPOINT: ${S3_ENDPOINT:-}
DISABLE_SIGNUP: ${DISABLE_SIGNUP:-}
DISABLE_LOCAL_AUTH: ${DISABLE_LOCAL_AUTH:-}
volumes:
pgdata:
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: ['./drizzle/schema.ts', './drizzle/auth-schema.ts'],
out: './drizzle/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,218 @@
CREATE TYPE "part_type" AS ENUM('reasoning', 'text', 'tool-call', 'file');--> statement-breakpoint
CREATE TYPE "role" AS ENUM('user', 'assistant');--> statement-breakpoint
CREATE TYPE "status" AS ENUM('pending', 'completed', 'failed', 'cancelled');--> statement-breakpoint
CREATE TABLE "accounts" (
"id" text PRIMARY KEY,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "agents" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"name" text NOT NULL,
"system_prompt" text,
"image_url" text,
"default_model_id" text,
"config" jsonb DEFAULT '{}',
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "attachments" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"topic_id" text NOT NULL,
"message_id" text NOT NULL,
"file_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "embeddings" (
"id" text PRIMARY KEY,
"file_id" text NOT NULL,
"content" text NOT NULL,
"embedding" vector(1536),
"metadata" jsonb
);
--> statement-breakpoint
CREATE TABLE "files" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"name" text NOT NULL,
"mime_type" text NOT NULL,
"url" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "generations" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"topic_id" text NOT NULL,
"model_id" text NOT NULL,
"status" "status" NOT NULL,
"tokens" jsonb,
"error" text
);
--> statement-breakpoint
CREATE TABLE "message_parts" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"topic_id" text NOT NULL,
"message_id" text NOT NULL,
"tool_call_id" text,
"type" "part_type" NOT NULL,
"content" text,
"provider_options" jsonb,
"finished" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"last_updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "messages" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"topic_id" text NOT NULL,
"parent_message_id" text,
"generation_id" text,
"role" "role" NOT NULL,
"content" text,
"focused_index" integer,
"deleted" boolean DEFAULT false,
"updated_at" timestamp DEFAULT now() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "models" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"external_id" text NOT NULL,
"provider_id" text NOT NULL,
"name" text NOT NULL,
"cost" jsonb DEFAULT '{}' NOT NULL,
"input_modalities" text[] DEFAULT '{}'::text[] NOT NULL,
"output_modalities" text[] DEFAULT '{}'::text[] NOT NULL,
"capabilities" text[] DEFAULT '{}'::text[] NOT NULL,
"context_window" integer,
"supported_parameters" text[],
"is_custom" boolean DEFAULT false NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"released_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "providers" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"type" text NOT NULL,
"name" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"config" jsonb DEFAULT '{}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" text PRIMARY KEY,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL UNIQUE,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "settings" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"system_assistants" jsonb DEFAULT '{}' NOT NULL,
"appearance" jsonb
);
--> statement-breakpoint
CREATE TABLE "tool_calls" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"tool_name" text NOT NULL,
"status" "status" DEFAULT 'pending'::"status" NOT NULL,
"input" jsonb,
"output" jsonb,
"error" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "topics" (
"id" text PRIMARY KEY,
"user_id" text NOT NULL,
"agent_id" text NOT NULL,
"name" text NOT NULL,
"renaming" boolean DEFAULT false,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY,
"name" text NOT NULL,
"email" text NOT NULL UNIQUE,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "verifications" (
"id" text PRIMARY KEY,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "accounts_userId_idx" ON "accounts" ("user_id");--> statement-breakpoint
CREATE INDEX "agents_userId_idx" ON "agents" ("user_id");--> statement-breakpoint
CREATE INDEX "attachments_topicId_idx" ON "attachments" ("topic_id");--> statement-breakpoint
CREATE INDEX "attachments_messageId_idx" ON "attachments" ("message_id");--> statement-breakpoint
CREATE INDEX "attachments_userId_idx" ON "attachments" ("user_id");--> statement-breakpoint
CREATE INDEX "embeddings_fileId_idx" ON "embeddings" ("file_id");--> statement-breakpoint
CREATE INDEX "files_userId_idx" ON "files" ("user_id");--> statement-breakpoint
CREATE INDEX "generations_topicId_idx" ON "generations" ("topic_id");--> statement-breakpoint
CREATE INDEX "generations_userId_idx" ON "generations" ("user_id");--> statement-breakpoint
CREATE INDEX "message_parts_topicId_idx" ON "message_parts" ("topic_id");--> statement-breakpoint
CREATE INDEX "message_parts_messageId_idx" ON "message_parts" ("message_id");--> statement-breakpoint
CREATE INDEX "message_parts_userId_idx" ON "message_parts" ("user_id");--> statement-breakpoint
CREATE INDEX "messages_topicId_idx" ON "messages" ("topic_id");--> statement-breakpoint
CREATE INDEX "messages_parentMessageId_idx" ON "messages" ("parent_message_id");--> statement-breakpoint
CREATE INDEX "messages_userId_idx" ON "messages" ("user_id");--> statement-breakpoint
CREATE INDEX "models_providerId_idx" ON "models" ("provider_id");--> statement-breakpoint
CREATE INDEX "models_userId_idx" ON "models" ("user_id");--> statement-breakpoint
CREATE INDEX "providers_userId_idx" ON "providers" ("user_id");--> statement-breakpoint
CREATE INDEX "sessions_userId_idx" ON "sessions" ("user_id");--> statement-breakpoint
CREATE INDEX "settings_userId_idx" ON "settings" ("user_id");--> statement-breakpoint
CREATE INDEX "tool_calls_userId_idx" ON "tool_calls" ("user_id");--> statement-breakpoint
CREATE INDEX "topics_agentId_idx" ON "topics" ("agent_id");--> statement-breakpoint
CREATE INDEX "topics_userId_idx" ON "topics" ("user_id");--> statement-breakpoint
CREATE INDEX "verifications_identifier_idx" ON "verifications" ("identifier");--> statement-breakpoint
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "agents" ADD CONSTRAINT "agents_default_model_id_models_id_fkey" FOREIGN KEY ("default_model_id") REFERENCES "models"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_topic_id_topics_id_fkey" FOREIGN KEY ("topic_id") REFERENCES "topics"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_message_id_messages_id_fkey" FOREIGN KEY ("message_id") REFERENCES "messages"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "files"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "embeddings" ADD CONSTRAINT "embeddings_file_id_files_id_fkey" FOREIGN KEY ("file_id") REFERENCES "files"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "generations" ADD CONSTRAINT "generations_topic_id_topics_id_fkey" FOREIGN KEY ("topic_id") REFERENCES "topics"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "message_parts" ADD CONSTRAINT "message_parts_topic_id_topics_id_fkey" FOREIGN KEY ("topic_id") REFERENCES "topics"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "message_parts" ADD CONSTRAINT "message_parts_message_id_messages_id_fkey" FOREIGN KEY ("message_id") REFERENCES "messages"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "message_parts" ADD CONSTRAINT "message_parts_tool_call_id_tool_calls_id_fkey" FOREIGN KEY ("tool_call_id") REFERENCES "tool_calls"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_topic_id_topics_id_fkey" FOREIGN KEY ("topic_id") REFERENCES "topics"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_generation_id_generations_id_fkey" FOREIGN KEY ("generation_id") REFERENCES "generations"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "models" ADD CONSTRAINT "models_provider_id_providers_id_fkey" FOREIGN KEY ("provider_id") REFERENCES "providers"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "topics" ADD CONSTRAINT "topics_agent_id_agents_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE CASCADE;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
ALTER TABLE "files" ADD COLUMN "size" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "messages" ADD COLUMN "active_child_id" text;--> statement-breakpoint
UPDATE "messages" m
SET "active_child_id" = (
SELECT child."id"
FROM "messages" child
WHERE child."parent_message_id" = m."id"
ORDER BY child."created_at" ASC
OFFSET (m."focused_index" - 1)
LIMIT 1
)
WHERE m."focused_index" IS NOT NULL AND m."focused_index" > 0;--> statement-breakpoint
ALTER TABLE "messages" DROP COLUMN "focused_index";
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
import { defineRelations } from "drizzle-orm";
import * as schema from "./schema";
export const relations = defineRelations(schema, (r) => ({
users: {
sessions: r.many.sessions(),
accounts: r.many.accounts(),
},
sessions: {
users: r.one.users({
from: r.sessions.userId,
to: r.users.id
}),
},
accounts: {
users: r.one.users({
from: r.accounts.userId,
to: r.users.id
}),
},
agents: {
topics: r.many.topics({
from: r.agents.id,
to: r.topics.agentId
}),
},
topics: {
messages: r.many.messages({
from: r.topics.id,
to: r.messages.topicId
}),
generations: r.many.generations({
from: r.topics.id,
to: r.generations.topicId
}),
agent: r.one.agents({
from: r.topics.agentId,
to: r.agents.id,
optional: false,
}),
},
messages: {
topic: r.one.topics({
from: r.messages.topicId,
to: r.topics.id,
optional: false,
}),
parts: r.many.messageParts({
from: r.messages.id,
to: r.messageParts.messageId
}),
attachments: r.many.attachments({
from: r.messages.id,
to: r.attachments.messageId
}),
generation: r.one.generations({
from: r.messages.generationId,
to: r.generations.id
}),
parent: r.one.messages({
from: r.messages.parentMessageId,
to: r.messages.id
}),
children: r.many.messages({
from: r.messages.id,
to: r.messages.parentMessageId
}),
},
attachments: {
file: r.one.files({
from: r.attachments.fileId,
to: r.files.id,
optional: false,
}),
},
messageParts: {
toolCall: r.one.toolCalls({
from: r.messageParts.toolCallId,
to: r.toolCalls.id
}),
},
models: {
provider: r.one.providers({
from: r.models.providerId,
to: r.providers.id,
optional: false,
}),
},
providers: {
models: r.many.models({
from: r.providers.id,
to: r.models.providerId
}),
},
}))
+315
View File
@@ -0,0 +1,315 @@
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<{
rename: {
enabled: boolean;
modelId: string | null;
};
rerank: {
enabled: boolean;
modelId: string | null;
};
}>().notNull().default({} as any),
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().$onUpdate(() => /* @__PURE__ */ new Date()),
}, (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' }),
config: jsonb('config').$type<{
search?: {
enabled: boolean;
rerank: boolean;
maxResults: number;
}
}>().default({}),
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'),
activeChildId: text('active_child_id'),
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().$onUpdate(() => /* @__PURE__ */ new Date()),
}, (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(),
size: integer('size').notNull().default(0),
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, { onDelete: 'cascade' }),
messageId: text('message_id').notNull().references(() => messages.id, { onDelete: 'cascade' }),
fileId: text('file_id').notNull().references(() => files.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
}, (table) => [
index('attachments_topicId_idx').on(table.topicId),
index('attachments_messageId_idx').on(table.messageId),
index('attachments_userId_idx').on(table.userId),
]);
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const sessions = pgTable(
"sessions",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
},
(table) => [index("sessions_userId_idx").on(table.userId)],
);
export const accounts = pgTable(
"accounts",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("accounts_userId_idx").on(table.userId)],
);
export const verifications = pgTable(
"verifications",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verifications_identifier_idx").on(table.identifier)],
);
-38
View File
@@ -1,38 +0,0 @@
import dotenv from 'dotenv';
dotenv.config();
import jwt from 'jsonwebtoken';
const secret = process.env.TRIPLIT_JWT_SECRET ?? process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error('No secret provided');
}
const anonKey = jwt.sign(
{
'x-triplit-token-type': 'anon',
'x-triplit-project-id': 'local-project-id',
},
secret,
{
noTimestamp: true,
algorithm: 'HS256',
},
);
const serviceKey = jwt.sign(
{
'x-triplit-token-type': 'secret',
'x-triplit-project-id': 'local-project-id',
},
secret,
{
noTimestamp: true,
algorithm: 'HS256',
},
);
console.log('ANON_KEY:', anonKey);
console.log('SERVICE_KEY:', serviceKey);
+7 -12
View File
@@ -1,18 +1,13 @@
import { triplitAdapter } from '@daveyplate/better-auth-triplit'; import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
import { HttpClient } from '@triplit/client'; import { db } from "../server/lib/db";
import { betterAuth } from 'better-auth/minimal'; import { betterAuth } from 'better-auth/minimal';
import { schema } from '../triplit/schema'; import * as schema from '../drizzle/schema';
const httpClient = new HttpClient({
schema,
serverUrl: process.env.NUXT_PUBLIC_TRIPLIT_URL,
token: process.env.TRIPLIT_SERVICE_TOKEN,
});
export const auth = betterAuth({ export const auth = betterAuth({
database: triplitAdapter({ database: drizzleAdapter(db, {
httpClient, provider: "pg",
secretKey: process.env.BETTER_AUTH_SECRET, schema,
usePlural: true,
}), }),
session: { session: {
+10 -30
View File
@@ -69,14 +69,6 @@ export default defineNuxtConfig({
`, `,
tagPosition: 'bodyOpen', tagPosition: 'bodyOpen',
type: 'text/javascript', type: 'text/javascript',
},
{
src: 'https://cdn.jsdelivr.net/npm/eruda',
},
{
innerHTML: `
eruda.init();
`,
} }
] ]
}, },
@@ -115,8 +107,8 @@ export default defineNuxtConfig({
'@vue/devtools-core', '@vue/devtools-core',
'@vue/devtools-kit', '@vue/devtools-kit',
'@sentry/nuxt', '@sentry/nuxt',
'@floating-ui/vue',
'@nuxt/hints/runtime/hydration/component', '@nuxt/hints/runtime/hydration/component',
'@nuxt/hints/runtime/lazy-load/composables',
'@tanstack/vue-virtual', '@tanstack/vue-virtual',
'shiki', 'shiki',
'better-auth/vue', 'better-auth/vue',
@@ -125,14 +117,12 @@ export default defineNuxtConfig({
'remark-parse', 'remark-parse',
'remark-rehype', 'remark-rehype',
'remark-math', 'remark-math',
'remark-breaks',
'rehype-katex', 'rehype-katex',
'unist-util-visit', 'unist-util-visit',
'@triplit/client',
'@triplit/db',
'sorted-btree',
'elen',
'comlink', 'comlink',
'nanoid' 'nanoid',
'drizzle-orm/pg-core'
], ],
}, },
build: { build: {
@@ -142,34 +132,24 @@ 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,
// }, // },
// nitro: {
// experimental: {
// wasm: true
// }
// },
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,
+76 -80
View File
@@ -1,82 +1,78 @@
{ {
"name": "veridian", "name": "veridian",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
"triplit": "triplit dev -s sqlite", "dev": "nuxt dev",
"dev": "nuxt dev", "generate": "nuxt generate",
"generate": "nuxt generate", "preview": "nuxt preview",
"preview": "nuxt preview", "postinstall": "nuxt prepare"
"postinstall": "nuxt prepare" },
}, "dependencies": {
"dependencies": { "@ai-sdk/anthropic": "^3.0.71",
"@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cerebras": "^2.0.45",
"@ai-sdk/cohere": "^3.0.21", "@ai-sdk/cohere": "^3.0.30",
"@ai-sdk/google": "^3.0.29", "@ai-sdk/google": "^3.0.61",
"@ai-sdk/openai-compatible": "^2.0.30", "@ai-sdk/huggingface": "^1.0.43",
"@aws-sdk/client-s3": "^3.1000.0", "@ai-sdk/mistral": "^3.0.30",
"@aws-sdk/s3-request-presigner": "^3.1000.0", "@ai-sdk/openai": "^3.0.52",
"@daveyplate/better-auth-triplit": "^0.2.2", "@ai-sdk/openai-compatible": "^2.0.41",
"@floating-ui/vue": "^1.1.10", "@aws-sdk/client-s3": "^3.1028.0",
"@iconify-json/mynaui": "^1.2.17", "@aws-sdk/s3-request-presigner": "^3.1028.0",
"@nuxt/fonts": "0.14.0", "@better-auth/drizzle-adapter": "https://pkg.pr.new/better-auth/better-auth/@better-auth/drizzle-adapter@6913",
"@nuxt/hints": "1.0.0-alpha.5", "@floating-ui/vue": "^1.1.11",
"@nuxt/icon": "2.2.0", "@iconify-json/mynaui": "^1.2.17",
"@openrouter/ai-sdk-provider": "^2.2.3", "@nuxt/fonts": "0.14.0",
"@sentry/nuxt": "^10.39.0", "@nuxt/hints": "1.0.0-alpha.5",
"@tanstack/vue-virtual": "^3.13.18", "@nuxt/icon": "2.2.0",
"@triplit/client": "^1.0.50", "@openrouter/ai-sdk-provider": "^2.5.1",
"@triplit/server": "^1.1.8", "@pydantic/monty": "^0.0.17",
"@types/big.js": "^6.2.2", "@sentry/nuxt": "^10.48.0",
"ai": "^6.0.89", "@tanstack/vue-virtual": "^3.13.23",
"ai-sdk-ollama": "^3.7.1", "ai": "^6.0.156",
"better-auth": "^1.4.18", "ai-sdk-ollama": "^3.8.3",
"big.js": "^7.0.1", "better-auth": "^1.6.2",
"comlink": "^4.4.2", "comlink": "^4.4.2",
"glob": "^13.0.5", "drizzle-orm": "^1.0.0-beta.21",
"nanoid": "^5.1.6", "glob": "^13.0.6",
"nuxt": "^4.3.1", "longcat-ai-sdk-provider": "^0.0.5",
"rehype-katex": "^7.0.1", "nanoid": "^5.1.7",
"remark-breaks": "^4.0.0", "nuxt": "^4.4.2",
"remark-gfm": "^4.0.1", "pg": "^8.20.0",
"remark-math": "^6.0.0", "rehype-katex": "^7.0.1",
"remark-parse": "^11.0.0", "remark-breaks": "^4.0.0",
"remark-rehype": "^11.1.2", "remark-gfm": "^4.0.1",
"shiki": "^3.22.0", "remark-math": "^6.0.0",
"triplit-nuxt": "0.3.1-prerelease.5", "remark-parse": "^11.0.0",
"unified": "^11.0.5", "remark-rehype": "^11.1.2",
"vue": "^3.5.28", "shiki": "^3.23.0",
"vue-router": "^5.0.3", "unified": "^11.0.5",
"zod": "^4.3.6" "vue": "^3.6.0-beta.10",
}, "vue-router": "^5.0.4",
"devDependencies": { "zod": "^4.3.6"
"@iconify-json/logos": "^1.2.10", },
"@iconify-json/svg-spinners": "^1.2.4", "devDependencies": {
"@iconify-json/tabler": "^1.2.26", "@iconify-json/logos": "^1.2.11",
"@triplit/cli": "^1.0.61", "@iconify-json/svg-spinners": "^1.2.4",
"@types/jsonwebtoken": "^9.0.10", "@iconify-json/tabler": "^1.2.33",
"@types/node": "^25.3.0", "@types/node": "^25.6.0",
"@unocss/nuxt": "^66.6.0", "@types/pg": "^8.20.0",
"jsonwebtoken": "^9.0.3", "@unocss/nuxt": "^66.6.8",
"knip": "^5.84.1", "drizzle-kit": "^1.0.0-beta.21",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"unocss": "^66.6.0" "unocss": "^66.6.8"
}, },
"trustedDependencies": [ "trustedDependencies": [
"@parcel/watcher", "@parcel/watcher",
"@sentry/cli", "@sentry/cli",
"core-js", "core-js",
"esbuild", "esbuild",
"unrs-resolver", "unrs-resolver",
"vue-demi" "vue-demi"
], ],
"patchedDependencies": { "overrides": {
"@triplit/db@1.1.10": "patches/@triplit%2Fdb@1.1.10.patch", "@vercel/nft": "^0.27.4",
"@triplit/client@1.0.50": "patches/@triplit%2Fclient@1.0.50.patch" "vite": "8.0.0-beta.15"
}, }
"overrides": {
"@vercel/nft": "^0.27.4",
"vite": "8.0.0-beta.15"
}
} }
-169
View File
@@ -1,169 +0,0 @@
diff --git a/dist/client/triplit-client.d.ts b/dist/client/triplit-client.d.ts
index 85dc8f6d97ded0e0e4e7bfdfedb1d02ea33405d7..dd9e6e78114f174e97f28cc9e6ac95e3d3037d44 100644
--- a/dist/client/triplit-client.d.ts
+++ b/dist/client/triplit-client.d.ts
@@ -15,6 +15,7 @@ export declare class TriplitClient<M extends Models<M> = Models> {
* The sync engine is responsible for managing the connection to the server and syncing data
*/
syncEngine: SyncEngine;
+ private hasPendingWrites;
private _token;
private claimsPath;
private _serverUrl;
@@ -36,6 +37,11 @@ export declare class TriplitClient<M extends Models<M> = Models> {
*/
constructor(options?: ClientOptions<M>);
get ready(): Promise<void>;
+ /**
+ * Flushes updates to the database and syncs with the server. This function may be a no-op if no
+ * writes have been made since the last flush.
+ */
+ flush(syncWrites?: boolean): Promise<void>;
/**
* Gets the schema of the database
*
@@ -99,7 +105,7 @@ export declare class TriplitClient<M extends Models<M> = Models> {
*
* @param collectionName - The name of the collection to insert into
* @param object - The entity to insert
- * @returns The transaction ID and the inserted entity, if successful
+ * @returns - The inserted entity, if successful
*/
insert<CN extends CollectionNameFromModels<M>>(collectionName: CN, object: WriteModel<M, CN>): Promise<import("@triplit/db").Unalias<import("@triplit/db").Decoded<M[CN]["schema"]>>>;
/**
@@ -108,7 +114,6 @@ export declare class TriplitClient<M extends Models<M> = Models> {
* @param collectionName - The name of the collection to update
* @param entityId - The id of the entity to update
* @param updater - A function that provides the current entity and allows you to modify it
- * @returns The transaction ID
*/
update<CN extends CollectionNameFromModels<M>>(collectionName: CN, entityId: string, data: UpdatePayload<M, CN>): Promise<void>;
/**
@@ -116,7 +121,6 @@ export declare class TriplitClient<M extends Models<M> = Models> {
*
* @param collectionName - The name of the collection to delete from
* @param entityId - The id of the entity to delete
- * @returns The transaction ID
*/
delete<CN extends CollectionNameFromModels<M>>(collectionName: CN, entityId: string): Promise<void>;
entityIsInCache(collection: string, entityId: string): Promise<boolean>;
diff --git a/dist/client/triplit-client.js b/dist/client/triplit-client.js
index c1f54b5b9abb11c4c983cf368d66c8d22379172a..4b9b19f7cf65a603e228b9aec68c1a1218b91eb2 100644
--- a/dist/client/triplit-client.js
+++ b/dist/client/triplit-client.js
@@ -23,6 +23,7 @@ export class TriplitClient {
* The sync engine is responsible for managing the connection to the server and syncing data
*/
syncEngine;
+ hasPendingWrites = false;
_token = undefined;
claimsPath = undefined;
_serverUrl = undefined;
@@ -69,13 +70,21 @@ export class TriplitClient {
this.db = decoded ? this.db.withSessionVars(decoded) : this.db;
}
});
- this.db.onCommit(
- // @ts-expect-error
- throttle(async (tx) => {
- await this.db.updateQueryViews();
- this.db.broadcastToQuerySubscribers();
- await this.syncEngine.syncWrites();
- }, 20, { leading: false, trailing: true }));
+ let writeTimeout = undefined;
+ this.db.onCommit(async () => {
+ this.hasPendingWrites = true;
+ if (writeTimeout) {
+ clearTimeout(writeTimeout);
+ }
+ else {
+ // on the very first write in a batch, flush without writing to the server
+ await this.flush(false);
+ }
+ writeTimeout = setTimeout(() => {
+ this.flush();
+ writeTimeout = undefined;
+ }, 20);
+ });
this.db.onSchemaChange((change) => {
if (change.successful) {
this.http.updateOptions({
@@ -155,6 +164,20 @@ export class TriplitClient {
return this.awaitReady;
return Promise.resolve();
}
+ /**
+ * Flushes updates to the database and syncs with the server. This function may be a no-op if no
+ * writes have been made since the last flush.
+ */
+ async flush(syncWrites = true) {
+ if (!this.hasPendingWrites)
+ return;
+ await this.db.updateQueryViews();
+ this.db.broadcastToQuerySubscribers();
+ if (syncWrites) {
+ await this.syncEngine.syncWrites();
+ this.hasPendingWrites = false;
+ }
+ }
/**
* Gets the schema of the database
*
@@ -314,7 +337,7 @@ export class TriplitClient {
*
* @param collectionName - The name of the collection to insert into
* @param object - The entity to insert
- * @returns The transaction ID and the inserted entity, if successful
+ * @returns - The inserted entity, if successful
*/
async insert(collectionName, object) {
if (this.awaitReady)
@@ -332,7 +355,6 @@ export class TriplitClient {
* @param collectionName - The name of the collection to update
* @param entityId - The id of the entity to update
* @param updater - A function that provides the current entity and allows you to modify it
- * @returns The transaction ID
*/
async update(collectionName, entityId, data) {
if (this.awaitReady)
@@ -349,7 +371,6 @@ export class TriplitClient {
*
* @param collectionName - The name of the collection to delete from
* @param entityId - The id of the entity to delete
- * @returns The transaction ID
*/
async delete(collectionName, entityId) {
if (this.awaitReady)
@@ -976,32 +997,6 @@ function flipOrder(order) {
return undefined;
return order.map((o) => [o[0], o[1] === 'ASC' ? 'DESC' : 'ASC']);
}
-function throttle(func, limit, options) {
- let inThrottle;
- let lastArgs = null;
- return function () {
- const args = arguments;
- if (!inThrottle) {
- if (options?.leading !== false) {
- func(args);
- }
- else {
- lastArgs = args;
- }
- inThrottle = true;
- setTimeout(() => {
- if (options?.trailing && lastArgs) {
- func(lastArgs);
- lastArgs = null;
- }
- inThrottle = false;
- }, limit);
- }
- else {
- lastArgs = args;
- }
- };
-}
function validateServerUrl(serverUrl) {
if (serverUrl &&
!serverUrl.startsWith('http://') &&
-167
View File
@@ -1,167 +0,0 @@
diff --git a/node_modules/@triplit/db/.bun-tag-16a302de81445385 b/.bun-tag-16a302de81445385
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/node_modules/@triplit/db/.bun-tag-38646fa5cb007988 b/.bun-tag-38646fa5cb007988
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/node_modules/@triplit/db/.bun-tag-c381351c2a0f3c94 b/.bun-tag-c381351c2a0f3c94
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/dist/db.js b/dist/db.js
index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..0ce27b58838db11fd51fcf873a182d528d5823fb 100644
--- a/dist/db.js
+++ b/dist/db.js
@@ -17,7 +17,6 @@ import { Type } from './schema/data-types/index.js';
import { getCollectionPermissions } from './permissions.js';
import { QueryBuilder } from './query/query-builder.js';
import { validateSchema } from './schema/validation.js';
-import { tryPreloadingOptionalDeps } from './utils/optional-dep.js';
export class DB {
entityStore;
clock;
@@ -44,7 +43,7 @@ export class DB {
});
this.ivm =
options.ivm ??
- new IVM(
+ new IVM(
// @ts-expect-error - TODO: handle more generalized internal typings
this);
if (options.schema) {
@@ -73,9 +72,9 @@ export class DB {
getSchema() {
return this.schema;
}
- subscribe(query, onResults, onError,
- // TODO: will we need this?
- options = {}) {
+ subscribe(query, onResults, onError,
+ // TODO: will we need this?
+ options = {}) {
const preparedQuery = prepareQuery(query, this.schema?.collections, this.systemVars, this.session, {
applyPermission: options.skipRules ? undefined : 'read',
});
@@ -136,11 +135,11 @@ export class DB {
}
const timestamp = await this.entityStore.metadataStore.getTimestampForEntity(this.kv, collection, entityId);
if (
- // TODO: determine if timestamp can ever be undefined
- // I think the only case could be if the entity was optimistically inserted
- // on the client but never synced to the server
- // assuming that we don't delete metadata when we delete entities
- timestamp &&
+ // TODO: determine if timestamp can ever be undefined
+ // I think the only case could be if the entity was optimistically inserted
+ // on the client but never synced to the server
+ // assuming that we don't delete metadata when we delete entities
+ timestamp &&
HybridLogicalClock.compare(timestamp, options.queryState.timestamp) < 0) {
if (!entitiesThatHaveNotChanged[collection]) {
entitiesThatHaveNotChanged[collection] = new Set();
@@ -192,9 +191,9 @@ export class DB {
onResults(relevantChanges, options.queryKey);
isInitialResponse = false;
};
- return this.ivm.subscribe(preparedQuery,
- // @ts-expect-error - Ignoring because method is deprecated
- callback, options.errorCallback);
+ return this.ivm.subscribe(preparedQuery,
+ // @ts-expect-error - Ignoring because method is deprecated
+ callback, options.errorCallback);
}
async fetch(query, options) {
const preparedQuery = prepareQuery(query, this.schema?.collections, this.systemVars, this.session, {
@@ -307,9 +306,7 @@ export class DB {
// TODO call the listeners in the entity store
// Trigger subscription updates
await this.ivm.bufferChanges(changes);
- for (const listener of this.onCommitListeners) {
- listener(changes);
- }
+ await Promise.all([...this.onCommitListeners].map((listener) => listener(changes)));
return output;
}
async applyChanges(changes, options) {
@@ -609,7 +606,6 @@ export async function createDB(options) {
if (options.kv) {
savedSchema = await DB.getSchemaFromStorage(options.kv);
}
- await tryPreloadingOptionalDeps();
db = new DB({ ...options, schema: savedSchema });
let schemaChange = undefined;
// A schema is provided, attempt to apply it
diff --git a/dist/schema/data-types/type.js b/dist/schema/data-types/type.js
index 2e1281c5577b48ffe609a08da933b5f342a485fa..e88487852f6c49185144a9057ffb11d93a3b6cc8 100644
--- a/dist/schema/data-types/type.js
+++ b/dist/schema/data-types/type.js
@@ -3,7 +3,6 @@ import { DBDeserializationError, DBSerializationError, JSONDeserializationError,
import { prefixOperations, SET_OP_PREFIX, SUPPORTED_OPERATIONS, } from './operations.js';
import { DEFAULTABLE_TYPE_KEYS_SET, PRIMITIVE_TYPE_KEYS_SET } from './index.js';
import { hasNoValue, isDefaultFunction } from '../../utils/value.js';
-import { getOptionalDep } from '../../utils/optional-dep.js';
/**
* Returns an empty object for the given type
* If the type is not a record, it returns undefined
@@ -151,8 +150,8 @@ export function encode(type, input) {
throw new DBSerializationError(`set<${type.items.type}>`, input);
}
throw new UnrecognizedAttributeTypeError(
- // @ts-expect-error If this has an error, it means we are missing a case above
- type.type, 'Failed to encode value');
+ // @ts-expect-error If this has an error, it means we are missing a case above
+ type.type, 'Failed to encode value');
}
export function validateEncoded(type, encoded, options) {
switch (type.type) {
@@ -219,8 +218,8 @@ export function validateEncoded(type, encoded, options) {
};
}
throw new UnrecognizedAttributeTypeError(
- // @ts-expect-error If this has an error, it means we are missing a case above
- type.type, 'Failed to validate value');
+ // @ts-expect-error If this has an error, it means we are missing a case above
+ type.type, 'Failed to validate value');
}
export function decode(type, encoded) {
switch (type.type) {
@@ -274,8 +273,8 @@ export function decode(type, encoded) {
throw new DBDeserializationError(`set<${type.items.type}>`, encoded);
}
throw new UnrecognizedAttributeTypeError(
- // @ts-expect-error If this has an error, it means we are missing a case above
- type.type, 'Failed to decode value');
+ // @ts-expect-error If this has an error, it means we are missing a case above
+ type.type, 'Failed to decode value');
}
// FOR SET KEYS
// Must encode to string
@@ -407,8 +406,8 @@ export function supportedOperations(type) {
if (type.type === 'string')
return SUPPORTED_OPERATIONS.string;
throw new UnrecognizedAttributeTypeError(
- // @ts-expect-error If this has an error, it means we are missing a case above
- type.type, 'Failed to get supported operations');
+ // @ts-expect-error If this has an error, it means we are missing a case above
+ type.type, 'Failed to get supported operations');
}
/**
* Checks if the type has a default value that can be configured
@@ -440,9 +439,6 @@ function calcDefaultValue(config) {
else if (func === 'uuidv4') {
return crypto.randomUUID();
}
- else if (func === 'uuidv7') {
- return getOptionalDep('uuidv7').uuidv7();
- }
else if (func === 'now') {
return new Date().toISOString();
}
diff --git a/dist/utils/optional-dep.d.ts b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/utils/optional-dep.d.ts
deleted file mode 100644
index aa726389af2582fdb764fe313691423ffde89c4c..0000000000000000000000000000000000000000
diff --git a/dist/utils/optional-dep.js b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/utils/optional-dep.js
deleted file mode 100644
index 850317d787608b58f4083aa91e282c3598e32fa3..0000000000000000000000000000000000000000
diff --git a/dist/utils/optional-dep.js.map b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/utils/optional-dep.js.map
deleted file mode 100644
index d39488cfa8b65ac4d7e8b9909c82b7af8cc838e1..0000000000000000000000000000000000000000
+205
View File
@@ -0,0 +1,205 @@
import { db } from '../server/lib/db';
import * as drizzleSchema from '../drizzle/schema';
import { HttpClient } from '@triplit/client';
import { schema } from '../triplit/schema';
import fs from 'node:fs/promises';
export const httpClient = new HttpClient({
schema,
serverUrl: process.env.NUXT_LOCAL_TRIPLIT_URL || process.env.NUXT_PUBLIC_TRIPLIT_URL,
token: process.env.TRIPLIT_SERVICE_TOKEN,
});
/**
* Simple Argument Parser
* Use: --skip=users,accounts --topics-file=ids.txt
*/
const args = process.argv.slice(2);
const skipList = args.find(a => a.startsWith('--skip='))?.split('=')[1].split(',') || [];
const topicsFilePath = args.find(a => a.startsWith('--topics-file='))?.split('=')[1];
async function migrate() {
console.log('🚀 Starting Complex Relational Migration...');
// Load allowed topic IDs if a file was provided
let allowedTopicIds: Set<string> | null = null;
if (topicsFilePath) {
try {
const fileContent = await fs.readFile(topicsFilePath, 'utf-8');
allowedTopicIds = new Set(fileContent.split('\n').map(id => id.trim()).filter(Boolean));
console.log(`📂 Loaded ${allowedTopicIds.size} topic IDs from filter file.`);
} catch (e) {
console.error(`❌ Failed to read topics file: ${topicsFilePath}`);
process.exit(1);
}
}
const shouldSkip = (name: string) => skipList.includes(name);
if (!shouldSkip('users')) {
console.log('📦 Migrating Users...');
const users = await httpClient.fetch(httpClient.query('users'));
if (users.length) {
await db.insert(drizzleSchema.users).values(users).onConflictDoNothing();
console.log(` - Migrated ${users.length} users`);
}
}
if (!shouldSkip('accounts')) {
console.log('📦 Migrating Accounts...');
const accounts = await httpClient.fetch(httpClient.query('accounts'));
if (accounts.length) {
await db.insert(drizzleSchema.accounts).values(accounts).onConflictDoNothing();
console.log(` - Migrated ${accounts.length} accounts`);
}
}
if (!shouldSkip('sessions')) {
console.log('📦 Migrating Sessions...');
const sessions = await httpClient.fetch(httpClient.query('sessions'));
if (sessions.length) {
await db.insert(drizzleSchema.sessions).values(sessions).onConflictDoNothing();
console.log(` - Migrated ${sessions.length} sessions`);
}
}
if (!shouldSkip('verifications')) {
console.log('📦 Migrating Verifications...');
const verifications = await httpClient.fetch(httpClient.query('verifications'));
if (verifications.length) {
await db.insert(drizzleSchema.verifications).values(verifications).onConflictDoNothing();
console.log(` - Migrated ${verifications.length} verifications`);
}
}
if (!shouldSkip('settings')) {
console.log('📦 Migrating Settings...');
const settings = await httpClient.fetch(httpClient.query('settings'));
if (settings.length) {
await db.insert(drizzleSchema.settings).values(settings).onConflictDoNothing();
console.log(` - Migrated ${settings.length} settings`);
}
}
// Providers and Models are usually needed for Topics/Agents, so skip with caution
let providers = await httpClient.fetch(httpClient.query('providers'));
if (!shouldSkip('providers') && providers.length) {
console.log('📦 Migrating Providers...');
const cleanedProviders = providers.map(p => ({
...p,
config: typeof p.config === 'string' ? JSON.parse(p.config) : p.config
}));
await db.insert(drizzleSchema.providers).values(cleanedProviders).onConflictDoNothing();
console.log(` - Migrated ${providers.length} providers`);
}
const rawModels = await httpClient.fetch(httpClient.query('models'));
const models = rawModels.map(m => ({
...m,
cost: typeof m.cost === 'string' ? JSON.parse(m.cost) : m.cost,
attributes: typeof m.attributes === 'string' ? JSON.parse(m.attributes) : m.attributes
}));
if (!shouldSkip('models') && models.length) {
console.log('📦 Migrating Models...');
for (const model of models) {
const { attributes, ...rest } = model;
let drizzleModel = {
inputModalities: Array.from(attributes.inputModalities || ['text']),
outputModalities: Array.from(attributes.outputModalities || ['text']),
capabilities: Array.from(attributes.capabilities || []),
contextWindow: attributes.contextWindow,
supportedParameters: Array.from(attributes.supported_parameters || []),
...rest,
};
await db.insert(drizzleSchema.models).values(drizzleModel).onConflictDoNothing();
}
console.log(` - Migrated ${models.length} models`);
}
let agents = await httpClient.fetch(httpClient.query('agents'));
if (!shouldSkip('agents') && agents.length) {
console.log('📦 Migrating Agents...');
await db.insert(drizzleSchema.agents).values(agents).onConflictDoNothing();
console.log(` - Migrated ${agents.length} agents`);
}
console.log('📦 Migrating Topics...');
let topics = await httpClient.fetch(httpClient.query('topics'));
// Filter by Topic ID file if provided
if (allowedTopicIds) {
topics = topics.filter(t => allowedTopicIds!.has(t.id));
}
if (topics.length) {
await db.insert(drizzleSchema.topics).values(topics).onConflictDoNothing();
console.log(` - Migrated ${topics.length} topics`);
}
// Relational Filtering Helper
const isTopicAllowed = (topicId: string) => topics.some(t => t.id === topicId);
let generations = await httpClient.fetch(httpClient.query('generations'));
generations = generations.filter(g => isTopicAllowed(g.topicId));
if (generations.length) {
console.log('📦 Migrating Generations...');
await db.insert(drizzleSchema.generations).values(generations).onConflictDoNothing();
console.log(` - Migrated ${generations.length} generations`);
}
let messages = await httpClient.fetch(httpClient.query('messages').Order('createdAt', 'ASC'));
messages = messages.filter(m => isTopicAllowed(m.topicId));
if (messages.length) {
console.log('📦 Migrating Messages...');
for (const msg of messages) {
await db.insert(drizzleSchema.messages).values(msg).onConflictDoNothing();
}
console.log(` - Migrated ${messages.length} messages`);
}
if (!shouldSkip('tool_calls')) {
console.log('📦 Migrating Tool Calls...');
let toolCalls = await httpClient.fetch(httpClient.query('tool_calls'));
if (toolCalls.length) {
const cleaned = toolCalls.map(tc => ({
...tc,
input: typeof tc.input === 'string' ? JSON.parse(tc.input) : tc.input,
output: typeof tc.output === 'string' ? JSON.parse(tc.output) : tc.output,
error: typeof tc.error === 'string' ? JSON.parse(tc.error) : tc.error,
}));
await db.insert(drizzleSchema.toolCalls).values(cleaned).onConflictDoNothing();
console.log(` - Migrated ${toolCalls.length} tool calls`);
}
}
let parts = await httpClient.fetch(httpClient.query('message_parts').Order('createdAt', 'ASC'));
parts = parts.filter(p => isTopicAllowed(p.topicId));
if (parts.length) {
console.log('📦 Migrating Message Parts...');
for (const part of parts) {
await db.insert(drizzleSchema.messageParts).values(part).onConflictDoNothing();
}
console.log(` - Migrated ${parts.length} parts`);
}
if (!shouldSkip('files')) {
console.log('📦 Migrating Files...');
let files = await httpClient.fetch(httpClient.query('files'));
if (files.length) {
await db.insert(drizzleSchema.files).values(files).onConflictDoNothing();
console.log(` - Migrated ${files.length} files`);
}
}
console.log('📦 Migrating Attachments...');
let attachments = await httpClient.fetch(httpClient.query('attachments'));
attachments = attachments.filter(a => isTopicAllowed(a.topicId));
if (attachments.length) {
await db.insert(drizzleSchema.attachments).values(attachments).onConflictDoNothing();
console.log(` - Migrated ${attachments.length} attachments`);
}
console.log('✅ Relational migration finished.');
process.exit(0);
}
migrate();

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