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:
+22
-6
@@ -1,6 +1,22 @@
|
||||
TRIPLIT_SERVICE_TOKEN="your-triplit-service-token" # given to you on startup, or by running `tsx generate-tokens.ts`
|
||||
NUXT_TRIPLIT_ANON_TOKEN="your-anonymous-triplit-token" # also given to you on startup, or by running `tsx generate-tokens.ts`
|
||||
BETTER_AUTH_SECRET="super-secret" # openssl rand -base64 32
|
||||
TRIPLIT_JWT_SECRET="super-secret"
|
||||
EXTERNAL_JWT_SECRET=${BETTER_AUTH_SECRET} # no need to change this
|
||||
NUXT_PUBLIC_TRIPLIT_URL="http://localhost:6543" # your triplit server url
|
||||
# Database
|
||||
POSTGRES_DB=veridian
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=changeme
|
||||
DATABASE_URL=postgresql://postgres:changeme@db:5432/veridian
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -21,9 +21,10 @@ server/ # Server-side API routes
|
||||
├── chat/ # Generation, cancel endpoints
|
||||
└── provider/ # Model fetching from providers
|
||||
|
||||
triplit/ # Database
|
||||
├── schema.ts # Full schema with auth + app collections
|
||||
└── auth-schema.ts # Auth collections (users, sessions, accounts)
|
||||
drizzle/ # Database (migrated from Triplit)
|
||||
├── schema.ts # Full schema with auth + app tables
|
||||
├── relations.ts # Table relationships
|
||||
└── migrations/ # SQL migration files
|
||||
|
||||
lib/ # Shared utilities
|
||||
├── auth.ts # Better-auth server config
|
||||
@@ -39,7 +40,9 @@ 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
|
||||
|
||||
@@ -57,15 +60,12 @@ database commands.
|
||||
Group imports in this order (alphabetical within groups):
|
||||
1. Type imports (`types/`)
|
||||
2. Dependency imports (npm packages)
|
||||
3. Triplit imports (`#triplit/`, `@triplit/`)
|
||||
4. Vue/Nuxt imports (`vue`, `#app`, `~~/`, `~/`, `@/`)
|
||||
5. Local imports (`lib/`, `server/`)
|
||||
3. Vue/Nuxt imports (`vue`, `#app`, `~~/`, `~/`, `@/`)
|
||||
4. Local imports (`lib/`, `server/`)
|
||||
|
||||
```typescript
|
||||
// Example import order
|
||||
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 { ref, computed } from 'vue';
|
||||
import { useFeature } from '~/composables/useFeature';
|
||||
@@ -128,25 +128,30 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
```
|
||||
|
||||
## Database Query Pattern (Triplit)
|
||||
## Database Query Pattern (Drizzle)
|
||||
|
||||
```typescript
|
||||
// Client queries use useQuery() with auto-includes
|
||||
useQuery('collection', triplit, triplit.query('collection').Include('relation'));
|
||||
// Server-side queries use Drizzle ORM
|
||||
import { db } from '~~/server/db';
|
||||
import { agents } from '~~/drizzle/schema';
|
||||
|
||||
// Server-side
|
||||
import { httpClient } from '~~/server/lib/triplit';
|
||||
await httpClient.fetchOne(httpClient.query('collection').Where('id', '=', providerId));
|
||||
// Query with relations
|
||||
const result = await db.query.agents.findMany({
|
||||
where: eq(agents.userId, userId),
|
||||
with: { topics: true, messages: true },
|
||||
});
|
||||
|
||||
// Insert with conflict handling
|
||||
await db.insert(agents).values(data).onConflictDoNothing();
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. **Login**: Client uses `authClient.signIn.email()` → derives encryption key from password
|
||||
2. **Session**: Better-auth creates session token → stored in cookie
|
||||
3. **Triplit**: Session token passed to `triplit.startSession(token)` for DB access
|
||||
4. **API Keys**: Encrypted client-side with AES-GCM (key derived from password + userId)
|
||||
3. **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
|
||||
|
||||
@@ -159,10 +164,8 @@ await httpClient.fetchOne(httpClient.query('collection').Where('id', '=', provid
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `TRIPLIT_SERVICE_TOKEN` | Admin DB access |
|
||||
| `NUXT_TRIPLIT_ANON_TOKEN` | Anonymous DB access |
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `BETTER_AUTH_SECRET` | Auth encryption |
|
||||
| `NUXT_PUBLIC_TRIPLIT_URL` | DB server URL |
|
||||
|
||||
## Key Composables
|
||||
|
||||
@@ -176,37 +179,11 @@ await httpClient.fetchOne(httpClient.query('collection').Where('id', '=', provid
|
||||
| `useTheme()` | Accent/neutral theme cookies |
|
||||
| `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
|
||||
|
||||
- **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
|
||||
- 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
|
||||
|
||||
@@ -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
@@ -20,6 +20,19 @@ WORKDIR /app
|
||||
# Only `.output` folder is needed from the build stage
|
||||
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
|
||||
EXPOSE 3000/tcp
|
||||
ENTRYPOINT [ "bun", "--bun", "run", "/app/server/index.mjs" ]
|
||||
ENTRYPOINT [ "bun", "run", "/app/scripts/start.ts" ]
|
||||
|
||||
+5
-4
@@ -2,10 +2,11 @@
|
||||
import '~/assets/css/reset.css';
|
||||
import '~/assets/css/base.css';
|
||||
|
||||
const { accent, neutral, hinting } = useUserSettings();
|
||||
|
||||
// by default, disable hinting
|
||||
if (Number.isNaN(Number(hinting.value))) hinting.value = '0';
|
||||
const { user } = useAuth();
|
||||
const { accent, neutral, hinting, refresh: refreshSettings } = await useUserSettings();
|
||||
watch(user, () => {
|
||||
refreshSettings();
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
useHead({
|
||||
|
||||
+46
-3
@@ -186,6 +186,10 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
display: flex;
|
||||
@@ -220,6 +224,45 @@ button.accent:hover {
|
||||
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 {
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
.reasoning-contaizner.middle {
|
||||
.reasoning-container.middle {
|
||||
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)
|
||||
}
|
||||
|
||||
.reasoning-contaizner.bottom {
|
||||
.reasoning-container.bottom {
|
||||
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000)
|
||||
}
|
||||
|
||||
|
||||
@@ -67,10 +67,6 @@ h6 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import ImageViewer from '~/components/ImageViewer.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
file: {
|
||||
id: string;
|
||||
@@ -7,7 +9,7 @@ const props = defineProps<{
|
||||
status?: 'uploading' | 'uploaded' | 'error';
|
||||
url: string;
|
||||
progress?: number;
|
||||
}
|
||||
};
|
||||
}>();
|
||||
|
||||
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 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(() => {
|
||||
if (isImage.value) return 'i-mynaui-image';
|
||||
if (isVideo.value) return 'i-mynaui-video';
|
||||
@@ -29,11 +35,23 @@ const fileExtension = computed(() => {
|
||||
const parts = props.file.name.split('.');
|
||||
return parts.length > 1 ? parts.pop()?.toUpperCase() : 'FILE';
|
||||
});
|
||||
|
||||
function openImageViewer() {
|
||||
imageViewerOriginRect.value = imageRef.value?.getBoundingClientRect() ?? null;
|
||||
imageViewerOpen.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full w-fit">
|
||||
<img v-if="isImage" :src="props.file.url" class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover" />
|
||||
<div class="relative h-full w-fit flex">
|
||||
<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"
|
||||
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" />
|
||||
@@ -53,10 +71,5 @@ const fileExtension = computed(() => {
|
||||
<span class="text-xs text-white">{{ file.progress || 0 }}%</span>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -21,7 +21,7 @@ const handleDelete = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full w-fit">
|
||||
<div class="relative h-full w-fit flex">
|
||||
<Display :file="props.file" />
|
||||
|
||||
<button @click="handleDelete"
|
||||
|
||||
+141
-30
@@ -1,14 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { BaseMessage } from '~/composables/useChat';
|
||||
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 { Agent } from '~/composables/useAgents';
|
||||
import type FileSelector from './FileSelector.vue';
|
||||
|
||||
const { allModels } = useModels();
|
||||
const { user } = useAuth();
|
||||
const { allModels } = await useModels();
|
||||
const { updateAgent, patchAgentLocally } = await useAgents();
|
||||
|
||||
const inputHeight: Ref<string> = ref('auto');
|
||||
const fileSelectorRef = ref<InstanceType<typeof FileSelector> | null>(null);
|
||||
@@ -30,19 +28,48 @@ watch(textAreaValue, (newValue) => {
|
||||
watch(files, (newFiles) => {
|
||||
inputValue.value.fileIds = newFiles.map(f => f.id);
|
||||
});
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [value: BaseMessage, model: ModelWithProvider | null];
|
||||
cancel: [];
|
||||
resize: [];
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
loading?: boolean;
|
||||
agent: Agent | null;
|
||||
agent: Readonly<Agent> | null;
|
||||
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 handlePaste = async (event: ClipboardEvent) => {
|
||||
@@ -82,8 +109,11 @@ const initializeModel = () => {
|
||||
const updateAgentDefaultModel = async (modelId: string) => {
|
||||
if (!props.agent) return;
|
||||
try {
|
||||
await triplit.update('agents', props.agent.id, (agent) => {
|
||||
agent.defaultModelId = modelId;
|
||||
await $fetch(`/api/agent/${props.agent.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
defaultModelId: modelId,
|
||||
},
|
||||
});
|
||||
} catch (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) {
|
||||
emit('submit', inputValue.value, selectedModel.value);
|
||||
inputValue.value = { content: '', fileIds: [] };
|
||||
emit('submit', structuredClone(toRaw(inputValue.value)), selectedModel.value);
|
||||
files.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) => {
|
||||
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.shiftKey) return;
|
||||
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;
|
||||
if (!textarea) return;
|
||||
const mirror = mirrorRef.value;
|
||||
if (!textarea || !mirror) return;
|
||||
|
||||
inputHeight.value = 'auto';
|
||||
await nextTick();
|
||||
emit('resize');
|
||||
nextTick().then(() => {
|
||||
mirror.textContent = (textarea.value || '\u200b') + '\n';
|
||||
|
||||
const lineHeight = 24;
|
||||
const maxLines = 10;
|
||||
const maxHeight = maxLines * lineHeight;
|
||||
const textareaStyles = getComputedStyle(textarea);
|
||||
mirror.style.width = textareaStyles.width;
|
||||
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) {
|
||||
inputHeight.value = `${maxHeight}px`;
|
||||
} else {
|
||||
inputHeight.value = `${newHeight}px`;
|
||||
}
|
||||
}, { immediate: true });
|
||||
watch(textAreaValue, resizeTextArea, { immediate: true });
|
||||
|
||||
let hasCommandKey = false;
|
||||
if (import.meta.server) {
|
||||
@@ -198,20 +290,34 @@ onBeforeMount(() => {
|
||||
tempInput = (document.getElementById('chat') as HTMLInputElement)?.value ?? '';
|
||||
});
|
||||
|
||||
let resizeRafId: number | undefined;
|
||||
const handleWindowResize = () => {
|
||||
if (resizeRafId) return;
|
||||
resizeRafId = requestAnimationFrame(() => {
|
||||
resizeRafId = undefined;
|
||||
resizeTextArea();
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
textAreaValue.value = tempInput;
|
||||
document.addEventListener('keydown', handleWindowKeyDown);
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
inputRef.value?.addEventListener('paste', handlePaste);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleWindowKeyDown);
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
if (resizeRafId !== undefined) cancelAnimationFrame(resizeRafId);
|
||||
inputRef.value?.removeEventListener('paste', handlePaste);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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)]
|
||||
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">
|
||||
@@ -225,23 +331,28 @@ onUnmounted(() => {
|
||||
<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.`"
|
||||
@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 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 -->
|
||||
<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"
|
||||
: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" />
|
||||
</div>
|
||||
<button aria-label="Send message" @click="handleSubmit"
|
||||
: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',
|
||||
(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)]',
|
||||
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-else class="text-5 i-mynaui-send-solid"></span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useAgents } from '~/composables/useAgents';
|
||||
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
|
||||
|
||||
const { agents } = useAgents();
|
||||
const { agents } = await useAgents();
|
||||
const route = useRoute();
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
@@ -116,14 +116,19 @@ onMounted(() => { inputRef.value?.focus(); });
|
||||
|
||||
<template>
|
||||
<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 -->
|
||||
<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"
|
||||
aria-haspopup="listbox" :aria-expanded="flattenedResults.length > 0" :aria-controls="listboxId"
|
||||
: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)]"
|
||||
@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 ref="navRef"
|
||||
@@ -134,7 +139,7 @@ onMounted(() => { inputRef.value?.focus(); });
|
||||
<!-- AGENT ROW -->
|
||||
<div v-if="item.type === 'agent'" :id="item.uiId" role="option"
|
||||
: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)]'">
|
||||
<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)]']">
|
||||
@@ -148,7 +153,7 @@ onMounted(() => { inputRef.value?.focus(); });
|
||||
<!-- TOPIC ROW -->
|
||||
<div v-else :id="item.uiId" role="option" :aria-selected="selectedIndex === index"
|
||||
@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)]'">
|
||||
<!-- Visual Tree Lines -->
|
||||
<div class="absolute -left-4 top-0 bottom-0 w-[1px] h-[110%] bg-[var(--text-dim)]"></div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import SystemAssistants from '~/components/Settings/SystemAssistants.vue';
|
||||
import AppearanceSettings from '~/components/Settings/AppearanceSettings.vue';
|
||||
import AIServiceProvider from '~/components/Settings/AIServiceProvider.vue';
|
||||
|
||||
const { providers } = useModels();
|
||||
const { providers } = await useModels();
|
||||
|
||||
const PAGES_CONFIG = {
|
||||
general: {
|
||||
@@ -39,6 +39,21 @@ const props = defineProps<{
|
||||
|
||||
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(() => {
|
||||
// 1. Get the base config (e.g., 'providers' or 'general')
|
||||
const config = PAGES_CONFIG[props.page as keyof typeof PAGES_CONFIG] || PAGES_CONFIG.general;
|
||||
@@ -70,14 +85,27 @@ const runtimePage = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-2 flex h-[70vh] h-[70dvh] w-[85vw]">
|
||||
<nav class="w-64 flex flex-col gap-1 mr-2 overflow-y-auto">
|
||||
<!-- If the page has a custom sidebar (for nested lists), show it; otherwise show default nav -->
|
||||
<component v-if="runtimePage?.sidebar" :is="runtimePage.sidebar"
|
||||
@navigate="(p: string, params?: string) => setOptions({ page: p, params })" :params="props.params" />
|
||||
<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-full md:w-64 flex-col gap-1 mr-2 overflow-y-auto text-xl md:text-base',
|
||||
// Mobile: hidden by default, shown as fixed overlay
|
||||
'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 })"
|
||||
: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']">
|
||||
<!-- If the page has a custom sidebar (for nested lists), show it; otherwise show default nav -->
|
||||
<component v-if="runtimePage?.sidebar" :is="runtimePage.sidebar" @navigate="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">
|
||||
<span :class="['w-5 h-5 text-5', config.icon]"></span>
|
||||
{{ config.label }}
|
||||
@@ -87,17 +115,24 @@ const runtimePage = computed(() => {
|
||||
|
||||
<!-- DYNAMIC CONTENT -->
|
||||
<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 ">
|
||||
<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
|
||||
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">
|
||||
<span class="i-mynaui-x-solid"></span>
|
||||
</button>
|
||||
</header>
|
||||
<component @navigate="(p: string, params?: string) => setOptions({ page: p, params })"
|
||||
:is="runtimePage.component" :params="props.params" />
|
||||
<component @navigate="handleNavigate" :is="runtimePage.component" :params="props.params" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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" />
|
||||
<div class="flex justify-end gap-2 m-2">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,9 +53,9 @@ onUnmounted(() => {
|
||||
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-2" leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 -translate-y-2">
|
||||
<div v-if="open" class="z-50 fixed top-1/2 left-1/2 -translate-x-1/2 flex items-center justify-center">
|
||||
<div 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
|
||||
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>
|
||||
<Settings v-if="page === DialogType.Settings" :page="data.page" :params="data.params" />
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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<{
|
||||
placement?: Placement;
|
||||
@@ -8,7 +8,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const triggerRef = ref(null);
|
||||
const triggerRef = ref<HTMLElement | null>(null);
|
||||
const dropdownRef = ref(null);
|
||||
|
||||
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',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [offset(6), flip(), shift({ padding: 10 })],
|
||||
middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
|
||||
transform: false,
|
||||
});
|
||||
|
||||
@@ -55,8 +55,12 @@ defineExpose({ close });
|
||||
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-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"
|
||||
:class="[dropdownClass, transformOrigin]">
|
||||
<div v-if="isOpen" ref="dropdownRef" :style="{
|
||||
...floatingStyles,
|
||||
visibility: middlewareData.hide?.referenceHidden
|
||||
? 'hidden'
|
||||
: 'visible',
|
||||
}" class="fixed z-9999" :class="[dropdownClass, transformOrigin]">
|
||||
<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">
|
||||
<slot name="dropdown" :close="close" />
|
||||
|
||||
@@ -8,8 +8,6 @@ const props = defineProps<{
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
const files = defineModel<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -20,8 +18,6 @@ const files = defineModel<{
|
||||
}[]>({ required: false, default: [] });
|
||||
const rawFiles = ref<File[]>([]);
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const activeUploads = ref<Map<string, XMLHttpRequest>>(new Map());
|
||||
|
||||
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', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
file: {
|
||||
name: fileName,
|
||||
mimeType: fileType,
|
||||
}
|
||||
}),
|
||||
},
|
||||
}) as { url: string; assetUrl: string };
|
||||
|
||||
await uploadWithProgress(file, uploadUrl, id);
|
||||
|
||||
await triplit.insert('files', {
|
||||
id,
|
||||
userId: user.value!.id,
|
||||
name: fileName,
|
||||
mimeType: fileType,
|
||||
url: assetUrl,
|
||||
});
|
||||
await $fetch('/api/file', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
id,
|
||||
name: fileName,
|
||||
mimeType: fileType,
|
||||
size: file.size,
|
||||
url: assetUrl,
|
||||
},
|
||||
})
|
||||
|
||||
files.value = files.value.map(f => {
|
||||
if (f.name === file.name) {
|
||||
@@ -148,12 +147,6 @@ watch(files, async (newFiles, oldFiles) => {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -185,21 +178,21 @@ onUnmounted(() => {
|
||||
<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)]">
|
||||
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>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #dropdown="{ close }">
|
||||
<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()"
|
||||
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>Upload image</span>
|
||||
</button>
|
||||
<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>Upload file</span>
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
@@ -7,20 +7,21 @@ defineProps<{
|
||||
|
||||
const TITLE = 'Cerebras';
|
||||
|
||||
const BACKGROUND_COLOR = "#F15A29";
|
||||
const BACKGROUND_COLOR = "#FFF";
|
||||
const AVATAR_SCALE = 0.8;
|
||||
</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;` : '']">
|
||||
<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});` : '']"
|
||||
viewBox="0 0 24 24" :width="size" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>{{ TITLE }}</title>
|
||||
<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"
|
||||
:fill="!avatar && color ? '#F15A29' : ''" :fillRule="!avatar && color ? 'evenodd' : ''" />
|
||||
:fill="avatar || color ? '#F15A29' : ''" :fillRule="avatar || color ? 'evenodd' : ''" />
|
||||
<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" />
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
size?: string | number;
|
||||
color?: boolean;
|
||||
avatar?: boolean;
|
||||
}>();
|
||||
|
||||
const TITLE = 'ClosedRouter';
|
||||
const BACKGROUND_COLOR = "#050816";
|
||||
const AVATAR_SCALE = 0.7;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center"
|
||||
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
|
||||
<svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
|
||||
viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>{{ TITLE }}</title>
|
||||
|
||||
<g transform="translate(-10.909 -10)" stroke-width="2.1818">
|
||||
<path
|
||||
d="m16.364 14.364v6.5455c0 2.41 1.9537 4.3636 4.3636 4.3636h4.3636c2.41 0 4.3636 1.9537 4.3636 4.3636v0"
|
||||
:stroke="color ? '#00e87a' : 'currentColor'" stroke-linecap="round" stroke-linejoin="round" />
|
||||
|
||||
<g :fill="BACKGROUND_COLOR">
|
||||
<circle cx="16.364" cy="14.364" r="3.2727" :stroke="color ? '#00e87a' : 'currentColor'" />
|
||||
<circle cx="29.455" cy="29.636" r="3.2727" :stroke="color ? '#00e87a' : 'currentColor'" />
|
||||
<circle cx="29.455" cy="14.364" r="3.2727" :stroke="color ? '#7aa2ff' : 'currentColor'" />
|
||||
</g>
|
||||
|
||||
<path d="m20.727 14.364h5.4545" :stroke="color ? '#7aa2ff' : 'currentColor'" stroke-linecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -15,8 +15,8 @@ const BACKGROUND_COLOR = "#fff";
|
||||
<div class="inline-flex items-center justify-center"
|
||||
: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});` : '']"
|
||||
fill="currentColor" fill-rule="evenodd" style="flex: none; line-height: 1;" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
:fill="avatar ? '#000' : 'currentColor'" fill-rule="evenodd" style="flex: none; line-height: 1;"
|
||||
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>{{ TITLE }}</title>
|
||||
<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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -5,16 +5,31 @@ const props = defineProps<{ code: string; language: string }>();
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const MAX_LENGTH = 15000;
|
||||
|
||||
const renderId = `${useId()}-${hashSync(props.code + props.language)}`;
|
||||
const copied = 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 { 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'
|
||||
}
|
||||
);
|
||||
@@ -72,15 +87,23 @@ console.log("shiki codeblock rendered in", Date.now() - start);
|
||||
{{ parsed?.displayLang }}
|
||||
</div>
|
||||
<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()"
|
||||
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' }}
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<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
|
||||
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>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
import type { MessagePart } from '~/composables/useChat';
|
||||
|
||||
const props = defineProps<{
|
||||
part: Readonly<Entity<typeof schema, 'message_parts'>>;
|
||||
part: Readonly<MessagePart>;
|
||||
}>();
|
||||
|
||||
const reasoningOpen = ref(!props.part.finished);
|
||||
@@ -30,6 +29,12 @@ const handleScroll = () => {
|
||||
|
||||
const topThreshold = 0.02 * clientHeight;
|
||||
|
||||
if (scrollHeight <= clientHeight) {
|
||||
// the container does not have enough content to scroll
|
||||
scrollState.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollTop <= topThreshold) {
|
||||
scrollState.value = 'top';
|
||||
} else if (scrollTop + 100 >= scrollHeight - clientHeight) {
|
||||
@@ -48,28 +53,48 @@ const toggleReasoning = async () => {
|
||||
scrollToBottom('instant');
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="text-[--text-tertiary]">
|
||||
<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'
|
||||
]">
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="w-[24px] h-[24px] 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>
|
||||
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-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>
|
||||
Deep Thinking
|
||||
</span>
|
||||
<span class="i-mynaui-chevron-down w-4 h-4 text-[var(--text-secondary)]"
|
||||
:class="reasoningOpen ? '' : '-rotate-90'"></span>
|
||||
</button>
|
||||
<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">
|
||||
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" />
|
||||
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content!" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
import type { MessagePart } from '~/composables/useChat';
|
||||
|
||||
const props = defineProps<{
|
||||
part: Readonly<Entity<typeof schema, 'message_parts'>>;
|
||||
part: Readonly<MessagePart>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" />
|
||||
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content!" />
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
import type { ToolCall } from '~/composables/useChat';
|
||||
|
||||
const props = defineProps<{
|
||||
toolCall: Readonly<Entity<typeof schema, 'tool_calls'>>;
|
||||
toolCall: Readonly<ToolCall>;
|
||||
}>();
|
||||
|
||||
const activeTab = ref('input');
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
import type { ToolCall } from '~/composables/useChat';
|
||||
import Debug from './Debug.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
toolCall: Readonly<Entity<typeof schema, 'tool_calls'>>;
|
||||
toolCall: Readonly<ToolCall>;
|
||||
}>();
|
||||
|
||||
const deubgToolCallOpen = ref(false);
|
||||
@@ -17,7 +16,7 @@ const toggleDebugToolCall = () => {
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<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 gap-1">
|
||||
<div
|
||||
@@ -34,7 +33,7 @@ const toggleDebugToolCall = () => {
|
||||
<div
|
||||
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<button @click="toggleDebugToolCall"
|
||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden hover:bg-[var(--color-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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ const toggleTokenDropdown = () => {
|
||||
|
||||
<template>
|
||||
<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" />
|
||||
<Text v-else-if="part.type === 'text'" :part="part" />
|
||||
<Tool v-else-if="part.type === 'tool-call'" :toolCall="part.toolCall!" />
|
||||
@@ -54,7 +54,7 @@ const toggleTokenDropdown = () => {
|
||||
</div>
|
||||
|
||||
<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="i-tabler-coins"></span>
|
||||
{{ message.generation?.tokens?.output }}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<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 { triggerElement, activeGeneration: generation, close, isOpen } = useTokenDropdown();
|
||||
|
||||
const { floatingStyles } = useFloating(triggerElement, tokenDetailsDropdownRef, {
|
||||
const { floatingStyles, middlewareData } = useFloating(triggerElement, tokenDetailsDropdownRef, {
|
||||
placement: 'bottom-end',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(4),
|
||||
flip(),
|
||||
shift({ padding: 10 }),
|
||||
hide(),
|
||||
],
|
||||
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">
|
||||
|
||||
<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">
|
||||
|
||||
<!-- Output Details Section -->
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
import * as schema from '~~/drizzle/schema';
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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" />
|
||||
<div v-if="message.attachments.length > 0" class="flex flex-col gap-2">
|
||||
<MarkdownRenderer v-if="message.content" :finished="true" :content="message.content" :id="message.id" />
|
||||
<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"
|
||||
class="flex flex-wrap items-center gap-2">
|
||||
<div
|
||||
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>
|
||||
|
||||
@@ -1,70 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import type { Message } from '~/composables/useChat';
|
||||
import { assert } from '~~/utils/assert';
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const { message } = defineProps<{
|
||||
message: Message
|
||||
}>();
|
||||
|
||||
const focusedIndex = computed({
|
||||
get: () => {
|
||||
return message.focusedIndex || 0;
|
||||
},
|
||||
set: (newValue) => {
|
||||
triplit.update('messages', message.id, {
|
||||
focusedIndex: newValue
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
regenerate: [];
|
||||
delete: [];
|
||||
edit: [value: string];
|
||||
patch: [updates: Partial<Message>];
|
||||
}>();
|
||||
|
||||
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) => {
|
||||
if (newCount === oldCount) return;
|
||||
|
||||
// if we are deleting children, only move the focus index if its no longer valid
|
||||
// e.g. if we we have 4 children, and are focused on the 2nd, if we delete it,
|
||||
// we want to keep the focus on the 2nd index. It just makes me feel better
|
||||
if (newCount === 0) {
|
||||
emit('patch', { activeChildId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldCount > newCount) {
|
||||
if (message.deleted === true && focusedIndex.value === newCount) {
|
||||
focusedIndex.value = Math.max(0, newCount - 1);
|
||||
return;
|
||||
const activeChild = message.children.find(c => c.id === message.activeChildId);
|
||||
if (!activeChild) {
|
||||
emit('patch', { activeChildId: message.children[newCount - 1]?.id ?? null });
|
||||
}
|
||||
|
||||
if (focusedIndex.value > newCount) {
|
||||
|
||||
focusedIndex.value = newCount;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.deleted === true) {
|
||||
focusedIndex.value = newCount - 1;
|
||||
return;
|
||||
const newChild = message.children[newCount - 1];
|
||||
if (newChild) {
|
||||
emit('patch', { activeChildId: newChild.id });
|
||||
}
|
||||
|
||||
focusedIndex.value = newCount;
|
||||
});
|
||||
|
||||
const activeMessage = computed(() => {
|
||||
if (message.children.length === 0 || (focusedIndex.value === 0 && !message.deleted)) {
|
||||
return message;
|
||||
if (message.activeChildId && message.children.length > 0) {
|
||||
const child = message.children.find(c => c.id === message.activeChildId);
|
||||
if (child) return child;
|
||||
}
|
||||
|
||||
if (message.deleted === true) {
|
||||
return message.children[Math.min(focusedIndex.value, message.children.length - 1)];
|
||||
if (message.deleted === true && message.children.length > 0) {
|
||||
return message.children[0];
|
||||
}
|
||||
|
||||
return message.children[focusedIndex.value - 1];
|
||||
return message;
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
regenerate: []
|
||||
delete: []
|
||||
}>();
|
||||
|
||||
const copied = ref(false);
|
||||
const copyMessage = async () => {
|
||||
if (activeMessage.value!.role === 'user') {
|
||||
@@ -91,33 +94,40 @@ const regenerateMessage = async () => {
|
||||
}
|
||||
|
||||
const handleEdit = async () => {
|
||||
const originalContent = message.content!;
|
||||
|
||||
openDialog<string>(DialogType.Textbox, async (res) => {
|
||||
if (res.ok) {
|
||||
if (!res.data) return;
|
||||
|
||||
await triplit.update('messages', message.id, {
|
||||
content: res.data
|
||||
const req = await $fetch(`/api/messages/${message.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
content: res.data
|
||||
},
|
||||
onRequest() {
|
||||
message.content = res.data;
|
||||
emit('edit', res.data);
|
||||
},
|
||||
onResponseError() {
|
||||
message.content = originalContent;
|
||||
emit('edit', originalContent);
|
||||
},
|
||||
});
|
||||
|
||||
assert('flush' in triplit);
|
||||
await triplit.flush();
|
||||
if (!req.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
await regenerateMessage();
|
||||
}
|
||||
}, {
|
||||
title: 'Edit Message',
|
||||
initialValue: message.content
|
||||
initialValue: message.content!
|
||||
});
|
||||
};
|
||||
|
||||
const deleteMessage = () => {
|
||||
if (focusedIndex.value !== 0 && focusedIndex.value === message.children.length) {
|
||||
focusedIndex.value = Math.max(0, focusedIndex.value - 1);
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
emit('delete');
|
||||
});
|
||||
emit('delete');
|
||||
};
|
||||
|
||||
const messageCount = computed(() => {
|
||||
@@ -127,6 +137,27 @@ const messageCount = computed(() => {
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -138,21 +169,21 @@ const messageCount = computed(() => {
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div v-if="messageCount > 1" class="flex gap-1">
|
||||
<Tooltip :inert="focusedIndex === 0" :hotkey="['alt', '[']">
|
||||
<button @click="focusedIndex = focusedIndex! - 1"
|
||||
class="hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Tooltip :inert="currentChildIndex === 0" :hotkey="['alt', '[']">
|
||||
<button @click="navigateChild(-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)]"
|
||||
:class="focusedIndex === 0 ? 'opacity-0' : ''"></span>
|
||||
:class="currentChildIndex === 0 ? 'opacity-0' : ''"></span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span class="text-xs text-[var(--text-secondary)]">
|
||||
{{ focusedIndex + 1 }} / {{ messageCount }}
|
||||
{{ currentChildIndex + 1 }} / {{ messageCount }}
|
||||
</span>
|
||||
<Tooltip :inert="focusedIndex + 1 === messageCount" :hotkey="['alt', ']']">
|
||||
<button @click="focusedIndex = focusedIndex + 1"
|
||||
class="hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<Tooltip :inert="currentChildIndex + 1 === messageCount" :hotkey="['alt', ']']">
|
||||
<button @click="navigateChild(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)]"
|
||||
:class="focusedIndex + 1 === messageCount ? 'opacity-0' : ''"></span>
|
||||
:class="currentChildIndex + 1 === messageCount ? 'opacity-0' : ''"></span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</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)]">
|
||||
<Tooltip :hotkey="['ctrl', 'shift', 'enter']">
|
||||
<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>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<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>
|
||||
</button>
|
||||
<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>
|
||||
</button>
|
||||
<Tooltip :hotkey="['ctrl', 'shift', 'backspace']">
|
||||
<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>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<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<{
|
||||
model: Model;
|
||||
@@ -22,8 +24,6 @@ const emit = defineEmits<{
|
||||
edit: [model: Model];
|
||||
}>();
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const formatContextWindow = (window: number | null | undefined): string => {
|
||||
if (!window) return '';
|
||||
if (window >= 1000000) return `${(window / 1000000).toFixed(0)}M`;
|
||||
@@ -31,28 +31,16 @@ const formatContextWindow = (window: number | null | undefined): string => {
|
||||
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(() => {
|
||||
return props.showCost && (props.model.cost.prompt || props.model.cost.completion || props.model.cost.request);
|
||||
});
|
||||
|
||||
const toggleModel = async (id: string) => {
|
||||
await triplit.update('models', id, {
|
||||
await updateModel(id, {
|
||||
enabled: !props.model.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
const deleteModel = async () => {
|
||||
await triplit.delete('models', props.model.id);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
emit('edit', props.model);
|
||||
};
|
||||
@@ -79,11 +67,11 @@ const handleEdit = () => {
|
||||
<div v-if="showEdit" class="flex items-center gap-2">
|
||||
<!-- Edit button - only for custom models -->
|
||||
<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>
|
||||
</button>
|
||||
<button @click="deleteModel"
|
||||
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)]">
|
||||
<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)]">
|
||||
<span class="i-mynaui-trash text-3.5"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -91,7 +79,11 @@ const handleEdit = () => {
|
||||
|
||||
<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">
|
||||
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>
|
||||
<template v-if="showCost">
|
||||
<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-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">
|
||||
<span class="i-mynaui-image text-2.5 text-emerald"></span>
|
||||
</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">
|
||||
<span class="i-mynaui-atom text-2.5 text-[var(--reasoning-accent)]"></span>
|
||||
</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">
|
||||
<span class="i-mynaui-tool text-2.5 text-sky"></span>
|
||||
</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)]">
|
||||
{{ formatContextWindow(model.attributes.contextWindow) }}
|
||||
{{ formatContextWindow(model.contextWindow) }}
|
||||
</span>
|
||||
|
||||
<Slider v-if="showEdit" :checked="model.enabled" @click="toggleModel(model.id)" />
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useFloating, offset, flip, shift, autoUpdate, size } from '@floating-ui/vue';
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||
import { useFloating, offset, flip, shift, autoUpdate, size, hide } from '@floating-ui/vue';
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
import type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels';
|
||||
import { sortByReleaseDate } from '~/utils/sort';
|
||||
import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
const { allModels } = useModels();
|
||||
const { allModels } = await useModels();
|
||||
const { addShortcut } = useKeyboardShortcuts();
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -28,7 +27,7 @@ const virtualizerRef = ref<InstanceType<typeof RowVirtualizerFixed> | null>(null
|
||||
const navigatingWithKeyboard = ref(false);
|
||||
const focusedOptionId = ref<string | null>(null);
|
||||
|
||||
const { floatingStyles, placement } = useFloating(dropdownButton, dropdownContent, {
|
||||
const { floatingStyles, placement, middlewareData } = useFloating(dropdownButton, dropdownContent, {
|
||||
placement: 'bottom-start',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
@@ -43,6 +42,7 @@ const { floatingStyles, placement } = useFloating(dropdownButton, dropdownConten
|
||||
},
|
||||
padding: 10,
|
||||
}),
|
||||
hide()
|
||||
],
|
||||
transform: false,
|
||||
});
|
||||
@@ -65,8 +65,8 @@ const filteredProviders = computed(() => {
|
||||
|
||||
const flatOptions = computed(() => {
|
||||
type FlatItem =
|
||||
| { type: 'header'; id: string; provider: Entity<typeof schema, 'providers'> }
|
||||
| { type: 'model'; id: string; model: Entity<typeof schema, 'models'>; provider: Entity<typeof schema, 'providers'> };
|
||||
| { type: 'header'; id: string; provider: Provider }
|
||||
| { type: 'model'; id: string; model: Model; provider: Provider };
|
||||
|
||||
const options: FlatItem[] = [];
|
||||
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 };
|
||||
closeDropdown();
|
||||
};
|
||||
@@ -212,11 +212,11 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<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="[
|
||||
isOpen
|
||||
? '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"
|
||||
:model-id="selectedModel.externalId" size="22" />
|
||||
@@ -236,8 +236,12 @@ onUnmounted(() => {
|
||||
<KeepAlive>
|
||||
<div v-if="isOpen" v-click-outside="closeDropdown" ref="dropdownContent" role="listbox"
|
||||
aria-label="Select model"
|
||||
:aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined"
|
||||
:style="floatingStyles"
|
||||
:aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined" :style="{
|
||||
...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="transformOrigin">
|
||||
|
||||
@@ -259,14 +263,14 @@ onUnmounted(() => {
|
||||
<template v-else>
|
||||
<RowVirtualizerFixed ref="virtualizerRef" :items="flatOptions" key-field="id"
|
||||
:scroll-element="scrollContainerRef" :item-size="42" :overscan="10">
|
||||
<template v-slot="{ item, index }">
|
||||
<template v-slot="{ item }">
|
||||
<template v-if="item.type === 'header'">
|
||||
<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">
|
||||
{{ item.provider.name }}
|
||||
<button
|
||||
@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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -275,7 +279,7 @@ onUnmounted(() => {
|
||||
<button :id="`model-option-${item.model.id}`" role="option"
|
||||
:aria-selected="focusedOptionId === item.model.id"
|
||||
@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="{
|
||||
'bg-[var(--color-hover)]': selectedModel?.id === item.model.id,
|
||||
'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)]">
|
||||
<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' });">
|
||||
<span class="i-mynaui-cog-four text-4.5"></span>
|
||||
<span>Manage Providers</span>
|
||||
|
||||
@@ -21,17 +21,6 @@ const updateScrollMargin = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateScrollMargin);
|
||||
|
||||
onMounted(() => {
|
||||
resizeObserver.observe(containerRef.value!);
|
||||
updateScrollMargin();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver.disconnect();
|
||||
});
|
||||
|
||||
const rowVirtualizer = useVirtualizer(computed(() => ({
|
||||
count: props.items.length,
|
||||
getScrollElement: () => props.scrollElement,
|
||||
@@ -39,22 +28,25 @@ const rowVirtualizer = useVirtualizer(computed(() => ({
|
||||
overscan: props.overscan,
|
||||
scrollMargin: scrollMargin.value,
|
||||
getItemKey: (index: number) => props.keyField ? props.items[index]?.[props.keyField] || index : index,
|
||||
initialRect: {
|
||||
width: 0,
|
||||
height: props.prerender ? props.minItemSize * props.prerender : 0
|
||||
},
|
||||
})));
|
||||
|
||||
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems());
|
||||
const totalSize = computed(() => rowVirtualizer.value.getTotalSize());
|
||||
|
||||
const measureElement = (el: Element) => {
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', updateScrollMargin);
|
||||
updateScrollMargin();
|
||||
});
|
||||
|
||||
rowVirtualizer.value.measureElement(el)
|
||||
}
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateScrollMargin);
|
||||
});
|
||||
|
||||
const measureElement = (el: any) => {
|
||||
if (el) {
|
||||
rowVirtualizer.value.measureElement(el);
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.items, () => {
|
||||
rowVirtualizer.value.measure();
|
||||
@@ -69,7 +61,6 @@ watch(() => props.items, () => {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
|
||||
}">
|
||||
<slot :item="props.items[virtualRow.index]" :index="virtualRow.index" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual';
|
||||
|
||||
const props = defineProps<{
|
||||
items: any[];
|
||||
items: readonly any[] | any[];
|
||||
keyField?: string;
|
||||
scrollElement: HTMLElement | null;
|
||||
itemSize: number;
|
||||
|
||||
@@ -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>
|
||||
@@ -1,16 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { sortByReleaseDate } from '~/utils/sort';
|
||||
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 RowVirtualizerDynamic from '../RowVirtualizerDynamic.vue';
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const props = defineProps<{
|
||||
params?: string;
|
||||
}>();
|
||||
|
||||
const { providers } = useModels();
|
||||
const { providers, updateProvider, createModel, updateModel } = await useModels();
|
||||
|
||||
const scrollContainerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -32,10 +31,10 @@ const modelSearch = ref('');
|
||||
|
||||
watch(() => props.params, () => {
|
||||
if (props.params === undefined) return;
|
||||
console.log(scrollContainerRef.value);
|
||||
apiKey.value = provider.value?.config.apiKey ?? '';
|
||||
apiProxyUrl.value = provider.value?.config.apiProxyUrl ?? '';
|
||||
modelSearch.value = '';
|
||||
apiKeyVisible.value = false;
|
||||
|
||||
nextTick(() => {
|
||||
if (scrollContainerRef.value) {
|
||||
@@ -65,11 +64,13 @@ if (import.meta.client) {
|
||||
};
|
||||
|
||||
const toggleProvider = async () => {
|
||||
await triplit.update('providers', provider.value!.id, {
|
||||
await updateProvider(provider.value!.id, {
|
||||
enabled: !provider.value!.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
let apiKeyTimeout: NodeJS.Timeout | undefined;
|
||||
|
||||
const updateApiKey = async (value: string) => {
|
||||
if (!provider.value) return;
|
||||
|
||||
@@ -82,91 +83,63 @@ const updateApiKey = async (value: string) => {
|
||||
);
|
||||
const encypted = await encryptData(key, value);
|
||||
|
||||
await triplit.update('providers', provider.value.id, {
|
||||
config: {
|
||||
...provider.value.config,
|
||||
apiKey: uint8ArrayToBase64(encypted),
|
||||
},
|
||||
});
|
||||
if (apiKeyTimeout) {
|
||||
clearTimeout(apiKeyTimeout);
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (!provider.value) return;
|
||||
|
||||
apiProxyUrl.value = value;
|
||||
|
||||
await triplit.update('providers', provider.value.id, {
|
||||
config: {
|
||||
...provider.value.config,
|
||||
apiProxyUrl: value,
|
||||
},
|
||||
});
|
||||
if (proxyUrlTimeout) {
|
||||
clearTimeout(proxyUrlTimeout);
|
||||
}
|
||||
|
||||
proxyUrlTimeout = setTimeout(async () => {
|
||||
await updateProvider(provider.value!.id, {
|
||||
config: {
|
||||
...provider.value!.config,
|
||||
apiProxyUrl: value,
|
||||
},
|
||||
});
|
||||
}, 700);
|
||||
};
|
||||
|
||||
const fetchingModels = ref(false);
|
||||
|
||||
const fetchModels = async () => {
|
||||
const { user } = useAuth()
|
||||
|
||||
fetchingModels.value = true;
|
||||
|
||||
try {
|
||||
const modelsData = await $fetch(`/api/provider/${provider.value!.id}/models`, {
|
||||
const response = await $fetch(`/api/provider/${provider.value!.id}/models`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
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
|
||||
const apiModelIds = new Set(modelsData.models.values().map((m: any) => m.id));
|
||||
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) {
|
||||
// Optional: show a success toast
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch models:', error);
|
||||
// handle error (toast, etc)
|
||||
} finally {
|
||||
fetchingModels.value = false;
|
||||
}
|
||||
@@ -175,23 +148,64 @@ const fetchModels = async () => {
|
||||
const deleteModels = async () => {
|
||||
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 () => {
|
||||
if (!provider.value) return;
|
||||
|
||||
await Promise.all(provider.value.models.map(m => triplit.update('models', m.id, {
|
||||
enabled: true
|
||||
})));
|
||||
const originalModels = provider.value.models;
|
||||
|
||||
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 () => {
|
||||
if (!provider.value) return;
|
||||
|
||||
await Promise.all(provider.value.models.map(m => triplit.update('models', m.id, {
|
||||
enabled: false
|
||||
})));
|
||||
const originalModels = provider.value.models;
|
||||
|
||||
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(() =>
|
||||
@@ -272,13 +286,13 @@ const openEditPanel = (model: Model) => {
|
||||
formData.value = {
|
||||
name: model.name || '',
|
||||
externalId: model.externalId || '',
|
||||
contextWindow: model.attributes?.contextWindow?.toString() || '',
|
||||
capabilities: [...(model.attributes?.capabilities || [])],
|
||||
contextWindow: model.contextWindow?.toString() || '',
|
||||
capabilities: model.capabilities,
|
||||
promptCost: model.cost?.prompt || '',
|
||||
completionCost: model.cost?.completion || '',
|
||||
reasoning: model.attributes?.capabilities?.has('reasoning') || false,
|
||||
tools: model.attributes?.capabilities?.has('tools') || false,
|
||||
vision: model.attributes?.capabilities?.has('vision') || false,
|
||||
reasoning: model.capabilities.includes('reasoning'),
|
||||
tools: model.capabilities.includes('tools'),
|
||||
vision: model.capabilities.includes('vision'),
|
||||
};
|
||||
showAddModelPanel.value = true;
|
||||
};
|
||||
@@ -289,11 +303,11 @@ const { user } = useAuth();
|
||||
const previewModel = computed(() => {
|
||||
if (!formData.value.name || !formData.value.externalId) return null;
|
||||
|
||||
const inputModalities = new Set<string>(['text']);
|
||||
const outputModalities = new Set<string>(['text']);
|
||||
const inputModalities = ['text'];
|
||||
const outputModalities = ['text'];
|
||||
|
||||
if (formData.value.capabilities.includes('vision')) {
|
||||
inputModalities.add('image');
|
||||
inputModalities.push('image');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -306,15 +320,15 @@ const previewModel = computed(() => {
|
||||
prompt: formData.value.promptCost ? formatMoney(formData.value.promptCost) : undefined,
|
||||
completion: formData.value.completionCost ? formatMoney(formData.value.completionCost) : undefined,
|
||||
},
|
||||
attributes: {
|
||||
inputModalities,
|
||||
outputModalities,
|
||||
capabilities: new Set(formData.value.capabilities.filter(c => c !== 'vision') as any),
|
||||
contextWindow: formData.value.contextWindow ? parseInt(formData.value.contextWindow) : null,
|
||||
},
|
||||
inputModalities,
|
||||
outputModalities,
|
||||
capabilities: formData.value.capabilities.filter(c => c !== 'vision') as any,
|
||||
contextWindow: formData.value.contextWindow ? parseInt(formData.value.contextWindow) : null,
|
||||
supportedParameters: [],
|
||||
isCustom: true,
|
||||
enabled: true,
|
||||
provider: provider.value!,
|
||||
releasedAt: null,
|
||||
} as Model;
|
||||
});
|
||||
|
||||
@@ -323,11 +337,9 @@ const saveCustomModel = async () => {
|
||||
|
||||
const { id, ...previewModelWithoutId } = previewModel.value!;
|
||||
if (editingModel.value) {
|
||||
// Update existing model
|
||||
await triplit.update('models', editingModel.value.id, previewModelWithoutId);
|
||||
await updateModel(editingModel.value!.id, { ...previewModelWithoutId });
|
||||
} else {
|
||||
// Insert new custom model
|
||||
await triplit.insert('models', previewModelWithoutId);
|
||||
await createModel(previewModelWithoutId as Model);
|
||||
}
|
||||
|
||||
showAddModelPanel.value = false;
|
||||
@@ -382,7 +394,7 @@ defineEmits(['navigate']);
|
||||
autocomplete="false" spellcheck="false"
|
||||
@input="updateApiKey(($event.target! as HTMLInputElement).value)" />
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -391,7 +403,7 @@ defineEmits(['navigate']);
|
||||
<div class="flex flex-row justify-between gap-16">
|
||||
<label class="whitespace-nowrap" for="provider-api-key">API Proxy URL</label>
|
||||
<div class="text-sm font-mono flex flex-row rounded-md bg-[var(--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"
|
||||
id="provider-proxy-url" :value="apiProxyUrl"
|
||||
@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">
|
||||
Model List
|
||||
<span class="text-sm text-[var(--text-secondary)] font-normal text-xs flex items-center gap-1">
|
||||
{{ provider?.models.length }} models available <button
|
||||
class="p-0.5 hover:bg-[var(--color-hover)] rounded transition-colors duration-200"
|
||||
@click="deleteModels">
|
||||
<span class="i-mynaui-x-solid"></span>
|
||||
</button>
|
||||
{{ provider?.models.length }} models available
|
||||
<Dropdown placement="bottom">
|
||||
<template #default="{ toggle, setRef }">
|
||||
<button :ref="setRef" @click="toggle"
|
||||
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>
|
||||
</h4>
|
||||
|
||||
@@ -423,40 +457,40 @@ defineEmits(['navigate']);
|
||||
<input class="placeholder:text-[var(--text-tertiary)] p-0 bg-transparent" v-model="modelSearch"
|
||||
type="text" placeholder="Search models..." />
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
fetch models
|
||||
</button>
|
||||
|
||||
<div class="flex">
|
||||
<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)]">
|
||||
<span class="i-mynaui-plus text-5"></span>
|
||||
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-solid text-5"></span>
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<Dropdown placement="bottom-end">
|
||||
<template #default="{ toggle, setRef }">
|
||||
<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>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #dropdown="{ 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>
|
||||
Enable All
|
||||
</button>
|
||||
<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>
|
||||
Disable All
|
||||
</button>
|
||||
@@ -481,7 +515,7 @@ defineEmits(['navigate']);
|
||||
{{ editingModel ? 'Edit Custom Model' : 'Add Custom Model' }}
|
||||
</h5>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -518,7 +552,7 @@ defineEmits(['navigate']);
|
||||
class="px-3 py-1.5 text-xs rounded-md border transition-colors duration-200 capitalize"
|
||||
:class="formData.capabilities.includes(cap)
|
||||
? '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 }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -565,11 +599,11 @@ defineEmits(['navigate']);
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<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
|
||||
</button>
|
||||
<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' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -595,7 +629,7 @@ defineEmits(['navigate']);
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<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 }">
|
||||
<ModelItem :model="model" @edit="openEditPanel" />
|
||||
</template>
|
||||
@@ -609,7 +643,7 @@ defineEmits(['navigate']);
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<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 }">
|
||||
<ModelItem :model="model" @edit="openEditPanel" />
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 neutrals = ['zinc', 'slate', 'obsidian'];
|
||||
@@ -27,19 +27,19 @@ defineEmits(['navigate']);
|
||||
<h4 class="font-medium">Theme</h4>
|
||||
<div class="flex gap-2">
|
||||
<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)]' : ''">
|
||||
<span class="i-tabler-device-desktop text-4"></span>
|
||||
<span>System</span>
|
||||
</button>
|
||||
<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)]' : ''">
|
||||
<span class="i-mynaui-moon text-4"></span>
|
||||
<span>Dark</span>
|
||||
</button>
|
||||
<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)]' : ''">
|
||||
<span class="i-mynaui-sun text-4"></span>
|
||||
<span>Light</span>
|
||||
@@ -51,7 +51,7 @@ defineEmits(['navigate']);
|
||||
<h4 class="text-sm font-medium">Accent Color</h4>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<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="[
|
||||
settings.appearance.accent === accent ? 'dark:border-white/70 border-black/70' : 'border-transparent'
|
||||
]" :style="`background-color: var(--accent-${accent})`" :title="accent" />
|
||||
@@ -62,9 +62,9 @@ defineEmits(['navigate']);
|
||||
<h4 class="text-sm font-medium">Neutral Color</h4>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<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="[
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ const handleEdit = (model: Model) => {
|
||||
|
||||
<template>
|
||||
<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"
|
||||
:show-release-date="true" @edit="handleEdit" />
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Providers } from '~/types/model';
|
||||
import { providerIcons } from '~/utils/model-mapping';
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
const { providers } = useModels();
|
||||
const { providers, updateProvider } = await useModels();
|
||||
|
||||
const props = defineProps<{
|
||||
params?: string;
|
||||
@@ -11,32 +9,11 @@ const props = defineProps<{
|
||||
|
||||
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 provider = providers.value!.find(p => p.id === id);
|
||||
if (!provider) return;
|
||||
|
||||
await triplit.update('providers', provider.id, {
|
||||
await updateProvider(provider.id, {
|
||||
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))]">
|
||||
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => p.enabled)"
|
||||
:key="p.id"
|
||||
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-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 items-center gap-2 mb-2">
|
||||
<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)]" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<!-- <input type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-border)]" /> -->
|
||||
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
|
||||
</div>
|
||||
</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))]">
|
||||
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => !p.enabled)"
|
||||
:key="p.id"
|
||||
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-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 items-center gap-2 mb-2">
|
||||
<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)]" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<!-- <input type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-border)]" /> -->
|
||||
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { providerIcons } from '~/utils/model-mapping';
|
||||
defineProps<{
|
||||
params?: string;
|
||||
}>();
|
||||
const { providers } = useModels();
|
||||
const { providers } = await useModels();
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
@@ -12,34 +12,34 @@ defineEmits(['navigate']);
|
||||
<template>
|
||||
<div class="flex flex-col gap-1 overflow-auto">
|
||||
<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)]">
|
||||
<span class="i-mynaui-chevron-left text-4"></span> Back to General
|
||||
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 md:text-4"></span> Back to General
|
||||
</button>
|
||||
|
||||
<button @click="$emit('navigate', 'providers')"
|
||||
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-envelope-open text-4"></span> All
|
||||
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 md:text-4"></span> All
|
||||
</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)"
|
||||
: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">
|
||||
<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>
|
||||
</div>
|
||||
</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"
|
||||
@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">
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -5,8 +5,8 @@ const props = defineProps<{
|
||||
params?: string;
|
||||
}>();
|
||||
|
||||
const { providers, allModels } = useModels();
|
||||
const { settings, updateSettings } = useUserSettings();
|
||||
const { providers, allModels } = await useModels();
|
||||
const { settings, updateSettings } = await useUserSettings();
|
||||
|
||||
const toggle = async (key: string) => {
|
||||
const current = (settings.value.systemAssistants as any)[key];
|
||||
@@ -39,7 +39,6 @@ const getModel = (id: string | null | undefined) => {
|
||||
}
|
||||
|
||||
defineEmits(['navigate']);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,10 +47,11 @@ defineEmits(['navigate']);
|
||||
<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">
|
||||
<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>
|
||||
<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)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { assert } from '~~/utils/assert';
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
const { user, signOut } = useAuth();
|
||||
|
||||
// to prevent the user details from going blank for a
|
||||
@@ -23,8 +22,6 @@ const { isHovered } = useSidenavContext();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
assert('disconnect' in triplit);
|
||||
triplit.disconnect();
|
||||
|
||||
await navigateTo('/auth/login');
|
||||
};
|
||||
@@ -33,20 +30,21 @@ const handleLogout = async () => {
|
||||
<template>
|
||||
<header class="flex items-center justify-between overflow-hidden">
|
||||
<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
|
||||
: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" />
|
||||
<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>
|
||||
<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 }}
|
||||
</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'
|
||||
]">
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
@@ -58,13 +56,13 @@ const handleLogout = async () => {
|
||||
<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">
|
||||
<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>Settings</span>
|
||||
</button>
|
||||
<hr class="border-t border-[var(--color-border)]" />
|
||||
<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>Log out</span>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { navigateTo } from '#app';
|
||||
const route = useRoute();
|
||||
const { agents, getAgent } = useAgents();
|
||||
const { agents, getAgent } = await useAgents();
|
||||
|
||||
const agentId = computed(() => route.params.id as string);
|
||||
const activeAgent = getAgent(agentId);
|
||||
@@ -10,32 +11,35 @@ const { isHovered } = useSidenavContext();
|
||||
|
||||
<template>
|
||||
<header class="flex items-center rounded-lg overflow-hidden flex-1 min-w-0">
|
||||
<div :style="isHovered ? 'width: 32px;' : 'width: 0px;'"
|
||||
:class="['flex flex-shrink-0 transform-origin-left-center items-center overflow-hidden transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-95']">
|
||||
<div :class="[
|
||||
'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="/"
|
||||
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>
|
||||
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-5.5 md:text-4.5"></span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<Dropdown placement="bottom" dropdown-class="max-w-48">
|
||||
<template #default="{ toggle, setRef }">
|
||||
<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="['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"
|
||||
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>
|
||||
<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 }}
|
||||
</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
|
||||
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>
|
||||
</button>
|
||||
@@ -43,7 +47,7 @@ const { isHovered } = useSidenavContext();
|
||||
|
||||
<template #dropdown="{ 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 }}
|
||||
</button>
|
||||
</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>
|
||||
@@ -13,14 +13,14 @@ const props = defineProps<{
|
||||
props.icon ? 'px-1' : 'px-2',
|
||||
props.active
|
||||
? '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 v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
|
||||
<span class="text-4.5" :class="props.icon"></span>
|
||||
<div v-if="props.icon" class="h-9 w-9 md:h-7 md:w-7 flex items-center justify-center">
|
||||
<span class="text-5.5 md:text-4.5" :class="props.icon"></span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
|
||||
<span class="md:text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
|
||||
}}</span>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -31,14 +31,14 @@ const props = defineProps<{
|
||||
props.icon ? 'px-1' : 'px-2',
|
||||
props.active
|
||||
? '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 v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
|
||||
<span class="text-4.5" :class="props.icon"></span>
|
||||
<div v-if="props.icon" class="h-9 w-9 md:h-7 md:w-7 flex items-center justify-center">
|
||||
<span class="text-5.5 md:text-4.5" :class="props.icon"></span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
|
||||
<span class="md:text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">{{ props.name
|
||||
}}</span>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { assert } from '~~/utils/assert';
|
||||
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 dropdownTrigger = ref<HTMLElement | null>(null);
|
||||
@@ -9,9 +8,8 @@ const dropdownContent = ref(null);
|
||||
const activeMenuTopicId = ref<string | null>(null);
|
||||
|
||||
const route = useRoute();
|
||||
const { getAgent } = useAgents();
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
const { getAgent, patchTopicLocally, deleteTopic: deleteAgentTopic } = await useAgents();
|
||||
const { autoRename } = useTopic();
|
||||
|
||||
const navRef = ref<HTMLElement | null>(null);
|
||||
const agentId = computed(() => route.params.id as string);
|
||||
@@ -27,10 +25,10 @@ const topics = computed(() => {
|
||||
return activeAgent.value?.topics || [];
|
||||
})
|
||||
|
||||
const { floatingStyles, placement } = useFloating(dropdownTrigger, dropdownContent, {
|
||||
const { floatingStyles, placement, middlewareData } = useFloating(dropdownTrigger, dropdownContent, {
|
||||
placement: 'bottom-end',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [offset(6), flip(), shift({ padding: 10 })],
|
||||
middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
|
||||
transform: false,
|
||||
});
|
||||
|
||||
@@ -52,59 +50,16 @@ const menuTopic = computed(() =>
|
||||
);
|
||||
|
||||
const topicsOpen = ref(true);
|
||||
let activeAutoRenames = reactive(new Map<string, string>());
|
||||
|
||||
const autoRenameTopic = async (topicId: string) => {
|
||||
const { setPage } = useSettings();
|
||||
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;
|
||||
}
|
||||
autoRename(topicId);
|
||||
}
|
||||
|
||||
const cancelAutoRename = async (topicId: string) => {
|
||||
await triplit.update('topics', topicId, {
|
||||
renaming: false,
|
||||
});
|
||||
|
||||
const renameId = activeAutoRenames.get(topicId);
|
||||
if (!renameId) return;
|
||||
|
||||
await $fetch(`/api/auto-rename/cancel`, {
|
||||
body: {
|
||||
renameId,
|
||||
},
|
||||
// TODO: make this more optimistic
|
||||
await $fetch(`/api/topic/${topicId}/auto-rename/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
activeAutoRenames.delete(topicId);
|
||||
}
|
||||
|
||||
const renameTopicId = ref<string | null>(null);
|
||||
@@ -123,10 +78,16 @@ const startRename = (topicId: string, currentName: string) => {
|
||||
|
||||
const saveRename = async () => {
|
||||
if (renameTopicId.value && newTopicName.value.trim()) {
|
||||
await triplit.update('topics', renameTopicId.value, {
|
||||
name: newTopicName.value.trim()
|
||||
patchTopicLocally(renameTopicId.value, { name: newTopicName.value.trim() });
|
||||
|
||||
$fetch(`/api/topic/${renameTopicId.value}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: newTopicName.value.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
cancelRename();
|
||||
};
|
||||
|
||||
@@ -143,9 +104,8 @@ const deleteTopic = async (topicId: string) => {
|
||||
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) => {
|
||||
@@ -193,10 +153,10 @@ onMounted(() => {
|
||||
|
||||
<!-- Topics Section -->
|
||||
<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">
|
||||
<span class="text-sm font-medium">Topics</span>
|
||||
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="md:text-sm font-medium">Topics</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>
|
||||
</button>
|
||||
|
||||
@@ -207,22 +167,23 @@ onMounted(() => {
|
||||
<template v-slot="{ item: topic }">
|
||||
<a :key="topic.id" data-action="navigate" :data-topic-id="topic.id"
|
||||
: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 }">
|
||||
<input v-if="renameTopicId === topic.id && !topic.renaming" id="topic-rename-input"
|
||||
v-model="newTopicName" @keydown.enter="saveRename" @keydown.escape="cancelRename"
|
||||
@blur="saveRename"
|
||||
class="flex-1 bg-transparent border-none outline-none text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" />
|
||||
class="flex-1 bg-transparent border-none outline-none md:text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" />
|
||||
<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>
|
||||
<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 }}
|
||||
</span>
|
||||
|
||||
<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)]">
|
||||
<span class="pointer-events-none h-4.5 w-4.5 i-tabler-dots"></span>
|
||||
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)]"
|
||||
: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>
|
||||
</a>
|
||||
</template>
|
||||
@@ -237,32 +198,37 @@ onMounted(() => {
|
||||
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" class="fixed z-15"
|
||||
:class="transformOrigin">
|
||||
<div v-if="dropdownOpen" ref="dropdownContent" :style="{
|
||||
...floatingStyles,
|
||||
visibility: middlewareData.hide?.referenceHidden
|
||||
? 'hidden'
|
||||
: 'visible',
|
||||
}" class="fixed z-20" :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">
|
||||
|
||||
|
||||
<!-- Dynamic content based on menuTopic -->
|
||||
<template v-if="menuTopic">
|
||||
<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
|
||||
</button>
|
||||
<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
|
||||
</button>
|
||||
|
||||
<button :disabled="menuTopic.renaming ?? false"
|
||||
@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
|
||||
</button>
|
||||
|
||||
<div class="h-px bg-[var(--color-border)] my-1" />
|
||||
|
||||
<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
|
||||
</button>
|
||||
</template>
|
||||
@@ -271,4 +237,4 @@ onMounted(() => {
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</nav>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue';
|
||||
import type { DialogType } from '~/composables/useDialog';
|
||||
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
const { agents, createAgent } = useAgents();
|
||||
const { agents, createAgent, deleteAgent, updateAgent } = await useAgents();
|
||||
const agentsOpen = ref(true);
|
||||
const creatingAgent = ref(false);
|
||||
|
||||
@@ -12,21 +12,13 @@ const dropdownTrigger = ref<HTMLElement | null>(null);
|
||||
const dropdownContent = ref(null);
|
||||
const activeMenuAgentId = ref<string | null>(null);
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const navRef = ref<HTMLElement | null>(null);
|
||||
const toggleAgentsList = () => {
|
||||
agentsOpen.value = !agentsOpen.value;
|
||||
};
|
||||
|
||||
const newAgent = async () => {
|
||||
creatingAgent.value = true;
|
||||
try {
|
||||
const agent = await createAgent();
|
||||
return navigateTo(`/agent/${agent.id}`);
|
||||
} finally {
|
||||
creatingAgent.value = false;
|
||||
}
|
||||
await createAgent();
|
||||
};
|
||||
|
||||
const renameAgentId = ref<string | null>(null);
|
||||
@@ -44,11 +36,7 @@ const startRename = (agentId: string, currentName: string) => {
|
||||
};
|
||||
|
||||
const saveRename = async () => {
|
||||
if (renameAgentId.value && newAgentName.value.trim()) {
|
||||
await triplit.update('agents', renameAgentId.value, {
|
||||
name: newAgentName.value.trim()
|
||||
});
|
||||
}
|
||||
updateAgent(renameAgentId.value!, { name: newAgentName.value.trim() });
|
||||
cancelRename();
|
||||
};
|
||||
|
||||
@@ -57,14 +45,10 @@ const cancelRename = () => {
|
||||
newAgentName.value = '';
|
||||
};
|
||||
|
||||
const deleteAgent = async (agentId: string) => {
|
||||
await triplit.delete('agents', agentId);
|
||||
}
|
||||
|
||||
const { floatingStyles, placement } = useFloating(dropdownTrigger, dropdownContent, {
|
||||
const { floatingStyles, placement, middlewareData } = useFloating(dropdownTrigger, dropdownContent, {
|
||||
placement: 'bottom-end',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [offset(6), flip(), shift({ padding: 10 })],
|
||||
middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
|
||||
transform: false,
|
||||
});
|
||||
|
||||
@@ -81,7 +65,7 @@ const closeDropdown = () => {
|
||||
};
|
||||
|
||||
const menuAgent = computed(() =>
|
||||
agents.value.find(t => t.id === activeMenuAgentId.value)
|
||||
agents.value.find(a => a.id === activeMenuAgentId.value)
|
||||
);
|
||||
|
||||
const handleNavClick = (e: MouseEvent) => {
|
||||
@@ -121,16 +105,26 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<nav ref="navRef" class="flex flex-col gap-1 overflow-auto">
|
||||
<SidenavItem @click="openDialog(DialogType.QuickSwitcher)" name="Search" icon="i-mynaui-search" />
|
||||
<SidenavItem to="/" name="Home" icon="i-mynaui-home" />
|
||||
<SidenavItem @click="openDialog(DialogType.QuickSwitcher)" name="Search" icon="i-mynaui-search">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="flex bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
|
||||
<kbd class="font-mono text-[10px] case-capital">ctrl</kbd>
|
||||
</span>
|
||||
<span class="flex bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
|
||||
<kbd class="font-mono text-[10px] case-capital">k</kbd>
|
||||
</span>
|
||||
</div>
|
||||
</SidenavItem>
|
||||
<SidenavItem to="/" name="Home" icon="i-mynaui-home" active />
|
||||
<SidenavItem to="/resources" name="Resources" icon="i-tabler-books" />
|
||||
|
||||
<!-- Header Toggle -->
|
||||
<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">
|
||||
<span class="text-sm">Agents</span>
|
||||
<span class="md:text-sm">Agents</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>
|
||||
</button>
|
||||
|
||||
@@ -138,10 +132,10 @@ onMounted(() => {
|
||||
<Collapsible :is-open="agentsOpen">
|
||||
<div class="flex flex-col transform-origin-top pt-1 pb-1 px-1">
|
||||
<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">
|
||||
<div class="h-7 w-7 flex items-center justify-center">
|
||||
<span v-if="creatingAgent" class="i-svg-spinners-ring-resize text-4.5"></span>
|
||||
<span v-else class="i-mynaui-plus text-4.5"></span>
|
||||
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-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-5 md:text-4"></span>
|
||||
<span v-else class="i-mynaui-plus-solid text-5 md:text-4"></span>
|
||||
</div>
|
||||
<span>New Agent</span>
|
||||
</button>
|
||||
@@ -152,18 +146,19 @@ onMounted(() => {
|
||||
<template v-slot="{ item: agent }">
|
||||
<a data-action="navigate" :data-agent-id="agent.id" :href="`/agent/${agent.id}`"
|
||||
: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"
|
||||
@keydown.stop.enter="saveRename" @keydown.escape="cancelRename" @blur="saveRename"
|
||||
class="flex-1 bg-transparent border-none outline-none text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" />
|
||||
@keydown.prevent.enter="saveRename" @keydown.escape="cancelRename"
|
||||
@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
|
||||
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 }}
|
||||
</span>
|
||||
|
||||
<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)]">
|
||||
<span class="pointer-events-none h-4.5 w-4.5 i-tabler-dots"></span>
|
||||
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-6 w-6 md:h-4.5 md:w-4.5 i-tabler-dots"></span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
@@ -179,21 +174,25 @@ onMounted(() => {
|
||||
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" class="fixed z-15"
|
||||
:class="transformOrigin">
|
||||
<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="menuAgent">
|
||||
<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
|
||||
</button>
|
||||
|
||||
<div class="h-px bg-[var(--color-border)] my-1" />
|
||||
|
||||
<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
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -96,6 +96,7 @@ onUnmounted(() => {
|
||||
const navKind = computed(() => {
|
||||
if (route.path === '/') return 'home';
|
||||
if (route.path.startsWith('/agent/')) return 'agent';
|
||||
if (route.path === '/resources') return 'resources';
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
@@ -103,39 +104,50 @@ const navKind = computed(() => {
|
||||
<template>
|
||||
<div class="relative">
|
||||
<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)]',
|
||||
open ? 'w-full mr-2' : 'w-0 mr-0',
|
||||
isResizing ? 'transition-none' : ''
|
||||
'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' : 'w-0',
|
||||
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"
|
||||
@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">
|
||||
<!-- Header -->
|
||||
<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'" />
|
||||
<SidenavHeaderResources v-else-if="navKind === 'resources'" />
|
||||
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
|
||||
|
||||
<div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
|
||||
<div :style="isHovering ? 'width: 32px;' : 'width: 0px;'"
|
||||
: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']">
|
||||
<div :class="[
|
||||
'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', '[']">
|
||||
<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
|
||||
class="i-mynaui-panel-left-close text-5 transition-inherit transform-origin-right-center"
|
||||
:class="isHovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95'"></span>
|
||||
class="i-mynaui-panel-left-close opacity-100 scale-100 text-6 md:text-5 transition-all transform-origin-right-center"
|
||||
:class="isHovering ? '' : 'md:opacity-0 md:scale-95'"></span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
|
||||
<Tooltip :hotkey="['ctrl', 'alt', 'n']">
|
||||
<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>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -145,14 +157,15 @@ const navKind = computed(() => {
|
||||
<!-- Main Menu -->
|
||||
<SidenavNavHome v-if="navKind === 'home'" />
|
||||
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
||||
<SidenavNavResources v-else-if="navKind === 'resources'" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2">
|
||||
<div class="flex">
|
||||
<Tooltip :hotkey="['ctrl', ',']">
|
||||
<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)]">
|
||||
<span class="i-mynaui-cog-four text-5"></span>
|
||||
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-6 md:text-5"></span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -164,8 +177,17 @@ const navKind = computed(() => {
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
<!-- resize handle -->
|
||||
<div @mousedown="onResizeStart" class="absolute top-0 right-0 bottom-0 p-1 cursor-ew-resize">
|
||||
<!-- resize handle - hidden on mobile -->
|
||||
<div @mousedown="onResizeStart" class="md:flex hidden absolute top-0 left-full bottom-0 p-1 cursor-ew-resize">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (max-width: 767px) {
|
||||
.mobile-full-width {
|
||||
width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -26,12 +26,16 @@ watch(() => props.checked, (newValue) => {
|
||||
|
||||
<template>
|
||||
<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)]"
|
||||
:aria-disabled="(props.disabled === true) ? 'true' : 'false'" :aria-label="label" :aria-labelledby="id"
|
||||
:tabindex="(disabled) ? '-1' : '0'" @click="(e) => $emit('click', e)" :aria-checked="active"
|
||||
:data-state="(active) ? 'checked' : 'unchecked'">
|
||||
<div
|
||||
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>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,7 @@ const props = defineProps<{
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
const { updateSettings, settings } = useUserSettings();
|
||||
const { updateSettings, settings } = await useUserSettings();
|
||||
|
||||
const selectTheme = (colorScheme: Theme) => {
|
||||
updateSettings({ appearance: { colorScheme } });
|
||||
@@ -20,7 +20,7 @@ const themeOptions: DropdownItem[] = [
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
@@ -28,9 +28,9 @@ const currentOption = computed(
|
||||
<Dropdown placement="top-end" dropdown-class="min-w-35">
|
||||
<template #default="{ toggle, setRef }">
|
||||
<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="{
|
||||
'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-11 w-11 text-7': size === 'large',
|
||||
}">
|
||||
@@ -40,7 +40,7 @@ const currentOption = computed(
|
||||
|
||||
<template #dropdown="{ 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="{
|
||||
'bg-[var(--color-hover)]': option.id === currentOption?.id,
|
||||
}">
|
||||
|
||||
@@ -6,8 +6,14 @@ const props = defineProps<{
|
||||
const { show, hide } = useTooltip();
|
||||
const triggerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const hasCursor = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
hasCursor.value = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||
});
|
||||
|
||||
const onMouseEnter = () => {
|
||||
if (triggerRef.value) {
|
||||
if (triggerRef.value && hasCursor.value) {
|
||||
show(triggerRef.value, props.hotkey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<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();
|
||||
|
||||
watch(activeElement, () => {
|
||||
console.log('activeElement', activeElement.value);
|
||||
});
|
||||
|
||||
const tooltipRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const { floatingStyles, placement } = useFloating(activeElement, tooltipRef, {
|
||||
const { floatingStyles, placement, middlewareData } = useFloating(activeElement, tooltipRef, {
|
||||
placement: 'top',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [offset(6), flip(), shift({ padding: 8 })],
|
||||
middleware: [offset(6), flip(), shift({ padding: 8 }), hide()],
|
||||
transform: false,
|
||||
});
|
||||
|
||||
@@ -29,8 +25,8 @@ watch(activeElement, (newEl, oldEl) => {
|
||||
});
|
||||
|
||||
// Also reset isMoving when the tooltip fully closes
|
||||
watch(isVisible, (visible) => {
|
||||
if (!visible) isMoving.value = false;
|
||||
watch([isVisible, middlewareData], ([visible, middlewareData]) => {
|
||||
if (!visible || middlewareData.hide?.referenceHidden) isMoving.value = 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'
|
||||
: 'opacity, transform',
|
||||
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="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
@@ -1,75 +1,213 @@
|
||||
import type { Entity } from "@triplit/client";
|
||||
import type schema from "#triplit/schema";
|
||||
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 = () => {
|
||||
const nuxtApp = useNuxtApp();
|
||||
const triplit = useTriplitClient();
|
||||
export const useAgents = async () => {
|
||||
const agents = useState<AgentWithTopics[]>('agents_state', () => []);
|
||||
const loaded = useState('agents_loaded', () => false);
|
||||
|
||||
// dont leaking between different users/requests
|
||||
if (!nuxtApp._agentsState) {
|
||||
nuxtApp._agentsState = {
|
||||
list: ref<Agent[]>([]),
|
||||
initPromise: null as Promise<void> | null,
|
||||
};
|
||||
}
|
||||
const { refresh } = await useFetch<AgentWithTopics[]>('/api/agents', {
|
||||
key: 'agents_request',
|
||||
immediate: !loaded.value,
|
||||
onRequest() {
|
||||
loaded.value = true;
|
||||
},
|
||||
onResponse({ response }) {
|
||||
if (response.ok) {
|
||||
agents.value = response._data ?? [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const state = nuxtApp._agentsState as {
|
||||
list: Ref<Agent[]>;
|
||||
initPromise: Promise<void> | null;
|
||||
};
|
||||
|
||||
const init = (): Promise<void> => {
|
||||
if (state.initPromise) return state.initPromise;
|
||||
|
||||
state.initPromise = (async () => {
|
||||
const query = triplit.query('agents')
|
||||
.Include('topics', (rel) => rel('topics').Order('createdAt', 'DESC'))
|
||||
.Order('createdAt', 'ASC');
|
||||
|
||||
const { results } = await useQuery('agents', triplit, query)
|
||||
watch(results, (newAgents) => {
|
||||
if (newAgents && newAgents.length > 0) {
|
||||
state.list.value = newAgents as unknown as Agent[];
|
||||
}
|
||||
}, { immediate: true, flush: 'sync' });
|
||||
})();
|
||||
|
||||
return state.initPromise;
|
||||
};
|
||||
|
||||
const getAgent = (id: MaybeRef<string>) => {
|
||||
return computed(() => state.list.value.find((agent) => agent.id === toRef(id).value) || null);
|
||||
}
|
||||
|
||||
const createAgent = async () => {
|
||||
const triplit = useTriplitClient();
|
||||
const createAgent = async (navigate: boolean = true) => {
|
||||
const { user } = useAuth();
|
||||
if (!user.value) throw new Error('No user');
|
||||
if (!user.value) {
|
||||
console.error('No user');
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
await triplit.insert('agents', {
|
||||
id,
|
||||
name: 'New Agent',
|
||||
const agentId = nanoid();
|
||||
|
||||
const agent = {
|
||||
id: agentId,
|
||||
userId: user.value.id,
|
||||
name: 'New Agent',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
defaultModelId: 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);
|
||||
await triplit.flush();
|
||||
return topic;
|
||||
}
|
||||
|
||||
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 {
|
||||
init,
|
||||
agents: state.list,
|
||||
agents,
|
||||
refresh,
|
||||
createAgent,
|
||||
createTopic,
|
||||
getAgent,
|
||||
createAgent
|
||||
patchAgentLocally,
|
||||
patchTopicLocally,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
deleteTopic
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -30,11 +30,9 @@ export const useAuth = () => {
|
||||
return sessionPromise;
|
||||
}
|
||||
|
||||
let finish: (value: Result<sessionData, AuthError>) => void;
|
||||
sessionFetching.value = true;
|
||||
sessionPromise = new Promise(async (resolve, reject) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const { promise, resolve } = Promise.withResolvers<Result<sessionData, AuthError>>();
|
||||
sessionPromise = promise;
|
||||
let data: {
|
||||
session: InferSessionFromClient<BetterAuthClientOptions>;
|
||||
user: InferUserFromClient<BetterAuthClientOptions>;
|
||||
@@ -52,18 +50,17 @@ export const useAuth = () => {
|
||||
} else {
|
||||
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) {
|
||||
console.error('Failed to fetch session:', error);
|
||||
resolve(Err(AuthError.NetworkError));
|
||||
} finally {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -71,15 +68,6 @@ export const useAuth = () => {
|
||||
authClient.$store.listen('$sessionSignal', async (signal) => {
|
||||
if (!signal) return;
|
||||
await fetchSession();
|
||||
|
||||
if (!session.value) return;
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
if ('updateOptions' in triplit) {
|
||||
triplit.updateOptions({
|
||||
token: session.value.token,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,11 +94,6 @@ export const useAuth = () => {
|
||||
|
||||
user.value = data.user;
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
if ('startSession' in triplit && data.token) {
|
||||
await triplit.startSession(data.token);
|
||||
}
|
||||
|
||||
clearNuxtData();
|
||||
|
||||
return Ok({ user: data.user, token: data.token });
|
||||
@@ -147,10 +130,6 @@ export const useAuth = () => {
|
||||
|
||||
user.value = data.user;
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
assert('startSession' in triplit);
|
||||
await triplit.startSession(data.token);
|
||||
|
||||
clearNuxtData();
|
||||
|
||||
return Ok({ user: data.user, token: data.token });
|
||||
@@ -176,10 +155,6 @@ export const useAuth = () => {
|
||||
user.value = null;
|
||||
session.value = null;
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
assert('disconnect' in triplit);
|
||||
triplit.disconnect();
|
||||
|
||||
clearNuxtData();
|
||||
|
||||
return Ok(undefined);
|
||||
|
||||
+381
-411
@@ -1,33 +1,27 @@
|
||||
import type schema from "#triplit/schema";
|
||||
import type { Entity } from "@triplit/client";
|
||||
import type { FilePart, ImagePart, ModelMessage } from "ai";
|
||||
import { nanoid } from "nanoid";
|
||||
import { decrypt, base64ToUint8Array } from "~/utils/crypto";
|
||||
import { type Result, Ok, Err, attempt } from "~~/types/result";
|
||||
import { assert } from "~~/utils/assert";
|
||||
import { nanoid } from 'nanoid';
|
||||
import * as schema from '~~/drizzle/schema';
|
||||
import { Err, Ok, type Result } from '~~/types/result';
|
||||
import { buildMessageTree } from '~~/utils/message';
|
||||
|
||||
export type BaseMessage = {
|
||||
content: string;
|
||||
fileIds: string[];
|
||||
}
|
||||
|
||||
export type MessageEntity = Entity<typeof schema, 'messages'> & {
|
||||
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 ToolCall = typeof schema.toolCalls.$inferSelect
|
||||
|
||||
export type Message =
|
||||
MessageEntity & {
|
||||
children: (MessageEntity | undefined)[];
|
||||
}
|
||||
export type MessagePart = typeof schema.messageParts.$inferSelect & { toolCall: ToolCall | null }
|
||||
|
||||
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 {
|
||||
NoModel = 0,
|
||||
NoProvider,
|
||||
NoAgent,
|
||||
NoUser,
|
||||
NoTopic,
|
||||
DatabaseOperationFailed,
|
||||
FailedToDecryptProviderApiKey,
|
||||
GenerationFailed,
|
||||
@@ -37,315 +31,373 @@ export enum ChatErrorType {
|
||||
Unimplemented,
|
||||
}
|
||||
|
||||
export const useChat = (agentId: string) => {
|
||||
const triplit = useTriplitClient();
|
||||
type Event = { type: string; payload: any; timestamp: number };
|
||||
|
||||
const createTopic = async () => {
|
||||
const { user } = useAuth();
|
||||
if (!user.value) {
|
||||
console.error('No user');
|
||||
const TARGET_UPDATES_PER_SECOND = 24;
|
||||
|
||||
export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
|
||||
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;
|
||||
}
|
||||
|
||||
const newTopic = await triplit.insert('topics', {
|
||||
name: 'New Topic',
|
||||
userId: user.value.id,
|
||||
agentId,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
cb();
|
||||
}
|
||||
|
||||
assert('flush' in triplit);
|
||||
await triplit.flush();
|
||||
|
||||
return newTopic;
|
||||
const scheduleFlush = () => {
|
||||
if (flushTimeout) return;
|
||||
flushTimeout = setTimeout(flushTextDeltas, 1000 / TARGET_UPDATES_PER_SECOND);
|
||||
};
|
||||
|
||||
const marshallMessages = (agent: Entity<typeof schema, 'agents'>, messages: Readonly<MessageEntity[]>): Result<ModelMessage[], string> => {
|
||||
const marshalledMessages: ModelMessage[] = [];
|
||||
const processEvent = (event: Event) => {
|
||||
if (!data.value) return;
|
||||
|
||||
if (agent && agent.systemPrompt) {
|
||||
marshalledMessages.push({
|
||||
role: 'system',
|
||||
content: agent.systemPrompt,
|
||||
});
|
||||
const { type, payload } = event;
|
||||
|
||||
switch (type) {
|
||||
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) => {
|
||||
switch (message.role) {
|
||||
case 'user':
|
||||
const attachments = message.attachments.map(attachment => {
|
||||
if (attachment.mimeType.startsWith('image/')) {
|
||||
return {
|
||||
type: 'image',
|
||||
image: attachment.url,
|
||||
};
|
||||
}
|
||||
sse = new EventSource(`/api/topic/${id}?lastUpdate=${lastUpdate}&count=${msgCount}`);
|
||||
|
||||
return {
|
||||
type: 'file',
|
||||
data: attachment.url,
|
||||
filename: attachment.name,
|
||||
mediaType: attachment.mimeType,
|
||||
};
|
||||
}) as (FilePart | ImagePart)[];
|
||||
sse.addEventListener("error", (e) => {
|
||||
console.error('sse error', e);
|
||||
setTimeout(connectSSE, 1500);
|
||||
})
|
||||
|
||||
marshalledMessages.push({
|
||||
role: 'user',
|
||||
// TODO: when we have images or files, this is where we need to handle them
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: message.content
|
||||
},
|
||||
...attachments,
|
||||
],
|
||||
});
|
||||
break;
|
||||
case 'assistant':
|
||||
(message.parts || []).forEach((part) => {
|
||||
if (!part) return Err('Part is undefined')
|
||||
sse.addEventListener("message", (e) => {
|
||||
const raw = JSON.parse(e.data);
|
||||
const events: Event[] = Array.isArray(raw) ? raw : [raw];
|
||||
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
case 'reasoning': {
|
||||
marshalledMessages.push({
|
||||
role: 'assistant',
|
||||
content: part.content,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'tool-call': {
|
||||
if (part.toolCall === null) return Err('Tool call is null')
|
||||
for (const { type, payload, timestamp } of events) {
|
||||
if (!type || !payload) continue;
|
||||
|
||||
if (part.toolCall.status === 'pending') {
|
||||
return Err('Marshalling tool call that is still pending. This is likely a UI bug if this happens.')
|
||||
}
|
||||
if (type === 'initial_state') {
|
||||
data.value = payload;
|
||||
resolve()
|
||||
continue;
|
||||
}
|
||||
|
||||
let inputValue: string = '';
|
||||
|
||||
switch (part.toolCall.input!.type) {
|
||||
case 'text':
|
||||
inputValue = part.toolCall.input!.value;
|
||||
break;
|
||||
case 'json':
|
||||
inputValue = JSON.parse(part.toolCall.input!.value);
|
||||
break;
|
||||
}
|
||||
|
||||
marshalledMessages.push({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: part.toolCall.id,
|
||||
toolName: part.toolCall.toolName,
|
||||
input: inputValue,
|
||||
},
|
||||
],
|
||||
providerOptions: part.providerOptions,
|
||||
});
|
||||
|
||||
if (part.toolCall.status === 'failed') {
|
||||
let failureType: 'error-text' | 'error-json';
|
||||
let failureValue: string;
|
||||
|
||||
if (part.toolCall.error === null || part.toolCall.error === undefined) {
|
||||
failureType = 'error-text';
|
||||
failureValue = 'An unknown error occurred';
|
||||
} else {
|
||||
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}`)
|
||||
processEvent({ type, payload, timestamp });
|
||||
}
|
||||
});
|
||||
|
||||
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(
|
||||
key,
|
||||
base64ToUint8Array(provider.config.apiKey)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt provider API key:', error);
|
||||
return Err(ChatErrorType.FailedToDecryptProviderApiKey);
|
||||
if (connect) {
|
||||
onBeforeUnmount(() => {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTextDeltas();
|
||||
}
|
||||
if (sse) {
|
||||
console.log('[SSE] Closing connection');
|
||||
sse.close();
|
||||
sse = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
if (import.meta.server) {
|
||||
const id = unref(topicId);
|
||||
const { data: ssrData } = await useFetch<Topic & { messages: Message[] }>(`/api/topic/${id}`);
|
||||
data.value = ssrData.value;
|
||||
} else {
|
||||
// if (data.value === undefined) {
|
||||
// const id = unref(topicId);
|
||||
// const { data: ssrData } = await useFetch<Topic & { messages: Message[] }>(`/api/topic/${id}`);
|
||||
// data.value = ssrData.value;
|
||||
// }
|
||||
|
||||
await connectSSE();
|
||||
}
|
||||
}
|
||||
|
||||
const topic = computed(() => ({
|
||||
...data.value,
|
||||
messages: buildMessageTree(data.value?.messages || [])
|
||||
}));
|
||||
|
||||
const getProviderAPIKey = async (provider: Provider): Promise<Result<string | undefined, ChatErrorType>> => {
|
||||
if (provider.config.apiKey === undefined) {
|
||||
return Ok(undefined);
|
||||
}
|
||||
|
||||
try {
|
||||
await $fetch('/api/chat/generate', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
messages,
|
||||
topicId: topic.id,
|
||||
parentMessageId,
|
||||
model: {
|
||||
providerId: provider.id,
|
||||
modelId: model.id,
|
||||
args,
|
||||
},
|
||||
providerApiKey: providerApiKey,
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
|
||||
return Ok(undefined);
|
||||
return Ok(await decrypt(
|
||||
key,
|
||||
base64ToUint8Array(provider.config.apiKey)
|
||||
));
|
||||
} catch (error) {
|
||||
console.error('Failed to generate:', error);
|
||||
return Err(ChatErrorType.GenerationFailed);
|
||||
console.error('Failed to decrypt provider API key:', error);
|
||||
return Err(ChatErrorType.FailedToDecryptProviderApiKey);
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async (
|
||||
message: BaseMessage,
|
||||
topic: Entity<typeof schema, 'topics'>,
|
||||
topicMessages: MessageEntity[],
|
||||
agent: Entity<typeof schema, 'agents'>,
|
||||
provider: Entity<typeof schema, 'providers'>,
|
||||
model: Entity<typeof schema, 'models'>,
|
||||
baseMessage: BaseMessage,
|
||||
onRequest?: () => void,
|
||||
): Promise<Result<void, ChatErrorType>> => {
|
||||
const { user } = useAuth();
|
||||
if (!user.value) {
|
||||
console.error('No user');
|
||||
return Err(ChatErrorType.NoUser);
|
||||
}
|
||||
if (!user.value) return Err(ChatErrorType.NoUser);
|
||||
|
||||
const messageId = nanoid();
|
||||
const attachmentsPromise = message.fileIds.map(async fileId => {
|
||||
const file = await triplit.fetchOne(triplit.query('files').Where('id', '=', fileId));
|
||||
assert(file !== null);
|
||||
|
||||
return await triplit.insert('attachments', {
|
||||
userId: user.value!.id,
|
||||
topicId: topic.id,
|
||||
messageId: messageId,
|
||||
fileId: fileId,
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
url: file.url,
|
||||
createdAt: file.createdAt,
|
||||
})!;
|
||||
});
|
||||
|
||||
const newMessage = await triplit.insert('messages', {
|
||||
id: messageId,
|
||||
userId: user.value.id,
|
||||
topicId: topic.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
content: message.content,
|
||||
role: 'user',
|
||||
}).catch(async error => {
|
||||
console.error('Failed to insert message:', error);
|
||||
await triplit.delete('messages', messageId);
|
||||
return Err(ChatErrorType.DatabaseOperationFailed);
|
||||
}) as Message;
|
||||
|
||||
const attachments = await Promise.all(attachmentsPromise);
|
||||
|
||||
newMessage.attachments = attachments;
|
||||
|
||||
const messages = marshallMessages(
|
||||
agent,
|
||||
topicMessages.concat(newMessage)
|
||||
);
|
||||
|
||||
if (messages.ok === false) {
|
||||
console.error('Failed to marshall messages:', messages.error);
|
||||
return Err(ChatErrorType.MarshallFailed)
|
||||
}
|
||||
|
||||
const args = {
|
||||
temperature: 1,
|
||||
max_tokens: 100,
|
||||
top_p: 1,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
};
|
||||
|
||||
return startGeneration(messages.data, args, topic, provider, model).then(async res => {
|
||||
if (res.ok === false) {
|
||||
console.error('Failed to start generation:', res.error);
|
||||
await triplit.delete('messages', messageId);
|
||||
try {
|
||||
const message = {
|
||||
id: nanoid(),
|
||||
role: 'user',
|
||||
content: baseMessage.content,
|
||||
fileIds: baseMessage.fileIds,
|
||||
}
|
||||
|
||||
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
|
||||
@@ -364,11 +416,8 @@ export const useChat = (agentId: string) => {
|
||||
*/
|
||||
const regenerateMessage = async (
|
||||
messageId: string,
|
||||
topic: Entity<typeof schema, 'topics'>,
|
||||
topicMessages: MessageEntity[],
|
||||
agent: Entity<typeof schema, 'agents'>,
|
||||
provider: Entity<typeof schema, 'providers'>,
|
||||
model: Entity<typeof schema, 'models'>
|
||||
model: ModelWithProvider
|
||||
): Promise<Result<void, ChatErrorType>> => {
|
||||
const targetMessage = topicMessages.find(message => message.id === messageId);
|
||||
if (!targetMessage) {
|
||||
@@ -376,16 +425,8 @@ export const useChat = (agentId: string) => {
|
||||
}
|
||||
let targetMessageIndex = topicMessages.indexOf(targetMessage);
|
||||
|
||||
const args = {
|
||||
temperature: 1,
|
||||
max_tokens: 100,
|
||||
top_p: 1,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
};
|
||||
|
||||
let parentMessageId = null;
|
||||
let focusedMessages;
|
||||
let parentMessageId = undefined;
|
||||
let focusedMessages: MessageEntity[] | undefined;
|
||||
if (targetMessage.role === 'user') {
|
||||
// we need to find the next agent message
|
||||
while (targetMessageIndex < topicMessages.length) {
|
||||
@@ -408,130 +449,59 @@ export const useChat = (agentId: string) => {
|
||||
focusedMessages = topicMessages;
|
||||
}
|
||||
|
||||
if (parentMessageId === null) {
|
||||
const messages = marshallMessages(
|
||||
agent,
|
||||
topicMessages
|
||||
);
|
||||
if (messages.ok === false) {
|
||||
console.error('Failed to marshall messages:', messages.error);
|
||||
return Err(ChatErrorType.MarshallFailed)
|
||||
}
|
||||
startGeneration(model, parentMessageId);
|
||||
|
||||
return startGeneration(messages.data, args, topic, provider, model);
|
||||
}
|
||||
return Ok(undefined);
|
||||
}
|
||||
|
||||
const focusedMessageIndex = topicMessages.findIndex(m => m.id === parentMessageId);
|
||||
if (focusedMessageIndex !== topicMessages.length - 1) {
|
||||
const patchMessageLocally = (id: string, updates: Partial<Message>) => {
|
||||
if (!data.value) return;
|
||||
|
||||
}
|
||||
|
||||
const messages = marshallMessages(
|
||||
agent,
|
||||
focusedMessages
|
||||
data.value.messages = data.value.messages.map(m =>
|
||||
m.id === id ? { ...m, ...updates } : m
|
||||
);
|
||||
if (messages.ok === false) {
|
||||
console.error('Failed to marshall messages:', messages.error);
|
||||
return Err(ChatErrorType.MarshallFailed)
|
||||
}
|
||||
|
||||
return startGeneration(messages.data, args, topic, provider, model, parentMessageId);
|
||||
}
|
||||
|
||||
enum AutoRenameError {
|
||||
AutoRenameDisabled = 0,
|
||||
NoModelSelected,
|
||||
NoModelFound,
|
||||
ModelDisabled,
|
||||
DatabaseOperationFailed,
|
||||
FailedToDecryptProviderApiKey,
|
||||
FailedToGenerate,
|
||||
}
|
||||
const autoRename = async (topicId: string, prompt: string): Promise<Result<string, AutoRenameError>> => {
|
||||
const { settings } = useUserSettings();
|
||||
const deleteMessagesFocusedDescendant = async (messageId: string): Promise<Result<void, Error>> => {
|
||||
if (!data.value) return Err(new Error('No data'));
|
||||
|
||||
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) {
|
||||
return Err(AutoRenameError.AutoRenameDisabled);
|
||||
}
|
||||
const previousMessages = data.value!.messages;
|
||||
|
||||
if (!settings.value.systemAssistants.rename.modelId) {
|
||||
return Err(AutoRenameError.NoModelSelected);
|
||||
}
|
||||
const res = await $fetch(`/api/messages/${messageId}`, {
|
||||
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')));
|
||||
|
||||
if (modelResult.ok === false) {
|
||||
return Err(AutoRenameError.DatabaseOperationFailed);
|
||||
}
|
||||
|
||||
const model = modelResult.data;
|
||||
|
||||
if (!model) {
|
||||
return Err(AutoRenameError.NoModelFound);
|
||||
}
|
||||
|
||||
if (model.enabled === false || model.provider?.enabled === false) {
|
||||
return Err(AutoRenameError.ModelDisabled)
|
||||
}
|
||||
|
||||
let providerApiKey: string | undefined = undefined;
|
||||
if (model.provider!.config.apiKey !== undefined) {
|
||||
try {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
|
||||
providerApiKey = await decrypt(
|
||||
key,
|
||||
base64ToUint8Array(model.provider!.config.apiKey)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt provider API key:', error);
|
||||
return Err(AutoRenameError.FailedToDecryptProviderApiKey);
|
||||
}
|
||||
}
|
||||
|
||||
await triplit.update('topics', topicId, {
|
||||
renaming: true
|
||||
const parent = data.value!.messages.find(m => m.id === msg.parentMessageId);
|
||||
data.value!.messages = data.value!.messages.filter(m => m.id !== msg.id);
|
||||
if (parent?.deleted) {
|
||||
data.value!.messages = data.value!.messages.filter(m => m.id !== parent.id);
|
||||
}
|
||||
},
|
||||
onResponseError() {
|
||||
data.value!.messages = previousMessages;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// @ts-ignore - excessive stack depth
|
||||
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);
|
||||
if (!res.ok) {
|
||||
return Err(new Error('Failed to delete message'));
|
||||
}
|
||||
|
||||
return Ok(undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
topic,
|
||||
sendMessage,
|
||||
AutoRenameError,
|
||||
autoRename,
|
||||
startGeneration,
|
||||
regenerateMessage,
|
||||
createTopic,
|
||||
patchMessageLocally,
|
||||
deleteMessagesFocusedDescendant
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+144
-47
@@ -1,54 +1,38 @@
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
import { nanoid } from 'nanoid';
|
||||
import * as schema from '~~/drizzle/schema';
|
||||
|
||||
type Provider = Entity<typeof schema, 'providers'>;
|
||||
type Model = Entity<typeof schema, 'models'>;
|
||||
export type Model = typeof schema.models.$inferSelect;
|
||||
export type Provider = typeof schema.providers.$inferSelect;
|
||||
|
||||
export interface ModelWithProvider extends Model {
|
||||
provider: Provider;
|
||||
}
|
||||
|
||||
export interface ProviderWithModels extends Provider {
|
||||
export type ProviderWithModels = Provider & {
|
||||
models: Model[];
|
||||
}
|
||||
defaultBaseUrl?: string;
|
||||
};
|
||||
|
||||
export const useModels = () => {
|
||||
const nuxtApp = useNuxtApp();
|
||||
const triplit = useTriplitClient();
|
||||
export type ModelWithProvider = Model & {
|
||||
provider: Provider;
|
||||
};
|
||||
|
||||
if (!nuxtApp._modelsState) {
|
||||
nuxtApp._modelsState = {
|
||||
providers: shallowRef([]),
|
||||
isReady: ref(false)
|
||||
};
|
||||
}
|
||||
export const useModels = async () => {
|
||||
const providers = useState<ProviderWithModels[]>('models_state', () => []);
|
||||
const loaded = useState('models_loaded', () => false);
|
||||
|
||||
|
||||
const state = nuxtApp._modelsState as {
|
||||
providers: Ref<ProviderWithModels[]>;
|
||||
initPromise: Promise<void> | null;
|
||||
};
|
||||
|
||||
const init = (): Promise<void> => {
|
||||
if (state.initPromise) return state.initPromise;
|
||||
|
||||
state.initPromise = (async () => {
|
||||
const query = triplit.query('providers').Include('models');
|
||||
|
||||
const { results } = await useQuery('providers', triplit, query)
|
||||
watch(results, (newProviders) => {
|
||||
if (newProviders && newProviders.length > 0) {
|
||||
state.providers.value = newProviders as unknown as ProviderWithModels[];
|
||||
}
|
||||
}, { immediate: true, flush: 'sync' });
|
||||
})();
|
||||
|
||||
return state.initPromise;
|
||||
};
|
||||
const { refresh } = await useFetch<ProviderWithModels[]>('/api/providers', {
|
||||
key: 'models_request',
|
||||
immediate: !loaded.value,
|
||||
onRequest() {
|
||||
loaded.value = true;
|
||||
},
|
||||
onResponse({ response }) {
|
||||
if (response.ok) {
|
||||
providers.value = response._data ?? [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const allModels = computed<ModelWithProvider[]>(() => {
|
||||
const result: ModelWithProvider[] = [];
|
||||
for (const provider of state.providers.value) {
|
||||
for (const provider of providers.value) {
|
||||
if (!provider.enabled) continue;
|
||||
for (const model of provider.models || []) {
|
||||
if (!model.enabled) continue;
|
||||
@@ -63,19 +47,132 @@ export const useModels = () => {
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
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 {
|
||||
init,
|
||||
providers: state.providers,
|
||||
providers,
|
||||
allModels,
|
||||
getModel,
|
||||
getProvider,
|
||||
getFirstAvailableModel
|
||||
getFirstAvailableModel,
|
||||
createModel,
|
||||
updateModel,
|
||||
updateProvider,
|
||||
deleteModel
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -37,6 +37,18 @@ export const useSidebar = () => {
|
||||
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 {
|
||||
open: readonly(open),
|
||||
toggle,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type { schema } from '#triplit/schema';
|
||||
import * as schema from '~~/drizzle/schema';
|
||||
|
||||
type Generation = Entity<typeof schema, 'generations'>;
|
||||
type Generation = typeof schema.generations.$inferSelect;
|
||||
|
||||
export const useTokenDropdown = () => {
|
||||
const isOpen = useState('token-dropdown:open', () => false);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Err, Ok, type Result } from "~~/types/result";
|
||||
|
||||
export const useTopic = () => {
|
||||
enum AutoRenameError {
|
||||
AutoRenameDisabled = 0,
|
||||
NoModelSelected,
|
||||
NoModelFound,
|
||||
ModelDisabled,
|
||||
DatabaseOperationFailed,
|
||||
FailedToDecryptProviderApiKey,
|
||||
FailedToGenerate,
|
||||
}
|
||||
const autoRename = async (topicId: string): Promise<Result<string, AutoRenameError>> => {
|
||||
const { settings } = await useUserSettings();
|
||||
|
||||
if (!settings.value.systemAssistants.rename.enabled) {
|
||||
return Err(AutoRenameError.AutoRenameDisabled);
|
||||
}
|
||||
|
||||
if (!settings.value.systemAssistants.rename.modelId) {
|
||||
return Err(AutoRenameError.NoModelSelected);
|
||||
}
|
||||
|
||||
const { allModels } = await useModels();
|
||||
|
||||
const model = allModels.value.find(model => model.id === settings.value.systemAssistants.rename.modelId);
|
||||
|
||||
if (!model) {
|
||||
return Err(AutoRenameError.NoModelFound);
|
||||
}
|
||||
|
||||
if (model.enabled === false || model.provider?.enabled === false) {
|
||||
return Err(AutoRenameError.ModelDisabled)
|
||||
}
|
||||
|
||||
let providerApiKey: string | undefined = undefined;
|
||||
if (model.provider!.config.apiKey !== undefined) {
|
||||
try {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
|
||||
providerApiKey = await decrypt(
|
||||
key,
|
||||
base64ToUint8Array(model.provider!.config.apiKey)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt provider API key:', error);
|
||||
return Err(AutoRenameError.FailedToDecryptProviderApiKey);
|
||||
}
|
||||
}
|
||||
|
||||
const { agents } = await useAgents();
|
||||
|
||||
try {
|
||||
// @ts-ignore - excessive stack depth
|
||||
const res = await $fetch(`/api/topic/${topicId}/auto-rename`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
modelId: model.id,
|
||||
providerApiKey,
|
||||
}),
|
||||
onRequest() {
|
||||
const topics = agents.value.flatMap(agent => agent.topics);
|
||||
const topic = topics.find(topic => topic.id === topicId);
|
||||
if (topic) {
|
||||
agents.value = agents.value.map(agent => {
|
||||
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: true } : t) } : agent;
|
||||
});
|
||||
}
|
||||
},
|
||||
onRequestError() {
|
||||
const topics = agents.value.flatMap(agent => agent.topics);
|
||||
const topic = topics.find(topic => topic.id === topicId);
|
||||
if (topic) {
|
||||
agents.value = agents.value.map(agent => {
|
||||
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: false } : t) } : agent;
|
||||
});
|
||||
}
|
||||
},
|
||||
}) as { ok: true, renameId: string } | { ok: false, code: string };
|
||||
if (!res.ok) {
|
||||
console.error('Failed to auto-rename:', res.code);
|
||||
return Err(AutoRenameError.FailedToGenerate);
|
||||
}
|
||||
|
||||
return Ok(res.renameId);
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-rename:', error);
|
||||
return Err(AutoRenameError.FailedToGenerate);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
autoRename
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
export const useUserEvents = () => {
|
||||
const eventSource = ref<EventSource | null>(null);
|
||||
const isConnected = useState('userEvents:connected', () => false);
|
||||
const lastEventTimestamp = useState<number>('userEvents:lastTimestamp', () => 0);
|
||||
|
||||
const reconnectAttempts = ref(0);
|
||||
const connectionStartTime = ref<number>(0);
|
||||
const pendingReconnect = ref<NodeJS.Timeout | null>(null);
|
||||
|
||||
const BASE_DELAY = 1000;
|
||||
const MAX_DELAY = 30000;
|
||||
const MAX_RECONNECT_ATTEMPTS = 10;
|
||||
|
||||
const cleanup = () => {
|
||||
if (pendingReconnect.value) {
|
||||
clearTimeout(pendingReconnect.value);
|
||||
}
|
||||
if (eventSource.value) {
|
||||
eventSource.value.close();
|
||||
eventSource.value = null;
|
||||
}
|
||||
isConnected.value = false;
|
||||
};
|
||||
|
||||
const getReconnectDelay = (attempt: number): number => {
|
||||
const exponentialDelay = Math.min(
|
||||
BASE_DELAY * Math.pow(2, attempt),
|
||||
MAX_DELAY
|
||||
);
|
||||
const jitter = exponentialDelay * Math.random() * 0.25;
|
||||
return exponentialDelay + jitter;
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.error('Max reconnection attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = getReconnectDelay(reconnectAttempts.value);
|
||||
console.log(`Scheduling reconnect in ${delay}ms (attempt ${reconnectAttempts.value + 1})`);
|
||||
|
||||
pendingReconnect.value = setTimeout(() => {
|
||||
reconnectAttempts.value++;
|
||||
connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
cleanup();
|
||||
|
||||
connectionStartTime.value = Date.now();
|
||||
reconnectAttempts.value = 0;
|
||||
|
||||
const source = new EventSource('/api/events');
|
||||
eventSource.value = source;
|
||||
|
||||
source.onopen = () => {
|
||||
console.log('User events connected');
|
||||
isConnected.value = true;
|
||||
reconnectAttempts.value = 0;
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
isConnected.value = false;
|
||||
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
console.log('EventSource closed, scheduling reconnect');
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
source.addEventListener('message', async (event) => {
|
||||
if (event.data === '') return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.timestamp && data.timestamp > lastEventTimestamp.value) {
|
||||
lastEventTimestamp.value = data.timestamp;
|
||||
}
|
||||
|
||||
handleUserEvent(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse user event:', e);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUserEvent = async (data: { entity: string; op: string; payload: any; timestamp?: number }) => {
|
||||
const { agents } = await useAgents();
|
||||
|
||||
switch (data.entity) {
|
||||
case 'topics': {
|
||||
switch (data.op) {
|
||||
case 'create': {
|
||||
const existing = agents.value.find(a => a.id === data.payload.agentId);
|
||||
if (existing) {
|
||||
const topicExists = existing.topics.some(t => t.id === data.payload.id);
|
||||
if (!topicExists) {
|
||||
agents.value = agents.value.map(agent => {
|
||||
return agent.id === data.payload.agentId
|
||||
? { ...agent, topics: [data.payload, ...agent.topics] }
|
||||
: agent;
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
const topics = agents.value.flatMap(agent => agent.topics);
|
||||
const topic = topics.find(t => t.id === data.payload.topicId);
|
||||
if (topic) {
|
||||
agents.value = agents.value.map(agent => {
|
||||
return agent.id === topic.agentId
|
||||
? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...t, ...data.payload } : t) }
|
||||
: agent;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
const topics = agents.value.flatMap(agent => agent.topics);
|
||||
const topic = topics.find(t => t.id === data.payload.topicId);
|
||||
if (topic) {
|
||||
agents.value = agents.value.map(agent => {
|
||||
return agent.id === topic.agentId
|
||||
? { ...agent, topics: agent.topics.filter(t => t.id !== topic.id) }
|
||||
: agent;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'agents': {
|
||||
switch (data.op) {
|
||||
case 'create': {
|
||||
const existing = agents.value.find(a => a.id === data.payload.id);
|
||||
if (existing) {
|
||||
agents.value = agents.value.map(agent =>
|
||||
agent.id === data.payload.id ? { ...agent, ...data.payload } : agent
|
||||
);
|
||||
} else {
|
||||
agents.value = [...agents.value, data.payload];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
agents.value = agents.value.map(agent =>
|
||||
agent.id === data.payload.id ? { ...agent, ...data.payload } : agent
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
agents.value = agents.value.filter(agent => agent.id !== data.payload.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reconcile = async () => {
|
||||
console.log('Reconciling user state...');
|
||||
const { data } = await useFetch<AgentWithTopics[]>('/api/agents');
|
||||
if (data.value) {
|
||||
const { agents } = await useAgents();
|
||||
agents.value = data.value;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
connect();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
return {
|
||||
eventSource,
|
||||
isConnected,
|
||||
connect,
|
||||
cleanup,
|
||||
reconcile,
|
||||
};
|
||||
};
|
||||
+105
-172
@@ -1,197 +1,130 @@
|
||||
import { schema } from '#triplit/schema';
|
||||
import { type Entity } from '@triplit/client';
|
||||
import { computed, watch, ref, type Ref } from 'vue';
|
||||
import * as schema from '~~/drizzle/schema';
|
||||
|
||||
type UserSettings = Entity<typeof schema, 'settings'>;
|
||||
type UserSettings = typeof schema.settings.$inferSelect;
|
||||
type Appearance = UserSettings['appearance'];
|
||||
type SystemAssistants = UserSettings['systemAssistants'];
|
||||
|
||||
interface UserSettingsState {
|
||||
accent: Ref<string>;
|
||||
neutral: Ref<string>;
|
||||
hinting: Ref<string>;
|
||||
colorSchemePreference: Ref<'light' | 'dark' | 'system'>;
|
||||
colorSchemeClass: Ref<'light' | 'dark' | undefined>;
|
||||
remoteSettings: Ref<UserSettings | null>;
|
||||
initPromise: Promise<void> | null;
|
||||
}
|
||||
const defaultAppearance: Appearance = {
|
||||
colorScheme: 'system',
|
||||
accent: 'violet',
|
||||
neutral: 'zinc',
|
||||
hinting: 0,
|
||||
fontSize: 'md',
|
||||
};
|
||||
|
||||
export const useUserSettings = () => {
|
||||
const nuxtApp = useNuxtApp();
|
||||
const triplit = useTriplitClient();
|
||||
const { user, loggedIn } = useAuth();
|
||||
export const useUserSettings = async () => {
|
||||
const settings = useState<UserSettings | null>('settings_state', () => null);
|
||||
const loaded = useState('settings_loaded', () => false);
|
||||
const syncing = useState('settings_syncing', () => false);
|
||||
|
||||
if (!nuxtApp._userSettingsState) {
|
||||
nuxtApp._userSettingsState = {
|
||||
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 localAppearance = useState<Appearance | null>('settings_local_appearance', () => null);
|
||||
const localSystemAssistants = useState<SystemAssistants | null>('settings_local_sa', () => null);
|
||||
|
||||
const state = nuxtApp._userSettingsState as UserSettingsState;
|
||||
|
||||
const init = (): Promise<void> => {
|
||||
if (state.initPromise) return state.initPromise;
|
||||
|
||||
state.initPromise = (async () => {
|
||||
const { results } = await useQuery('settings', triplit, triplit.query('settings'));
|
||||
|
||||
watch(results, (val) => {
|
||||
if (val && val.length > 0) {
|
||||
state.remoteSettings.value = val[0] as UserSettings;
|
||||
} else {
|
||||
state.remoteSettings.value = null;
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
watch(state.remoteSettings, (newSettings) => {
|
||||
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 });
|
||||
const { refresh } = await useFetch<UserSettings>('/api/settings', {
|
||||
key: 'settings_request',
|
||||
immediate: !loaded.value,
|
||||
onRequest() {
|
||||
loaded.value = true;
|
||||
},
|
||||
onResponse({ response }) {
|
||||
if (response.ok && response._data) {
|
||||
settings.value = response._data;
|
||||
localAppearance.value = { ...defaultAppearance, ...response._data.appearance };
|
||||
localSystemAssistants.value = response._data.systemAssistants;
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
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(() => {
|
||||
if (state.colorSchemePreference.value === 'system') {
|
||||
if (effectiveAppearance.value.colorScheme === 'system') {
|
||||
if (import.meta.client) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return 'dark';
|
||||
}
|
||||
return state.colorSchemePreference.value as 'dark' | 'light';
|
||||
return effectiveAppearance.value.colorScheme as 'dark' | 'light';
|
||||
});
|
||||
|
||||
const settings = computed(() => {
|
||||
const remote = state.remoteSettings.value;
|
||||
return {
|
||||
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
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
const effectiveSettings = computed(() => ({
|
||||
appearance: effectiveAppearance.value,
|
||||
systemAssistants: effectiveSystemAssistants.value,
|
||||
}));
|
||||
|
||||
return {
|
||||
init,
|
||||
settings,
|
||||
remoteSettings: state.remoteSettings,
|
||||
settings: effectiveSettings,
|
||||
rawSettings: readonly(settings),
|
||||
updateSettings,
|
||||
accent: state.accent,
|
||||
neutral: state.neutral,
|
||||
hinting: state.hinting,
|
||||
syncing: readonly(syncing),
|
||||
loaded: readonly(loaded),
|
||||
refresh,
|
||||
accent: computed(() => effectiveAppearance.value.accent ?? 'violet'),
|
||||
neutral: computed(() => effectiveAppearance.value.neutral ?? 'zinc'),
|
||||
hinting: computed(() => effectiveAppearance.value.hinting ?? 0),
|
||||
colorScheme: {
|
||||
preference: state.colorSchemePreference,
|
||||
preference: computed(() => effectiveAppearance.value.colorScheme ?? 'system'),
|
||||
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
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// 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.
|
||||
const { colorScheme } = useUserSettings();
|
||||
// const { colorScheme } = useUserSettings();
|
||||
|
||||
const colorScheme = {
|
||||
class: 'dark',
|
||||
}
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
@@ -13,12 +17,14 @@ preloadRouteComponents('/');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="p-2 bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<div class="p-2 w-full h-full">
|
||||
<main
|
||||
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">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import Dialog from '~/components/Dialog/index.vue';
|
||||
|
||||
const { colorScheme } = useUserSettings();
|
||||
const { colorScheme } = await useUserSettings();
|
||||
const { toggle: toggleSidebar } = useSidebar();
|
||||
const { addShortcut } = useKeyboardShortcuts();
|
||||
|
||||
@@ -16,11 +16,16 @@ useHead({
|
||||
class: colorScheme.class
|
||||
}
|
||||
});
|
||||
|
||||
if (import.meta.client) {
|
||||
const { connect } = useUserEvents();
|
||||
connect();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidenav />
|
||||
<div class="p-2 w-full h-full">
|
||||
<div class="p-2 w-full h-full max-w-full overflow-x-hidden">
|
||||
<main
|
||||
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">
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
import type { BaseMessage } from '~/composables/useChat';
|
||||
import type { ModelWithProvider } from '~/composables/useModels';
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const route = useRoute();
|
||||
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
|
||||
const pendingMessage = ref<Message | null>(null);
|
||||
|
||||
const { open: sidebarOpen, openSidebar } = useSidebar();
|
||||
const { createTopic, sendMessage, autoRename } = useChat(route.params.id as string);
|
||||
const { getAgent } = useAgents();
|
||||
const { providers } = useModels();
|
||||
const { autoRename: autoRenameTopic } = useTopic();
|
||||
const { getAgent, createTopic } = await useAgents();
|
||||
const { providers } = await useModels();
|
||||
|
||||
const agent = getAgent(route.params.id as string);
|
||||
|
||||
@@ -31,10 +29,12 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("handleSubmit", message);
|
||||
|
||||
pendingMessage.value = {
|
||||
id: '',
|
||||
userId: user.value!.id,
|
||||
topicId: null,
|
||||
topicId: '',
|
||||
content: message.content,
|
||||
role: 'user',
|
||||
parts: [],
|
||||
@@ -42,41 +42,38 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
|
||||
parentMessageId: null,
|
||||
children: [],
|
||||
generationId: null,
|
||||
focusedIndex: null,
|
||||
activeChildId: null,
|
||||
attachments: [],
|
||||
deleted: false,
|
||||
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');
|
||||
|
||||
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}`);
|
||||
|
||||
return sendMessage(message, topic, [], agent.value!, model.provider, model).then(async res => {
|
||||
if (res.ok === false) {
|
||||
console.error('Failed to send message:', res.error);
|
||||
autoRenameTopic(topic.id);
|
||||
|
||||
pendingMessage.value = null;
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
return startGeneration(model, undefined, route.params.id as string);
|
||||
};
|
||||
</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="flex items-center gap-2 max-w-full">
|
||||
<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>
|
||||
</button>
|
||||
<h4 class="text-lg text-ellipsis overflow-hidden whitespace-nowrap">
|
||||
<h4 class="text-lg truncate">
|
||||
{{ agent?.name }}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
const { getAgent } = useAgents();
|
||||
const triplit = useTriplitClient();
|
||||
const { getAgent, patchAgentLocally } = await useAgents();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const { open: sidebarOpen, openSidebar } = useSidebar();
|
||||
|
||||
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('/');
|
||||
|
||||
const handleInput = async (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.value.trimStart().length === 0) {
|
||||
return;
|
||||
let debounceTimeout: NodeJS.Timeout | null = null;
|
||||
const debouncedUpdate = async (updates: Partial<Agent>) => {
|
||||
if (debounceTimeout !== null) {
|
||||
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 target = e.target as HTMLTextAreaElement;
|
||||
let value: string | undefined = target.value;
|
||||
|
||||
if (value.trimStart().length === 0) {
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
await triplit.update('agents', agent.value!.id, {
|
||||
systemPrompt: target.value,
|
||||
});
|
||||
let systemPrompt = (e.target as HTMLTextAreaElement).value as string | null;
|
||||
if (!systemPrompt!.trim()) systemPrompt = null;
|
||||
debouncedUpdate({ systemPrompt });
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -37,7 +58,7 @@ const changeSystemPrompt = async (e: Event) => {
|
||||
<div class="h-14 flex items-center justify-between px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="!sidebarOpen" @click="openSidebar"
|
||||
class="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>
|
||||
</button>
|
||||
</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" />
|
||||
<span class="text-16 i-mynaui-check-hexagon"></span>
|
||||
</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"
|
||||
type="text" :value="agent?.name" />
|
||||
type="text" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 w-full h-full mb-14">
|
||||
<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)]"
|
||||
:value="agent?.systemPrompt" @input="changeSystemPrompt"></textarea>
|
||||
@input="changeSystemPrompt"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,131 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type schema from '#triplit/schema';
|
||||
import type { Entity } from '@triplit/client';
|
||||
import type { BaseMessage } from '~/composables/useChat';
|
||||
import { type BaseMessage, type Message, ChatErrorType } from '~/composables/useChat';
|
||||
import type { ModelWithProvider } from '~/composables/useModels';
|
||||
import { buildFocusedMessageTree } from '~~/utils/message';
|
||||
|
||||
const rootStart = Date.now();
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const chatPaneWrapper = ref<HTMLElement | null>(null);
|
||||
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
|
||||
const route = useRoute();
|
||||
const { sendMessage, regenerateMessage } = useChat(route.params.id as string);
|
||||
const { getAgent } = useAgents();
|
||||
const { getAgent, deleteTopic } = await useAgents();
|
||||
const { open: sidebarOpen, openSidebar } = useSidebar();
|
||||
const { providers, allModels } = useModels();
|
||||
const { providers, allModels } = await useModels();
|
||||
const { addShortcut } = useKeyboardShortcuts();
|
||||
|
||||
const agent = getAgent(route.params.id as string);
|
||||
|
||||
const topicQuery = computed(() =>
|
||||
triplit
|
||||
.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 || []
|
||||
};
|
||||
});
|
||||
const topicId = computed(() => route.params.topicId as string);
|
||||
const { topic, sendMessage, startGeneration, regenerateMessage, patchMessageLocally, deleteMessagesFocusedDescendant } = await useChat(topicId);
|
||||
|
||||
watch(() => topic.value?.name, (newTopicName) => {
|
||||
if (newTopicName !== undefined) {
|
||||
@@ -139,7 +30,14 @@ const submitMessage = async (message: BaseMessage, model: ModelWithProvider | nu
|
||||
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) {
|
||||
console.error('Failed to send message:', res.error);
|
||||
const chatInput = document.getElementById('chat') as HTMLInputElement | null;
|
||||
@@ -153,128 +51,86 @@ const submitMessage = async (message: BaseMessage, model: ModelWithProvider | nu
|
||||
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) => {
|
||||
if (!agent.value!.defaultModelId) {
|
||||
console.error('No model selected');
|
||||
return;
|
||||
}
|
||||
|
||||
let messageId;
|
||||
if (
|
||||
(message.focusedIndex !== undefined && message.focusedIndex !== null)
|
||||
&& message.focusedIndex > 0
|
||||
&& message.children.length > 0
|
||||
) {
|
||||
messageId = message.children[message.focusedIndex - 1]!.id;
|
||||
let messageId: string;
|
||||
if (message.activeChildId && message.children.length > 0) {
|
||||
messageId = message.activeChildId;
|
||||
} else {
|
||||
messageId = message.id;
|
||||
}
|
||||
|
||||
const model = allModels.value.find(m => m.id === agent.value!.defaultModelId);
|
||||
if (!model) {
|
||||
console.error('Model not found');
|
||||
if (!model || !model.provider) {
|
||||
console.error('Model not found or provider not found');
|
||||
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) {
|
||||
console.error('Failed to regenerate message:', ChatErrorType[res.error]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const deeplyDeleteMessage = async (message: MessageEntity) => {
|
||||
triplit.delete('messages', message.id);
|
||||
const flatMessages = computed(() => {
|
||||
const messages: Message[] = [];
|
||||
|
||||
if (message.generationId !== null && message.generationId !== undefined) {
|
||||
triplit.delete('generations', message.generationId);
|
||||
for (const message of topic.value?.messages ?? []) {
|
||||
messages.push(message);
|
||||
if (message.children.length > 0) {
|
||||
messages.push(...message.children as Message[]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const part of message.parts || []) {
|
||||
triplit.delete('message_parts', part.id);
|
||||
return messages;
|
||||
});
|
||||
|
||||
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) {
|
||||
triplit.delete('topics', topic.value!.id);
|
||||
return navigateTo(`/agent/${route.params.id}/`);
|
||||
const result = await deleteMessagesFocusedDescendant(messageId);
|
||||
if (!result.ok) {
|
||||
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) => {
|
||||
if (rootMessage.role === 'user') {
|
||||
deeplyDeleteMessage(rootMessage);
|
||||
return;
|
||||
}
|
||||
const handleResize = () => {
|
||||
const el = chatPaneWrapper.value;
|
||||
if (!el) return;
|
||||
|
||||
if (rootMessage.deleted === true) {
|
||||
const message = rootMessage.children[rootMessage.focusedIndex!];
|
||||
if (!message) {
|
||||
console.error('Message not found');
|
||||
return;
|
||||
}
|
||||
const atBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) <= 80;
|
||||
if (!atBottom) return;
|
||||
|
||||
deeplyDeleteMessage(message);
|
||||
|
||||
if (rootMessage.children.filter(child => child!.id !== message.id).length === 0) {
|
||||
deeplyDeleteMessage(rootMessage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// - If the message has children, check if they are all soft deleted
|
||||
// - If they are all soft deleted, delete the message
|
||||
// - If they are not all soft deleted, mark only this message as deleted
|
||||
if (
|
||||
(rootMessage.focusedIndex !== undefined && rootMessage.focusedIndex !== null)
|
||||
&& rootMessage.focusedIndex > 0
|
||||
&& rootMessage.children.length > 0
|
||||
) {
|
||||
// we are a child message
|
||||
const message = rootMessage.children[rootMessage.focusedIndex - 1]!;
|
||||
if (!message) {
|
||||
console.error('Message not found');
|
||||
return;
|
||||
}
|
||||
|
||||
deeplyDeleteMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// we have no children
|
||||
if (rootMessage.children.length === 0) {
|
||||
deeplyDeleteMessage(rootMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// we are a root message and we have at least one living child, soft delete
|
||||
await triplit.update('messages', rootMessage.id, {
|
||||
deleted: true
|
||||
nextTick().then(() => {
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom('instant');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const activeGeneration = computed(() => {
|
||||
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);
|
||||
@@ -318,9 +174,11 @@ addShortcut(['alt', '['], async (event) => {
|
||||
|
||||
const lastMessage = topic.value?.messages?.at(-1);
|
||||
if (lastMessage && lastMessage.children.length > 0) {
|
||||
await triplit.update('messages', lastMessage.id, {
|
||||
focusedIndex: Math.max(0, lastMessage.focusedIndex! - 1)
|
||||
});
|
||||
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
|
||||
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);
|
||||
if (lastMessage && lastMessage.children.length > 0) {
|
||||
await triplit.update('messages', lastMessage.id, {
|
||||
focusedIndex: Math.min(lastMessage.children.length, lastMessage.focusedIndex! + 1)
|
||||
});
|
||||
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
|
||||
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.stopPropagation();
|
||||
|
||||
await triplit.update('messages', lastMessage.id, {
|
||||
focusedIndex: Math.max(0, lastMessage.focusedIndex! - 1)
|
||||
});
|
||||
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
|
||||
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.stopPropagation();
|
||||
|
||||
await triplit.update('messages', lastMessage.id, {
|
||||
focusedIndex: Math.min(lastMessage.children.length, lastMessage.focusedIndex! + 1)
|
||||
});
|
||||
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
|
||||
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(() => {
|
||||
scrollToBottom('instant');
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
};
|
||||
|
||||
console.log("full page render took", Date.now() - rootStart);
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribeTopic?.();
|
||||
unsubscribeMessages?.();
|
||||
unsubscribeAttachments?.();
|
||||
unsubscribeParts?.();
|
||||
unsubscribeGenerations?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<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="flex items-center gap-2 max-w-full">
|
||||
<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>
|
||||
</button>
|
||||
<h4 class="text-lg text-ellipsis overflow-hidden whitespace-nowrap">
|
||||
<h4 class="text-lg truncate">
|
||||
{{ topic?.name }}
|
||||
</h4>
|
||||
</div>
|
||||
@@ -403,7 +283,8 @@ onUnmounted(() => {
|
||||
<Suspense>
|
||||
<template v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
|
||||
<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)" />
|
||||
</template>
|
||||
</Suspense>
|
||||
@@ -412,7 +293,8 @@ onUnmounted(() => {
|
||||
<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"
|
||||
: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>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { deriveKey } from '~/utils/crypto';
|
||||
import { initSettings } from '~/utils/settings';
|
||||
|
||||
const { client: authClient, signIn, session } = useAuth();
|
||||
|
||||
@@ -101,8 +100,6 @@ const submit = async () => {
|
||||
const key = await deriveKey(form.password, res.data.user.id);
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||
|
||||
initSettings(res.data.user.id);
|
||||
|
||||
return navigateTo(to ?? '/');
|
||||
};
|
||||
</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)]">
|
||||
New to Veridian?
|
||||
<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
|
||||
</NuxtLink>
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { deriveKey } from '~/utils/crypto';
|
||||
import { initSettings } from '~/utils/settings';
|
||||
|
||||
const { client: authClient, signUp, session } = useAuth();
|
||||
|
||||
@@ -134,8 +133,6 @@ const submit = async () => {
|
||||
const key = await deriveKey(form.password, res.data.user.id);
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||
|
||||
initSettings(res.data.user.id);
|
||||
|
||||
return navigateTo(to ?? '/');
|
||||
};
|
||||
</script>
|
||||
@@ -203,7 +200,7 @@ const submit = async () => {
|
||||
<p class="text-sm text-center mt-3 text-[var(--text-secondary)]">
|
||||
Already have an account?
|
||||
<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
|
||||
</NuxtLink>
|
||||
</p>
|
||||
|
||||
+28
-44
@@ -1,13 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { assert } from '~~/utils/assert';
|
||||
import type { BaseMessage } from '~/composables/useChat';
|
||||
import type { Agent } from '~/composables/useAgents';
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const { open: sidebarOpen, openSidebar } = useSidebar();
|
||||
const { agents, createAgent } = useAgents();
|
||||
const { providers, getFirstAvailableModel, allModels } = useModels();
|
||||
const { agents, createAgent, createTopic } = await useAgents();
|
||||
const { providers } = await useModels();
|
||||
|
||||
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
|
||||
|
||||
@@ -80,60 +76,48 @@ const agent = computed(() => {
|
||||
return agents.value?.[0] ?? null;
|
||||
});
|
||||
|
||||
const { autoRename: autoRenameTopic } = useTopic();
|
||||
|
||||
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) {
|
||||
console.error('No model selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const { createTopic, autoRename, sendMessage } = useChat(agent.id);
|
||||
if (!message.content) return;
|
||||
|
||||
const topic = await createTopic();
|
||||
if (!topic) throw new Error('Failed to create topic');
|
||||
let targetAgent = agents.value?.[0] ?? null;
|
||||
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) {
|
||||
console.error('Failed to send message:', res.error);
|
||||
await navigateTo(`/agent/${agent.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();
|
||||
// });
|
||||
// }
|
||||
await navigateTo('/');
|
||||
nextTick(() => {
|
||||
inputValue.value = message;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await navigateTo(`/agent/${targetAgent.id}/topic/${topic.id}`);
|
||||
|
||||
autoRenameTopic(topic.id);
|
||||
|
||||
return startGeneration(model, undefined, targetAgent.id);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -166,7 +150,7 @@ onUnmounted(() => {
|
||||
<div class="h-14 flex items-center justify-between px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="!sidebarOpen" @click="openSidebar"
|
||||
class="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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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,11 +1,10 @@
|
||||
import { assert } from "~~/utils/assert";
|
||||
|
||||
export default defineNuxtPlugin({
|
||||
name: 'better-auth-triplit',
|
||||
name: 'better-auth',
|
||||
enforce: 'pre',
|
||||
async setup(nuxtApp) {
|
||||
if (import.meta.client) {
|
||||
const triplit = useTriplitClient();
|
||||
const { session, fetchSession } = useAuth();
|
||||
|
||||
nuxtApp.hook('app:mounted', async () => {
|
||||
@@ -16,13 +15,6 @@ export default defineNuxtPlugin({
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!session.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
assert('startSession' in triplit)
|
||||
await triplit.startSession(session.value.token);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,8 +4,6 @@ export default defineNuxtPlugin({
|
||||
name: 'better-auth-fetch-plugin',
|
||||
enforce: 'pre',
|
||||
async setup(nuxtApp) {
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
// Flag if request is cached
|
||||
nuxtApp.payload.isCached = Boolean(useRequestEvent()?.context.cache);
|
||||
if (nuxtApp.payload.serverRendered && !nuxtApp.payload.prerenderedAt && !nuxtApp.payload.isCached) {
|
||||
@@ -18,14 +16,6 @@ export default defineNuxtPlugin({
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!session.value) return;
|
||||
|
||||
assert('updateOptions' in triplit);
|
||||
triplit.updateOptions({
|
||||
serverUrl: process.env.NUXT_LOCAL_TRIPLIT_URL || process.env.NUXT_PUBLIC_TRIPLIT_URL,
|
||||
token: session.value.token,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export default defineNuxtPlugin({
|
||||
name: 'global-data',
|
||||
enforce: 'pre',
|
||||
async setup() {
|
||||
await Promise.all([
|
||||
useAgents().init(),
|
||||
useModels().init(),
|
||||
useUserSettings().init()
|
||||
]);
|
||||
|
||||
console.log('Global data ready');
|
||||
}
|
||||
});
|
||||
@@ -1,16 +1,59 @@
|
||||
import { unified } from 'unified';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkMath from 'remark-math';
|
||||
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) => {
|
||||
const remark =
|
||||
unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkMath)
|
||||
.use(remarkBreaks)
|
||||
.use(remarkSplitBlocks)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeKatex, { output: 'mathml' });
|
||||
|
||||
|
||||
+2
-13
@@ -1,16 +1,5 @@
|
||||
import { schema } from '#triplit/schema';
|
||||
import type { Entity } from '@triplit/client';
|
||||
import * as schema from '~~/drizzle/schema';
|
||||
|
||||
export const Providers = ['openrouter', 'ollama', 'cerebras', 'google', 'longcat', 'cohere'] as const;
|
||||
export const SupportedModalities = ['text', 'image', 'audio', 'video', 'pdf'] as const;
|
||||
|
||||
export const providerBaseUrls = {
|
||||
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'> };
|
||||
export type Model = typeof schema.models.$inferSelect;
|
||||
|
||||
@@ -50,6 +50,11 @@ import {
|
||||
LogoAionLabs,
|
||||
LogoMicrosoft,
|
||||
LogoInflection,
|
||||
LogoVllm,
|
||||
LogoClosedRouter,
|
||||
LogoPoolside,
|
||||
LogoTencent,
|
||||
LogoHuggingFace,
|
||||
} from '#components';
|
||||
import { markRaw } from 'vue';
|
||||
|
||||
@@ -159,7 +164,7 @@ const MODEL_MAPPINGS: ModelConfig[] = [
|
||||
},
|
||||
{
|
||||
Icon: markRaw(LogoZAI),
|
||||
keywords: [/^glm-4/, /\/glm-4/, /zai-/, /-glm-4/, /glm-\d/]
|
||||
keywords: [/zai-/, /(^|\/|-)?glm-?\d/]
|
||||
},
|
||||
{
|
||||
Icon: markRaw(LogoGLMV),
|
||||
@@ -186,6 +191,7 @@ const MODEL_MAPPINGS: ModelConfig[] = [
|
||||
/magistral/,
|
||||
/devstral/,
|
||||
/voxtral/,
|
||||
/leanstral/,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -311,6 +317,14 @@ const MODEL_MAPPINGS: ModelConfig[] = [
|
||||
{
|
||||
Icon: markRaw(LogoInflection),
|
||||
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),
|
||||
openai: markRaw(LogoOpenAI),
|
||||
azure: null,
|
||||
anthropic: null,
|
||||
anthropic: markRaw(LogoAnthropic),
|
||||
cohere: markRaw(LogoCohere),
|
||||
huggingface: null,
|
||||
huggingface: markRaw(LogoHuggingFace),
|
||||
openrouter: markRaw(LogoOpenrouter),
|
||||
cerebras: markRaw(LogoCerebras),
|
||||
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) {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
export const initSettings = async (userId: string) => {
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const settings = await triplit.fetchOne(triplit.query('settings').Where('userId', '=', userId));
|
||||
|
||||
if (!settings) {
|
||||
console.log('no settings');
|
||||
await triplit.insert('settings', {
|
||||
userId: userId,
|
||||
systemAssistants: {
|
||||
rename: {
|
||||
enabled: false,
|
||||
prompt: null,
|
||||
modelId: null,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export const sortByReleaseDate = (a: { releasedAt?: Date | null } & Record<string, unknown>, b: { releasedAt?: Date | null } & Record<string, unknown>) => {
|
||||
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) return 1;
|
||||
if (!b.releasedAt) return -1;
|
||||
|
||||
@@ -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:
|
||||
@@ -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
@@ -0,0 +1,95 @@
|
||||
import { defineRelations } from "drizzle-orm";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export const relations = defineRelations(schema, (r) => ({
|
||||
users: {
|
||||
sessions: r.many.sessions(),
|
||||
accounts: r.many.accounts(),
|
||||
},
|
||||
sessions: {
|
||||
users: r.one.users({
|
||||
from: r.sessions.userId,
|
||||
to: r.users.id
|
||||
}),
|
||||
},
|
||||
accounts: {
|
||||
users: r.one.users({
|
||||
from: r.accounts.userId,
|
||||
to: r.users.id
|
||||
}),
|
||||
},
|
||||
agents: {
|
||||
topics: r.many.topics({
|
||||
from: r.agents.id,
|
||||
to: r.topics.agentId
|
||||
}),
|
||||
},
|
||||
topics: {
|
||||
messages: r.many.messages({
|
||||
from: r.topics.id,
|
||||
to: r.messages.topicId
|
||||
}),
|
||||
generations: r.many.generations({
|
||||
from: r.topics.id,
|
||||
to: r.generations.topicId
|
||||
}),
|
||||
agent: r.one.agents({
|
||||
from: r.topics.agentId,
|
||||
to: r.agents.id,
|
||||
optional: false,
|
||||
}),
|
||||
},
|
||||
messages: {
|
||||
topic: r.one.topics({
|
||||
from: r.messages.topicId,
|
||||
to: r.topics.id,
|
||||
optional: false,
|
||||
}),
|
||||
parts: r.many.messageParts({
|
||||
from: r.messages.id,
|
||||
to: r.messageParts.messageId
|
||||
}),
|
||||
attachments: r.many.attachments({
|
||||
from: r.messages.id,
|
||||
to: r.attachments.messageId
|
||||
}),
|
||||
generation: r.one.generations({
|
||||
from: r.messages.generationId,
|
||||
to: r.generations.id
|
||||
}),
|
||||
parent: r.one.messages({
|
||||
from: r.messages.parentMessageId,
|
||||
to: r.messages.id
|
||||
}),
|
||||
children: r.many.messages({
|
||||
from: r.messages.id,
|
||||
to: r.messages.parentMessageId
|
||||
}),
|
||||
},
|
||||
attachments: {
|
||||
file: r.one.files({
|
||||
from: r.attachments.fileId,
|
||||
to: r.files.id,
|
||||
optional: false,
|
||||
}),
|
||||
},
|
||||
messageParts: {
|
||||
toolCall: r.one.toolCalls({
|
||||
from: r.messageParts.toolCallId,
|
||||
to: r.toolCalls.id
|
||||
}),
|
||||
},
|
||||
models: {
|
||||
provider: r.one.providers({
|
||||
from: r.models.providerId,
|
||||
to: r.providers.id,
|
||||
optional: false,
|
||||
}),
|
||||
},
|
||||
providers: {
|
||||
models: r.many.models({
|
||||
from: r.providers.id,
|
||||
to: r.models.providerId
|
||||
}),
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,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)],
|
||||
);
|
||||
@@ -1,38 +0,0 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const secret = process.env.TRIPLIT_JWT_SECRET ?? process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('No secret provided');
|
||||
}
|
||||
|
||||
const anonKey = jwt.sign(
|
||||
{
|
||||
'x-triplit-token-type': 'anon',
|
||||
'x-triplit-project-id': 'local-project-id',
|
||||
},
|
||||
secret,
|
||||
{
|
||||
noTimestamp: true,
|
||||
algorithm: 'HS256',
|
||||
},
|
||||
);
|
||||
|
||||
const serviceKey = jwt.sign(
|
||||
{
|
||||
'x-triplit-token-type': 'secret',
|
||||
'x-triplit-project-id': 'local-project-id',
|
||||
},
|
||||
secret,
|
||||
{
|
||||
noTimestamp: true,
|
||||
algorithm: 'HS256',
|
||||
},
|
||||
);
|
||||
|
||||
console.log('ANON_KEY:', anonKey);
|
||||
console.log('SERVICE_KEY:', serviceKey);
|
||||
+7
-12
@@ -1,18 +1,13 @@
|
||||
import { triplitAdapter } from '@daveyplate/better-auth-triplit';
|
||||
import { HttpClient } from '@triplit/client';
|
||||
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
|
||||
import { db } from "../server/lib/db";
|
||||
import { betterAuth } from 'better-auth/minimal';
|
||||
import { schema } from '../triplit/schema';
|
||||
|
||||
const httpClient = new HttpClient({
|
||||
schema,
|
||||
serverUrl: process.env.NUXT_PUBLIC_TRIPLIT_URL,
|
||||
token: process.env.TRIPLIT_SERVICE_TOKEN,
|
||||
});
|
||||
import * as schema from '../drizzle/schema';
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: triplitAdapter({
|
||||
httpClient,
|
||||
secretKey: process.env.BETTER_AUTH_SECRET,
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
usePlural: true,
|
||||
}),
|
||||
|
||||
session: {
|
||||
|
||||
+10
-30
@@ -69,14 +69,6 @@ export default defineNuxtConfig({
|
||||
`,
|
||||
tagPosition: 'bodyOpen',
|
||||
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-kit',
|
||||
'@sentry/nuxt',
|
||||
'@floating-ui/vue',
|
||||
'@nuxt/hints/runtime/hydration/component',
|
||||
'@nuxt/hints/runtime/lazy-load/composables',
|
||||
'@tanstack/vue-virtual',
|
||||
'shiki',
|
||||
'better-auth/vue',
|
||||
@@ -125,14 +117,12 @@ export default defineNuxtConfig({
|
||||
'remark-parse',
|
||||
'remark-rehype',
|
||||
'remark-math',
|
||||
'remark-breaks',
|
||||
'rehype-katex',
|
||||
'unist-util-visit',
|
||||
'@triplit/client',
|
||||
'@triplit/db',
|
||||
'sorted-btree',
|
||||
'elen',
|
||||
'comlink',
|
||||
'nanoid'
|
||||
'nanoid',
|
||||
'drizzle-orm/pg-core'
|
||||
],
|
||||
},
|
||||
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
|
||||
// experimental: {
|
||||
// viewTransition: true,
|
||||
// },
|
||||
|
||||
// nitro: {
|
||||
// experimental: {
|
||||
// wasm: true
|
||||
// }
|
||||
// },
|
||||
|
||||
modules: [
|
||||
'@nuxt/hints',
|
||||
'@unocss/nuxt',
|
||||
'triplit-nuxt',
|
||||
// '@sentry/nuxt/module',
|
||||
'@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: {
|
||||
enabled: true,
|
||||
|
||||
|
||||
+76
-80
@@ -1,82 +1,78 @@
|
||||
{
|
||||
"name": "veridian",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"triplit": "triplit dev -s sqlite",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/cerebras": "^2.0.34",
|
||||
"@ai-sdk/cohere": "^3.0.21",
|
||||
"@ai-sdk/google": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@aws-sdk/client-s3": "^3.1000.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1000.0",
|
||||
"@daveyplate/better-auth-triplit": "^0.2.2",
|
||||
"@floating-ui/vue": "^1.1.10",
|
||||
"@iconify-json/mynaui": "^1.2.17",
|
||||
"@nuxt/fonts": "0.14.0",
|
||||
"@nuxt/hints": "1.0.0-alpha.5",
|
||||
"@nuxt/icon": "2.2.0",
|
||||
"@openrouter/ai-sdk-provider": "^2.2.3",
|
||||
"@sentry/nuxt": "^10.39.0",
|
||||
"@tanstack/vue-virtual": "^3.13.18",
|
||||
"@triplit/client": "^1.0.50",
|
||||
"@triplit/server": "^1.1.8",
|
||||
"@types/big.js": "^6.2.2",
|
||||
"ai": "^6.0.89",
|
||||
"ai-sdk-ollama": "^3.7.1",
|
||||
"better-auth": "^1.4.18",
|
||||
"big.js": "^7.0.1",
|
||||
"comlink": "^4.4.2",
|
||||
"glob": "^13.0.5",
|
||||
"nanoid": "^5.1.6",
|
||||
"nuxt": "^4.3.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"shiki": "^3.22.0",
|
||||
"triplit-nuxt": "0.3.1-prerelease.5",
|
||||
"unified": "^11.0.5",
|
||||
"vue": "^3.5.28",
|
||||
"vue-router": "^5.0.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/logos": "^1.2.10",
|
||||
"@iconify-json/svg-spinners": "^1.2.4",
|
||||
"@iconify-json/tabler": "^1.2.26",
|
||||
"@triplit/cli": "^1.0.61",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.3.0",
|
||||
"@unocss/nuxt": "^66.6.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"knip": "^5.84.1",
|
||||
"typescript": "^5.9.3",
|
||||
"unocss": "^66.6.0"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@parcel/watcher",
|
||||
"@sentry/cli",
|
||||
"core-js",
|
||||
"esbuild",
|
||||
"unrs-resolver",
|
||||
"vue-demi"
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@triplit/db@1.1.10": "patches/@triplit%2Fdb@1.1.10.patch",
|
||||
"@triplit/client@1.0.50": "patches/@triplit%2Fclient@1.0.50.patch"
|
||||
},
|
||||
"overrides": {
|
||||
"@vercel/nft": "^0.27.4",
|
||||
"vite": "8.0.0-beta.15"
|
||||
}
|
||||
"name": "veridian",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.71",
|
||||
"@ai-sdk/cerebras": "^2.0.45",
|
||||
"@ai-sdk/cohere": "^3.0.30",
|
||||
"@ai-sdk/google": "^3.0.61",
|
||||
"@ai-sdk/huggingface": "^1.0.43",
|
||||
"@ai-sdk/mistral": "^3.0.30",
|
||||
"@ai-sdk/openai": "^3.0.52",
|
||||
"@ai-sdk/openai-compatible": "^2.0.41",
|
||||
"@aws-sdk/client-s3": "^3.1028.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||
"@better-auth/drizzle-adapter": "https://pkg.pr.new/better-auth/better-auth/@better-auth/drizzle-adapter@6913",
|
||||
"@floating-ui/vue": "^1.1.11",
|
||||
"@iconify-json/mynaui": "^1.2.17",
|
||||
"@nuxt/fonts": "0.14.0",
|
||||
"@nuxt/hints": "1.0.0-alpha.5",
|
||||
"@nuxt/icon": "2.2.0",
|
||||
"@openrouter/ai-sdk-provider": "^2.5.1",
|
||||
"@pydantic/monty": "^0.0.17",
|
||||
"@sentry/nuxt": "^10.48.0",
|
||||
"@tanstack/vue-virtual": "^3.13.23",
|
||||
"ai": "^6.0.156",
|
||||
"ai-sdk-ollama": "^3.8.3",
|
||||
"better-auth": "^1.6.2",
|
||||
"comlink": "^4.4.2",
|
||||
"drizzle-orm": "^1.0.0-beta.21",
|
||||
"glob": "^13.0.6",
|
||||
"longcat-ai-sdk-provider": "^0.0.5",
|
||||
"nanoid": "^5.1.7",
|
||||
"nuxt": "^4.4.2",
|
||||
"pg": "^8.20.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"shiki": "^3.23.0",
|
||||
"unified": "^11.0.5",
|
||||
"vue": "^3.6.0-beta.10",
|
||||
"vue-router": "^5.0.4",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/logos": "^1.2.11",
|
||||
"@iconify-json/svg-spinners": "^1.2.4",
|
||||
"@iconify-json/tabler": "^1.2.33",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@unocss/nuxt": "^66.6.8",
|
||||
"drizzle-kit": "^1.0.0-beta.21",
|
||||
"typescript": "^5.9.3",
|
||||
"unocss": "^66.6.8"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@parcel/watcher",
|
||||
"@sentry/cli",
|
||||
"core-js",
|
||||
"esbuild",
|
||||
"unrs-resolver",
|
||||
"vue-demi"
|
||||
],
|
||||
"overrides": {
|
||||
"@vercel/nft": "^0.27.4",
|
||||
"vite": "8.0.0-beta.15"
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
diff --git a/dist/client/triplit-client.d.ts b/dist/client/triplit-client.d.ts
|
||||
index 85dc8f6d97ded0e0e4e7bfdfedb1d02ea33405d7..dd9e6e78114f174e97f28cc9e6ac95e3d3037d44 100644
|
||||
--- a/dist/client/triplit-client.d.ts
|
||||
+++ b/dist/client/triplit-client.d.ts
|
||||
@@ -15,6 +15,7 @@ export declare class TriplitClient<M extends Models<M> = Models> {
|
||||
* The sync engine is responsible for managing the connection to the server and syncing data
|
||||
*/
|
||||
syncEngine: SyncEngine;
|
||||
+ private hasPendingWrites;
|
||||
private _token;
|
||||
private claimsPath;
|
||||
private _serverUrl;
|
||||
@@ -36,6 +37,11 @@ export declare class TriplitClient<M extends Models<M> = Models> {
|
||||
*/
|
||||
constructor(options?: ClientOptions<M>);
|
||||
get ready(): Promise<void>;
|
||||
+ /**
|
||||
+ * Flushes updates to the database and syncs with the server. This function may be a no-op if no
|
||||
+ * writes have been made since the last flush.
|
||||
+ */
|
||||
+ flush(syncWrites?: boolean): Promise<void>;
|
||||
/**
|
||||
* Gets the schema of the database
|
||||
*
|
||||
@@ -99,7 +105,7 @@ export declare class TriplitClient<M extends Models<M> = Models> {
|
||||
*
|
||||
* @param collectionName - The name of the collection to insert into
|
||||
* @param object - The entity to insert
|
||||
- * @returns The transaction ID and the inserted entity, if successful
|
||||
+ * @returns - The inserted entity, if successful
|
||||
*/
|
||||
insert<CN extends CollectionNameFromModels<M>>(collectionName: CN, object: WriteModel<M, CN>): Promise<import("@triplit/db").Unalias<import("@triplit/db").Decoded<M[CN]["schema"]>>>;
|
||||
/**
|
||||
@@ -108,7 +114,6 @@ export declare class TriplitClient<M extends Models<M> = Models> {
|
||||
* @param collectionName - The name of the collection to update
|
||||
* @param entityId - The id of the entity to update
|
||||
* @param updater - A function that provides the current entity and allows you to modify it
|
||||
- * @returns The transaction ID
|
||||
*/
|
||||
update<CN extends CollectionNameFromModels<M>>(collectionName: CN, entityId: string, data: UpdatePayload<M, CN>): Promise<void>;
|
||||
/**
|
||||
@@ -116,7 +121,6 @@ export declare class TriplitClient<M extends Models<M> = Models> {
|
||||
*
|
||||
* @param collectionName - The name of the collection to delete from
|
||||
* @param entityId - The id of the entity to delete
|
||||
- * @returns The transaction ID
|
||||
*/
|
||||
delete<CN extends CollectionNameFromModels<M>>(collectionName: CN, entityId: string): Promise<void>;
|
||||
entityIsInCache(collection: string, entityId: string): Promise<boolean>;
|
||||
diff --git a/dist/client/triplit-client.js b/dist/client/triplit-client.js
|
||||
index c1f54b5b9abb11c4c983cf368d66c8d22379172a..4b9b19f7cf65a603e228b9aec68c1a1218b91eb2 100644
|
||||
--- a/dist/client/triplit-client.js
|
||||
+++ b/dist/client/triplit-client.js
|
||||
@@ -23,6 +23,7 @@ export class TriplitClient {
|
||||
* The sync engine is responsible for managing the connection to the server and syncing data
|
||||
*/
|
||||
syncEngine;
|
||||
+ hasPendingWrites = false;
|
||||
_token = undefined;
|
||||
claimsPath = undefined;
|
||||
_serverUrl = undefined;
|
||||
@@ -69,13 +70,21 @@ export class TriplitClient {
|
||||
this.db = decoded ? this.db.withSessionVars(decoded) : this.db;
|
||||
}
|
||||
});
|
||||
- this.db.onCommit(
|
||||
- // @ts-expect-error
|
||||
- throttle(async (tx) => {
|
||||
- await this.db.updateQueryViews();
|
||||
- this.db.broadcastToQuerySubscribers();
|
||||
- await this.syncEngine.syncWrites();
|
||||
- }, 20, { leading: false, trailing: true }));
|
||||
+ let writeTimeout = undefined;
|
||||
+ this.db.onCommit(async () => {
|
||||
+ this.hasPendingWrites = true;
|
||||
+ if (writeTimeout) {
|
||||
+ clearTimeout(writeTimeout);
|
||||
+ }
|
||||
+ else {
|
||||
+ // on the very first write in a batch, flush without writing to the server
|
||||
+ await this.flush(false);
|
||||
+ }
|
||||
+ writeTimeout = setTimeout(() => {
|
||||
+ this.flush();
|
||||
+ writeTimeout = undefined;
|
||||
+ }, 20);
|
||||
+ });
|
||||
this.db.onSchemaChange((change) => {
|
||||
if (change.successful) {
|
||||
this.http.updateOptions({
|
||||
@@ -155,6 +164,20 @@ export class TriplitClient {
|
||||
return this.awaitReady;
|
||||
return Promise.resolve();
|
||||
}
|
||||
+ /**
|
||||
+ * Flushes updates to the database and syncs with the server. This function may be a no-op if no
|
||||
+ * writes have been made since the last flush.
|
||||
+ */
|
||||
+ async flush(syncWrites = true) {
|
||||
+ if (!this.hasPendingWrites)
|
||||
+ return;
|
||||
+ await this.db.updateQueryViews();
|
||||
+ this.db.broadcastToQuerySubscribers();
|
||||
+ if (syncWrites) {
|
||||
+ await this.syncEngine.syncWrites();
|
||||
+ this.hasPendingWrites = false;
|
||||
+ }
|
||||
+ }
|
||||
/**
|
||||
* Gets the schema of the database
|
||||
*
|
||||
@@ -314,7 +337,7 @@ export class TriplitClient {
|
||||
*
|
||||
* @param collectionName - The name of the collection to insert into
|
||||
* @param object - The entity to insert
|
||||
- * @returns The transaction ID and the inserted entity, if successful
|
||||
+ * @returns - The inserted entity, if successful
|
||||
*/
|
||||
async insert(collectionName, object) {
|
||||
if (this.awaitReady)
|
||||
@@ -332,7 +355,6 @@ export class TriplitClient {
|
||||
* @param collectionName - The name of the collection to update
|
||||
* @param entityId - The id of the entity to update
|
||||
* @param updater - A function that provides the current entity and allows you to modify it
|
||||
- * @returns The transaction ID
|
||||
*/
|
||||
async update(collectionName, entityId, data) {
|
||||
if (this.awaitReady)
|
||||
@@ -349,7 +371,6 @@ export class TriplitClient {
|
||||
*
|
||||
* @param collectionName - The name of the collection to delete from
|
||||
* @param entityId - The id of the entity to delete
|
||||
- * @returns The transaction ID
|
||||
*/
|
||||
async delete(collectionName, entityId) {
|
||||
if (this.awaitReady)
|
||||
@@ -976,32 +997,6 @@ function flipOrder(order) {
|
||||
return undefined;
|
||||
return order.map((o) => [o[0], o[1] === 'ASC' ? 'DESC' : 'ASC']);
|
||||
}
|
||||
-function throttle(func, limit, options) {
|
||||
- let inThrottle;
|
||||
- let lastArgs = null;
|
||||
- return function () {
|
||||
- const args = arguments;
|
||||
- if (!inThrottle) {
|
||||
- if (options?.leading !== false) {
|
||||
- func(args);
|
||||
- }
|
||||
- else {
|
||||
- lastArgs = args;
|
||||
- }
|
||||
- inThrottle = true;
|
||||
- setTimeout(() => {
|
||||
- if (options?.trailing && lastArgs) {
|
||||
- func(lastArgs);
|
||||
- lastArgs = null;
|
||||
- }
|
||||
- inThrottle = false;
|
||||
- }, limit);
|
||||
- }
|
||||
- else {
|
||||
- lastArgs = args;
|
||||
- }
|
||||
- };
|
||||
-}
|
||||
function validateServerUrl(serverUrl) {
|
||||
if (serverUrl &&
|
||||
!serverUrl.startsWith('http://') &&
|
||||
@@ -1,167 +0,0 @@
|
||||
diff --git a/node_modules/@triplit/db/.bun-tag-16a302de81445385 b/.bun-tag-16a302de81445385
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/node_modules/@triplit/db/.bun-tag-38646fa5cb007988 b/.bun-tag-38646fa5cb007988
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/node_modules/@triplit/db/.bun-tag-c381351c2a0f3c94 b/.bun-tag-c381351c2a0f3c94
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/dist/db.js b/dist/db.js
|
||||
index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..0ce27b58838db11fd51fcf873a182d528d5823fb 100644
|
||||
--- a/dist/db.js
|
||||
+++ b/dist/db.js
|
||||
@@ -17,7 +17,6 @@ import { Type } from './schema/data-types/index.js';
|
||||
import { getCollectionPermissions } from './permissions.js';
|
||||
import { QueryBuilder } from './query/query-builder.js';
|
||||
import { validateSchema } from './schema/validation.js';
|
||||
-import { tryPreloadingOptionalDeps } from './utils/optional-dep.js';
|
||||
export class DB {
|
||||
entityStore;
|
||||
clock;
|
||||
@@ -44,7 +43,7 @@ export class DB {
|
||||
});
|
||||
this.ivm =
|
||||
options.ivm ??
|
||||
- new IVM(
|
||||
+ new IVM(
|
||||
// @ts-expect-error - TODO: handle more generalized internal typings
|
||||
this);
|
||||
if (options.schema) {
|
||||
@@ -73,9 +72,9 @@ export class DB {
|
||||
getSchema() {
|
||||
return this.schema;
|
||||
}
|
||||
- subscribe(query, onResults, onError,
|
||||
- // TODO: will we need this?
|
||||
- options = {}) {
|
||||
+ subscribe(query, onResults, onError,
|
||||
+ // TODO: will we need this?
|
||||
+ options = {}) {
|
||||
const preparedQuery = prepareQuery(query, this.schema?.collections, this.systemVars, this.session, {
|
||||
applyPermission: options.skipRules ? undefined : 'read',
|
||||
});
|
||||
@@ -136,11 +135,11 @@ export class DB {
|
||||
}
|
||||
const timestamp = await this.entityStore.metadataStore.getTimestampForEntity(this.kv, collection, entityId);
|
||||
if (
|
||||
- // TODO: determine if timestamp can ever be undefined
|
||||
- // I think the only case could be if the entity was optimistically inserted
|
||||
- // on the client but never synced to the server
|
||||
- // assuming that we don't delete metadata when we delete entities
|
||||
- timestamp &&
|
||||
+ // TODO: determine if timestamp can ever be undefined
|
||||
+ // I think the only case could be if the entity was optimistically inserted
|
||||
+ // on the client but never synced to the server
|
||||
+ // assuming that we don't delete metadata when we delete entities
|
||||
+ timestamp &&
|
||||
HybridLogicalClock.compare(timestamp, options.queryState.timestamp) < 0) {
|
||||
if (!entitiesThatHaveNotChanged[collection]) {
|
||||
entitiesThatHaveNotChanged[collection] = new Set();
|
||||
@@ -192,9 +191,9 @@ export class DB {
|
||||
onResults(relevantChanges, options.queryKey);
|
||||
isInitialResponse = false;
|
||||
};
|
||||
- return this.ivm.subscribe(preparedQuery,
|
||||
- // @ts-expect-error - Ignoring because method is deprecated
|
||||
- callback, options.errorCallback);
|
||||
+ return this.ivm.subscribe(preparedQuery,
|
||||
+ // @ts-expect-error - Ignoring because method is deprecated
|
||||
+ callback, options.errorCallback);
|
||||
}
|
||||
async fetch(query, options) {
|
||||
const preparedQuery = prepareQuery(query, this.schema?.collections, this.systemVars, this.session, {
|
||||
@@ -307,9 +306,7 @@ export class DB {
|
||||
// TODO call the listeners in the entity store
|
||||
// Trigger subscription updates
|
||||
await this.ivm.bufferChanges(changes);
|
||||
- for (const listener of this.onCommitListeners) {
|
||||
- listener(changes);
|
||||
- }
|
||||
+ await Promise.all([...this.onCommitListeners].map((listener) => listener(changes)));
|
||||
return output;
|
||||
}
|
||||
async applyChanges(changes, options) {
|
||||
@@ -609,7 +606,6 @@ export async function createDB(options) {
|
||||
if (options.kv) {
|
||||
savedSchema = await DB.getSchemaFromStorage(options.kv);
|
||||
}
|
||||
- await tryPreloadingOptionalDeps();
|
||||
db = new DB({ ...options, schema: savedSchema });
|
||||
let schemaChange = undefined;
|
||||
// A schema is provided, attempt to apply it
|
||||
diff --git a/dist/schema/data-types/type.js b/dist/schema/data-types/type.js
|
||||
index 2e1281c5577b48ffe609a08da933b5f342a485fa..e88487852f6c49185144a9057ffb11d93a3b6cc8 100644
|
||||
--- a/dist/schema/data-types/type.js
|
||||
+++ b/dist/schema/data-types/type.js
|
||||
@@ -3,7 +3,6 @@ import { DBDeserializationError, DBSerializationError, JSONDeserializationError,
|
||||
import { prefixOperations, SET_OP_PREFIX, SUPPORTED_OPERATIONS, } from './operations.js';
|
||||
import { DEFAULTABLE_TYPE_KEYS_SET, PRIMITIVE_TYPE_KEYS_SET } from './index.js';
|
||||
import { hasNoValue, isDefaultFunction } from '../../utils/value.js';
|
||||
-import { getOptionalDep } from '../../utils/optional-dep.js';
|
||||
/**
|
||||
* Returns an empty object for the given type
|
||||
* If the type is not a record, it returns undefined
|
||||
@@ -151,8 +150,8 @@ export function encode(type, input) {
|
||||
throw new DBSerializationError(`set<${type.items.type}>`, input);
|
||||
}
|
||||
throw new UnrecognizedAttributeTypeError(
|
||||
- // @ts-expect-error If this has an error, it means we are missing a case above
|
||||
- type.type, 'Failed to encode value');
|
||||
+ // @ts-expect-error If this has an error, it means we are missing a case above
|
||||
+ type.type, 'Failed to encode value');
|
||||
}
|
||||
export function validateEncoded(type, encoded, options) {
|
||||
switch (type.type) {
|
||||
@@ -219,8 +218,8 @@ export function validateEncoded(type, encoded, options) {
|
||||
};
|
||||
}
|
||||
throw new UnrecognizedAttributeTypeError(
|
||||
- // @ts-expect-error If this has an error, it means we are missing a case above
|
||||
- type.type, 'Failed to validate value');
|
||||
+ // @ts-expect-error If this has an error, it means we are missing a case above
|
||||
+ type.type, 'Failed to validate value');
|
||||
}
|
||||
export function decode(type, encoded) {
|
||||
switch (type.type) {
|
||||
@@ -274,8 +273,8 @@ export function decode(type, encoded) {
|
||||
throw new DBDeserializationError(`set<${type.items.type}>`, encoded);
|
||||
}
|
||||
throw new UnrecognizedAttributeTypeError(
|
||||
- // @ts-expect-error If this has an error, it means we are missing a case above
|
||||
- type.type, 'Failed to decode value');
|
||||
+ // @ts-expect-error If this has an error, it means we are missing a case above
|
||||
+ type.type, 'Failed to decode value');
|
||||
}
|
||||
// FOR SET KEYS
|
||||
// Must encode to string
|
||||
@@ -407,8 +406,8 @@ export function supportedOperations(type) {
|
||||
if (type.type === 'string')
|
||||
return SUPPORTED_OPERATIONS.string;
|
||||
throw new UnrecognizedAttributeTypeError(
|
||||
- // @ts-expect-error If this has an error, it means we are missing a case above
|
||||
- type.type, 'Failed to get supported operations');
|
||||
+ // @ts-expect-error If this has an error, it means we are missing a case above
|
||||
+ type.type, 'Failed to get supported operations');
|
||||
}
|
||||
/**
|
||||
* Checks if the type has a default value that can be configured
|
||||
@@ -440,9 +439,6 @@ function calcDefaultValue(config) {
|
||||
else if (func === 'uuidv4') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
- else if (func === 'uuidv7') {
|
||||
- return getOptionalDep('uuidv7').uuidv7();
|
||||
- }
|
||||
else if (func === 'now') {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
diff --git a/dist/utils/optional-dep.d.ts b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/utils/optional-dep.d.ts
|
||||
deleted file mode 100644
|
||||
index aa726389af2582fdb764fe313691423ffde89c4c..0000000000000000000000000000000000000000
|
||||
diff --git a/dist/utils/optional-dep.js b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/utils/optional-dep.js
|
||||
deleted file mode 100644
|
||||
index 850317d787608b58f4083aa91e282c3598e32fa3..0000000000000000000000000000000000000000
|
||||
diff --git a/dist/utils/optional-dep.js.map b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/utils/optional-dep.js.map
|
||||
deleted file mode 100644
|
||||
index d39488cfa8b65ac4d7e8b9909c82b7af8cc838e1..0000000000000000000000000000000000000000
|
||||
@@ -0,0 +1,205 @@
|
||||
import { db } from '../server/lib/db';
|
||||
import * as drizzleSchema from '../drizzle/schema';
|
||||
import { HttpClient } from '@triplit/client';
|
||||
import { schema } from '../triplit/schema';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
export const httpClient = new HttpClient({
|
||||
schema,
|
||||
serverUrl: process.env.NUXT_LOCAL_TRIPLIT_URL || process.env.NUXT_PUBLIC_TRIPLIT_URL,
|
||||
token: process.env.TRIPLIT_SERVICE_TOKEN,
|
||||
});
|
||||
|
||||
/**
|
||||
* Simple Argument Parser
|
||||
* Use: --skip=users,accounts --topics-file=ids.txt
|
||||
*/
|
||||
const args = process.argv.slice(2);
|
||||
const skipList = args.find(a => a.startsWith('--skip='))?.split('=')[1].split(',') || [];
|
||||
const topicsFilePath = args.find(a => a.startsWith('--topics-file='))?.split('=')[1];
|
||||
|
||||
async function migrate() {
|
||||
console.log('🚀 Starting Complex Relational Migration...');
|
||||
|
||||
// Load allowed topic IDs if a file was provided
|
||||
let allowedTopicIds: Set<string> | null = null;
|
||||
if (topicsFilePath) {
|
||||
try {
|
||||
const fileContent = await fs.readFile(topicsFilePath, 'utf-8');
|
||||
allowedTopicIds = new Set(fileContent.split('\n').map(id => id.trim()).filter(Boolean));
|
||||
console.log(`📂 Loaded ${allowedTopicIds.size} topic IDs from filter file.`);
|
||||
} catch (e) {
|
||||
console.error(`❌ Failed to read topics file: ${topicsFilePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const shouldSkip = (name: string) => skipList.includes(name);
|
||||
|
||||
if (!shouldSkip('users')) {
|
||||
console.log('📦 Migrating Users...');
|
||||
const users = await httpClient.fetch(httpClient.query('users'));
|
||||
if (users.length) {
|
||||
await db.insert(drizzleSchema.users).values(users).onConflictDoNothing();
|
||||
console.log(` - Migrated ${users.length} users`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldSkip('accounts')) {
|
||||
console.log('📦 Migrating Accounts...');
|
||||
const accounts = await httpClient.fetch(httpClient.query('accounts'));
|
||||
if (accounts.length) {
|
||||
await db.insert(drizzleSchema.accounts).values(accounts).onConflictDoNothing();
|
||||
console.log(` - Migrated ${accounts.length} accounts`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldSkip('sessions')) {
|
||||
console.log('📦 Migrating Sessions...');
|
||||
const sessions = await httpClient.fetch(httpClient.query('sessions'));
|
||||
if (sessions.length) {
|
||||
await db.insert(drizzleSchema.sessions).values(sessions).onConflictDoNothing();
|
||||
console.log(` - Migrated ${sessions.length} sessions`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldSkip('verifications')) {
|
||||
console.log('📦 Migrating Verifications...');
|
||||
const verifications = await httpClient.fetch(httpClient.query('verifications'));
|
||||
if (verifications.length) {
|
||||
await db.insert(drizzleSchema.verifications).values(verifications).onConflictDoNothing();
|
||||
console.log(` - Migrated ${verifications.length} verifications`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldSkip('settings')) {
|
||||
console.log('📦 Migrating Settings...');
|
||||
const settings = await httpClient.fetch(httpClient.query('settings'));
|
||||
if (settings.length) {
|
||||
await db.insert(drizzleSchema.settings).values(settings).onConflictDoNothing();
|
||||
console.log(` - Migrated ${settings.length} settings`);
|
||||
}
|
||||
}
|
||||
|
||||
// Providers and Models are usually needed for Topics/Agents, so skip with caution
|
||||
let providers = await httpClient.fetch(httpClient.query('providers'));
|
||||
if (!shouldSkip('providers') && providers.length) {
|
||||
console.log('📦 Migrating Providers...');
|
||||
const cleanedProviders = providers.map(p => ({
|
||||
...p,
|
||||
config: typeof p.config === 'string' ? JSON.parse(p.config) : p.config
|
||||
}));
|
||||
await db.insert(drizzleSchema.providers).values(cleanedProviders).onConflictDoNothing();
|
||||
console.log(` - Migrated ${providers.length} providers`);
|
||||
}
|
||||
|
||||
const rawModels = await httpClient.fetch(httpClient.query('models'));
|
||||
const models = rawModels.map(m => ({
|
||||
...m,
|
||||
cost: typeof m.cost === 'string' ? JSON.parse(m.cost) : m.cost,
|
||||
attributes: typeof m.attributes === 'string' ? JSON.parse(m.attributes) : m.attributes
|
||||
}));
|
||||
|
||||
if (!shouldSkip('models') && models.length) {
|
||||
console.log('📦 Migrating Models...');
|
||||
for (const model of models) {
|
||||
const { attributes, ...rest } = model;
|
||||
let drizzleModel = {
|
||||
inputModalities: Array.from(attributes.inputModalities || ['text']),
|
||||
outputModalities: Array.from(attributes.outputModalities || ['text']),
|
||||
capabilities: Array.from(attributes.capabilities || []),
|
||||
contextWindow: attributes.contextWindow,
|
||||
supportedParameters: Array.from(attributes.supported_parameters || []),
|
||||
...rest,
|
||||
};
|
||||
await db.insert(drizzleSchema.models).values(drizzleModel).onConflictDoNothing();
|
||||
}
|
||||
console.log(` - Migrated ${models.length} models`);
|
||||
}
|
||||
|
||||
let agents = await httpClient.fetch(httpClient.query('agents'));
|
||||
if (!shouldSkip('agents') && agents.length) {
|
||||
console.log('📦 Migrating Agents...');
|
||||
await db.insert(drizzleSchema.agents).values(agents).onConflictDoNothing();
|
||||
console.log(` - Migrated ${agents.length} agents`);
|
||||
}
|
||||
|
||||
console.log('📦 Migrating Topics...');
|
||||
let topics = await httpClient.fetch(httpClient.query('topics'));
|
||||
// Filter by Topic ID file if provided
|
||||
if (allowedTopicIds) {
|
||||
topics = topics.filter(t => allowedTopicIds!.has(t.id));
|
||||
}
|
||||
if (topics.length) {
|
||||
await db.insert(drizzleSchema.topics).values(topics).onConflictDoNothing();
|
||||
console.log(` - Migrated ${topics.length} topics`);
|
||||
}
|
||||
|
||||
// Relational Filtering Helper
|
||||
const isTopicAllowed = (topicId: string) => topics.some(t => t.id === topicId);
|
||||
|
||||
let generations = await httpClient.fetch(httpClient.query('generations'));
|
||||
generations = generations.filter(g => isTopicAllowed(g.topicId));
|
||||
if (generations.length) {
|
||||
console.log('📦 Migrating Generations...');
|
||||
await db.insert(drizzleSchema.generations).values(generations).onConflictDoNothing();
|
||||
console.log(` - Migrated ${generations.length} generations`);
|
||||
}
|
||||
|
||||
let messages = await httpClient.fetch(httpClient.query('messages').Order('createdAt', 'ASC'));
|
||||
messages = messages.filter(m => isTopicAllowed(m.topicId));
|
||||
if (messages.length) {
|
||||
console.log('📦 Migrating Messages...');
|
||||
for (const msg of messages) {
|
||||
await db.insert(drizzleSchema.messages).values(msg).onConflictDoNothing();
|
||||
}
|
||||
console.log(` - Migrated ${messages.length} messages`);
|
||||
}
|
||||
|
||||
if (!shouldSkip('tool_calls')) {
|
||||
console.log('📦 Migrating Tool Calls...');
|
||||
let toolCalls = await httpClient.fetch(httpClient.query('tool_calls'));
|
||||
if (toolCalls.length) {
|
||||
const cleaned = toolCalls.map(tc => ({
|
||||
...tc,
|
||||
input: typeof tc.input === 'string' ? JSON.parse(tc.input) : tc.input,
|
||||
output: typeof tc.output === 'string' ? JSON.parse(tc.output) : tc.output,
|
||||
error: typeof tc.error === 'string' ? JSON.parse(tc.error) : tc.error,
|
||||
}));
|
||||
await db.insert(drizzleSchema.toolCalls).values(cleaned).onConflictDoNothing();
|
||||
console.log(` - Migrated ${toolCalls.length} tool calls`);
|
||||
}
|
||||
}
|
||||
|
||||
let parts = await httpClient.fetch(httpClient.query('message_parts').Order('createdAt', 'ASC'));
|
||||
parts = parts.filter(p => isTopicAllowed(p.topicId));
|
||||
if (parts.length) {
|
||||
console.log('📦 Migrating Message Parts...');
|
||||
for (const part of parts) {
|
||||
await db.insert(drizzleSchema.messageParts).values(part).onConflictDoNothing();
|
||||
}
|
||||
console.log(` - Migrated ${parts.length} parts`);
|
||||
}
|
||||
|
||||
if (!shouldSkip('files')) {
|
||||
console.log('📦 Migrating Files...');
|
||||
let files = await httpClient.fetch(httpClient.query('files'));
|
||||
if (files.length) {
|
||||
await db.insert(drizzleSchema.files).values(files).onConflictDoNothing();
|
||||
console.log(` - Migrated ${files.length} files`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📦 Migrating Attachments...');
|
||||
let attachments = await httpClient.fetch(httpClient.query('attachments'));
|
||||
attachments = attachments.filter(a => isTopicAllowed(a.topicId));
|
||||
if (attachments.length) {
|
||||
await db.insert(drizzleSchema.attachments).values(attachments).onConflictDoNothing();
|
||||
console.log(` - Migrated ${attachments.length} attachments`);
|
||||
}
|
||||
|
||||
console.log('✅ Relational migration finished.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
migrate();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user