commit
a0d60b2053
36 changed files with 1175 additions and 76 deletions
|
|
@ -43,6 +43,9 @@ IDK, calm down
|
||||||
- [ ] File support
|
- [ ] File support
|
||||||
- [ ] Streams on the server
|
- [ ] Streams on the server
|
||||||
- [ ] Syntax highlighting with Shiki/markdown renderer
|
- [ ] Syntax highlighting with Shiki/markdown renderer
|
||||||
|
- [ ] Eliminate FOUC
|
||||||
|
- [ ] Cascade deletes and shit in Convex
|
||||||
|
- [ ] Error notification central, specially for BYOK models like o3
|
||||||
|
|
||||||
### Extra
|
### Extra
|
||||||
|
|
||||||
|
|
@ -51,3 +54,4 @@ IDK, calm down
|
||||||
- [ ] Chat branching
|
- [ ] Chat branching
|
||||||
- [ ] Image generation
|
- [ ] Image generation
|
||||||
- [ ] Chat sharing
|
- [ ] Chat sharing
|
||||||
|
- [ ] 404 page/redirect
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,11 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "^1.7.1",
|
"@floating-ui/dom": "^1.7.1",
|
||||||
"better-auth": "^1.2.9"
|
"@fontsource-variable/fraunces": "^5.2.7",
|
||||||
|
"@fontsource-variable/geist-mono": "^5.2.6",
|
||||||
|
"@fontsource-variable/inter": "^5.2.6",
|
||||||
|
"better-auth": "^1.2.9",
|
||||||
|
"openai": "^5.3.0",
|
||||||
|
"zod": "^3.25.64"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
pnpm-lock.yaml
generated
47
pnpm-lock.yaml
generated
|
|
@ -11,9 +11,24 @@ importers:
|
||||||
'@floating-ui/dom':
|
'@floating-ui/dom':
|
||||||
specifier: ^1.7.1
|
specifier: ^1.7.1
|
||||||
version: 1.7.1
|
version: 1.7.1
|
||||||
|
'@fontsource-variable/fraunces':
|
||||||
|
specifier: ^5.2.7
|
||||||
|
version: 5.2.7
|
||||||
|
'@fontsource-variable/geist-mono':
|
||||||
|
specifier: ^5.2.6
|
||||||
|
version: 5.2.6
|
||||||
|
'@fontsource-variable/inter':
|
||||||
|
specifier: ^5.2.6
|
||||||
|
version: 5.2.6
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.2.9
|
specifier: ^1.2.9
|
||||||
version: 1.2.9
|
version: 1.2.9
|
||||||
|
openai:
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0(ws@8.18.2)(zod@3.25.64)
|
||||||
|
zod:
|
||||||
|
specifier: ^3.25.64
|
||||||
|
version: 3.25.64
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@better-auth-kit/convex':
|
'@better-auth-kit/convex':
|
||||||
specifier: ^1.2.2
|
specifier: ^1.2.2
|
||||||
|
|
@ -564,6 +579,15 @@ packages:
|
||||||
'@floating-ui/utils@0.2.9':
|
'@floating-ui/utils@0.2.9':
|
||||||
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
|
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
|
||||||
|
|
||||||
|
'@fontsource-variable/fraunces@5.2.7':
|
||||||
|
resolution: {integrity: sha512-PYIcwL3+0SA2IEAcA9ma07Rz8DCbJdLy7Hb1syq/FIcCyf1zSlHRRcC0a33PnBCZ9Q7B+01kFH0cS29yqWEk3w==}
|
||||||
|
|
||||||
|
'@fontsource-variable/geist-mono@5.2.6':
|
||||||
|
resolution: {integrity: sha512-vw6T9JGTrYJ980bn7W8iTPhe2jVK5ifunVs7xh9dfTVArjDSkJs03JjeZrH5LKEpGABLXSlSlNU57HRm4tmFMg==}
|
||||||
|
|
||||||
|
'@fontsource-variable/inter@5.2.6':
|
||||||
|
resolution: {integrity: sha512-jks/bficUPQ9nn7GvXvHtlQIPudW7Wx8CrlZoY8bhxgeobNxlQan8DclUJuYF2loYRrGpfrhCIZZspXYysiVGg==}
|
||||||
|
|
||||||
'@hexagon/base64@1.1.28':
|
'@hexagon/base64@1.1.28':
|
||||||
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
|
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
|
||||||
|
|
||||||
|
|
@ -1791,6 +1815,18 @@ packages:
|
||||||
nwsapi@2.2.20:
|
nwsapi@2.2.20:
|
||||||
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==}
|
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==}
|
||||||
|
|
||||||
|
openai@5.3.0:
|
||||||
|
resolution: {integrity: sha512-VIKmoF7y4oJCDOwP/oHXGzM69+x0dpGFmN9QmYO+uPbLFOmmnwO+x1GbsgUtI+6oraxomGZ566Y421oYVu191w==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
ws: ^8.18.0
|
||||||
|
zod: ^3.23.8
|
||||||
|
peerDependenciesMeta:
|
||||||
|
ws:
|
||||||
|
optional: true
|
||||||
|
zod:
|
||||||
|
optional: true
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
@ -2754,6 +2790,12 @@ snapshots:
|
||||||
|
|
||||||
'@floating-ui/utils@0.2.9': {}
|
'@floating-ui/utils@0.2.9': {}
|
||||||
|
|
||||||
|
'@fontsource-variable/fraunces@5.2.7': {}
|
||||||
|
|
||||||
|
'@fontsource-variable/geist-mono@5.2.6': {}
|
||||||
|
|
||||||
|
'@fontsource-variable/inter@5.2.6': {}
|
||||||
|
|
||||||
'@hexagon/base64@1.1.28': {}
|
'@hexagon/base64@1.1.28': {}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
@ -3998,6 +4040,11 @@ snapshots:
|
||||||
|
|
||||||
nwsapi@2.2.20: {}
|
nwsapi@2.2.20: {}
|
||||||
|
|
||||||
|
openai@5.3.0(ws@8.18.2)(zod@3.25.64):
|
||||||
|
optionalDependencies:
|
||||||
|
ws: 8.18.2
|
||||||
|
zod: 3.25.64
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
|
|
|
||||||
89
src/app.css
89
src/app.css
|
|
@ -1,4 +1,7 @@
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@import '@fontsource-variable/inter';
|
||||||
|
@import '@fontsource-variable/geist-mono';
|
||||||
|
@import '@fontsource-variable/fraunces';
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
|
@ -35,6 +38,14 @@
|
||||||
--sidebar-accent-foreground: oklch(0.3963 0.0251 285.1962);
|
--sidebar-accent-foreground: oklch(0.3963 0.0251 285.1962);
|
||||||
--sidebar-border: oklch(0.9383 0.0026 48.7178);
|
--sidebar-border: oklch(0.9383 0.0026 48.7178);
|
||||||
--sidebar-ring: oklch(0.5916 0.218 0.5844);
|
--sidebar-ring: oklch(0.5916 0.218 0.5844);
|
||||||
|
--font-sans:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||||
|
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||||
|
--font-mono:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||||
|
monospace;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||||
|
|
@ -53,8 +64,8 @@
|
||||||
--card-foreground: oklch(0.8456 0.0302 341.4597);
|
--card-foreground: oklch(0.8456 0.0302 341.4597);
|
||||||
--popover: oklch(0.1548 0.0132 338.9015);
|
--popover: oklch(0.1548 0.0132 338.9015);
|
||||||
--popover-foreground: oklch(0.9647 0.0091 341.8035);
|
--popover-foreground: oklch(0.9647 0.0091 341.8035);
|
||||||
--primary: oklch(0.4607 0.1853 4.0994);
|
--primary: oklch(0.5797 0.1194 237.7893);
|
||||||
--primary-foreground: oklch(0.856 0.0618 346.3684);
|
--primary-foreground: oklch(1 0 0);
|
||||||
--secondary: oklch(0.3137 0.0306 310.061);
|
--secondary: oklch(0.3137 0.0306 310.061);
|
||||||
--secondary-foreground: oklch(0.8483 0.0382 307.9613);
|
--secondary-foreground: oklch(0.8483 0.0382 307.9613);
|
||||||
--muted: oklch(0.2634 0.0219 309.4748);
|
--muted: oklch(0.2634 0.0219 309.4748);
|
||||||
|
|
@ -65,7 +76,7 @@
|
||||||
--destructive-foreground: oklch(1 0 0);
|
--destructive-foreground: oklch(1 0 0);
|
||||||
--border: oklch(0.3286 0.0154 343.4461);
|
--border: oklch(0.3286 0.0154 343.4461);
|
||||||
--input: oklch(0.3387 0.0195 332.8347);
|
--input: oklch(0.3387 0.0195 332.8347);
|
||||||
--ring: oklch(0.5916 0.218 0.5844);
|
--ring: oklch(0.5797 0.1194 237.7893);
|
||||||
--chart-1: oklch(0.5316 0.1409 355.1999);
|
--chart-1: oklch(0.5316 0.1409 355.1999);
|
||||||
--chart-2: oklch(0.5633 0.1912 306.8561);
|
--chart-2: oklch(0.5633 0.1912 306.8561);
|
||||||
--chart-3: oklch(0.7227 0.1502 60.5799);
|
--chart-3: oklch(0.7227 0.1502 60.5799);
|
||||||
|
|
@ -78,7 +89,15 @@
|
||||||
--sidebar-accent: oklch(0.2337 0.0261 338.1961);
|
--sidebar-accent: oklch(0.2337 0.0261 338.1961);
|
||||||
--sidebar-accent-foreground: oklch(0.9674 0.0013 286.3752);
|
--sidebar-accent-foreground: oklch(0.9674 0.0013 286.3752);
|
||||||
--sidebar-border: oklch(0 0 0);
|
--sidebar-border: oklch(0 0 0);
|
||||||
--sidebar-ring: oklch(0.5916 0.218 0.5844);
|
--sidebar-ring: oklch(0.5797 0.1194 237.7893);
|
||||||
|
--font-sans:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||||
|
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||||
|
--font-mono:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||||
|
monospace;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||||
|
|
@ -90,6 +109,59 @@
|
||||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--font-mono: var(--font-mono);
|
||||||
|
--font-serif: var(--font-serif);
|
||||||
|
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
|
--shadow-2xs: var(--shadow-2xs);
|
||||||
|
--shadow-xs: var(--shadow-xs);
|
||||||
|
--shadow-sm: var(--shadow-sm);
|
||||||
|
--shadow: var(--shadow);
|
||||||
|
--shadow-md: var(--shadow-md);
|
||||||
|
--shadow-lg: var(--shadow-lg);
|
||||||
|
--shadow-xl: var(--shadow-xl);
|
||||||
|
--shadow-2xl: var(--shadow-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
|
|
@ -125,12 +197,13 @@
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
|
||||||
--font-sans:
|
--font-sans:
|
||||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
'Inter Variable', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
--font-serif: 'Fraunces Variable', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||||
--font-mono:
|
--font-mono:
|
||||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||||
monospace;
|
'Courier New', monospace;
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<title>Thom Chat</title>
|
<title>Thom Chat</title>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover" style="background: oklch(0.2409 0.0201 307.5346)">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { action, internalQuery, internalMutation } from './_generated/server';
|
import { action, internalQuery, internalMutation, query as convexQuery } from './_generated/server';
|
||||||
import { internal } from './_generated/api';
|
import { internal } from './_generated/api';
|
||||||
import { ConvexHandler, type ConvexReturnType } from '@better-auth-kit/convex/handler';
|
import { ConvexHandler, type ConvexReturnType } from '@better-auth-kit/convex/handler';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
const { betterAuth, query, insert, update, delete_, count, getSession } = ConvexHandler({
|
const { betterAuth, query, insert, update, delete_, count, getSession } = ConvexHandler({
|
||||||
action,
|
action,
|
||||||
|
|
@ -10,3 +11,34 @@ const { betterAuth, query, insert, update, delete_, count, getSession } = Convex
|
||||||
}) as ConvexReturnType;
|
}) as ConvexReturnType;
|
||||||
|
|
||||||
export { betterAuth, query, insert, update, delete_, count, getSession };
|
export { betterAuth, query, insert, update, delete_, count, getSession };
|
||||||
|
|
||||||
|
export type SessionObj = {
|
||||||
|
_creationTime: number;
|
||||||
|
_id: string;
|
||||||
|
expiresAt: string;
|
||||||
|
ipAddress: string;
|
||||||
|
token: string;
|
||||||
|
updatedAt: string;
|
||||||
|
userAgent: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const publicGetSession = convexQuery({
|
||||||
|
args: {
|
||||||
|
session_token: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const s = await ctx.runQuery(internal.betterAuth.getSession, {
|
||||||
|
sessionToken: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without this if, typescript goes bonkers
|
||||||
|
if (!s) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is also needed. I don't know why :(
|
||||||
|
const ret = s as SessionObj;
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
82
src/lib/backend/convex/chat.ts
Normal file
82
src/lib/backend/convex/chat.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
import { Provider } from '../../types';
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { providerValidator } from './schema';
|
||||||
|
|
||||||
|
export const all = query({
|
||||||
|
args: {
|
||||||
|
user_id: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const allKeys = await ctx.db
|
||||||
|
.query('user_keys')
|
||||||
|
.withIndex('by_user', (q) => q.eq('user_id', args.user_id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return Object.values(Provider).reduce(
|
||||||
|
(acc, key) => {
|
||||||
|
acc[key] = allKeys.find((item) => item.provider === key)?.key;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<Provider, string | undefined>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: {
|
||||||
|
user_id: v.string(),
|
||||||
|
provider: providerValidator,
|
||||||
|
session_token: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.runQuery(internal.betterAuth.getSession, {
|
||||||
|
sessionToken: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await ctx.db
|
||||||
|
.query('user_keys')
|
||||||
|
.withIndex('by_provider_user', (q) =>
|
||||||
|
q.eq('provider', args.provider).eq('user_id', args.user_id)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return key?.key;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const set = mutation({
|
||||||
|
args: {
|
||||||
|
provider: providerValidator,
|
||||||
|
user_id: v.string(),
|
||||||
|
key: v.string(),
|
||||||
|
session_token: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.runQuery(internal.betterAuth.getSession, {
|
||||||
|
sessionToken: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query('user_keys')
|
||||||
|
.withIndex('by_provider_user', (q) =>
|
||||||
|
q.eq('provider', args.provider).eq('user_id', args.user_id)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.replace(existing._id, args);
|
||||||
|
} else {
|
||||||
|
await ctx.db.insert('user_keys', args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
51
src/lib/backend/convex/conversations.ts
Normal file
51
src/lib/backend/convex/conversations.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
import { api } from './_generated/api';
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { type Id } from './_generated/dataModel';
|
||||||
|
import { type SessionObj } from './betterAuth';
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: {
|
||||||
|
session_token: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||||
|
session_token: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
const s = session as SessionObj;
|
||||||
|
|
||||||
|
const conversations = await ctx.db
|
||||||
|
.query('conversations')
|
||||||
|
.withIndex('by_user', (q) => q.eq('user_id', s.userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return conversations;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const create = mutation({
|
||||||
|
args: {
|
||||||
|
session_token: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args): Promise<Id<'conversations'>> => {
|
||||||
|
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||||
|
session_token: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await ctx.db.insert('conversations', {
|
||||||
|
title: 'Untitled (for now)',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Id type is janking out
|
||||||
|
user_id: session.userId as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
});
|
||||||
102
src/lib/backend/convex/messages.ts
Normal file
102
src/lib/backend/convex/messages.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { api } from './_generated/api';
|
||||||
|
import { messageRoleValidator, providerValidator } from './schema';
|
||||||
|
import { Id } from './_generated/dataModel';
|
||||||
|
|
||||||
|
export const getAllFromConversation = query({
|
||||||
|
args: {
|
||||||
|
conversation_id: v.string(),
|
||||||
|
session_token: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||||
|
session_token: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await ctx.db
|
||||||
|
.query('messages')
|
||||||
|
.withIndex('by_conversation', (q) => q.eq('conversation_id', args.conversation_id))
|
||||||
|
.order('asc')
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const create = mutation({
|
||||||
|
args: {
|
||||||
|
conversation_id: v.string(),
|
||||||
|
content: v.string(),
|
||||||
|
role: messageRoleValidator,
|
||||||
|
session_token: v.string(),
|
||||||
|
|
||||||
|
// Optional, coming from SK API route
|
||||||
|
model_id: v.optional(v.string()),
|
||||||
|
provider: v.optional(providerValidator),
|
||||||
|
token_count: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args): Promise<Id<'messages'>> => {
|
||||||
|
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||||
|
session_token: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await ctx.runQuery(api.messages.getAllFromConversation, {
|
||||||
|
conversation_id: args.conversation_id,
|
||||||
|
session_token: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
|
||||||
|
if (lastMessage?.role === args.role) {
|
||||||
|
throw new Error('Last message has the same role, forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await ctx.db.insert('messages', {
|
||||||
|
conversation_id: args.conversation_id,
|
||||||
|
content: args.content,
|
||||||
|
role: args.role,
|
||||||
|
// Optional, coming from SK API route
|
||||||
|
model_id: args.model_id,
|
||||||
|
provider: args.provider,
|
||||||
|
token_count: args.token_count,
|
||||||
|
});
|
||||||
|
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateContent = mutation({
|
||||||
|
args: {
|
||||||
|
session_token: v.string(),
|
||||||
|
message_id: v.string(),
|
||||||
|
content: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||||
|
session_token: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await ctx.db.get(args.message_id as Id<'messages'>);
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
throw new Error('Message not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(message._id, {
|
||||||
|
content: args.content,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
import { defineSchema, defineTable } from 'convex/server';
|
import { defineSchema, defineTable } from 'convex/server';
|
||||||
import { v } from 'convex/values';
|
import { type Infer, v } from 'convex/values';
|
||||||
import { Provider } from '../../../lib/types';
|
import { Provider } from '../../../lib/types';
|
||||||
|
|
||||||
export const providerValidator = v.union(...Object.values(Provider).map((p) => v.literal(p)));
|
export const providerValidator = v.union(...Object.values(Provider).map((p) => v.literal(p)));
|
||||||
|
export const messageRoleValidator = v.union(
|
||||||
|
v.literal('user'),
|
||||||
|
v.literal('assistant'),
|
||||||
|
v.literal('system')
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MessageRole = Infer<typeof messageRoleValidator>;
|
||||||
|
|
||||||
export default defineSchema({
|
export default defineSchema({
|
||||||
user_keys: defineTable({
|
user_keys: defineTable({
|
||||||
|
|
@ -23,4 +30,17 @@ export default defineSchema({
|
||||||
.index('by_model_provider', ['model_id', 'provider'])
|
.index('by_model_provider', ['model_id', 'provider'])
|
||||||
.index('by_provider_user', ['provider', 'user_id'])
|
.index('by_provider_user', ['provider', 'user_id'])
|
||||||
.index('by_model_provider_user', ['model_id', 'provider', 'user_id']),
|
.index('by_model_provider_user', ['model_id', 'provider', 'user_id']),
|
||||||
|
conversations: defineTable({
|
||||||
|
user_id: v.string(),
|
||||||
|
title: v.string(),
|
||||||
|
}).index('by_user', ['user_id']),
|
||||||
|
messages: defineTable({
|
||||||
|
conversation_id: v.string(),
|
||||||
|
role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')),
|
||||||
|
content: v.string(),
|
||||||
|
// Optional, coming from SK API route
|
||||||
|
model_id: v.optional(v.string()),
|
||||||
|
provider: v.optional(providerValidator),
|
||||||
|
token_count: v.optional(v.number()),
|
||||||
|
}).index('by_conversation', ['conversation_id']),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
/* These compiler options are required by Convex */
|
/* These compiler options are required by Convex */
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@ import { v } from 'convex/values';
|
||||||
import { providerValidator } from './schema';
|
import { providerValidator } from './schema';
|
||||||
import * as array from '../../utils/array';
|
import * as array from '../../utils/array';
|
||||||
import * as object from '../../utils/object';
|
import * as object from '../../utils/object';
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { Provider } from '../../types';
|
||||||
|
|
||||||
|
export const getModelKey = (args: { provider: Provider; model_id: string }) => {
|
||||||
|
return `${args.provider}:${args.model_id}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const get_enabled = query({
|
export const get_enabled = query({
|
||||||
args: {
|
args: {
|
||||||
|
|
@ -14,7 +20,7 @@ export const get_enabled = query({
|
||||||
.withIndex('by_user', (q) => q.eq('user_id', args.user_id))
|
.withIndex('by_user', (q) => q.eq('user_id', args.user_id))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
return array.toMap(models, (m) => [`${m.provider}:${m.model_id}`, m]);
|
return array.toMap(models, (m) => [getModelKey(m), m]);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -36,14 +42,41 @@ export const is_enabled = query({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: {
|
||||||
|
provider: providerValidator,
|
||||||
|
model_id: v.string(),
|
||||||
|
user_id: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const model = await ctx.db
|
||||||
|
.query('user_enabled_models')
|
||||||
|
.withIndex('by_model_provider_user', (q) =>
|
||||||
|
q.eq('model_id', args.model_id).eq('provider', args.provider).eq('user_id', args.user_id)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return model;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const set = mutation({
|
export const set = mutation({
|
||||||
args: {
|
args: {
|
||||||
provider: providerValidator,
|
provider: providerValidator,
|
||||||
model_id: v.string(),
|
model_id: v.string(),
|
||||||
user_id: v.string(),
|
user_id: v.string(),
|
||||||
enabled: v.boolean(),
|
enabled: v.boolean(),
|
||||||
|
session_token: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.runQuery(internal.betterAuth.getSession, {
|
||||||
|
sessionToken: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query('user_enabled_models')
|
.query('user_enabled_models')
|
||||||
.withIndex('by_model_provider', (q) =>
|
.withIndex('by_model_provider', (q) =>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,28 @@
|
||||||
import { v } from 'convex/values';
|
import { v } from 'convex/values';
|
||||||
import { Provider } from '../../types';
|
import { Provider } from '../../types';
|
||||||
|
import { api, internal } from './_generated/api';
|
||||||
import { mutation, query } from './_generated/server';
|
import { mutation, query } from './_generated/server';
|
||||||
import { providerValidator } from './schema';
|
import { providerValidator } from './schema';
|
||||||
|
import { type SessionObj } from './betterAuth';
|
||||||
|
|
||||||
export const all = query({
|
export const all = query({
|
||||||
args: {
|
args: {
|
||||||
user_id: v.string(),
|
session_token: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||||
|
session_token: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = session as SessionObj;
|
||||||
|
|
||||||
const allKeys = await ctx.db
|
const allKeys = await ctx.db
|
||||||
.query('user_keys')
|
.query('user_keys')
|
||||||
.withIndex('by_user', (q) => q.eq('user_id', args.user_id))
|
.withIndex('by_user', (q) => q.eq('user_id', s.userId))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
return Object.values(Provider).reduce(
|
return Object.values(Provider).reduce(
|
||||||
|
|
@ -25,15 +37,23 @@ export const all = query({
|
||||||
|
|
||||||
export const get = query({
|
export const get = query({
|
||||||
args: {
|
args: {
|
||||||
user_id: v.string(),
|
|
||||||
provider: providerValidator,
|
provider: providerValidator,
|
||||||
|
session_token: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||||
|
session_token: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = session as SessionObj;
|
||||||
|
|
||||||
const key = await ctx.db
|
const key = await ctx.db
|
||||||
.query('user_keys')
|
.query('user_keys')
|
||||||
.withIndex('by_provider_user', (q) =>
|
.withIndex('by_provider_user', (q) => q.eq('provider', args.provider).eq('user_id', s.userId))
|
||||||
q.eq('provider', args.provider).eq('user_id', args.user_id)
|
|
||||||
)
|
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
return key?.key;
|
return key?.key;
|
||||||
|
|
@ -45,8 +65,17 @@ export const set = mutation({
|
||||||
provider: providerValidator,
|
provider: providerValidator,
|
||||||
user_id: v.string(),
|
user_id: v.string(),
|
||||||
key: v.string(),
|
key: v.string(),
|
||||||
|
session_token: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.runQuery(internal.betterAuth.getSession, {
|
||||||
|
sessionToken: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query('user_keys')
|
.query('user_keys')
|
||||||
.withIndex('by_provider_user', (q) =>
|
.withIndex('by_provider_user', (q) =>
|
||||||
|
|
|
||||||
9
src/lib/backend/models/all.ts
Normal file
9
src/lib/backend/models/all.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Provider } from '$lib/types';
|
||||||
|
import { type OpenRouterModel } from './open-router';
|
||||||
|
|
||||||
|
export type ProviderModelMap = {
|
||||||
|
[Provider.OpenRouter]: OpenRouterModel;
|
||||||
|
[Provider.HuggingFace]: never;
|
||||||
|
[Provider.OpenAI]: never;
|
||||||
|
[Provider.Anthropic]: never;
|
||||||
|
};
|
||||||
35
src/lib/cache/cached-query.svelte.ts
vendored
35
src/lib/cache/cached-query.svelte.ts
vendored
|
|
@ -1,7 +1,7 @@
|
||||||
import { useQuery as convexUseQuery } from 'convex-svelte';
|
import { useQuery as convexUseQuery } from 'convex-svelte';
|
||||||
import { SessionStorageCache } from './session-cache.js';
|
import { SessionStorageCache } from './session-cache.js';
|
||||||
import { getFunctionName, type FunctionReference, type OptionalRestArgs } from 'convex/server';
|
import { getFunctionName, type FunctionArgs, type FunctionReference } from 'convex/server';
|
||||||
import { watch } from 'runed';
|
import { extract, watch } from 'runed';
|
||||||
|
|
||||||
interface CachedQueryOptions {
|
interface CachedQueryOptions {
|
||||||
cacheKey?: string;
|
cacheKey?: string;
|
||||||
|
|
@ -19,36 +19,32 @@ interface QueryResult<T> {
|
||||||
|
|
||||||
const globalCache = new SessionStorageCache('convex-query-cache');
|
const globalCache = new SessionStorageCache('convex-query-cache');
|
||||||
|
|
||||||
export function useCachedQuery<
|
export function useCachedQuery<Query extends FunctionReference<'query'>>(
|
||||||
Query extends FunctionReference<'query'>,
|
|
||||||
Args extends OptionalRestArgs<Query>,
|
|
||||||
>(
|
|
||||||
query: Query,
|
query: Query,
|
||||||
...args: Args extends undefined ? [] : [Args[0], CachedQueryOptions?]
|
queryArgs: FunctionArgs<Query> | (() => FunctionArgs<Query>),
|
||||||
|
options: CachedQueryOptions = {}
|
||||||
): QueryResult<Query['_returnType']> {
|
): QueryResult<Query['_returnType']> {
|
||||||
const [queryArgs, options = {}] = args as [Args[0]?, CachedQueryOptions?];
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
cacheKey,
|
cacheKey,
|
||||||
ttl = 7 * 24 * 60 * 60 * 1000, // 1 week default
|
ttl = 7 * 24 * 60 * 60 * 1000, // 1 week default
|
||||||
staleWhileRevalidate = true,
|
// staleWhileRevalidate = true,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Generate cache key from query reference and args
|
// Generate cache key from query reference and args
|
||||||
const key = cacheKey || `${getFunctionName(query)}:${JSON.stringify(queryArgs || {})}`;
|
const key = $derived(
|
||||||
|
cacheKey || `${getFunctionName(query)}:${JSON.stringify(extract(queryArgs))}`
|
||||||
|
);
|
||||||
|
|
||||||
// Get cached data
|
// Get cached data
|
||||||
const cachedData = enabled ? globalCache.get(key) : undefined;
|
const cachedData = $derived(enabled ? globalCache.get(key) : undefined);
|
||||||
|
|
||||||
// Convex query, used as soon as possible
|
// Convex query, used as soon as possible
|
||||||
const convexResult = convexUseQuery(query, queryArgs, {
|
const convexResult = convexUseQuery(query, queryArgs, {
|
||||||
// enabled: enabled && (!cachedData || !staleWhileRevalidate),
|
// enabled: enabled && (!cachedData || !staleWhileRevalidate),
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldUseCached = $derived(
|
const shouldUseCached = $derived(cachedData !== undefined && convexResult.isLoading);
|
||||||
cachedData !== undefined && (staleWhileRevalidate || convexResult.isLoading)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cache fresh data when available
|
// Cache fresh data when available
|
||||||
watch(
|
watch(
|
||||||
|
|
@ -80,17 +76,8 @@ export function invalidateQuery(query: FunctionReference<'query'>, queryArgs?: u
|
||||||
globalCache.delete(key);
|
globalCache.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateQueriesMatching(pattern: string | RegExp): void {
|
|
||||||
// Note: This is a simplified implementation
|
|
||||||
// In a real implementation, you'd need to track all cache keys
|
|
||||||
console.warn(
|
|
||||||
'invalidateQueriesMatching not fully implemented - consider using specific key invalidation'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearQueryCache(): void {
|
export function clearQueryCache(): void {
|
||||||
globalCache.clear();
|
globalCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
export { globalCache as queryCache };
|
export { globalCache as queryCache };
|
||||||
|
|
||||||
|
|
|
||||||
18
src/lib/spells/create-init.svelte.ts
Normal file
18
src/lib/spells/create-init.svelte.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
export function createInit(cb: () => void) {
|
||||||
|
let called = $state(false);
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (called) return;
|
||||||
|
called = true;
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.defineProperties(init, {
|
||||||
|
called: {
|
||||||
|
get() {
|
||||||
|
return called;
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
},
|
||||||
|
}) as typeof init & { readonly called: boolean };
|
||||||
|
}
|
||||||
118
src/lib/spells/persisted-obj.svelte.ts
Normal file
118
src/lib/spells/persisted-obj.svelte.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { on } from 'svelte/events';
|
||||||
|
import { createSubscriber } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
type Serializer<T> = {
|
||||||
|
serialize: (value: T) => string;
|
||||||
|
deserialize: (value: string) => T | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StorageType = 'local' | 'session';
|
||||||
|
|
||||||
|
function getStorage(storageType: StorageType, window: Window & typeof globalThis): Storage {
|
||||||
|
switch (storageType) {
|
||||||
|
case 'local':
|
||||||
|
return window.localStorage;
|
||||||
|
case 'session':
|
||||||
|
return window.sessionStorage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PersistedObjOptions<T> = {
|
||||||
|
/** The storage type to use. Defaults to `local`. */
|
||||||
|
storage?: StorageType;
|
||||||
|
/** The serializer to use. Defaults to `JSON.stringify` and `JSON.parse`. */
|
||||||
|
serializer?: Serializer<T>;
|
||||||
|
/** Whether to sync with the state changes from other tabs. Defaults to `true`. */
|
||||||
|
syncTabs?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createPersistedObj<T extends object>(
|
||||||
|
key: string,
|
||||||
|
initialValue: T,
|
||||||
|
options: PersistedObjOptions<T> = {}
|
||||||
|
): T {
|
||||||
|
const {
|
||||||
|
storage: storageType = 'local',
|
||||||
|
serializer = { serialize: JSON.stringify, deserialize: JSON.parse },
|
||||||
|
syncTabs = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let current = initialValue;
|
||||||
|
let storage: Storage | undefined;
|
||||||
|
let subscribe: VoidFunction | undefined;
|
||||||
|
let version = $state(0);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
storage = getStorage(storageType, window);
|
||||||
|
const existingValue = storage.getItem(key);
|
||||||
|
if (existingValue !== null) {
|
||||||
|
const deserialized = deserialize(existingValue);
|
||||||
|
if (deserialized) current = deserialized;
|
||||||
|
} else {
|
||||||
|
serialize(initialValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncTabs && storageType === 'local') {
|
||||||
|
subscribe = createSubscriber(() => {
|
||||||
|
return on(window, 'storage', handleStorageEvent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStorageEvent(event: StorageEvent): void {
|
||||||
|
if (event.key !== key || event.newValue === null) return;
|
||||||
|
const deserialized = deserialize(event.newValue);
|
||||||
|
if (deserialized) current = deserialized;
|
||||||
|
version += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserialize(value: string): T | undefined {
|
||||||
|
try {
|
||||||
|
return serializer.deserialize(value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error when parsing "${value}" from persisted store "${key}"`, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(value: T | undefined): void {
|
||||||
|
try {
|
||||||
|
if (value != undefined) {
|
||||||
|
storage?.setItem(key, serializer.serialize(value));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error when writing value from persisted store "${key}" to ${storage}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxies = new WeakMap();
|
||||||
|
const root = current;
|
||||||
|
|
||||||
|
const proxy = (value: unknown) => {
|
||||||
|
if (value === null || value?.constructor.name === 'Date' || typeof value !== 'object') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let p = proxies.get(value);
|
||||||
|
if (!p) {
|
||||||
|
p = new Proxy(value, {
|
||||||
|
get: (target, property) => {
|
||||||
|
subscribe?.();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
version;
|
||||||
|
return proxy(Reflect.get(target, property));
|
||||||
|
},
|
||||||
|
set: (target, property, value) => {
|
||||||
|
version += 1;
|
||||||
|
Reflect.set(target, property, value);
|
||||||
|
serialize(root);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
proxies.set(value, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
return proxy(root);
|
||||||
|
}
|
||||||
36
src/lib/state/models.svelte.ts
Normal file
36
src/lib/state/models.svelte.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
|
import { getModelKey } from '$lib/backend/convex/user_enabled_models';
|
||||||
|
import type { ProviderModelMap } from '$lib/backend/models/all';
|
||||||
|
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
|
||||||
|
import { createInit } from '$lib/spells/create-init.svelte';
|
||||||
|
import { Provider } from '$lib/types';
|
||||||
|
import { watch } from 'runed';
|
||||||
|
import { session } from './session.svelte';
|
||||||
|
|
||||||
|
export class Models {
|
||||||
|
enabled = $state({} as Record<string, unknown>);
|
||||||
|
|
||||||
|
init = createInit(() => {
|
||||||
|
const query = useCachedQuery(api.user_enabled_models.get_enabled, {
|
||||||
|
user_id: session.current?.user.id ?? '',
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
() => $state.snapshot(query.data),
|
||||||
|
(data) => {
|
||||||
|
if (data) this.enabled = data;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
from<P extends Provider>(provider: Provider) {
|
||||||
|
return page.data.models[provider].map((m: { id: string }) => {
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
enabled: this.enabled[getModelKey({ provider, model_id: m.id })] !== undefined,
|
||||||
|
};
|
||||||
|
}) as Array<ProviderModelMap[P] & { enabled: boolean }>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const models = new Models();
|
||||||
5
src/lib/state/settings.svelte.ts
Normal file
5
src/lib/state/settings.svelte.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createPersistedObj } from '$lib/spells/persisted-obj.svelte';
|
||||||
|
|
||||||
|
export const settings = createPersistedObj('settings', {
|
||||||
|
modelId: undefined as string | undefined,
|
||||||
|
});
|
||||||
|
|
@ -71,7 +71,7 @@ export function toMap<T, V>(
|
||||||
const map: Record<string, V> = {};
|
const map: Record<string, V> = {};
|
||||||
|
|
||||||
for (let i = 0; i < arr.length; i++) {
|
for (let i = 0; i < arr.length; i++) {
|
||||||
const [key, value] = fn(arr[i], i);
|
const [key, value] = fn(arr[i]!, i);
|
||||||
|
|
||||||
map[key] = value;
|
map[key] = value;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
src/lib/utils/is.ts
Normal file
3
src/lib/utils/is.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function isString(value: unknown): value is string {
|
||||||
|
return typeof value === 'string';
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,11 @@ export function keys<T extends object>(obj: T): Array<keyof T> {
|
||||||
return Object.keys(obj) as Array<keyof T>;
|
return Object.keys(obj) as Array<keyof T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// typed object.entries
|
||||||
|
export function entries<T extends object>(obj: T): Array<[keyof T, T[keyof T]]> {
|
||||||
|
return Object.entries(obj) as Array<[keyof T, T[keyof T]]>;
|
||||||
|
}
|
||||||
|
|
||||||
export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
|
export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(obj).filter(([key]) => !keys.includes(key as K))
|
Object.entries(obj).filter(([key]) => !keys.includes(key as K))
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
|
import { getOpenRouterModels, type OpenRouterModel } from '$lib/backend/models/open-router';
|
||||||
|
import { Provider } from '$lib/types';
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
const session = await locals.auth();
|
const [session, openRouterModels] = await Promise.all([locals.auth(), getOpenRouterModels()]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
|
models: {
|
||||||
|
[Provider.OpenRouter]: openRouterModels.unwrapOr([] as OpenRouterModel[]),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { ModeWatcher } from 'mode-watcher';
|
import { ModeWatcher } from 'mode-watcher';
|
||||||
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
||||||
|
import { models } from '$lib/state/models.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
setupConvex(PUBLIC_CONVEX_URL);
|
setupConvex(PUBLIC_CONVEX_URL);
|
||||||
|
models.init();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ModeWatcher />
|
<ModeWatcher />
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
<span {...avatar.fallback}>
|
<span {...avatar.fallback}>
|
||||||
{data.session.user.name
|
{data.session.user.name
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((i) => i[0].toUpperCase())
|
.map((i) => i[0]?.toUpperCase())
|
||||||
.join('')}
|
.join('')}
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@
|
||||||
const id = $props.id();
|
const id = $props.id();
|
||||||
|
|
||||||
const keyQuery = useCachedQuery(api.user_keys.get, {
|
const keyQuery = useCachedQuery(api.user_keys.get, {
|
||||||
user_id: session.current?.user.id ?? '',
|
|
||||||
provider,
|
provider,
|
||||||
|
session_token: session.current?.session.token ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
provider,
|
provider,
|
||||||
user_id: session.current?.user.id ?? '',
|
user_id: session.current?.user.id ?? '',
|
||||||
key: `${key}`,
|
key: `${key}`,
|
||||||
|
session_token: session.current?.session.token,
|
||||||
}),
|
}),
|
||||||
(e) => e
|
(e) => e
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { getOpenRouterModels, type OpenRouterModel } from '$lib/backend/models/open-router';
|
|
||||||
|
|
||||||
export async function load() {
|
|
||||||
return {
|
|
||||||
openRouterModels: (await getOpenRouterModels()).unwrapOr([] as OpenRouterModel[]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/backend/convex/_generated/api';
|
import { models } from '$lib/state/models.svelte';
|
||||||
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
|
|
||||||
import { session } from '$lib/state/session.svelte';
|
|
||||||
import { Provider } from '$lib/types.js';
|
import { Provider } from '$lib/types.js';
|
||||||
import ModelCard from './model-card.svelte';
|
import ModelCard from './model-card.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
|
|
||||||
const enabledModels = useCachedQuery(api.user_enabled_models.get_enabled, {
|
|
||||||
user_id: session.current?.user.id ?? '',
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -22,8 +14,7 @@
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="mt-8 flex flex-col gap-4">
|
<div class="mt-8 flex flex-col gap-4">
|
||||||
{#each data.openRouterModels as model (model.id)}
|
{#each models.from(Provider.OpenRouter) as model (model.id)}
|
||||||
{@const enabled = enabledModels.data?.[`${Provider.OpenRouter}:${model.id}`] !== undefined}
|
<ModelCard provider={Provider.OpenRouter} {model} enabled={model.enabled} />
|
||||||
<ModelCard provider={Provider.OpenRouter} {model} {enabled} />
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
|
|
||||||
async function toggleEnabled(v: boolean) {
|
async function toggleEnabled(v: boolean) {
|
||||||
enabled = v; // Optimistic!
|
enabled = v; // Optimistic!
|
||||||
|
console.log('hi');
|
||||||
if (!session.current?.user.id) return;
|
if (!session.current?.user.id) return;
|
||||||
|
|
||||||
const res = await ResultAsync.fromPromise(
|
const res = await ResultAsync.fromPromise(
|
||||||
|
|
@ -46,6 +47,7 @@
|
||||||
user_id: session.current.user.id,
|
user_id: session.current.user.id,
|
||||||
model_id: model.id,
|
model_id: model.id,
|
||||||
enabled: v,
|
enabled: v,
|
||||||
|
session_token: session.current?.session.token,
|
||||||
}),
|
}),
|
||||||
(e) => e
|
(e) => e
|
||||||
);
|
);
|
||||||
|
|
|
||||||
280
src/routes/api/generate-message/+server.ts
Normal file
280
src/routes/api/generate-message/+server.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
||||||
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
|
import type { Id } from '$lib/backend/convex/_generated/dataModel';
|
||||||
|
import type { SessionObj } from '$lib/backend/convex/betterAuth';
|
||||||
|
import { Provider } from '$lib/types';
|
||||||
|
import { error, json, type RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { ConvexHttpClient } from 'convex/browser';
|
||||||
|
import { ResultAsync } from 'neverthrow';
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
|
||||||
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
|
// Set to true to enable debug logging
|
||||||
|
const ENABLE_LOGGING = true;
|
||||||
|
|
||||||
|
const reqBodySchema = z.object({
|
||||||
|
message: z.string(),
|
||||||
|
model_id: z.string(),
|
||||||
|
|
||||||
|
session_token: z.string(),
|
||||||
|
conversation_id: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GenerateMessageRequestBody = z.infer<typeof reqBodySchema>;
|
||||||
|
|
||||||
|
export type GenerateMessageResponse = {
|
||||||
|
ok: true;
|
||||||
|
conversation_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function response(res: GenerateMessageResponse) {
|
||||||
|
return json(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(message: string, startTime: number): void {
|
||||||
|
if (!ENABLE_LOGGING) return;
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
console.log(`[GenerateMessage] ${message} (${elapsed}ms)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
|
||||||
|
|
||||||
|
async function generateAIResponse(
|
||||||
|
conversationId: string,
|
||||||
|
session: SessionObj,
|
||||||
|
modelId: string,
|
||||||
|
startTime: number
|
||||||
|
) {
|
||||||
|
log('Starting AI response generation in background', startTime);
|
||||||
|
|
||||||
|
const modelResult = await ResultAsync.fromPromise(
|
||||||
|
client.query(api.user_enabled_models.get, {
|
||||||
|
provider: Provider.OpenRouter,
|
||||||
|
model_id: modelId,
|
||||||
|
user_id: session.userId,
|
||||||
|
}),
|
||||||
|
(e) => `Failed to get model: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (modelResult.isErr()) {
|
||||||
|
log(`Background model query failed: ${modelResult.error}`, startTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = modelResult.value;
|
||||||
|
if (!model) {
|
||||||
|
log('Background: Model not found or not enabled', startTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Background: Model found and enabled', startTime);
|
||||||
|
|
||||||
|
const messagesQuery = await ResultAsync.fromPromise(
|
||||||
|
client.query(api.messages.getAllFromConversation, {
|
||||||
|
conversation_id: conversationId as Id<'conversations'>,
|
||||||
|
session_token: session.token,
|
||||||
|
}),
|
||||||
|
(e) => `Failed to get messages: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (messagesQuery.isErr()) {
|
||||||
|
log(`Background messages query failed: ${messagesQuery.error}`, startTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = messagesQuery.value;
|
||||||
|
log(`Background: Retrieved ${messages.length} messages from conversation`, startTime);
|
||||||
|
|
||||||
|
const keyResult = await ResultAsync.fromPromise(
|
||||||
|
client.query(api.user_keys.get, {
|
||||||
|
provider: Provider.OpenRouter,
|
||||||
|
session_token: session.token,
|
||||||
|
}),
|
||||||
|
(e) => `Failed to get API key: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (keyResult.isErr()) {
|
||||||
|
log(`Background API key query failed: ${keyResult.error}`, startTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = keyResult.value;
|
||||||
|
if (!key) {
|
||||||
|
log('Background: No API key found', startTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Background: API key retrieved successfully', startTime);
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
baseURL: 'https://openrouter.ai/api/v1',
|
||||||
|
apiKey: key,
|
||||||
|
});
|
||||||
|
|
||||||
|
const streamResult = await ResultAsync.fromPromise(
|
||||||
|
openai.chat.completions.create({
|
||||||
|
model: model.model_id,
|
||||||
|
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
|
max_tokens: 1000,
|
||||||
|
temperature: 0.7,
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
|
(e) => `OpenAI API call failed: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (streamResult.isErr()) {
|
||||||
|
log(`Background OpenAI stream creation failed: ${streamResult.error}`, startTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = streamResult.value;
|
||||||
|
log('Background: OpenAI stream created successfully', startTime);
|
||||||
|
|
||||||
|
// Create assistant message
|
||||||
|
const messageCreationResult = await ResultAsync.fromPromise(
|
||||||
|
client.mutation(api.messages.create, {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
content: '',
|
||||||
|
role: 'assistant',
|
||||||
|
session_token: session.token,
|
||||||
|
}),
|
||||||
|
(e) => `Failed to create assistant message: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (messageCreationResult.isErr()) {
|
||||||
|
log(`Background assistant message creation failed: ${messageCreationResult.error}`, startTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mid = messageCreationResult.value;
|
||||||
|
log('Background: Assistant message created', startTime);
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
let chunkCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunkCount++;
|
||||||
|
content += chunk.choices[0]?.delta?.content || '';
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
const updateResult = await ResultAsync.fromPromise(
|
||||||
|
client.mutation(api.messages.updateContent, {
|
||||||
|
message_id: mid,
|
||||||
|
content,
|
||||||
|
session_token: session.token,
|
||||||
|
}),
|
||||||
|
(e) => `Failed to update message content: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateResult.isErr()) {
|
||||||
|
log(
|
||||||
|
`Background message update failed on chunk ${chunkCount}: ${updateResult.error}`,
|
||||||
|
startTime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
`Background stream processing completed. Processed ${chunkCount} chunks, final content length: ${content.length}`,
|
||||||
|
startTime
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
log(`Background stream processing error: ${error}`, startTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
log('Starting message generation request', startTime);
|
||||||
|
|
||||||
|
const bodyResult = await ResultAsync.fromPromise(
|
||||||
|
request.json(),
|
||||||
|
() => 'Failed to parse request body'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bodyResult.isErr()) {
|
||||||
|
log(`Request body parsing failed: ${bodyResult.error}`, startTime);
|
||||||
|
return error(400, 'Failed to parse request body');
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Request body parsed successfully', startTime);
|
||||||
|
|
||||||
|
const parsed = reqBodySchema.safeParse(bodyResult.value);
|
||||||
|
if (!parsed.success) {
|
||||||
|
log(`Schema validation failed: ${parsed.error}`, startTime);
|
||||||
|
return error(400, parsed.error);
|
||||||
|
}
|
||||||
|
const args = parsed.data;
|
||||||
|
|
||||||
|
log('Schema validation passed', startTime);
|
||||||
|
|
||||||
|
const sessionResult = await ResultAsync.fromPromise(
|
||||||
|
client.query(api.betterAuth.publicGetSession, {
|
||||||
|
session_token: args.session_token,
|
||||||
|
}),
|
||||||
|
(e) => `Failed to get session: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sessionResult.isErr()) {
|
||||||
|
log(`Session query failed: ${sessionResult.error}`, startTime);
|
||||||
|
return error(401, 'Failed to authenticate');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessionResult.value;
|
||||||
|
if (!session) {
|
||||||
|
log('No session found - unauthorized', startTime);
|
||||||
|
return error(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Session authenticated successfully', startTime);
|
||||||
|
|
||||||
|
let conversationId = args.conversation_id;
|
||||||
|
if (!conversationId) {
|
||||||
|
const conversationResult = await ResultAsync.fromPromise(
|
||||||
|
client.mutation(api.conversations.create, {
|
||||||
|
session_token: args.session_token,
|
||||||
|
}),
|
||||||
|
(e) => `Failed to create conversation: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (conversationResult.isErr()) {
|
||||||
|
log(`Conversation creation failed: ${conversationResult.error}`, startTime);
|
||||||
|
return error(500, 'Failed to create conversation');
|
||||||
|
}
|
||||||
|
|
||||||
|
conversationId = conversationResult.value;
|
||||||
|
log('New conversation created', startTime);
|
||||||
|
} else {
|
||||||
|
log('Using existing conversation', startTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.message) {
|
||||||
|
const userMessageResult = await ResultAsync.fromPromise(
|
||||||
|
client.mutation(api.messages.create, {
|
||||||
|
conversation_id: conversationId as Id<'conversations'>,
|
||||||
|
content: args.message,
|
||||||
|
session_token: args.session_token,
|
||||||
|
model_id: args.model_id,
|
||||||
|
role: 'user',
|
||||||
|
}),
|
||||||
|
(e) => `Failed to create user message: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userMessageResult.isErr()) {
|
||||||
|
log(`User message creation failed: ${userMessageResult.error}`, startTime);
|
||||||
|
return error(500, 'Failed to create user message');
|
||||||
|
}
|
||||||
|
|
||||||
|
log('User message created', startTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start AI response generation in background - don't await
|
||||||
|
generateAIResponse(conversationId, session, args.model_id, startTime).catch((error) => {
|
||||||
|
log(`Background AI response generation error: ${error}`, startTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
log('Response sent, AI generation started in background', startTime);
|
||||||
|
return response({ ok: true, conversation_id: conversationId });
|
||||||
|
};
|
||||||
17
src/routes/api/generate-message/call.ts
Normal file
17
src/routes/api/generate-message/call.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { ResultAsync } from 'neverthrow';
|
||||||
|
import type { GenerateMessageRequestBody, GenerateMessageResponse } from './+server';
|
||||||
|
|
||||||
|
export async function callGenerateMessage(args: GenerateMessageRequestBody) {
|
||||||
|
const res = ResultAsync.fromPromise(
|
||||||
|
fetch('/api/generate-message', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(args),
|
||||||
|
}),
|
||||||
|
(e) => e
|
||||||
|
).map((r) => r.json() as Promise<GenerateMessageResponse>);
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,50 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import * as Icons from '$lib/components/icons';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||||
import PanelLeftIcon from '~icons/lucide/panel-left';
|
import { session } from '$lib/state/session.svelte.js';
|
||||||
|
import { settings } from '$lib/state/settings.svelte.js';
|
||||||
|
import { isString } from '$lib/utils/is.js';
|
||||||
import { Avatar } from 'melt/components';
|
import { Avatar } from 'melt/components';
|
||||||
import * as Icons from '$lib/components/icons';
|
import PanelLeftIcon from '~icons/lucide/panel-left';
|
||||||
import SendIcon from '~icons/lucide/send';
|
import SendIcon from '~icons/lucide/send';
|
||||||
|
import { callGenerateMessage } from '../api/generate-message/call.js';
|
||||||
|
import ModelPicker from './model-picker.svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { useCachedQuery } from '$lib/cache/cached-query.svelte.js';
|
||||||
|
import { api } from '$lib/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
|
|
||||||
|
let form = $state<HTMLFormElement>();
|
||||||
|
let textarea = $state<HTMLTextAreaElement>();
|
||||||
|
async function handleSubmit() {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const message = formData.get('message');
|
||||||
|
|
||||||
|
// TODO: Re-use zod here from server endpoint for better error messages?
|
||||||
|
if (!isString(message) || !session.current?.user.id || !settings.modelId) return;
|
||||||
|
|
||||||
|
if (textarea) textarea.value = '';
|
||||||
|
const res = await callGenerateMessage({
|
||||||
|
message,
|
||||||
|
session_token: session.current?.session.token,
|
||||||
|
conversation_id: page.params.id ?? undefined,
|
||||||
|
model_id: settings.modelId,
|
||||||
|
});
|
||||||
|
if (res.isErr()) return; // TODO: Handle error
|
||||||
|
|
||||||
|
const cid = res.value.conversation_id;
|
||||||
|
|
||||||
|
if (page.params.id !== cid) {
|
||||||
|
goto(`/chat/${cid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationsQuery = useCachedQuery(api.conversations.get, {
|
||||||
|
session_token: session.current?.session.token ?? '',
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -16,22 +54,26 @@
|
||||||
<Sidebar.Root>
|
<Sidebar.Root>
|
||||||
<Sidebar.Sidebar class="flex flex-col p-2">
|
<Sidebar.Sidebar class="flex flex-col p-2">
|
||||||
<div class="flex place-items-center justify-center py-2">
|
<div class="flex place-items-center justify-center py-2">
|
||||||
<span class="text-center text-lg font-bold">Thom Chat</span>
|
<span class="text-center font-serif text-lg">Thom.chat</span>
|
||||||
</div>
|
</div>
|
||||||
<Button href="/chat" class="w-full">New Chat</Button>
|
<Button href="/chat" class="w-full">New Chat</Button>
|
||||||
<div class="flex flex-1 flex-col overflow-y-auto">
|
<div class="flex flex-1 flex-col overflow-y-auto py-2">
|
||||||
<!-- chats -->
|
{#each conversationsQuery.data ?? [] as conversation (conversation._id)}
|
||||||
|
<a href={`/chat/${conversation._id}`} class="text-left hover:underline">
|
||||||
|
{conversation.title}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
{#if data.session !== null}
|
{#if data.session !== null}
|
||||||
<Button href="/account/api-keys" variant="ghost" class="h-auto w-full justify-start">
|
<Button href="/account/api-keys" variant="ghost" class="h-auto w-full justify-start">
|
||||||
<Avatar src={data.session.user.image ?? undefined}>
|
<Avatar src={data.session?.user.image ?? undefined}>
|
||||||
{#snippet children(avatar)}
|
{#snippet children(avatar)}
|
||||||
<img {...avatar.image} alt="Your avatar" class="size-10 rounded-full" />
|
<img {...avatar.image} alt="Your avatar" class="size-10 rounded-full" />
|
||||||
<span {...avatar.fallback}
|
<span {...avatar.fallback}
|
||||||
>{data.session?.user.name
|
>{data.session?.user.name
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((name) => name[0].toUpperCase())
|
.map((name) => name[0]?.toUpperCase())
|
||||||
.join('')}</span
|
.join('')}</span
|
||||||
>
|
>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
@ -51,12 +93,32 @@
|
||||||
<Sidebar.Trigger class="fixed top-3 left-2">
|
<Sidebar.Trigger class="fixed top-3 left-2">
|
||||||
<PanelLeftIcon />
|
<PanelLeftIcon />
|
||||||
</Sidebar.Trigger>
|
</Sidebar.Trigger>
|
||||||
<div class="flex size-full place-items-center justify-center">
|
<div class="mx-auto flex size-full max-w-3xl flex-col">
|
||||||
<div class="flex w-full max-w-lg flex-col place-items-center gap-1">
|
{@render children()}
|
||||||
<form class="relative h-18 w-full">
|
<div class="mt-auto flex w-full flex-col gap-1">
|
||||||
|
<ModelPicker class=" w-min " />
|
||||||
|
<div class="h-2" aria-hidden="true"></div>
|
||||||
|
<form
|
||||||
|
class="relative h-18 w-full"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}}
|
||||||
|
bind:this={form}
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
|
bind:this={textarea}
|
||||||
class="border-input bg-background ring-ring ring-offset-background h-full w-full resize-none rounded-lg border p-2 text-sm ring-offset-2 outline-none focus-visible:ring-2"
|
class="border-input bg-background ring-ring ring-offset-background h-full w-full resize-none rounded-lg border p-2 text-sm ring-offset-2 outline-none focus-visible:ring-2"
|
||||||
placeholder="Ask me anything..."
|
placeholder="Ask me anything..."
|
||||||
|
name="message"
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autofocus
|
||||||
|
autocomplete="off"
|
||||||
></textarea>
|
></textarea>
|
||||||
<Button type="submit" size="icon" class="absolute right-1 bottom-1 size-8">
|
<Button type="submit" size="icon" class="absolute right-1 bottom-1 size-8">
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,32 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
|
import { session } from '$lib/state/session.svelte';
|
||||||
|
import IconAi from '~icons/lucide/sparkles';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-1 flex-col items-center justify-center">
|
||||||
|
<div class="w-full p-2">
|
||||||
|
<h2 class="text-left font-serif text-3xl font-semibold">Hey there, Bozo!</h2>
|
||||||
|
<p class="mt-2 text-left text-lg">
|
||||||
|
{#if session.current?.user.name}
|
||||||
|
Oops, I meant {session.current?.user.name}.
|
||||||
|
{:else}
|
||||||
|
Be sure to login first.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex items-center gap-1">
|
||||||
|
{#each { length: 4 }}
|
||||||
|
<Button variant="outline" class="rounded-full">
|
||||||
|
<IconAi />
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="mt-2 flex flex-col gap-2 p-2">
|
||||||
|
{#each { length: 3 } as _, i (i)}
|
||||||
|
<li class={['py-2', i !== 2 && 'border-b']}>Hey AI, write me a poem</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
|
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
|
||||||
|
import { session } from '$lib/state/session.svelte';
|
||||||
|
|
||||||
|
const messages = useCachedQuery(api.messages.getAllFromConversation, () => ({
|
||||||
|
conversation_id: page.params.id ?? '',
|
||||||
|
session_token: session.current?.session.token ?? '',
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-1 flex-col overflow-x-clip overflow-y-auto py-4">
|
||||||
|
{#each messages.data ?? [] as message (message._id)}
|
||||||
|
{#if message.role === 'user'}
|
||||||
|
<div class="max-w-[80%] self-end bg-blue-900 p-2 text-white">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
{:else if message.role === 'assistant'}
|
||||||
|
<div class="max-w-[80%] p-2 text-white">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
30
src/routes/chat/model-picker.svelte
Normal file
30
src/routes/chat/model-picker.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
|
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
|
||||||
|
import { session } from '$lib/state/session.svelte';
|
||||||
|
import { settings } from '$lib/state/settings.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { class: className }: Props = $props();
|
||||||
|
|
||||||
|
const enabledModelsQuery = useCachedQuery(api.user_enabled_models.get_enabled, {
|
||||||
|
user_id: session.current?.user.id ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabledArr = $derived(Object.values(enabledModelsQuery.data ?? {}));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!enabledArr.find((m) => m.model_id === settings.modelId) && enabledArr.length > 0) {
|
||||||
|
settings.modelId = enabledArr[0]!.model_id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<select bind:value={settings.modelId} class="border {className}">
|
||||||
|
{#each enabledArr as model (model._id)}
|
||||||
|
<option value={model.model_id}>{model.model_id}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"types": [
|
"types": [
|
||||||
"unplugin-icons/types/svelte"
|
"unplugin-icons/types/svelte"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue