Performance enhancements galore! New themining system
This is once again a huge commit, but its mostly performance improvements along with some bug fixes and refactoring. It also includes changes to the theming systems. I'm still not 100% happy with the theming system, but its better than before. Model fetching has been dramatically improved! Nearly all the important computation and pre-processing has been moved to the server. This has also somehow fixed the way model details are loaded, which was causing many models to be missing their details despite models.dev having them. The markdown renderer has once again been changed, but I'm mostly certain that this is the last time major changes will be made to it. The renderer is not spamming components, bloating memory usage, and its not using a bug prone custom written chunking system. There's also a lot more that I haven't mentioned and honestly forgot. I need to get better commit hygiene tbh.
This commit is contained in:
@@ -4,10 +4,10 @@
|
|||||||
I'm not entire sure why, I think its a race condition, but I'm not sure.
|
I'm not entire sure why, I think its a race condition, but I'm not sure.
|
||||||
I _do not_ think that triplit-nuxt is the culprit, but I'm not sure.
|
I _do not_ think that triplit-nuxt is the culprit, but I'm not sure.
|
||||||
|
|
||||||
2. [X] The logic to handle hovering over the sidebar is half-baked at best. On the
|
2. [x] The logic to handle hovering over the sidebar is half-baked at best. On the
|
||||||
agents route, the back arrow sometimes stays full sized
|
agents route, the back arrow sometimes stays full sized
|
||||||
|
|
||||||
3. [X] If you open the theme switcher on the sidenav then close it and re-open
|
3. [x] If you open the theme switcher on the sidenav then close it and re-open
|
||||||
it _without_ moving your mouse off of the sidenav, the agent button/dropdown
|
it _without_ moving your mouse off of the sidenav, the agent button/dropdown
|
||||||
trigger will slowly crawl to the right (likely related to #2).
|
trigger will slowly crawl to the right (likely related to #2).
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# Todo
|
# Todo
|
||||||
|
|
||||||
- [X] Standardize on one icon set rather than 4 (lmao)
|
- [x] Standardize on one icon set rather than 4 (lmao)
|
||||||
- [ ] Make dropdowns a singleton component
|
- [-] Make dropdowns a singleton component (work in progress)
|
||||||
- [ ] Make the sidebar better :kekdoggo:. It's annoying to manage across routes
|
- [ ] Make the sidebar better :kekdoggo:. It's annoying to manage across routes
|
||||||
|
- [ ] Make topics manually renameable
|
||||||
|
- [ ] Improve latex rendering, make single line equations work _well_, and likely make it configurable
|
||||||
|
- [x] Implement appearence settings
|
||||||
|
- [x] Accent colors
|
||||||
|
- [x] Neutral colors
|
||||||
|
- [x] Hinting
|
||||||
|
- [ ] Font size/UI scale
|
||||||
|
- [ ] Fix custom longcat tool call
|
||||||
|
- [ ] add the ability to make a custom provider
|
||||||
|
- [ ] add browser testing (maybe https://vitest.dev/guide/browser/)
|
||||||
|
- [ ] Add a dropdown to the output token count on agent messages that shows you all the token info
|
||||||
|
- [ ] Add the ability to configure UI scale and font size
|
||||||
|
- [ ] Write good docs
|
||||||
|
- [ ] Custom theme colors
|
||||||
|
- [ ] Enable and disable all models button
|
||||||
|
- [ ] Make tools pluggable
|
||||||
|
- [ ] Make a tool connector that the server can use to connect to tools on a
|
||||||
|
remote system (would be helpful for agentic development tasks)
|
||||||
|
|||||||
+17
-15
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||||
|
// import 'katex/dist/katex.min.css'
|
||||||
import '~/assets/css/reset.css';
|
import '~/assets/css/reset.css';
|
||||||
import '~/assets/css/base.css';
|
import '~/assets/css/base.css';
|
||||||
|
|
||||||
@@ -9,7 +11,14 @@ if (Number.isNaN(Number(hinting.value))) hinting.value = '0';
|
|||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
style: `--color-accent: var(--accent-${accent.value}, var(--accent-violet)); --color-accent-hover: var(--accent-${accent.value}-hover, var(--accent-violet-hover)); --neutral-dark: var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark)); --neutral-light: var(--neutral-${neutral.value}-light, var(--neutral-zinc-light)); --accent-hinting: ${hinting.value}%; --base-hinting: calc(100% - var(--accent-hinting));`,
|
style: `
|
||||||
|
--neutral-100: var(--palette-${neutral.value}-100);
|
||||||
|
--neutral-200: var(--palette-${neutral.value}-200);
|
||||||
|
--neutral-300: var(--palette-${neutral.value}-300);
|
||||||
|
--color-accent: var(--accent-${accent.value}, var(--accent-violet));
|
||||||
|
--color-accent-hover: var(--accent-${accent.value}-hover, var(--accent-violet-hover));
|
||||||
|
--accent-hinting: ${hinting.value}%;
|
||||||
|
--base-hinting: calc(100% - var(--accent-hinting));`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,23 +35,16 @@ if (import.meta.client) {
|
|||||||
'--color-accent-hover',
|
'--color-accent-hover',
|
||||||
`var(--accent-${accent.value}-hover, var(--accent-violet-hover))`,
|
`var(--accent-${accent.value}-hover, var(--accent-violet-hover))`,
|
||||||
);
|
);
|
||||||
document.documentElement.style.setProperty(
|
// document.documentElement.style.setProperty(
|
||||||
'--neutral-dark',
|
// '--color-neutral',
|
||||||
`var(--neutral-${neutral.value}-dark, var(--neutral-zinc-dark))`,
|
// `var(--neutral-${neutral.value}, var(--neutral-zinc))`,
|
||||||
);
|
// );
|
||||||
document.documentElement.style.setProperty(
|
document.documentElement.style.setProperty('--neutral-100', `var(--palette-${neutral.value}-100)`);
|
||||||
'--neutral-light',
|
document.documentElement.style.setProperty('--neutral-200', `var(--palette-${neutral.value}-200)`);
|
||||||
`var(--neutral-${neutral.value}-light, var(--neutral-zinc-light))`,
|
document.documentElement.style.setProperty('--neutral-300', `var(--palette-${neutral.value}-300)`);
|
||||||
);
|
|
||||||
document.documentElement.style.setProperty('--accent-hinting', `${hinting.value}%`);
|
document.documentElement.style.setProperty('--accent-hinting', `${hinting.value}%`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useHead({
|
|
||||||
bodyAttrs: {
|
|
||||||
class: 'font-sans'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
+344
-21
@@ -1,9 +1,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--font-sans: system-ui, sans-serif;
|
|
||||||
--sidebar-width: 400px;
|
|
||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
|
|
||||||
--color-accent-text: #000;
|
--color-accent-text: #232625;
|
||||||
|
|
||||||
--reasoning-accent: #a62bcb;
|
--reasoning-accent: #a62bcb;
|
||||||
|
|
||||||
@@ -53,18 +51,68 @@
|
|||||||
--accent-magenta: #ec4899;
|
--accent-magenta: #ec4899;
|
||||||
--accent-magenta-hover: #db2777;
|
--accent-magenta-hover: #db2777;
|
||||||
|
|
||||||
/* Neutral Base Colors - different gray variations */
|
--bg-base: color-mix(in srgb, var(--neutral-100), var(--color-accent) var(--accent-hinting));
|
||||||
|
--bg-surface: color-mix(in srgb, var(--neutral-200), var(--color-accent) var(--accent-hinting));
|
||||||
/* zinc - deep gray-blue */
|
--bg-container: color-mix(in srgb, var(--neutral-300), var(--color-accent) var(--accent-hinting));
|
||||||
--neutral-zinc-dark: #171619;
|
|
||||||
--neutral-zinc-light: #fbfafd;
|
|
||||||
|
|
||||||
/* charcoal - deep black-gray */
|
|
||||||
--neutral-charcoal-dark: #0a0a0a;
|
|
||||||
--neutral-charcoal-light: #fefdff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root.dark {
|
:root.dark {
|
||||||
|
--palette-zinc-100: #09080A;
|
||||||
|
--palette-zinc-200: #1a191b;
|
||||||
|
--palette-zinc-300: #2e2d2f;
|
||||||
|
--palette-slate-100: #0f171c;
|
||||||
|
--palette-slate-200: #1e252b;
|
||||||
|
--palette-slate-300: #333b44;
|
||||||
|
--palette-obsidian-100: #050504;
|
||||||
|
--palette-obsidian-200: #0f0f0e;
|
||||||
|
--palette-obsidian-300: #1e1f1f;
|
||||||
|
|
||||||
|
--text-primary: color-mix(in srgb, #fafafa, var(--color-accent) var(--accent-hinting));
|
||||||
|
/* Selected topics */
|
||||||
|
--text-secondary: color-mix(in srgb, #a1a1aa, var(--color-accent) var(--accent-hinting));
|
||||||
|
/* Selected topics */
|
||||||
|
--text-tertiary: color-mix(in srgb, #71717a, var(--color-accent) var(--accent-hinting));
|
||||||
|
/* Tokens/s, Info */
|
||||||
|
--text-dim: color-mix(in srgb, #52525b, var(--color-accent) var(--accent-hinting));
|
||||||
|
/* Unselected/Disabled */
|
||||||
|
|
||||||
|
--color-hover: color-mix(in srgb, rgba(255, 255, 255, 0.08), var(--color-accent) var(--accent-hinting));
|
||||||
|
--color-active: color-mix(in srgb, rgba(255, 255, 255, 0.12), var(--color-accent) var(--accent-hinting));
|
||||||
|
|
||||||
|
--color-border: color-mix(in srgb, rgba(255, 255, 255, 0.1), var(--color-accent) var(--accent-hinting));
|
||||||
|
--color-border-active: color-mix(in srgb, rgba(255, 255, 255, 0.16), var(--color-accent) var(--accent-hinting));
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.light {
|
||||||
|
/* --palette-zinc-100: #f4f4f5; --palette-zinc-200: #e4e4e7; --palette-zinc-300: #d4d4d8;
|
||||||
|
--palette-slate-100: #f1f5f9; --palette-slate-200: #e2e8f0; --palette-slate-300: #cbd5e1;
|
||||||
|
--palette-obsidian-100: #fafafa; --palette-obsidian-200: #f5f5f5; --palette-obsidian-300: #e5e5e5; */
|
||||||
|
--palette-zinc-100: #d4d4d8;
|
||||||
|
--palette-zinc-200: #e4e4e7;
|
||||||
|
--palette-zinc-300: #f4f4f5;
|
||||||
|
--palette-slate-100: #cbd5e1;
|
||||||
|
--palette-slate-200: #e2e8f0;
|
||||||
|
--palette-slate-300: #f1f5f9;
|
||||||
|
--palette-obsidian-100: #e5e5e5;
|
||||||
|
--palette-obsidian-200: #ededed;
|
||||||
|
--palette-obsidian-300: #fcfcfc;
|
||||||
|
|
||||||
|
--text-primary: color-mix(in srgb, #09090b, var(--color-accent) var(--accent-hinting));
|
||||||
|
--text-secondary: color-mix(in srgb, #52525b, var(--color-accent) var(--accent-hinting));
|
||||||
|
--text-tertiary: color-mix(in srgb, #71717a, var(--color-accent) var(--accent-hinting));
|
||||||
|
--text-dim: color-mix(in srgb, #a1a1aa, var(--color-accent) var(--accent-hinting));
|
||||||
|
|
||||||
|
--color-hover: color-mix(in srgb, rgba(0, 0, 0, 0.08), var(--color-accent) var(--accent-hinting));
|
||||||
|
--color-active: color-mix(in srgb, rgba(0, 0, 0, 0.12), var(--color-accent) var(--accent-hinting));
|
||||||
|
|
||||||
|
--color-border: color-mix(in srgb, rgba(0, 0, 0, 0.08), var(--color-accent) var(--accent-hinting));
|
||||||
|
--color-border-active: color-mix(in srgb, rgba(0, 0, 0, 0.14), var(--color-accent) var(--accent-hinting));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* :root.dark {
|
||||||
|
--neutral-zinc: color-mix(in srgb, var(--base-hinting) #171619, var(--accent-hinting) var(--color-accent));
|
||||||
|
--neutral-charcoal: color-mix(in srgb, var(--base-hinting) #0a0a0a, var(--accent-hinting) var(--color-accent));
|
||||||
|
|
||||||
--color-base: color-mix(in srgb, var(--base-hinting) #040305, var(--accent-hinting) var(--color-accent));
|
--color-base: color-mix(in srgb, var(--base-hinting) #040305, var(--accent-hinting) var(--color-accent));
|
||||||
--color-highlight-low: color-mix(in srgb,
|
--color-highlight-low: color-mix(in srgb,
|
||||||
var(--base-hinting) rgba(255, 255, 255, 0.05),
|
var(--base-hinting) rgba(255, 255, 255, 0.05),
|
||||||
@@ -79,13 +127,13 @@
|
|||||||
--color-muted: color-mix(in srgb, var(--base-hinting) #a1a1a0, var(--accent-hinting) var(--color-accent));
|
--color-muted: color-mix(in srgb, var(--base-hinting) #a1a1a0, var(--accent-hinting) var(--color-accent));
|
||||||
--color-subtle: color-mix(in srgb, var(--base-hinting) #d1d1d0, var(--accent-hinting) var(--color-accent));
|
--color-subtle: color-mix(in srgb, var(--base-hinting) #d1d1d0, var(--accent-hinting) var(--color-accent));
|
||||||
--color-input: color-mix(in srgb, var(--base-hinting) #222124, var(--accent-hinting) var(--color-accent));
|
--color-input: color-mix(in srgb, var(--base-hinting) #222124, var(--accent-hinting) var(--color-accent));
|
||||||
--color-neutral: color-mix(in srgb,
|
|
||||||
var(--base-hinting) var(--neutral-dark),
|
|
||||||
var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-reasoning: color-mix(in srgb, var(--base-hinting) #66666a, var(--accent-hinting) var(--color-accent));
|
--color-reasoning: color-mix(in srgb, var(--base-hinting) #66666a, var(--accent-hinting) var(--color-accent));
|
||||||
}
|
}
|
||||||
|
|
||||||
:root.light {
|
:root.light {
|
||||||
|
--neutral-zinc: color-mix(in srgb, var(--base-hinting) #fbfafd, var(--accent-hinting) var(--color-accent));
|
||||||
|
--neutral-charcoal: color-mix(in srgb, var(--base-hinting) #fefdff, var(--accent-hinting) var(--color-accent));
|
||||||
|
|
||||||
--color-base: color-mix(in srgb, var(--base-hinting) #f2f0f0, var(--accent-hinting) var(--color-accent));
|
--color-base: color-mix(in srgb, var(--base-hinting) #f2f0f0, var(--accent-hinting) var(--color-accent));
|
||||||
--color-highlight-low: color-mix(in srgb,
|
--color-highlight-low: color-mix(in srgb,
|
||||||
var(--base-hinting) rgba(0, 0, 0, 0.06),
|
var(--base-hinting) rgba(0, 0, 0, 0.06),
|
||||||
@@ -100,18 +148,23 @@
|
|||||||
--color-muted: color-mix(in srgb, var(--base-hinting) #6b7270, var(--accent-hinting) var(--color-accent));
|
--color-muted: color-mix(in srgb, var(--base-hinting) #6b7270, var(--accent-hinting) var(--color-accent));
|
||||||
--color-subtle: color-mix(in srgb, var(--base-hinting) #9ba2a0, var(--accent-hinting) var(--color-accent));
|
--color-subtle: color-mix(in srgb, var(--base-hinting) #9ba2a0, var(--accent-hinting) var(--color-accent));
|
||||||
--color-input: color-mix(in srgb, var(--base-hinting) #ffffff, var(--accent-hinting) var(--color-accent));
|
--color-input: color-mix(in srgb, var(--base-hinting) #ffffff, var(--accent-hinting) var(--color-accent));
|
||||||
--color-neutral: color-mix(in srgb,
|
|
||||||
var(--base-hinting) var(--neutral-light),
|
|
||||||
var(--accent-hinting) var(--color-accent));
|
|
||||||
--color-reasoning: color-mix(in srgb, var(--base-hinting) #99979c, var(--accent-hinting) var(--color-accent));
|
--color-reasoning: color-mix(in srgb, var(--base-hinting) #99979c, var(--accent-hinting) var(--color-accent));
|
||||||
}
|
} */
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
|
font-family: "Geist", 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";
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: var(--color-base);
|
background-color: var(--bg-base);
|
||||||
color: var(--color-text);
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-mono {
|
||||||
|
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark {
|
html.dark {
|
||||||
@@ -155,3 +208,273 @@ button.accent:hover {
|
|||||||
.capitalize {
|
.capitalize {
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.has-obtrusive-scrollbars .chat-scroll-container {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-obtrusive-scrollbars .chatPane {
|
||||||
|
margin-right: calc(-1 * var(--thin-scrollbar-width));
|
||||||
|
width: calc(100% + var(--thin-scrollbar-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-blink {
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-rotate {
|
||||||
|
animation: rotate 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vl-toggle-switch[aria-disabled="true"] div {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vl-toggle-switch[data-state="checked"] {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vl-toggle-switch[data-state="checked"] div {
|
||||||
|
transform-origin: center right;
|
||||||
|
transform: translateX(calc(2.5em - 1em - 0.5rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.vl-toggle-switch:active div {
|
||||||
|
width: 1.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vl-toggle-switch[data-state="checked"]:active div {
|
||||||
|
transform: translateX(calc(2.5em - 1.3em - 0.5rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-contaizner.middle {
|
||||||
|
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent)
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-contaizner.top {
|
||||||
|
mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent)
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-contaizner.bottom {
|
||||||
|
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown renderer */
|
||||||
|
article>* {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article>*:first-child {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article>*:last-child {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article>*:only-child {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article math {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
article mtd {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article hr {
|
||||||
|
border: 1px solid var(--text-dim);
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article li {
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
article ul {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: 1.25rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article ul>li {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article ul>li::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0.5rem;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background-color: var(--text-tertiary);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
article ul>li::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 3px;
|
||||||
|
top: 23px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background-color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
article ul>li:last-child::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
article ol {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: 1.25rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
counter-reset: ordered-list-counter var(--start-value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
article ol[start] {
|
||||||
|
--start-value: calc(attr(start type(<number>)) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
article ol>li {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
counter-increment: ordered-list-counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
article ol>li::before {
|
||||||
|
content: counter(ordered-list-counter) ".";
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
width: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .shiki,
|
||||||
|
html.dark .shiki span {
|
||||||
|
color: var(--shiki-dark) !important;
|
||||||
|
background-color: var(--shiki-dark-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
article ol:only-child,
|
||||||
|
article ul:only-child {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
article code:not(pre code) {
|
||||||
|
background-color: var(--color-hover);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article blockquote {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-left: 4px solid var(--text-tertiary);
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: make these tables better, this is literally the first attempt from Gemini 3 flash */
|
||||||
|
article table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: left;
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
article table thead tr {
|
||||||
|
background-color: var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
article table th {
|
||||||
|
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
article table td {
|
||||||
|
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
article table tbody tr {
|
||||||
|
background-color: var(--color-hover);
|
||||||
|
transition: background-color 250ms cubic-bezier(0.5, 1, 0.89, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
article table tbody tr:nth-of-type(even) {
|
||||||
|
background-color: var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effect */
|
||||||
|
article table tbody tr:hover {
|
||||||
|
background-color: var(--color-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
article h1,
|
||||||
|
article h2,
|
||||||
|
article h3,
|
||||||
|
article h4,
|
||||||
|
article h5,
|
||||||
|
article h6 {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article .checkbox {
|
||||||
|
width: min-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* end markdown renderer */
|
||||||
|
|
||||||
|
.reasoning-contaizner.middle {
|
||||||
|
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent)
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-contaizner.top {
|
||||||
|
mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent)
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-contaizner.bottom {
|
||||||
|
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000)
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch, computed } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||||
import type { Entity } from '@triplit/client';
|
import type { Entity } from '@triplit/client';
|
||||||
import { schema } from '#triplit/schema';
|
import { schema } from '#triplit/schema';
|
||||||
|
|
||||||
const { allModels } = await useModels();
|
const { allModels } = await useModels();
|
||||||
|
|
||||||
|
const inputHeight: Ref<string> = ref('auto');
|
||||||
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||||
let tempInput = '';
|
let tempInput = '';
|
||||||
const inputValue = ref('');
|
const inputValue = defineModel<string>({ required: false, default: '' });
|
||||||
const triplit = useTriplitClient();
|
const triplit = useTriplitClient();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -22,10 +23,8 @@ const props = defineProps<{
|
|||||||
providers?: ProviderWithModels[];
|
providers?: ProviderWithModels[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Model selection state
|
|
||||||
const selectedModel = ref<ModelWithProvider | null>(null);
|
const selectedModel = ref<ModelWithProvider | null>(null);
|
||||||
|
|
||||||
// Initialize model selection based on agent's defaultModelId or first available
|
|
||||||
const initializeModel = () => {
|
const initializeModel = () => {
|
||||||
if (selectedModel.value) return;
|
if (selectedModel.value) return;
|
||||||
if (!props.providers || !props.agent) return;
|
if (!props.providers || !props.agent) return;
|
||||||
@@ -42,12 +41,10 @@ const initializeModel = () => {
|
|||||||
// Fall back to first available model
|
// Fall back to first available model
|
||||||
if (allModels.value.length > 0) {
|
if (allModels.value.length > 0) {
|
||||||
selectedModel.value = allModels.value[0]!;
|
selectedModel.value = allModels.value[0]!;
|
||||||
// Update agent's default model
|
|
||||||
updateAgentDefaultModel(selectedModel.value.id);
|
updateAgentDefaultModel(selectedModel.value.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update agent's default model in Triplit
|
|
||||||
const updateAgentDefaultModel = async (modelId: string) => {
|
const updateAgentDefaultModel = async (modelId: string) => {
|
||||||
if (!props.agent) return;
|
if (!props.agent) return;
|
||||||
try {
|
try {
|
||||||
@@ -103,8 +100,6 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
|||||||
|
|
||||||
inputValue.value =
|
inputValue.value =
|
||||||
inputValue.value.slice(0, cursorPosition) + '\n' + inputValue.value.slice(cursorPosition);
|
inputValue.value.slice(0, cursorPosition) + '\n' + inputValue.value.slice(cursorPosition);
|
||||||
await nextTick();
|
|
||||||
handleInput();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -112,11 +107,12 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInput = () => {
|
watch(inputValue, async () => {
|
||||||
const textarea = inputRef.value;
|
const textarea = inputRef.value;
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
|
|
||||||
textarea.style.height = 'auto';
|
inputHeight.value = 'auto';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
const lineHeight = 24;
|
const lineHeight = 24;
|
||||||
const maxLines = 10;
|
const maxLines = 10;
|
||||||
@@ -125,11 +121,11 @@ const handleInput = () => {
|
|||||||
const newHeight = textarea.scrollHeight;
|
const newHeight = textarea.scrollHeight;
|
||||||
|
|
||||||
if (newHeight > maxHeight) {
|
if (newHeight > maxHeight) {
|
||||||
textarea.style.height = `${maxHeight}px`;
|
inputHeight.value = `${maxHeight}px`;
|
||||||
} else {
|
} else {
|
||||||
textarea.style.height = `${newHeight}px`;
|
inputHeight.value = `${newHeight}px`;
|
||||||
}
|
}
|
||||||
};
|
}, { immediate: true });
|
||||||
|
|
||||||
let hasCommandKey = false;
|
let hasCommandKey = false;
|
||||||
if (import.meta.server) {
|
if (import.meta.server) {
|
||||||
@@ -145,40 +141,34 @@ onBeforeMount(() => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
inputValue.value = tempInput;
|
inputValue.value = tempInput;
|
||||||
nextTick(() => {
|
|
||||||
handleInput();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="['w-full flex max-h-full', $attrs.class]">
|
<div :class="['w-full flex max-h-full', $attrs.class]">
|
||||||
<div class="relative w-full flex flex-shrink-1 flex-col gap-3 p-3 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--color-input)]
|
<div class="relative w-full flex flex-shrink-1 flex-col gap-3 p-3 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--bg-container)]
|
||||||
border-[var(--color-highlight)] focus-within:border-[var(--color-highlight-high)]">
|
border-[var(--color-border)] focus-within:border-[var(--color-border-active)]">
|
||||||
<!-- Text Input -->
|
|
||||||
<div class="flex-1 min-w-0 max-h-full">
|
<div class="flex-1 min-w-0 max-h-full">
|
||||||
<!-- Grammarly literally breaks everything, go fuck yourself -->
|
<!-- Grammarly literally breaks everything, go fuck yourself -->
|
||||||
|
<!-- It is absolutely paramount that the closing tag for the textare has ZERO whitespace between the end of the textarea opening tag, otherwise there will be hydration errors -->
|
||||||
<textarea data-gramm="false" id="chat" v-model="inputValue" ref="inputRef"
|
<textarea data-gramm="false" id="chat" v-model="inputValue" ref="inputRef"
|
||||||
:placeholder="`Start something great. Press ${hasCommandKey ? '⌘ + Enter' : 'ctrl + Enter'} to insert a new line.`"
|
:placeholder="`Start something great. Press ${hasCommandKey ? '⌘ + Enter' : 'ctrl + Enter'} to insert a new line.`"
|
||||||
@keydown="handleKeyDown" @input="handleInput"
|
@keydown="handleKeyDown" :style="{ height: inputHeight }"
|
||||||
class="[scrollbar-width:none] w-full bg-transparent text-[var(--color-text)] resize-none outline-none text-[15px] leading-6 min-h-0 overflow-y-auto">
|
class="[scrollbar-width:none] w-full bg-transparent resize-none text-[0.95em] placeholder:text-[var(--text-tertiary)]"></textarea>
|
||||||
</textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<!-- TODO: since we dont want to model selector dropdown to potentially overflow, it has max-width: 100%, so, we need to maake the trigger large enough to fit the entire width of the dropdown -->
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<ModelSelector v-if="providers !== undefined" v-model="selectedModel" :providers="providers">
|
<ModelSelector v-if="providers !== undefined" v-model="selectedModel" :providers="providers" />
|
||||||
</ModelSelector>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Send/Stop Button -->
|
|
||||||
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() && !loading"
|
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() && !loading"
|
||||||
:class="[
|
:class="[
|
||||||
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
|
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
|
||||||
inputValue.trim() && !loading
|
inputValue.trim() && !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(--color-highlight-high)]',
|
: 'text-[var(--text-dim)]',
|
||||||
loading && 'bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)]',
|
loading && 'bg-[var(--color-hover)] hover:bg-[var(--color-active)]',
|
||||||
]">
|
]">
|
||||||
<Icon v-if="loading" name="mynaui:stop-solid" class="text-6.5" />
|
<Icon v-if="loading" name="mynaui:stop-solid" class="text-6.5" />
|
||||||
<Icon v-else name="mynaui:send-solid" class="text-5" />
|
<Icon v-else name="mynaui:send-solid" class="text-5" />
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
isOpen: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid transition-all duration-250 ease-in-out transform-origin-top-center" :class="[
|
||||||
|
isOpen ? 'grid-rows-[1fr] opacity-100 scale-100' : 'grid-rows-[0fr] opacity-0 scale-95',
|
||||||
|
]">
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+19
-85
@@ -1,97 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup>
|
||||||
import type { DropdownItem } from '~/types/dropdown';
|
import { useDropdown } from '~/composables/useDropdown';
|
||||||
|
const { dropdownState, closeDropdown } = useDropdown();
|
||||||
|
const menuRef = ref(null);
|
||||||
|
|
||||||
interface Props {
|
const currentItems = computed(() => dropdownState.itemsFactory?.());
|
||||||
items?: DropdownItem[];
|
|
||||||
placement?: 'right' | 'left' | 'center';
|
|
||||||
verticality?: 'asscending' | 'descending';
|
|
||||||
width?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
useClickOutside(menuRef, closeDropdown);
|
||||||
placement: 'right',
|
|
||||||
verticality: 'descending',
|
|
||||||
width: 'auto',
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits<(e: 'select', item: DropdownItem) => void>();
|
|
||||||
|
|
||||||
const triggerRef = ref<HTMLElement | null>(null);
|
|
||||||
const isOpen = defineModel<boolean>({ required: false });
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
isOpen.value = !isOpen.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const select = (item: DropdownItem) => {
|
|
||||||
if (item.disabled || item.divider) return;
|
|
||||||
emit('select', item);
|
|
||||||
isOpen.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const placementClasses = computed(() => {
|
|
||||||
let classes = '';
|
|
||||||
|
|
||||||
switch (props.placement) {
|
|
||||||
case 'right':
|
|
||||||
classes += 'right-0 ';
|
|
||||||
break;
|
|
||||||
case 'left':
|
|
||||||
classes += 'left-0 ';
|
|
||||||
break;
|
|
||||||
case 'center':
|
|
||||||
classes += 'left-1/2 -translate-x-1/2 ';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
classes += 'left-0 ';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (props.verticality) {
|
|
||||||
case 'asscending':
|
|
||||||
classes += 'bottom-full mb-1.5';
|
|
||||||
break;
|
|
||||||
case 'descending':
|
|
||||||
classes += 'top-full mt-1.5';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return classes;
|
|
||||||
});
|
|
||||||
|
|
||||||
useClickOutside(triggerRef, () => {
|
|
||||||
isOpen.value = false;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="triggerRef" :class="$attrs.class">
|
<Teleport to="body">
|
||||||
<slot name="trigger" :toggle="toggle" :is-open="isOpen"></slot>
|
|
||||||
|
|
||||||
<Transition enter-active-class="transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
<Transition enter-active-class="transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||||
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
|
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
|
||||||
leave-active-class="transition-all duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
leave-active-class="transition-all duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||||
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
|
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
|
||||||
<div v-if="isOpen" :class="[
|
<div v-if="dropdownState.open" ref="menuRef"
|
||||||
'absolute z-50 bg-[var(--color-neutral)] border border-[var(--color-highlight)] rounded-xl p-1.5 flex flex-col gap-1',
|
:style="{ top: `${dropdownState.y}px`, left: `${dropdownState.x}px`, width: dropdownState.options?.width || 'auto', minWidth: dropdownState.options?.minWidth || 'auto', maxWidth: dropdownState.options?.maxWidth || 'auto', transform: `translate(${dropdownState.options?.placement === 'right' ? '-100%' : dropdownState.options?.placement === 'center' ? '-50%' : '0'}, ${dropdownState.options?.verticality === 'ascending' ? 'calc(-100% - 0.25rem)' : '0.25rem'})` }"
|
||||||
placementClasses
|
class="flex flex-col gap-1 fixed z-[100] bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-lg p-1.5 shadow-xl">
|
||||||
]" :style="{ width: width !== 'auto' ? width : undefined }">
|
<!-- Render your items based on activeMenu.content -->
|
||||||
<template v-for="(item, index) in items" :key="index">
|
<button v-for="item in currentItems" @click="item.onClick(); closeDropdown()" :disabled="item.disabled"
|
||||||
<div v-if="item.divider" class="h-px bg-[var(--color-highlight)] my-1" />
|
class="disabled:opacity-50 disabled:cursor-not-allowed items-center gap-1 text-left px-3 py-1.5 text-sm hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||||
<button @click="select(item); item.onClick && item.onClick()" :disabled="item.disabled"
|
:class="{ 'text-red-600 hover:bg-red-600/20': item.danger, 'text-primary bg-[var(--color-hover)]': item.active }">
|
||||||
class="bg-transparent w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-left"
|
<Icon v-if="item.icon" :name="item.icon" class="w-4 h-4 flex-shrink-0" />
|
||||||
:class="[
|
<span class="text-sm whitespace-nowrap text-ellipsis max-w-full overflow-hidden">{{ item.label
|
||||||
item.disabled
|
}}</span>
|
||||||
? 'opacity-50 cursor-not-allowed'
|
</button>
|
||||||
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] cursor-pointer'
|
|
||||||
]">
|
|
||||||
<Icon v-if="item.icon" :name="item.icon" class="w-4 h-4 flex-shrink-0" />
|
|
||||||
<span class="text-sm whitespace-nowrap">{{ item.label }}</span>
|
|
||||||
<slot name="item-after" :item="item"></slot>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<slot name="content" :toggle="toggle"></slot>
|
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
size?: string | number;
|
||||||
|
color?: boolean;
|
||||||
|
avatar?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const TITLE = 'Cerebras';
|
||||||
|
|
||||||
|
const BACKGROUND_COLOR = "#F15A29";
|
||||||
|
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;"
|
||||||
|
: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' : ''" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -14,7 +14,8 @@ const AVATAR_SCALE = 0.75;
|
|||||||
<template>
|
<template>
|
||||||
<div class="inline-flex items-center justify-center"
|
<div class="inline-flex items-center justify-center"
|
||||||
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
|
:style="[`width: ${size}px; height: ${size}px;`, avatar ? `background-color: ${BACKGROUND_COLOR}; border-radius: 0.375rem;` : '']">
|
||||||
<svg :fill="avatar ? 'currentColor' : ''" fill-rule="evenodd" :height="size" style="flex: none; line-height: 1;"
|
<svg :fill="avatar || !color ? 'currentColor' : ''" fill-rule="evenodd" :height="size"
|
||||||
|
style="flex: none; line-height: 1;"
|
||||||
:style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
|
:style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
|
||||||
viewBox="0 0 24 24" :width="size" xmlns="http://www.w3.org/2000/svg">
|
viewBox="0 0 24 24" :width="size" xmlns="http://www.w3.org/2000/svg">
|
||||||
<title>{{ TITLE }}</title>
|
<title>{{ TITLE }}</title>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
size?: string | number;
|
||||||
|
color?: boolean;
|
||||||
|
avatar?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const TITLE = 'Ollama';
|
||||||
|
const AVATAR_SCALE = 0.75;
|
||||||
|
|
||||||
|
const BACKGROUND_COLOR = "#fff";
|
||||||
|
</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 :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">
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { h, resolveComponent } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps<{ node: any }>();
|
|
||||||
|
|
||||||
const render = () => {
|
|
||||||
const { node } = props;
|
|
||||||
|
|
||||||
if (node.type === 'text' || node.type === 'raw') return node.value;
|
|
||||||
|
|
||||||
if (node.type === 'element') {
|
|
||||||
if (node.tagName === 'code') {
|
|
||||||
const isBlock = node.position?.start.line !== node.position?.end.line;
|
|
||||||
if (isBlock && node.children?.[0]?.type === 'text') {
|
|
||||||
return h(resolveComponent('MarkdownShikiHighlight'), {
|
|
||||||
code: node.children[0].value,
|
|
||||||
lang: node.properties?.className?.[0]?.replace('language-', '') || 'text'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.tagName === 'table') {
|
|
||||||
return h('div', { class: 'table-wrapper' }, [
|
|
||||||
h(
|
|
||||||
'table',
|
|
||||||
node.properties,
|
|
||||||
node.children?.map((child: any, index: number) =>
|
|
||||||
h(resolveComponent('MarkdownAstNode'), {
|
|
||||||
node: child,
|
|
||||||
key: `table-child-${index}`
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return h(
|
|
||||||
node.tagName,
|
|
||||||
node.properties,
|
|
||||||
node.children?.map((child: any, index: number) =>
|
|
||||||
h(resolveComponent('MarkdownAstNode'), {
|
|
||||||
node: child,
|
|
||||||
key: `${node.tagName}-${index}`
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<component :is="render" />
|
|
||||||
</template>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { RootContent } from 'hast';
|
import { h, Text, computed } from 'vue';
|
||||||
import MarkdownShikiHighlight from './ShikiHighlight.vue';
|
import MarkdownShikiHighlight from './ShikiHighlight.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -10,292 +10,42 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const { $remark } = useNuxtApp();
|
const { $remark } = useNuxtApp();
|
||||||
|
|
||||||
function splitMarkdown(markdown: string): string[] {
|
const ast = computed(() => {
|
||||||
const paragraphs: string[] = [];
|
const mdast = $remark.parse(props.content);
|
||||||
let currentParagraph = "";
|
return $remark.runSync(mdast);
|
||||||
let isInCodeBlock = false;
|
|
||||||
|
|
||||||
const lines = markdown.split("\n");
|
|
||||||
|
|
||||||
for (let line of lines) {
|
|
||||||
if (line.trim().startsWith("```")) {
|
|
||||||
isInCodeBlock = !isInCodeBlock;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.trim() === "" && !isInCodeBlock) {
|
|
||||||
if (currentParagraph.trim() !== "") {
|
|
||||||
paragraphs.push(currentParagraph.trim());
|
|
||||||
currentParagraph = "";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
currentParagraph += (currentParagraph === "" ? "" : "\n") + line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentParagraph.trim() !== "") {
|
|
||||||
paragraphs.push(currentParagraph.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
return paragraphs;
|
|
||||||
}
|
|
||||||
|
|
||||||
const partseAst = async (content: string) => {
|
|
||||||
const mdast = $remark.parse(content);
|
|
||||||
const hast = $remark.run(mdast);
|
|
||||||
return hast;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSR Initial Load
|
|
||||||
const { data: hastParts } = await useAsyncData(`md-${props.id}`, async () => {
|
|
||||||
return (await partseAst(props.content)).children;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let activeIdx = 0;
|
const renderNode = (node: any, index: number): any => {
|
||||||
let partIdx = [0];
|
if (node.type === 'text' || node.type === 'raw') return h(Text, node.value);
|
||||||
|
|
||||||
if (import.meta.client && hastParts.value && hastParts.value.length > 0 && !props.finished) {
|
if (node.type === 'element') {
|
||||||
const initialParts = splitMarkdown(props.content);
|
if (node.tagName === 'code') {
|
||||||
|
const isBlock = node.position?.start.line !== node.position?.end.line;
|
||||||
|
if (isBlock && node.children?.[0]?.type === 'text') {
|
||||||
|
return h(MarkdownShikiHighlight, {
|
||||||
|
key: `code-${index}`,
|
||||||
|
code: node.children[0].value,
|
||||||
|
language: node.properties?.className?.[0]?.replace('language-', '') || 'text'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
activeIdx = Math.max(0, initialParts.length - 1);
|
const children = node.children?.map((child: any, i: number) => renderNode(child, i)) || [];
|
||||||
|
|
||||||
let currentOffset = 0;
|
return h(
|
||||||
for (let i = 0; i < initialParts.length; i++) {
|
node.tagName,
|
||||||
partIdx[i] = currentOffset;
|
{ ...node.properties, key: `${node.tagName}-${index}` },
|
||||||
|
children
|
||||||
const tempAst = await partseAst(initialParts[i]!);
|
);
|
||||||
currentOffset += tempAst.children.length;
|
|
||||||
}
|
}
|
||||||
}
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineRender(() => {
|
||||||
|
const children = ast.value?.children?.flatMap(renderNode) || [];
|
||||||
|
|
||||||
const parts = computed(() => {
|
return h('div', { class: 'prose-wrapper' }, [
|
||||||
return splitMarkdown(props.content);
|
h('article', { class: 'markdown-body' }, children)
|
||||||
})
|
]);
|
||||||
|
|
||||||
watch(parts, async (newParts) => {
|
|
||||||
if (!hastParts.value) hastParts.value = [];
|
|
||||||
|
|
||||||
while (activeIdx < newParts.length - 1) {
|
|
||||||
const finalHast = await partseAst(newParts[activeIdx]!);
|
|
||||||
|
|
||||||
const base: RootContent[] = hastParts.value!.slice(0, partIdx[activeIdx]);
|
|
||||||
hastParts.value = base.concat(finalHast.children);
|
|
||||||
|
|
||||||
partIdx[activeIdx + 1] = hastParts.value!.length;
|
|
||||||
activeIdx++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentString = newParts[activeIdx];
|
|
||||||
if (currentString !== undefined) {
|
|
||||||
const latestHast = await partseAst(currentString);
|
|
||||||
|
|
||||||
const stableBase = hastParts.value!.slice(0, partIdx[activeIdx]);
|
|
||||||
hastParts.value = stableBase.concat(latestHast.children);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="prose-wrapper">
|
|
||||||
<article class="markdown-body">
|
|
||||||
<template v-for="(node, index) in hastParts" :key="`${id}-${index}`">
|
|
||||||
<MarkdownAstNode :node="node" v-memo="[node.type === 'text' ? node.value : node.data]" />
|
|
||||||
</template>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
article>* {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
article>*:first-child {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
article>*:last-child {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
article>*:only-child {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
border: 1px solid var(--color-highlight-high);
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
min-height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
margin-left: 1.25rem;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul>li {
|
|
||||||
position: relative;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul>li::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0.5rem;
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
background-color: var(--color-muted);
|
|
||||||
border-radius: 50%;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul>li::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 3px;
|
|
||||||
top: 23px;
|
|
||||||
bottom: 0;
|
|
||||||
width: 1px;
|
|
||||||
background-color: var(--color-highlight);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul>li:last-child::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol {
|
|
||||||
list-style: none;
|
|
||||||
margin-left: 1.25rem;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
counter-reset: ordered-list-counter var(--start-value, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
ol[start] {
|
|
||||||
--start-value: calc(attr(start type(<number>)) - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
ol>li {
|
|
||||||
position: relative;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
counter-increment: ordered-list-counter;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol>li::before {
|
|
||||||
content: counter(ordered-list-counter) ".";
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
color: var(--color-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
width: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark .shiki,
|
|
||||||
html.dark .shiki span {
|
|
||||||
color: var(--shiki-dark) !important;
|
|
||||||
background-color: var(--shiki-dark-bg) !important;
|
|
||||||
/* Optional, if you also want font styles */
|
|
||||||
font-style: var(--shiki-dark-font-style) !important;
|
|
||||||
font-weight: var(--shiki-dark-font-weight) !important;
|
|
||||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol:only-child,
|
|
||||||
ul:only-child {
|
|
||||||
margin-top: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
code:not(pre code) {
|
|
||||||
background-color: var(--color-highlight);
|
|
||||||
padding: 0.125rem 0.25rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
color: var(--color-muted);
|
|
||||||
border-left: 4px solid var(--color-highlight-high);
|
|
||||||
padding-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrapper {
|
|
||||||
display: block;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: calc(var(--spacing) * 4) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TODO: make these tables better, this is literally the first attempt from Gemini 3 flash */
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
text-align: left;
|
|
||||||
background-color: var(--color-base);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
table thead tr {
|
|
||||||
background-color: var(--color-highlight-high);
|
|
||||||
}
|
|
||||||
|
|
||||||
table th {
|
|
||||||
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
table td {
|
|
||||||
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
table tbody tr {
|
|
||||||
background-color: var(--color-highlight);
|
|
||||||
transition: background-color 250ms cubic-bezier(0.5, 1, 0.89, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
table tbody tr:nth-of-type(even) {
|
|
||||||
background-color: var(--color-highlight-low);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover effect */
|
|
||||||
table tbody tr:hover {
|
|
||||||
background-color: var(--color-highlight-high);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
label>span {
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox {
|
|
||||||
width: min-content;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { type Grammar } from 'shiki';
|
||||||
import { hashSync } from '~/utils/hash';
|
import { hashSync } from '~/utils/hash';
|
||||||
const props = defineProps<{ code: string; lang: string }>();
|
const props = defineProps<{ code: string; language: string }>();
|
||||||
|
|
||||||
const renderId = hashSync(props.code + props.lang);
|
const start = Date.now();
|
||||||
const codeBlockRef = ref<HTMLDivElement | null>(null);
|
|
||||||
const codeHeight: Ref<string | number> = ref('auto');
|
const renderId = `${useId()}-${hashSync(props.code + props.language)}`;
|
||||||
const copied = ref(false);
|
const copied = ref(false);
|
||||||
const collapsed = ref(false);
|
const collapsed = ref(false);
|
||||||
|
|
||||||
const { data: parsed } = useAsyncData(`shiki-${renderId}`, async () => {
|
const { $shiki } = useNuxtApp();
|
||||||
const { html, displayLang } = await parseCode();
|
|
||||||
return { html, displayLang };
|
const { data: parsed, clear } = await useAsyncData(`shiki-${renderId}`,
|
||||||
});
|
() => parseCode(props.code, props.language.toLowerCase()),
|
||||||
|
{
|
||||||
|
watch: [() => props.code],
|
||||||
|
dedupe: 'defer'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const lineNumberWidth = computed(() => {
|
const lineNumberWidth = computed(() => {
|
||||||
if (!parsed.value?.html) return 1;
|
if (!parsed.value?.html) return 1;
|
||||||
// Count newlines in the generated HTML or the source code
|
// Count newlines in the generated HTML or the source code
|
||||||
@@ -19,25 +26,18 @@ const lineNumberWidth = computed(() => {
|
|||||||
return props.code.split('\n').length.toString().length;
|
return props.code.split('\n').length.toString().length;
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => props.code, async () => {
|
async function parseCode(code: string, lang: string) {
|
||||||
|
|
||||||
const { html: codeHtml } = await parseCode();
|
|
||||||
parsed.value = { html: codeHtml, displayLang: parsed.value!.displayLang };
|
|
||||||
});
|
|
||||||
|
|
||||||
async function parseCode() {
|
|
||||||
const shiki = await getShikiHighlighter();
|
|
||||||
let lang = props.lang.toLowerCase();
|
|
||||||
let displayLang = lang;
|
let displayLang = lang;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shikiLang = shiki.getLanguage(lang);
|
let shikiLang = await $shiki.getLanguage(lang);
|
||||||
displayLang = shikiLang.name;
|
|
||||||
|
displayLang = (shikiLang as unknown as Grammar).name;
|
||||||
} catch {
|
} catch {
|
||||||
lang = 'text';
|
lang = 'text';
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = shiki.codeToHtml(props.code.trim(), {
|
let html = await $shiki.codeToHtml(code.trim(), {
|
||||||
lang,
|
lang,
|
||||||
themes: { dark: 'vitesse-dark', light: 'vitesse-light' },
|
themes: { dark: 'vitesse-dark', light: 'vitesse-light' },
|
||||||
});
|
});
|
||||||
@@ -57,81 +57,47 @@ function copyCode() {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseCode() {
|
|
||||||
if (!codeBlockRef.value) return;
|
|
||||||
|
|
||||||
collapsed.value = !collapsed.value;
|
|
||||||
if (collapsed.value) {
|
|
||||||
codeHeight.value = codeBlockRef.value.scrollHeight;
|
|
||||||
nextTick(() => {
|
|
||||||
// since we are changing the height of an element, even though its
|
|
||||||
// to its own height, we are triggering a reflow, which means that
|
|
||||||
// if we didnt use requestAnimationFrame, the height would be set
|
|
||||||
// to 0 before the reflow is complete, which would cause the reflow
|
|
||||||
// to be ignored, and another one to be triggered with the new height
|
|
||||||
// of zero, causing the codeblock to snap shut immediately rather than
|
|
||||||
// animating. Not requestAnimationFrame because it doesnt work
|
|
||||||
// on firefox, but setTimeout works on both chrome and firefox
|
|
||||||
setTimeout(() => {
|
|
||||||
codeHeight.value = 0;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
codeBlockRef.value!.addEventListener('transitionend', () => {
|
|
||||||
if (collapsed.value) return;
|
|
||||||
codeHeight.value = 'auto';
|
|
||||||
}, { once: true })
|
|
||||||
|
|
||||||
const targetHeight = codeBlockRef.value.scrollHeight;
|
|
||||||
codeHeight.value = targetHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeStyle = computed(() => {
|
|
||||||
if (typeof codeHeight.value === 'number') {
|
|
||||||
return `height: ${codeHeight.value}px;`;
|
|
||||||
} else {
|
|
||||||
return `height: ${codeHeight.value};`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (copyTimeout) clearTimeout(copyTimeout);
|
if (copyTimeout) clearTimeout(copyTimeout);
|
||||||
|
clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("shiki codeblock rendered in", Date.now() - start);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col my-2 rounded-xl overflow-hidden">
|
<div class="flex flex-col my-2 rounded-xl overflow-hidden">
|
||||||
<div class="flex items-center pl-3 pr-1.5 py-1.5 text-sm font-sans bg-[var(--color-highlight)] justify-between">
|
<div class="flex items-center pl-3 pr-1.5 py-1.5 text-sm font-sans bg-[var(--color-hover)] justify-between">
|
||||||
<div class="capitalize">
|
<div class="capitalize">
|
||||||
{{ parsed?.displayLang }}
|
{{ parsed?.displayLang }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button @click="copyCode()"
|
<button @click="copyCode()"
|
||||||
class="flex items-center px-1 gap-0.5 rounded-md hover:bg-[var(--color-highlight)] 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)]">
|
||||||
Copy
|
Copy
|
||||||
<Icon v-if="!copied" name="mynaui:copy" class="text-4 text-[var(--color-text-subtle)]" />
|
<Icon v-if="!copied" name="mynaui:copy" class="text-4 text-[var(--text-secondary)]" />
|
||||||
<Icon v-else name="mynaui:check" class="text-4 text-emerald-500" />
|
<Icon v-else name="mynaui:check" class="text-4 text-emerald-500" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button @click="collapseCode()"
|
<button @click="collapsed = !collapsed"
|
||||||
class="flex items-center justify-center h-5.5 w-5.5 rounded-md hover:bg-[var(--color-highlight)] 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)]">
|
||||||
<Icon name="mynaui:chevron-down"
|
<Icon name="mynaui:chevron-down"
|
||||||
:class="['text-4 h-4 w-4 text-[var(--color-text-subtle)] transition-transform duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', collapsed ? '-rotate-90' : '']" />
|
:class="['text-4 h-4 w-4 text-[var(--text-secondary)] transition-transform duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', collapsed ? '-rotate-90' : '']" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="codeBlockRef"
|
<div class="grid transition-all duration-350 ease-in-out"
|
||||||
class="font-mono overflow-hidden code-container transition-height duration-300 ease-in-out"
|
:class="collapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'"
|
||||||
:style="`--line-number-width: ${lineNumberWidth}ch; ${codeStyle}`" :id="`code-${renderId}`"
|
:style="`--line-number-width: ${lineNumberWidth}ch;`" :id="`code-${renderId}`">
|
||||||
v-html="parsed?.html">
|
<div class="overflow-hidden code-container" v-html="parsed?.html"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.code-container>pre {
|
.code-container>pre {
|
||||||
overflow-x: auto;
|
overflow: auto hidden;
|
||||||
|
min-height: 0;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ if (props.error !== null && props.error !== undefined) {
|
|||||||
<template>
|
<template>
|
||||||
<span class="text-sm text-[var(--color-error)]">Generation failed</span>
|
<span class="text-sm text-[var(--color-error)]">Generation failed</span>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<ShikiHighlight :code="code ?? 'An unknown error occurred'" lang="json" />
|
<ShikiHighlight :code="code ?? 'An unknown error occurred'" language="json" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -51,35 +51,25 @@ const toggleReasoning = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button @click="toggleReasoning" :class="[
|
<div class="text-[--text-tertiary]">
|
||||||
'w-full hover:bg-[var(--color-highlight)] rounded-lg p-1 flex justify-between items-center text-[--color-reasoning] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
|
<button @click="toggleReasoning" :class="[
|
||||||
part.finished ? '' : 'cursor-default'
|
'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'
|
||||||
<div class="flex items-center gap-1">
|
]">
|
||||||
<div
|
<div class="flex items-center gap-1">
|
||||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center">
|
<div
|
||||||
<Icon name="mynaui:atom" class="w-3 h-3 text-[var(--reasoning-accent)]" />
|
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
|
||||||
|
<Icon name="mynaui:atom" class="w-3 h-3 text-[var(--reasoning-accent)]" />
|
||||||
|
</div>
|
||||||
|
Deep Thinking
|
||||||
|
</div>
|
||||||
|
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', reasoningOpen ? '' : '-rotate-90']" />
|
||||||
|
</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]">
|
||||||
|
<div class="p-2">
|
||||||
|
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" />
|
||||||
</div>
|
</div>
|
||||||
Deep Thinking
|
|
||||||
</div>
|
</div>
|
||||||
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', reasoningOpen ? '' : '-rotate-90']" />
|
|
||||||
</button>
|
|
||||||
<div v-if="reasoningOpen" ref="containerRef" @scroll="handleScroll"
|
|
||||||
:class="['reasoning-contaizner p-2 text-[--color-reasoning] max-h-[min(40vh,320px)] overflow-y-auto', scrollState]">
|
|
||||||
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
.reasoning-contaizner.middle {
|
|
||||||
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent)
|
|
||||||
}
|
|
||||||
|
|
||||||
.reasoning-contaizner.top {
|
|
||||||
mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent)
|
|
||||||
}
|
|
||||||
|
|
||||||
.reasoning-contaizner.bottom {
|
|
||||||
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000)
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -19,7 +19,7 @@ const indicatorStyle = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const shiki = await getShikiHighlighter();
|
const { $shiki } = useNuxtApp();
|
||||||
|
|
||||||
const html = ref('');
|
const html = ref('');
|
||||||
const lineNumberWidth = ref(1);
|
const lineNumberWidth = ref(1);
|
||||||
@@ -87,15 +87,15 @@ const input: ComputedRef<string> = computed(() => {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
input,
|
input,
|
||||||
(newCode) => {
|
async (newCode) => {
|
||||||
let lang = 'json';
|
let lang = 'json';
|
||||||
try {
|
try {
|
||||||
shiki.getLanguage(lang);
|
$shiki.getLanguage(lang);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
lang = 'text';
|
lang = 'text';
|
||||||
}
|
}
|
||||||
|
|
||||||
html.value = shiki.codeToHtml(newCode, {
|
html.value = await $shiki.codeToHtml(newCode, {
|
||||||
lang,
|
lang,
|
||||||
themes: {
|
themes: {
|
||||||
dark: 'vitesse-dark',
|
dark: 'vitesse-dark',
|
||||||
@@ -109,8 +109,8 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full border rounded-lg border-[var(--color-highlight)] flex flex-row h-80 overflow-hidden">
|
<div class="w-full border rounded-lg border-[var(--color-border)] flex flex-row h-80 overflow-hidden">
|
||||||
<div class="flex items-center gap-2 flex-col border-r border-[var(--color-highlight)] p-1 relative shrink-0">
|
<div class="flex items-center gap-2 flex-col border-r border-[var(--color-border)] p-1 relative shrink-0">
|
||||||
<button @click="activeTab = 'input'" :class="[
|
<button @click="activeTab = 'input'" :class="[
|
||||||
'function-call-tab-selector',
|
'function-call-tab-selector',
|
||||||
activeTab === 'input' ? 'text-orange-6' : ''
|
activeTab === 'input' ? 'text-orange-6' : ''
|
||||||
@@ -207,7 +207,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.function-call-tab-selector:hover:enabled {
|
.function-call-tab-selector:hover:enabled {
|
||||||
background-color: var(--color-highlight);
|
background-color: var(--color-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-call-tab-selector:disabled {
|
.function-call-tab-selector:disabled {
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ const toggleDebugToolCall = () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div
|
<div
|
||||||
class="select-none w-full hover:bg-[var(--color-highlight)] group rounded-lg p-1 flex justify-between items-center text-[--color-reasoning] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
class="select-none w-full hover:bg-[var(--color-hover)] group rounded-lg p-1 flex justify-between items-center text-[--text-tertiary] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||||
<div class="flex items-center justify-between w-full">
|
<div class="flex items-center justify-between w-full">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div
|
<div
|
||||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center">
|
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
|
||||||
<!-- Explicityly avoid setting the name via a reactive value, otherwise you risk corrupting the icon with that name globally -->
|
<!-- Explicityly avoid setting the name via a reactive value, otherwise you risk corrupting the icon with that name globally -->
|
||||||
<Icon v-if="toolCall.status === 'pending'" name="svg-spinners:180-ring-with-bg"
|
<Icon v-if="toolCall.status === 'pending'" name="svg-spinners:180-ring-with-bg"
|
||||||
class="w-3 h-3 text-[var(--color-subtle)]" />
|
class="w-3 h-3 text-[var(--text-secondary)]" />
|
||||||
<Icon v-else-if="toolCall.status === 'failed'" name="mynaui:x-solid"
|
<Icon v-else-if="toolCall.status === 'failed'" name="mynaui:x-solid"
|
||||||
class="w-3 h-3 text-[#ff3b3b]" />
|
class="w-3 h-3 text-[#ff3b3b]" />
|
||||||
<Icon v-else name="mynaui:tool" class="w-3 h-3 text-[var(--color-subtle)]" />
|
<Icon v-else name="mynaui:tool" class="w-3 h-3 text-[var(--text-secondary)]" />
|
||||||
</div>
|
</div>
|
||||||
{{ toolCall.toolName }}
|
{{ toolCall.toolName }}
|
||||||
</div>
|
</div>
|
||||||
@@ -35,8 +35,8 @@ const toggleDebugToolCall = () => {
|
|||||||
<div
|
<div
|
||||||
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||||
<button @click="toggleDebugToolCall"
|
<button @click="toggleDebugToolCall"
|
||||||
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden hover:bg-[var(--color-highlight)] 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)]">
|
||||||
<Icon name="mynaui:search" class="w-3 h-3 text-[var(--color-subtle)]" />
|
<Icon name="mynaui:search" class="w-3 h-3 text-[var(--text-secondary)]" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ defineProps<{
|
|||||||
<span class="flex items-center gap-1"
|
<span class="flex items-center gap-1"
|
||||||
v-if="message.generation?.status === 'pending' && (message.parts || []).length === 0">
|
v-if="message.generation?.status === 'pending' && (message.parts || []).length === 0">
|
||||||
<Icon name="svg-spinners:pulse-2" class="text-4" />
|
<Icon name="svg-spinners:pulse-2" class="text-4" />
|
||||||
<span class="text-sm text-[var(--color-muted)]">
|
<span class="text-sm text-[var(--text-secondary)]">
|
||||||
Preparing generating...
|
Preparing generating...
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -34,7 +34,7 @@ defineProps<{
|
|||||||
<Error :error="message.generation.error" />
|
<Error :error="message.generation.error" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-row justify-between text-zinc-400 dark:text-zinc-600 text-xs"
|
<div class="flex flex-row justify-between text-[var(--text-tertiary)] text-xs"
|
||||||
v-if="message.generation && message.generation.status !== 'pending'">
|
v-if="message.generation && message.generation.status !== 'pending'">
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<ModelIcon :size="12" :model-id="message.generation.modelId" />
|
<ModelIcon :size="12" :model-id="message.generation.modelId" />
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MarkdownRenderer :finished="true" class="max-w-full bg-[var(--color-highlight)] py-2 px-3 rounded-xl"
|
<MarkdownRenderer :finished="true" class="max-w-full bg-[var(--bg-container)] py-2 px-3 rounded-xl"
|
||||||
:content="message.content!" :id="message.id" />
|
:content="message.content!" :id="message.id" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ const messageCount = computed(() => {
|
|||||||
<button :inert="focusedIndex === 0" @click="focusedIndex = focusedIndex! - 1">
|
<button :inert="focusedIndex === 0" @click="focusedIndex = focusedIndex! - 1">
|
||||||
<Icon name="mynaui:chevron-left" :class="['w-4 h-4', focusedIndex === 0 ? 'opacity-0' : '']" />
|
<Icon name="mynaui:chevron-left" :class="['w-4 h-4', focusedIndex === 0 ? 'opacity-0' : '']" />
|
||||||
</button>
|
</button>
|
||||||
<span class="text-xs text-[var(--color-muted)]">
|
<span class="text-xs text-[var(--text-secondary)]">
|
||||||
{{ focusedIndex + 1 }} / {{ messageCount }}
|
{{ focusedIndex + 1 }} / {{ messageCount }}
|
||||||
</span>
|
</span>
|
||||||
<button :inert="focusedIndex + 1 === messageCount" @click="focusedIndex = focusedIndex + 1">
|
<button :inert="focusedIndex + 1 === messageCount" @click="focusedIndex = focusedIndex + 1">
|
||||||
@@ -129,18 +129,18 @@ const messageCount = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="activeMessage.generation?.status === 'pending' ? 'opacity-0!' : ''"
|
<div v-show="activeMessage.generation?.status !== 'pending'"
|
||||||
class="self-end mt-1 w-fit bg-[var(--color-highlight)] text-[var(--color-subtle)] gap-px flex items-center rounded-md overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
class="self-end mt-1 w-fit bg-[var(--bg-container)] text-[var(--text-secondary)] gap-px flex items-center rounded-md overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||||
<button @click="regenerateMessage"
|
<button @click="regenerateMessage"
|
||||||
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-highlight)]">
|
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]">
|
||||||
<Icon name="mynaui:refresh" class="text-4.5" />
|
<Icon name="mynaui:refresh" class="text-4.5" />
|
||||||
</button>
|
</button>
|
||||||
<button @click="copyMessage" :class="{ 'text-emerald-500': copied }"
|
<button @click="copyMessage" :class="{ 'text-emerald-500': copied }"
|
||||||
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-highlight)]">
|
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]">
|
||||||
<Icon :name="copied ? 'mynaui:check' : 'mynaui:copy'" class="text-4.5" />
|
<Icon :name="copied ? 'mynaui:check' : 'mynaui:copy'" class="text-4.5" />
|
||||||
</button>
|
</button>
|
||||||
<button @click="deleteMessage"
|
<button @click="deleteMessage"
|
||||||
class="flex justify-center items-center w-7 h-6 text-red-500 hover:bg-[var(--color-highlight)]">
|
class="flex justify-center items-center w-7 h-6 text-red-500 hover:bg-[var(--color-hover)]">
|
||||||
<Icon name="mynaui:trash" class="text-5" />
|
<Icon name="mynaui:trash" class="text-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ const config = computed(() => getModelConfig(props.modelId));
|
|||||||
v-bind="config.props" />
|
v-bind="config.props" />
|
||||||
<!-- Fallback if no logo matches -->
|
<!-- Fallback if no logo matches -->
|
||||||
<div v-else :style="{ width: `${props.size}px`, height: `${props.size}px` }"
|
<div v-else :style="{ width: `${props.size}px`, height: `${props.size}px` }"
|
||||||
class="bg-[var(--color-highlight)] rounded-md flex items-center justify-center">
|
class="bg-[var(--bg-container)] rounded-md flex items-center justify-center">
|
||||||
<Icon name="tabler:brain" class="text-5 text-[var(--color-muted)]" />
|
<Icon name="tabler:brain" class="text-5 text-[var(--text-secondary)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -19,8 +19,6 @@ const props = withDefaults(defineProps<{
|
|||||||
showEdit: false,
|
showEdit: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { Big } = await import('big.js');
|
|
||||||
|
|
||||||
const triplit = useTriplitClient();
|
const triplit = useTriplitClient();
|
||||||
|
|
||||||
const formatContextWindow = (window: number | null | undefined): string => {
|
const formatContextWindow = (window: number | null | undefined): string => {
|
||||||
@@ -38,15 +36,6 @@ const hasInputModality = (modality: string): boolean => {
|
|||||||
return (props.model.attributes.inputModalities as Readonly<Set<string>>).has(modality);
|
return (props.model.attributes.inputModalities as Readonly<Set<string>>).has(modality);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatBig = (bigValue: Big) => {
|
|
||||||
let str = bigValue.toString();
|
|
||||||
|
|
||||||
if (!str.includes('.')) return str + '.00';
|
|
||||||
if (str.split('.')[1]!.length === 1) return str + '0';
|
|
||||||
|
|
||||||
return str;
|
|
||||||
};
|
|
||||||
|
|
||||||
const showCost = computed(() => {
|
const showCost = computed(() => {
|
||||||
return props.showCost && (props.model.cost.prompt || props.model.cost.completion || props.model.cost.request);
|
return props.showCost && (props.model.cost.prompt || props.model.cost.completion || props.model.cost.request);
|
||||||
});
|
});
|
||||||
@@ -68,16 +57,16 @@ const deleteModel = async () => {
|
|||||||
<ModelIcon :class="{
|
<ModelIcon :class="{
|
||||||
'rounded-lg overflow-hidden': size === 'large',
|
'rounded-lg overflow-hidden': size === 'large',
|
||||||
'rounded-md overflow-hidden': size === 'medium' || size === 'small',
|
'rounded-md overflow-hidden': size === 'medium' || size === 'small',
|
||||||
}" :avatar="true" variant="color" :model-id="model.externalId"
|
}" :avatar="true" variant="color" :key="model.externalId" :model-id="model.externalId"
|
||||||
:size="size === 'small' ? '20' : size === 'medium' ? '26' : '32'" />
|
:size="size === 'small' ? '20' : size === 'medium' ? '26' : '32'" />
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 min-w-0 flex-1">
|
<div class="flex flex-col gap-1 min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-1 min-w-0">
|
<div class="flex items-center gap-1 min-w-0">
|
||||||
<span class="text-[15px] font-medium text-[var(--color-text)] truncate min-w-0">
|
<span class="text-[15px] font-medium text-[var(--text-primary)] truncate min-w-0">
|
||||||
{{ model.name }}
|
{{ model.name }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="showExternalId"
|
<span v-if="showExternalId" :title="model.externalId"
|
||||||
class="text-xs text-[var(--color-muted)] px-1 py-0.5 rounded bg-[var(--color-highlight)] whitespace-nowrap truncate max-w-[240px]">
|
class="text-xs text-[var(--text-secondary)] px-1 py-0.5 rounded bg-[var(--bg-container)] whitespace-nowrap truncate max-w-[240px]">
|
||||||
{{ model.externalId }}
|
{{ model.externalId }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="showEdit" class="flex items-center gap-2">
|
<div v-if="showEdit" class="flex items-center gap-2">
|
||||||
@@ -88,25 +77,22 @@ const deleteModel = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="details" class="flex items-center gap-1.5 flex-wrap">
|
<div v-if="details" class="flex items-center gap-1.5 flex-wrap text-[var(--text-tertiary)]">
|
||||||
<span v-if="showReleaseDate && model.releasedAt"
|
<span v-if="showReleaseDate && model.releasedAt" class="text-xs whitespace-nowrap">
|
||||||
class="text-xs text-[var(--color-muted)] whitespace-nowrap">
|
|
||||||
Released on {{ model.releasedAt.toISOString().split('T')[0] }}
|
Released on {{ model.releasedAt.toISOString().split('T')[0] }}
|
||||||
</span>
|
</span>
|
||||||
<template v-if="showCost">
|
<template v-if="showCost">
|
||||||
<span v-if="model.cost.prompt"
|
<span v-if="model.cost.prompt" class="text-xs whitespace-nowrap flex items-center">
|
||||||
class="text-xs text-[var(--color-muted)] whitespace-nowrap flex items-center">
|
<span class="w-1 h-1 rounded-full bg-[var(--text-secondary)] inline-block mr-1"></span>
|
||||||
<span class="w-1 h-1 rounded-full bg-[var(--color-muted)] inline-block mr-1"></span>
|
${{ model.cost.prompt }}/M input
|
||||||
${{ formatBig(Big(model.cost.prompt).mul(1000000)) }}/M input
|
|
||||||
</span>
|
</span>
|
||||||
<span v-if="model.cost.completion"
|
<span v-if="model.cost.completion" class="text-xs whitespace-nowrap flex items-center">
|
||||||
class="text-xs text-[var(--color-muted)] whitespace-nowrap flex items-center">
|
<span class="w-1 h-1 rounded-full bg-[var(--text-secondary)] inline-block mr-1"></span>
|
||||||
<span class="w-1 h-1 rounded-full bg-[var(--color-muted)] inline-block mr-1"></span>
|
${{ model.cost.completion }}/M output
|
||||||
${{ formatBig(Big(model.cost.completion).mul(1000000)) }}/M output
|
|
||||||
</span>
|
</span>
|
||||||
<span v-if="model.cost.request && model.cost.request !== '0'"
|
<span v-if="model.cost.request && model.cost.request !== '0'"
|
||||||
class="text-xs text-[var(--color-muted)] whitespace-nowrap flex items-center">
|
class="text-xs whitespace-nowrap flex items-center">
|
||||||
<span class="w-1 h-1 rounded-full bg-[var(--color-muted)] inline-block mr-1"></span>
|
<span class="w-1 h-1 rounded-full bg-[var(--text-secondary)] inline-block mr-1"></span>
|
||||||
{{ model.cost.request }}/request
|
{{ model.cost.request }}/request
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -131,7 +117,7 @@ const deleteModel = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span v-if="model.attributes.contextWindow"
|
<span v-if="model.attributes.contextWindow"
|
||||||
class="text-xs font-mono text-[var(--color-subtle)] px-1.5 py-0.5 rounded bg-[var(--color-highlight)]">
|
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.attributes.contextWindow) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, watch, nextTick } from 'vue';
|
||||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
|
||||||
import type { Entity } from '@triplit/client';
|
import type { Entity } from '@triplit/client';
|
||||||
import type schema from '#triplit/schema';
|
import type schema from '#triplit/schema';
|
||||||
|
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||||
|
import { sortByReleaseDate } from '~/utils/sort';
|
||||||
|
|
||||||
const { setPage } = useSettings();
|
const { setPage } = useSettings();
|
||||||
const { allModels } = await useModels();
|
const { allModels } = await useModels();
|
||||||
@@ -20,6 +21,9 @@ const searchInputRef = ref<HTMLInputElement | null>(null);
|
|||||||
const dropdownDirection = ref<'up' | 'down'>('up');
|
const dropdownDirection = ref<'up' | 'down'>('up');
|
||||||
const dropdownMaxHeight = ref<number | undefined>(undefined);
|
const dropdownMaxHeight = ref<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const navigatingWithKeyboard = ref(false);
|
||||||
|
const focusedOptionId = ref<string | null>(null);
|
||||||
|
|
||||||
const findContainer = (startingElement: HTMLElement): HTMLElement | null => {
|
const findContainer = (startingElement: HTMLElement): HTMLElement | null => {
|
||||||
let container: HTMLElement | null = startingElement;
|
let container: HTMLElement | null = startingElement;
|
||||||
while (container) {
|
while (container) {
|
||||||
@@ -58,9 +62,41 @@ const filteredProviders = computed(() => {
|
|||||||
return filterProvidersWithModel(props.providers, searchQuery.value).filter(p => p.enabled);
|
return filterProvidersWithModel(props.providers, searchQuery.value).filter(p => p.enabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const flatOptions = computed(() => {
|
||||||
|
const options: { id: string; model: Entity<typeof schema, 'models'>; provider: Entity<typeof schema, 'providers'> }[] = [];
|
||||||
|
for (const provider of filteredProviders.value) {
|
||||||
|
const enabledModels = provider.models.filter(m => m.enabled).sort((a, b) => b.releasedAt && a.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0);
|
||||||
|
for (const model of enabledModels) {
|
||||||
|
options.push({ id: model.id, model, provider });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
const focusedIndex = computed(() => {
|
||||||
|
if (!focusedOptionId.value) return -1;
|
||||||
|
return flatOptions.value.findIndex(o => o.id === focusedOptionId.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const setFocusToOption = (index: number) => {
|
||||||
|
if (flatOptions.value.length === 0) return;
|
||||||
|
navigatingWithKeyboard.value = true;
|
||||||
|
|
||||||
|
const clampedIndex = Math.max(0, Math.min(index, flatOptions.value.length - 1));
|
||||||
|
focusedOptionId.value = flatOptions.value[clampedIndex]!.id;
|
||||||
|
scrollFocusedIntoView();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollFocusedIntoView = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
const focusedEl = document.getElementById(`model-option-${focusedOptionId.value}`);
|
||||||
|
focusedEl?.scrollIntoView({ block: 'nearest' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const selectModel = (model: Entity<typeof schema, 'models'>, provider: Entity<typeof schema, 'providers'>) => {
|
const selectModel = (model: Entity<typeof schema, 'models'>, provider: Entity<typeof schema, 'providers'>) => {
|
||||||
selectedModel.value = { ...model, provider };
|
selectedModel.value = { ...model, provider };
|
||||||
isOpen.value = false;
|
closeDropdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(() => props.providers, () => {
|
watch(() => props.providers, () => {
|
||||||
@@ -89,34 +125,69 @@ watch(isOpen, (open) => {
|
|||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
searchInputRef.value?.focus();
|
searchInputRef.value?.focus();
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
focusedOptionId.value = null;
|
||||||
|
searchQuery.value = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleInputKeypress = (event: KeyboardEvent) => {
|
const closeDropdown = () => {
|
||||||
if (event.key === 'Enter') {
|
isOpen.value = false;
|
||||||
isOpen.value = false;
|
navigatingWithKeyboard.value = false;
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleSearchKeyDown = (event: KeyboardEvent) => {
|
||||||
// TODO: in the settings page, pressing escape closes BOTH
|
if (flatOptions.value.length === 0) return;
|
||||||
// the settings modal and the model selector dropdown
|
|
||||||
if (event.key === 'Escape') {
|
switch (event.key) {
|
||||||
isOpen.value = false;
|
case 'Escape':
|
||||||
|
closeDropdown();
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
if (focusedIndex.value === -1 || focusedIndex.value === flatOptions.value.length - 1) {
|
||||||
|
setFocusToOption(0);
|
||||||
|
} else {
|
||||||
|
setFocusToOption(focusedIndex.value + 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
if (focusedIndex.value === -1 || focusedIndex.value === 0) {
|
||||||
|
setFocusToOption(flatOptions.value.length - 1);
|
||||||
|
} else {
|
||||||
|
setFocusToOption(focusedIndex.value - 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
event.preventDefault();
|
||||||
|
setFocusToOption(0);
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
event.preventDefault();
|
||||||
|
setFocusToOption(flatOptions.value.length - 1);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
event.preventDefault();
|
||||||
|
console.log("enter", focusedOptionId.value, flatOptions.value[0]);
|
||||||
|
if (focusedOptionId.value) {
|
||||||
|
const option = flatOptions.value.find(o => o.id === focusedOptionId.value);
|
||||||
|
if (option) {
|
||||||
|
selectModel(option.model, option.provider);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flatOptions.value.length > 0) {
|
||||||
|
selectModel(flatOptions.value[0]!.model, flatOptions.value[0]!.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useClickOutside(dropdownRef, () => {
|
useClickOutside(dropdownRef, closeDropdown);
|
||||||
isOpen.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -125,8 +196,8 @@ onUnmounted(() => {
|
|||||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors duration-200"
|
class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors duration-200"
|
||||||
:class="[
|
:class="[
|
||||||
isOpen
|
isOpen
|
||||||
? 'bg-[var(--color-highlight)] text-[var(--color-text)]'
|
? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
|
||||||
: 'text-[var(--color-text-subtle)] hover:text-[var(--color-text)] hover:bg-[var(--color-highlight-low)]',
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--color-hover)]',
|
||||||
]">
|
]">
|
||||||
<ModelIcon v-if="selectedModel" class="text-white" :avatar="true" variant="color"
|
<ModelIcon v-if="selectedModel" class="text-white" :avatar="true" variant="color"
|
||||||
:model-id="selectedModel.externalId" size="22" />
|
:model-id="selectedModel.externalId" size="22" />
|
||||||
@@ -142,53 +213,58 @@ onUnmounted(() => {
|
|||||||
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
|
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||||
leave-active-class="transition-all duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
leave-active-class="transition-all 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">
|
leave-from-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 translate-y-1">
|
||||||
<div v-show="isOpen" ref="dropdownContentRef" :class="[
|
<div v-if="isOpen" ref="dropdownContentRef" role="listbox" aria-label="Select model"
|
||||||
dropdownDirection === 'up' ? 'bottom-full mb-2 origin-bottom' : 'top-full mt-2 origin-top',
|
:aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined" :class="[
|
||||||
]" :style="{ maxHeight: dropdownMaxHeight ? `${dropdownMaxHeight}px` : '460px', height: 'auto' }"
|
dropdownDirection === 'up' ? 'bottom-full mb-2 origin-bottom' : 'top-full mt-2 origin-top',
|
||||||
class="absolute left-0 max-w-[420px] w-full flex flex-col rounded-xl border border-[var(--color-highlight)] bg-[var(--color-neutral)] shadow-lg overflow-hidden z-50">
|
]" :style="{ maxHeight: dropdownMaxHeight ? `${dropdownMaxHeight}px` : '460px', height: 'auto' }"
|
||||||
|
class="absolute left-0 max-w-[420px] w-full flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden z-50">
|
||||||
<div>
|
<div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Icon name="mynaui:search"
|
<Icon name="mynaui:search"
|
||||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--color-text-subtle)]" />
|
class="absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--text-secondary)]" />
|
||||||
<input ref="searchInputRef" v-model="searchQuery" autocomplete="off" name="search"
|
<input ref="searchInputRef" v-model="searchQuery" autocomplete="off" name="search"
|
||||||
@keypress="handleInputKeypress" type="text" placeholder="Search models..."
|
@keydown="handleSearchKeyDown" type="text" placeholder="Search models..."
|
||||||
class="w-full pl-9 pr-3 py-2 text-sm text-[var(--color-text)] bg-transparent placeholder-[var(--color-text-subtle)] outline-none" />
|
class="placeholder:text-[var(--text-tertiary)] w-full pl-9 pr-3 py-2 text-sm text-[var(--text-primary)] bg-transparent placeholder-[var(--text-secondary)] outline-none" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto [scrollbar-width:thin] py-2 select-none max-w-full overflow-hidden">
|
<div class="flex-1 overflow-y-auto [scrollbar-width:thin] py-2 select-none max-w-full overflow-hidden">
|
||||||
<div v-if="filteredProviders.length === 0"
|
<div v-if="filteredProviders.length === 0"
|
||||||
class="px-4 py-8 text-center text-sm text-[var(--color-muted)]">
|
class="px-4 py-8 text-center text-sm text-[var(--text-secondary)]">
|
||||||
No models found
|
No models found
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="provider in filteredProviders" :key="provider.id" class="mb-2">
|
<div v-for="provider in filteredProviders" :key="provider.id" class="mb-2">
|
||||||
<div
|
<div v-if="provider.models.filter(m => m.enabled).length > 0"
|
||||||
class="px-4 py-1.5 text-[13px] font-medium text-[var(--color-muted)] capitalize tracking-wider flex justify-between">
|
class="px-4 py-1.5 text-[13px] font-medium text-[var(--text-secondary)] capitalize tracking-wider flex justify-between">
|
||||||
{{ provider.name }}
|
{{ provider.name }}
|
||||||
<button @click="isOpen = true; setPage('providers', provider.id)"
|
<button @click="isOpen = true; setPage('providers', provider.id)"
|
||||||
class="flex h-4.5 w-4.5 items-center justify-center hover:bg-[var(--color-highlight)] 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)]">
|
||||||
<Icon name="mynaui:cog-four" class="text-3.5" />
|
<Icon name="mynaui:cog-four" class="text-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button v-for="model in provider.models.filter(m => m.enabled).sort(sortByReleaseDate)"
|
||||||
v-for="model in provider.models.filter(m => m.enabled).sort((a, b) => b.releasedAt && a.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0)"
|
:key="model.id" :id="`model-option-${model.id}`" role="option"
|
||||||
:key="model.id" @click="selectModel(model, provider); isOpen = false"
|
:aria-selected="focusedOptionId === model.id"
|
||||||
class="text-white w-full min-h-9 px-4 py-2 flex items-center justify-between hover:bg-[var(--color-highlight-low)] transition-colors duration-150"
|
@click="selectModel(model, provider); isOpen = false"
|
||||||
:class="{ 'bg-[var(--color-highlight-low)]': selectedModel?.id === model.id }">
|
class="text-white w-full min-h-9 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 === model.id,
|
||||||
|
'ring-2 ring-inset ring-[var(--color-accent)]': focusedOptionId === model.id && navigatingWithKeyboard
|
||||||
|
}">
|
||||||
<ModelInfo :model="model" size="medium" />
|
<ModelInfo :model="model" size="medium" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-1 border-t border-[var(--color-highlight-low)]">
|
<div class="p-1 border-t border-[var(--color-border)]">
|
||||||
<button
|
<button
|
||||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--color-text-subtle)] hover:text-[var(--color-text)] hover:bg-[var(--color-highlight-low)] 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="isOpen = false; setPage('providers');">
|
@click="isOpen = false; setPage('providers');">
|
||||||
<Icon name="mynaui:cog-four" class="text-4" />
|
<Icon name="mynaui:cog-four" class="text-4.5" />
|
||||||
<span>Manage Providers</span>
|
<span>Manage Providers</span>
|
||||||
<Icon name="mynaui:arrow-right" class="text-3.5 ml-auto" />
|
<Icon name="mynaui:arrow-right" class="text-4 ml-auto" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { sortByReleaseDate } from '~/utils/sort';
|
||||||
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
|
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
|
||||||
import { providerBaseUrls, SupportedModalities, type Model } from '~/types/model';
|
import { providerBaseUrls, type Model } from '~/types/model';
|
||||||
import { useSettings } from '~/composables/useSettings';
|
import { useSettings } from '~/composables/useSettings';
|
||||||
import ModelItem from './ModelItem.vue';
|
import ModelItem from './ModelItem.vue';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
|
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
|
||||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
|
||||||
const triplit = useTriplitClient();
|
const triplit = useTriplitClient();
|
||||||
|
|
||||||
const { pageParams } = useSettings();
|
const { pageParams } = useSettings();
|
||||||
@@ -96,29 +96,17 @@ const updateProxyUrl = async (value: string) => {
|
|||||||
const fetchingModels = ref(false);
|
const fetchingModels = ref(false);
|
||||||
|
|
||||||
const fetchModels = async () => {
|
const fetchModels = async () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth()
|
||||||
|
|
||||||
fetchingModels.value = true;
|
fetchingModels.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [providerResponse, devDataResponse] = await Promise.all([
|
const modelsData = await $fetch(`/api/provider/${provider.value!.id}/models`, {
|
||||||
$fetch(`/api/provider/${provider.value!.id}/models`, {
|
method: 'POST',
|
||||||
method: 'POST',
|
body: JSON.stringify({
|
||||||
body: JSON.stringify({
|
providerApiKey: apiKey.value
|
||||||
providerApiKey: apiKey.value
|
})
|
||||||
})
|
}) as any;
|
||||||
}) as any,
|
|
||||||
$fetch('https://models.dev/api.json') as any
|
|
||||||
]);
|
|
||||||
|
|
||||||
// TODO: get model details correctly for ollama-cloud models
|
|
||||||
let providerType = provider.value!.type as string;
|
|
||||||
if (providerType === 'ollama') {
|
|
||||||
providerType = 'ollama-cloud';
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelDetails = devDataResponse[providerType]?.models || {};
|
|
||||||
console.log(modelDetails);
|
|
||||||
|
|
||||||
const existingModelsMap = new Map(
|
const existingModelsMap = new Map(
|
||||||
(provider.value?.models || []).map((m: any) => [m.externalId, m])
|
(provider.value?.models || []).map((m: any) => [m.externalId, m])
|
||||||
@@ -127,103 +115,34 @@ const fetchModels = async () => {
|
|||||||
const toInsert: any[] = [];
|
const toInsert: any[] = [];
|
||||||
const toUpdate: { id: string, data: any }[] = [];
|
const toUpdate: { id: string, data: any }[] = [];
|
||||||
|
|
||||||
providerResponse.models.forEach((pModel: any) => {
|
for (const model of modelsData.models) {
|
||||||
let slug: string = pModel.id.toLowerCase();
|
const existing = existingModelsMap.get(model.id);
|
||||||
if (providerType === 'ollama-cloud') {
|
|
||||||
slug = slug.replace(/:cloud$/, '');
|
|
||||||
slug = slug.replace(/-cloud$/, '');
|
|
||||||
slug = slug.replace(/:latest$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
let info = modelDetails[slug] || {};
|
|
||||||
|
|
||||||
const capabilities = [];
|
|
||||||
|
|
||||||
if (info.reasoning) {
|
|
||||||
capabilities.push('reasoning');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.tool_call) {
|
|
||||||
capabilities.push('tools');
|
|
||||||
}
|
|
||||||
|
|
||||||
let inputModalities = info.modalities?.input.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m));
|
|
||||||
if (inputModalities === undefined || inputModalities.length === 0) {
|
|
||||||
inputModalities = ['text'];
|
|
||||||
}
|
|
||||||
|
|
||||||
let outputModalities = info.modalities?.output.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m));
|
|
||||||
if (outputModalities === undefined || outputModalities.length === 0) {
|
|
||||||
outputModalities = ['text'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// merge pModel.attributes and info.modalities, with a preference for pModel.attributes
|
|
||||||
const attributes = {
|
|
||||||
inputModalities: new Set(inputModalities),
|
|
||||||
outputModalities: new Set(outputModalities),
|
|
||||||
capabilities,
|
|
||||||
contextWindow: pModel.context_length || info.limit?.context || null,
|
|
||||||
supported_parameters: new Set(pModel.supported_parameters || ["temperature", "max_tokens"]),
|
|
||||||
...(pModel.attributes || {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
let cost;
|
|
||||||
if (pModel.pricing === undefined) {
|
|
||||||
cost = {}
|
|
||||||
} else {
|
|
||||||
cost = {
|
|
||||||
prompt: pModel.pricing.prompt || null,
|
|
||||||
completion: pModel.pricing.completion || null,
|
|
||||||
request: pModel.pricing.request || null,
|
|
||||||
image: pModel.pricing.image || null,
|
|
||||||
imageTokens: pModel.pricing.image_tokens || null,
|
|
||||||
imageOutput: pModel.pricing.image_output || null,
|
|
||||||
audio: pModel.pricing.audio || null,
|
|
||||||
audioOutput: pModel.pricing.audio_output || null,
|
|
||||||
inputAudioCache: pModel.pricing.input_audio_cache || null,
|
|
||||||
webSearch: pModel.pricing.web_search || null,
|
|
||||||
internalReasoning: pModel.pricing.internal_reasoning || null,
|
|
||||||
inputCacheRead: pModel.pricing.input_cache_read || null,
|
|
||||||
inputCacheWrite: pModel.pricing.input_cache_write || null,
|
|
||||||
discount: pModel.pricing.discount || null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = existingModelsMap.get(pModel.id);
|
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// UPDATE logic: Remove 'id' from the payload as per Triplit requirements
|
const { id, ...existingWithoutId } = model;
|
||||||
const { id, ...existingWithoutId } = existing;
|
console.log("existingWithoutId", existingWithoutId);
|
||||||
|
|
||||||
toUpdate.push({
|
toUpdate.push({
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
data: {
|
data: existingWithoutId,
|
||||||
...existingWithoutId,
|
|
||||||
name: existing.name || pModel.name || info.name || pModel.id,
|
|
||||||
cost,
|
|
||||||
attributes, // Update tech specs
|
|
||||||
releasedAt: pModel.created ? new Date(pModel.created * 1000) : null,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// INSERT logic: This is a brand new model
|
|
||||||
toInsert.push({
|
toInsert.push({
|
||||||
userId: user.value?.id,
|
userId: user.value?.id!,
|
||||||
providerId: provider.value!.id,
|
externalId: model.id,
|
||||||
externalId: pModel.id,
|
providerId: provider.value!.id!,
|
||||||
name: pModel.name || info.name || pModel.id,
|
name: model.name || model.id,
|
||||||
|
cost: model.cost || {},
|
||||||
|
attributes: model.attributes,
|
||||||
isCustom: false,
|
isCustom: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
cost,
|
releasedAt: model.releasedAt,
|
||||||
attributes,
|
|
||||||
releasedAt: pModel.created ? new Date(pModel.created * 1000) : null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// delete models that are not in the API response and are not custom models
|
// delete models that are not in the API response and are not custom models
|
||||||
const apiModelIds = new Set(providerResponse.models.map((p: any) => p.id));
|
const apiModelIds = new Set(existingModelsMap.values().map((m: any) => m.externalId));
|
||||||
const toDelete = (provider.value?.models || []).filter((m: any) =>
|
const toDelete = (provider.value?.models || []).filter((m: any) =>
|
||||||
!m.isCustom && !apiModelIds.has(m.externalId)
|
!m.isCustom && !apiModelIds.has(m.externalId)
|
||||||
);
|
);
|
||||||
@@ -250,12 +169,12 @@ const deleteModels = async () => {
|
|||||||
|
|
||||||
const enabledModels = computed(() =>
|
const enabledModels = computed(() =>
|
||||||
filterModels(provider.value?.models.filter(m => m.enabled === true) as Model[] || [], modelSearch.value)
|
filterModels(provider.value?.models.filter(m => m.enabled === true) as Model[] || [], modelSearch.value)
|
||||||
.sort((a, b) => a.releasedAt && b.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0)
|
.sort(sortByReleaseDate)
|
||||||
)
|
)
|
||||||
|
|
||||||
const disabledModels = computed(() =>
|
const disabledModels = computed(() =>
|
||||||
filterModels(provider.value?.models.filter(m => m.enabled === false) as Model[] || [], modelSearch.value)
|
filterModels(provider.value?.models.filter(m => m.enabled === false) as Model[] || [], modelSearch.value)
|
||||||
.sort((a, b) => a.releasedAt && b.releasedAt ? b.releasedAt.getTime() - a.releasedAt.getTime() : 0)
|
.sort(sortByReleaseDate)
|
||||||
)
|
)
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -274,13 +193,13 @@ defineEmits(['navigate']);
|
|||||||
|
|
||||||
<div class="flex flex-row justify-between gap-16">
|
<div class="flex flex-row justify-between gap-16">
|
||||||
<label class="whitespace-nowrap" for="provider-api-key">API Key</label>
|
<label class="whitespace-nowrap" for="provider-api-key">API Key</label>
|
||||||
<div
|
<div class="text-sm font-mono flex flex-row rounded-md bg-[var(--bg-container)] items-center gap-1 w-7/10">
|
||||||
class="text-sm font-mono flex flex-row rounded-md bg-[var(--color-highlight)] items-center gap-1 w-7/10">
|
<input class="placeholder:text-[var(--text-tertiary)] w-full p-0 pl-2 py-1 bg-transparent"
|
||||||
<input class="w-full p-0 pl-2 py-1 bg-transparent" :type="apiKeyVisible ? 'text' : 'password'"
|
:type="apiKeyVisible ? 'text' : 'password'" id="provider-api-key" :value="apiKey"
|
||||||
id="provider-api-key" :value="apiKey" autocomplete="false" spellcheck="false"
|
autocomplete="false" spellcheck="false"
|
||||||
@input="updateApiKey(($event.target! as HTMLInputElement).value)" />
|
@input="updateApiKey(($event.target! as HTMLInputElement).value)" />
|
||||||
<button @click="apiKeyVisible = !apiKeyVisible"
|
<button @click="apiKeyVisible = !apiKeyVisible"
|
||||||
class="text-sm p-2 text-[var(--color-muted)] hover:text-[var(--color-text)]">
|
class="text-sm p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
|
||||||
<Icon :name="apiKeyVisible ? 'mynaui:eye' : 'mynaui:eye-slash'" class="text-4 min-h-4 min-w-4" />
|
<Icon :name="apiKeyVisible ? 'mynaui:eye' : 'mynaui:eye-slash'" class="text-4 min-h-4 min-w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,16 +207,16 @@ defineEmits(['navigate']);
|
|||||||
|
|
||||||
<div class="flex flex-row justify-between gap-16">
|
<div class="flex flex-row justify-between gap-16">
|
||||||
<label class="whitespace-nowrap" for="provider-api-key">API Proxy URL</label>
|
<label class="whitespace-nowrap" for="provider-api-key">API Proxy URL</label>
|
||||||
<div
|
<div class="text-sm font-mono flex flex-row rounded-md bg-[var(--bg-container)] items-center gap-1 w-7/10">
|
||||||
class="text-sm font-mono flex flex-row rounded-md bg-[var(--color-highlight)] items-center gap-1 w-7/10">
|
<input :placeholder="providerBaseUrls[provider!.type] ?? ''"
|
||||||
<input :placeholder="providerBaseUrls[provider!.type] ?? ''" class="w-full px-2 py-1 bg-transparent"
|
class="placeholder:text-[var(--text-tertiary)] w-full px-2 py-1 bg-transparent" type="text"
|
||||||
type="text" id="provider-proxy-url" :value="apiProxyUrl"
|
id="provider-proxy-url" :value="apiProxyUrl"
|
||||||
@input="updateProxyUrl(($event.target! as HTMLInputElement).value)" />
|
@input="updateProxyUrl(($event.target! as HTMLInputElement).value)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-row justify-center text-xs">
|
<div class="flex flex-row justify-center text-xs">
|
||||||
<p class="text-[var(--color-muted)]">
|
<p class="text-[var(--text-secondary)]">
|
||||||
<Icon name="mynaui:lock" /> Your API key is encrypted using <a
|
<Icon name="mynaui:lock" /> Your API key is encrypted using <a
|
||||||
href="https://datatracker.ietf.org/doc/html/draft-ietf-avt-srtp-aes-gcm-01">AES-GCM</a> encryption.
|
href="https://datatracker.ietf.org/doc/html/draft-ietf-avt-srtp-aes-gcm-01">AES-GCM</a> encryption.
|
||||||
</p>
|
</p>
|
||||||
@@ -307,26 +226,27 @@ defineEmits(['navigate']);
|
|||||||
<div class="pt-5 justify-between w-full flex flex-wrap gap-y-1 items-center">
|
<div class="pt-5 justify-between w-full flex flex-wrap gap-y-1 items-center">
|
||||||
<h4 class="whitespace-nowrap m-0 flex gap-x-2 items-start">
|
<h4 class="whitespace-nowrap m-0 flex gap-x-2 items-start">
|
||||||
Model List
|
Model List
|
||||||
<span class="text-sm text-[var(--color-muted)] font-normal text-xs flex items-center gap-1">
|
<span class="text-sm text-[var(--text-secondary)] font-normal text-xs flex items-center gap-1">
|
||||||
{{ provider?.models.length }} models available <button @click="deleteModels">
|
{{ provider?.models.length }} models available <button
|
||||||
|
class="p-0.5 hover:bg-[var(--color-hover)] rounded transition-colors duration-200"
|
||||||
|
@click="deleteModels">
|
||||||
<Icon name="mynaui:x-solid" />
|
<Icon name="mynaui:x-solid" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<div class="flex items-center justify-center px-2 py-1 bg-[var(--bg-container)] text-xs rounded-md">
|
||||||
class="flex items-center justify-center px-2 py-1 bg-[var(--color-highlight)] text-xs rounded-md">
|
<input class="placeholder:text-[var(--text-tertiary)] p-0 bg-transparent" v-model="modelSearch"
|
||||||
<input class="p-0 bg-transparent" v-model="modelSearch" type="text"
|
type="text" placeholder="Search models..." />
|
||||||
placeholder="Search models..." />
|
|
||||||
<button :class="modelSearch.length > 0 ? 'visible' : 'invisible'" @click="modelSearch = ''"
|
<button :class="modelSearch.length > 0 ? 'visible' : 'invisible'" @click="modelSearch = ''"
|
||||||
class="right-1 hover:bg-[var(--color-highlight)] rounded p-0.5">
|
class="right-1 hover:bg-[var(--color-hover)] rounded transition-colors duration-200 p-0.5">
|
||||||
<Icon name="mynaui:x" class="text-3.5 block text-[var(--color-subtle)]" />
|
<Icon name="mynaui:x" class="text-3.5 block text-[var(--text-secondary)]" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="fetchModels"
|
<button @click="fetchModels"
|
||||||
class="whitespace-nowrap flex bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] 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)]">
|
||||||
<Icon :class="[fetchingModels ? 'animate-rotate' : '']" name="mynaui:refresh" />
|
<Icon :class="[fetchingModels ? 'animate-rotate' : '']" name="mynaui:refresh" />
|
||||||
fetch models
|
fetch models
|
||||||
</button>
|
</button>
|
||||||
@@ -335,14 +255,14 @@ defineEmits(['navigate']);
|
|||||||
|
|
||||||
<div v-if="provider?.models?.length === 0" class="flex flex-row items-center justify-center gap-2 mt-2">
|
<div v-if="provider?.models?.length === 0" class="flex flex-row items-center justify-center gap-2 mt-2">
|
||||||
<Icon name="mynaui:info-circle" class="text-4" />
|
<Icon name="mynaui:info-circle" class="text-4" />
|
||||||
<span class="text-sm text-[var(--color-muted)]">
|
<span class="text-sm text-[var(--text-secondary)]">
|
||||||
No models found
|
No models found
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ClientOnly v-else>
|
<ClientOnly v-else>
|
||||||
<div class="flex flex-col gap-1 mt-2">
|
<div class="flex flex-col gap-1 mt-2">
|
||||||
<span v-if="enabledModels.length > 0" class="text-sm text-[var(--color-muted)]">
|
<span v-if="enabledModels.length > 0" class="text-sm text-[var(--text-secondary)]">
|
||||||
Enabled
|
Enabled
|
||||||
</span>
|
</span>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
@@ -359,7 +279,7 @@ defineEmits(['navigate']);
|
|||||||
</template>
|
</template>
|
||||||
</DynamicScroller>
|
</DynamicScroller>
|
||||||
|
|
||||||
<span v-if="disabledModels.length > 0" class="text-sm text-[var(--color-muted)]">
|
<span v-if="disabledModels.length > 0" class="text-sm text-[var(--text-secondary)]">
|
||||||
Disabled
|
Disabled
|
||||||
</span>
|
</span>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
@@ -383,19 +303,3 @@ defineEmits(['navigate']);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
.animate-rotate {
|
|
||||||
animation: rotate 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rotate {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,3 +1,81 @@
|
|||||||
<template>
|
<script setup lang="ts">
|
||||||
|
const { settings, updateSettings } = await useUserSettings();
|
||||||
|
const { colorScheme } = useTheme();
|
||||||
|
|
||||||
|
const accents = ['violet', 'volcano', 'lime', 'sky', 'coral', 'emerald', 'amber', 'rose', 'cyan', 'indigo', 'magenta'];
|
||||||
|
const neutrals = ['zinc', 'slate', 'obsidian'];
|
||||||
|
|
||||||
|
const updateAccent = (accent: string) => {
|
||||||
|
updateSettings({ appearance: { accent } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNeutral = (neutral: string) => {
|
||||||
|
updateSettings({ appearance: { neutral } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHinting = (e: Event) => {
|
||||||
|
const hinting = parseInt((e.target as HTMLInputElement).value);
|
||||||
|
updateSettings({ appearance: { hinting } });
|
||||||
|
};
|
||||||
|
|
||||||
|
defineEmits(['navigate']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6 mt-4">
|
||||||
|
<div class="flex flex-row items-center justify-between gap-2">
|
||||||
|
<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="colorScheme.preference.value === 'system' ? 'bg-[var(--color-hover)]' : ''">
|
||||||
|
<Icon name="tabler:device-desktop" class="text-4" />
|
||||||
|
<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="colorScheme.preference.value === 'dark' ? 'bg-[var(--color-hover)]' : ''">
|
||||||
|
<Icon name="mynaui:moon" class="text-4" />
|
||||||
|
<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="colorScheme.preference.value === 'light' ? 'bg-[var(--color-hover)]' : ''">
|
||||||
|
<Icon name="mynaui:sun" class="text-4" />
|
||||||
|
<span>Light</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<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="[
|
||||||
|
settings.appearance.accent === accent ? 'dark:border-white/70 border-black/70' : 'border-transparent'
|
||||||
|
]" :style="`background-color: var(--accent-${accent})`" :title="accent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<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="[
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h4 class="text-sm font-medium">Accent Hinting</h4>
|
||||||
|
<span class="text-xs text-zinc-500">{{ settings.appearance.hinting }}%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="100" step="1" :value="settings.appearance.hinting" @input="updateHinting"
|
||||||
|
class="accent-[var(--color-accent)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -96,14 +96,14 @@ onUnmounted(() => {
|
|||||||
enter-from-class="opacity-0 scale-95 translate-y-2" leave-from-class="opacity-100 scale-100 translate-y-0"
|
enter-from-class="opacity-0 scale-95 translate-y-2" leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||||
enter-to-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 -translate-y-2">
|
enter-to-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 -translate-y-2">
|
||||||
<div v-if="open" class="z-50 fixed top-1/2 left-1/2 -translate-x-1/2 flex items-center justify-center">
|
<div v-if="open" class="z-50 fixed top-1/2 left-1/2 -translate-x-1/2 flex items-center justify-center">
|
||||||
<div class="absolute w-[85vw] max-w-6xl h-[70vh] bg-[var(--color-base)] rounded-xl shadow-2xl border border-[var(--color-highlight)]
|
<div class="absolute w-[85vw] max-w-6xl h-[70vh] bg-[var(--bg-base)] rounded-2xl shadow-2xl border border-[var(--color-border)]
|
||||||
overflow-hidden flex max-h-[90vh] p-2">
|
overflow-hidden flex max-h-[90vh] p-2">
|
||||||
<nav class="w-64 flex flex-col gap-1 mr-2 overflow-y-auto">
|
<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 -->
|
<!-- 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="setPage" />
|
<component v-if="runtimePage?.sidebar" :is="runtimePage.sidebar" @navigate="setPage" />
|
||||||
|
|
||||||
<button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="setPage(id)"
|
<button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="setPage(id)"
|
||||||
:class="[currentPage === id ? 'bg-[var(--color-highlight)]' : 'hover:bg-[var(--color-highlight)]', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9']">
|
:class="[currentPage === id ? 'bg-[var(--color-hover)]' : 'hover:bg-[var(--color-hover)]', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9']">
|
||||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||||
<Icon :name="config.icon" class="w-5 h-5" />
|
<Icon :name="config.icon" class="w-5 h-5" />
|
||||||
{{ config.label }}
|
{{ config.label }}
|
||||||
@@ -114,11 +114,11 @@ onUnmounted(() => {
|
|||||||
<!-- DYNAMIC CONTENT -->
|
<!-- DYNAMIC CONTENT -->
|
||||||
<main class="flex-1 flex flex-col overflow-hidden">
|
<main class="flex-1 flex flex-col overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col flex-1 p-3 bg-[var(--color-neutral)] overflow-y-auto border rounded-lg border-[var(--color-highlight)]">
|
class="flex flex-col flex-1 p-3 bg-[var(--bg-surface)] overflow-y-auto border rounded-lg border-[var(--color-border)]">
|
||||||
<header class="flex items-center justify-between pl-2 pb-2 ">
|
<header class="flex items-center justify-between pl-2 pb-2 ">
|
||||||
<h2 class="text-lg font-semibold m-0">{{ runtimePage.label }}</h2>
|
<h2 class="text-lg font-semibold m-0 capitalize">{{ runtimePage.label }}</h2>
|
||||||
<button
|
<button
|
||||||
class="hover:bg-[var(--color-highlight)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]"
|
class="hover:bg-[var(--color-hover)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]"
|
||||||
@click="close">
|
@click="close">
|
||||||
<Icon name="mynaui:x-solid" />
|
<Icon name="mynaui:x-solid" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ defineEmits(['navigate']);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
hi
|
||||||
</template>
|
</template>
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const triplit = useTriplitClient();
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
model: ModelWithProvider;
|
model: ModelWithProvider;
|
||||||
}>();
|
}>();
|
||||||
@@ -8,7 +6,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="p-3 text-white flex max-w-full items-center justify-between gap-2 group hover:bg-[var(--color-highlight-low)] transition duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
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)]">
|
||||||
<ModelInfo :details="true" :model="model" :show-edit="true" :show-cost="true" :show-external-id="true"
|
<ModelInfo :details="true" :model="model" :show-edit="true" :show-cost="true" :show-external-id="true"
|
||||||
:show-release-date="true" />
|
:show-release-date="true" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Providers } from '~/types/model';
|
import { Providers } from '~/types/model';
|
||||||
|
import { providerIcons } from '~/utils/model-mapping';
|
||||||
|
|
||||||
const triplit = useTriplitClient();
|
const triplit = useTriplitClient();
|
||||||
const { providers, unsubscribe: unsubscribeModels } = await useModels();
|
const { providers, unsubscribe: unsubscribeModels } = await useModels();
|
||||||
|
|
||||||
if (providers.value === undefined) throw new Error('Providers not loaded');
|
if (providers.value === undefined) throw new Error('Providers not loaded');
|
||||||
|
|
||||||
|
// TODO: sometimes this code can create duplicate providers
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
for (const provider of Providers) {
|
for (const provider of Providers) {
|
||||||
if (!providers.value?.find(p => p.type === provider)) {
|
if (!providers.value?.find(p => p.type === provider)) {
|
||||||
@@ -20,6 +22,12 @@ for (const provider of Providers) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const provider of providers.value) {
|
||||||
|
if (!Providers.includes(provider.type)) {
|
||||||
|
await triplit.delete('providers', provider.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleProvider = async (id: string) => {
|
const toggleProvider = async (id: string) => {
|
||||||
const provider = providers.value!.find(p => p.id === id);
|
const provider = providers.value!.find(p => p.id === id);
|
||||||
if (!provider) return;
|
if (!provider) return;
|
||||||
@@ -39,7 +47,7 @@ defineEmits(['navigate']);
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||||
Enabled <span class="text-sm bg-[var(--color-highlight)] px-2 rounded-md py-0.5 text-[var(--color-muted)]">
|
Enabled <span class="text-sm bg-[var(--bg-container)] px-2 rounded-md py-0.5 text-[var(--text-secondary)]">
|
||||||
{{providers?.filter(p => p.enabled).length}}
|
{{providers?.filter(p => p.enabled).length}}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -47,21 +55,25 @@ defineEmits(['navigate']);
|
|||||||
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
||||||
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => p.enabled)"
|
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => p.enabled)"
|
||||||
:key="p.id"
|
:key="p.id"
|
||||||
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-highlight)] hover:border-[var(--color-highlight-high)]">
|
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-active)]">
|
||||||
<div class="flex flex-col flex-grow">
|
<div class="flex flex-col flex-grow">
|
||||||
<h3 class="text-md font-semibold text-start">{{ p.name }}</h3>
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<hr class="border-t border-[var(--color-highlight)]" />
|
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
|
||||||
|
class="w-8 h-8 text-[var(--text-primary)]" />
|
||||||
|
<h3 class="text-md font-semibold text-start capitalize">{{ p.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<hr class="border-t border-[var(--color-border)]" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end">
|
||||||
<!-- <input type="checkbox"
|
<!-- <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-highlight)]" /> -->
|
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-border)]" /> -->
|
||||||
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
|
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||||
Disabled <span class="text-sm bg-[var(--color-highlight)] px-2 rounded-md py-0.5 text-[var(--color-muted)]">
|
Disabled <span class="text-sm bg-[var(--bg-container)] px-2 rounded-md py-0.5 text-[var(--text-secondary)]">
|
||||||
{{providers?.filter(p => !p.enabled).length}}
|
{{providers?.filter(p => !p.enabled).length}}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -69,14 +81,18 @@ defineEmits(['navigate']);
|
|||||||
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
||||||
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => !p.enabled)"
|
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => !p.enabled)"
|
||||||
:key="p.id"
|
:key="p.id"
|
||||||
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-highlight)] hover:border-[var(--color-highlight-high)]">
|
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-active)]">
|
||||||
<div class="flex flex-col flex-grow">
|
<div class="flex flex-col flex-grow">
|
||||||
<h3 class="text-md font-semibold text-start">{{ p.name }}</h3>
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<hr class="border-t border-[var(--color-highlight)]" />
|
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
|
||||||
|
class="w-8 h-8 text-[var(--text-primary)]" />
|
||||||
|
<h3 class="text-md font-semibold text-start capitalize">{{ p.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<hr class="border-t border-[var(--color-border)]" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end">
|
||||||
<!-- <input type="checkbox"
|
<!-- <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-highlight)]" /> -->
|
class="w-4 h-4 text-blue-600 bg-transparent checked:bg-blue-600 checked:text-white checked:border-transparent focus:ring-0 border-2 border-[var(--color-border)]" /> -->
|
||||||
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
|
<Slider :checked="p.enabled" @click.stop="toggleProvider(p.id)" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { providerIcons } from '~/utils/model-mapping';
|
||||||
|
|
||||||
const { pageParams } = useSettings();
|
const { pageParams } = useSettings();
|
||||||
const { providers, unsubscribe: unsubscribeModels } = await useModels();
|
const { providers, unsubscribe: unsubscribeModels } = await useModels();
|
||||||
|
|
||||||
@@ -12,28 +14,36 @@ defineEmits(['navigate']);
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<button @click="$emit('navigate', 'general')"
|
<button @click="$emit('navigate', 'general')"
|
||||||
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||||
<Icon name="mynaui:chevron-left" class="text-4" /> Back to General
|
<Icon name="mynaui:chevron-left" class="text-4" /> Back to General
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button @click="$emit('navigate', 'providers')"
|
<button @click="$emit('navigate', 'providers')"
|
||||||
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
class="flex items-center gap-2 p-2 rounded-lg text-sm hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||||
<Icon name="mynaui:envelope-open" class="text-4" /> All
|
<Icon name="mynaui:envelope-open" class="text-4" /> All
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Enabled Providers</div>
|
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Enabled Providers</div>
|
||||||
|
|
||||||
<button v-for="p in providers?.filter(p => p.enabled)" :key="p.id" @click="$emit('navigate', 'providers', p.id)"
|
<button v-for="p in providers?.filter(p => p.enabled)" :key="p.id" @click="$emit('navigate', 'providers', p.id)"
|
||||||
:class="['flex items-center justify-between p-2 hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === pageParams[0] ? 'bg-[var(--color-highlight)]' : '']">
|
:class="['capitalize 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 === pageParams[0] ? 'bg-[var(--color-hover)]' : '']">
|
||||||
<span>{{ p.name }}</span>
|
<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)]" />
|
||||||
|
<span>{{ p.name }}</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Disabled Providers</div>
|
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Disabled Providers</div>
|
||||||
|
|
||||||
<button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
|
<button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
|
||||||
@click="$emit('navigate', 'providers', p.id)"
|
@click="$emit('navigate', 'providers', p.id)"
|
||||||
:class="['flex items-center justify-between p-2 hover:bg-[var(--color-highlight)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === pageParams[0] ? 'bg-[var(--color-highlight)]' : '']">
|
:class="['capitalize 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 === pageParams[0] ? 'bg-[var(--color-hover)]' : '']">
|
||||||
<span>{{ p.name }}</span>
|
<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)]" />
|
||||||
|
<span>{{ p.name }}</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,31 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const triplit = useTriplitClient();
|
import type { ModelWithProvider } from '~/composables/useModels';
|
||||||
const { providers, unsubscribe: unsubscribeModels, allModels } = await useModels();
|
|
||||||
const { settings, unsubscribe: unsubscribeSettings } = await useUserSettings();
|
const { providers, allModels } = await useModels();
|
||||||
|
const { settings, updateSettings } = await useUserSettings();
|
||||||
|
|
||||||
const toggle = async (key: string) => {
|
const toggle = async (key: string) => {
|
||||||
console.log(key);
|
const current = (settings.value.systemAssistants as any)[key];
|
||||||
|
await updateSettings({
|
||||||
await triplit.update('settings', settings.value.id, {
|
|
||||||
systemAssistants: {
|
systemAssistants: {
|
||||||
...settings.value.systemAssistants,
|
|
||||||
[key]: {
|
[key]: {
|
||||||
// @ts-ignore
|
...current,
|
||||||
...settings.value.systemAssistants[key],
|
enabled: !current.enabled
|
||||||
// @ts-ignore
|
|
||||||
enabled: !settings.value.systemAssistants[key].enabled
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateModel = async (key: string, model: ModelWithProvider | undefined | null) => {
|
const updateModel = async (key: string, model: ModelWithProvider | undefined | null) => {
|
||||||
await triplit.update('settings', settings.value.id, {
|
const current = (settings.value.systemAssistants as any)[key];
|
||||||
|
await updateSettings({
|
||||||
systemAssistants: {
|
systemAssistants: {
|
||||||
...settings.value.systemAssistants,
|
|
||||||
[key]: {
|
[key]: {
|
||||||
// @ts-ignore
|
...current,
|
||||||
...settings.value.systemAssistants[key],
|
|
||||||
modelId: model?.id ?? null
|
modelId: model?.id ?? null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,22 +36,18 @@ const getModel = (id: string | null | undefined) => {
|
|||||||
|
|
||||||
defineEmits(['navigate']);
|
defineEmits(['navigate']);
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
unsubscribeModels?.();
|
|
||||||
unsubscribeSettings?.();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4 flex-grow">
|
<div class="flex flex-col gap-4 flex-grow">
|
||||||
<div class="flex flex-col" v-for="(systemAssistant, key) in settings.systemAssistants">
|
<div class="flex flex-col" v-for="(systemAssistant, key) in settings.systemAssistants" :key="key">
|
||||||
<label :for="`slider-${key}`" class="flex justify-between gap-4 items-center">
|
<label :for="`slider-${key}`" class="flex justify-between gap-4 items-center">
|
||||||
<h4 class="capitalize">{{ key }}</h4>
|
<h4 class="capitalize">{{ key }}</h4>
|
||||||
<Slider :id="`slider-${key}`" :checked="systemAssistant.enabled" @click="toggle(key)" />
|
<Slider :id="`slider-${key}`" :checked="systemAssistant.enabled" @click="toggle(String(key))" />
|
||||||
</label>
|
</label>
|
||||||
<div class="flex-1 justify-between gap-4 items-center">
|
<div class="flex-1 justify-between gap-4 items-center">
|
||||||
<ModelSelector :providers="providers" :model-value="getModel(systemAssistant.modelId)"
|
<ModelSelector :providers="providers" :model-value="getModel(systemAssistant.modelId)"
|
||||||
@update:model-value="(model) => updateModel(key, model)" />
|
@update:model-value="(model) => updateModel(String(key), model)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const { user, signOut } = useAuth();
|
|||||||
// particular)
|
// particular)
|
||||||
const cachedUser = ref(user.value);
|
const cachedUser = ref(user.value);
|
||||||
|
|
||||||
|
const dropdownRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const dropdownOpen = ref(false);
|
||||||
|
|
||||||
watch(user, () => {
|
watch(user, () => {
|
||||||
if (user.value === null) return;
|
if (user.value === null) return;
|
||||||
|
|
||||||
@@ -20,8 +23,6 @@ const { toggle: toggleSettings } = useSettings();
|
|||||||
|
|
||||||
const { isHovered } = useSidenavContext();
|
const { isHovered } = useSidenavContext();
|
||||||
|
|
||||||
const profileOpen = ref(false);
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await signOut();
|
await signOut();
|
||||||
assert('disconnect' in triplit);
|
assert('disconnect' in triplit);
|
||||||
@@ -30,41 +31,62 @@ const handleLogout = async () => {
|
|||||||
await navigateTo('/auth/login');
|
await navigateTo('/auth/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
const profileItems: DropdownItem[] = [
|
useClickOutside(dropdownRef, () => {
|
||||||
{ label: 'Settings', icon: 'mynaui:cog-four', onClick: toggleSettings },
|
dropdownOpen.value = false;
|
||||||
{
|
});
|
||||||
label: 'Log out',
|
|
||||||
icon: 'mynaui:logout',
|
|
||||||
divider: true,
|
|
||||||
onClick: handleLogout,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="flex items-center justify-between overflow-hidden">
|
<header ref="dropdownRef" class="flex items-center justify-between overflow-hidden">
|
||||||
<Dropdown class="overflow-hidden" v-model="profileOpen" :items="profileItems" placement="left"
|
<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)]">
|
||||||
|
<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)]']">
|
||||||
|
<img v-if="cachedUser?.image" :src="cachedUser.image" class="w-full h-full object-cover" />
|
||||||
|
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--text-secondary)]" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="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',
|
||||||
|
isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-40'
|
||||||
|
]">
|
||||||
|
<Icon class="text-4 transition-transform duration-250 ease-in-out"
|
||||||
|
:class="dropdownOpen ? 'rotate-180' : ''" name="mynaui:chevron-down" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition enter-active-class="transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||||
|
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
|
||||||
|
leave-active-class="transition-all 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="dropdownOpen"
|
||||||
|
class="w-full top-full text-sm mt-1 absolute z-50 bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 flex flex-col gap-2">
|
||||||
|
<button @click="dropdownOpen = false; toggleSettings()"
|
||||||
|
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)]">
|
||||||
|
<Icon name="mynaui:cog-four" class="text-4.5" />
|
||||||
|
<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)]">
|
||||||
|
<Icon name="mynaui:logout" class="text-4.5" />
|
||||||
|
<span>Log out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- <Dropdown class="overflow-hidden" v-model="profileOpen" :items="profileItems" placement="left"
|
||||||
verticality="descending" width="100%">
|
verticality="descending" width="100%">
|
||||||
<template #trigger="{ toggle }">
|
<template #trigger="{ toggle }">
|
||||||
<button aria-label="open user dropdown"
|
<button aria-label="open user dropdown"
|
||||||
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] cursor-pointer transition-colors max-w-full"
|
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] cursor-pointer transition-colors max-w-full"
|
||||||
@click="toggle">
|
@click="toggle">
|
||||||
<div
|
|
||||||
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-highlight-high)]']">
|
|
||||||
<img v-if="cachedUser?.image" :src="cachedUser.image" class="w-full h-full object-cover" />
|
|
||||||
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--color-muted)]" />
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">{{
|
|
||||||
cachedUser?.name
|
|
||||||
}}</span>
|
|
||||||
<div :class="['transform-origin-left-center flex-shrink-0 w-4 h-4 text-[var(--color-muted)] 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'
|
|
||||||
]">
|
|
||||||
<Icon class="text-4" name="mynaui:chevron-down" />
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown> -->
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,20 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DropdownItem } from '~/types/dropdown';
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { agents, getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
|
const { agents, getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
|
||||||
|
const { openDropdown, dropdownState, closeDropdown } = useDropdown();
|
||||||
|
|
||||||
const activeAgent = computed(() => getAgent(route.params.id as string));
|
const activeAgent = computed(() => getAgent(route.params.id as string));
|
||||||
|
|
||||||
const { isHovered } = useSidenavContext();
|
const { isHovered, sidebarWidth } = useSidenavContext();
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
unsubscribeAgents?.();
|
unsubscribeAgents?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
const agentDropdownOpen = ref(false);
|
const toggleDropdown = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
const agentItems: DropdownItem[] = [];
|
if (dropdownState.open) {
|
||||||
|
closeDropdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsFactory = () => {
|
||||||
|
const items = [];
|
||||||
|
for (const agent of agents.value || []) {
|
||||||
|
items.push({
|
||||||
|
label: agent.name,
|
||||||
|
icon: 'mynaui:check-hexagon',
|
||||||
|
active: activeAgent.value?.id === agent.id,
|
||||||
|
onClick: () => navigateTo(`/agent/${agent.id}`)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
openDropdown(e, itemsFactory, { verticality: 'descending', placement: 'center', maxWidth: '200px' });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -22,41 +41,61 @@ const agentItems: DropdownItem[] = [];
|
|||||||
<div :style="isHovered ? 'width: 32px;' : 'width: 0px;'"
|
<div :style="isHovered ? 'width: 32px;' : 'width: 0px;'"
|
||||||
:class="['flex flex-shrink-0 transform-origin-left-center items-center overflow-hidden transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-95']">
|
:class="['flex flex-shrink-0 transform-origin-left-center items-center overflow-hidden transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-95']">
|
||||||
<NuxtLink to="/"
|
<NuxtLink to="/"
|
||||||
class="flex hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg decoration-none transition-inherit text-[var(--color-muted)] p-1.5">
|
class="flex hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg decoration-none transition-inherit text-[var(--text-secondary)] p-1.5">
|
||||||
<Icon name="mynaui:chevron-left" class="w-4.5 h-4.5" />
|
<Icon name="mynaui:chevron-left" class="w-4.5 h-4.5" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dropdown class="overflow-hidden" v-model="agentDropdownOpen" :items="agentItems" placement="center"
|
<button
|
||||||
width="calc(80% - 1rem)">
|
class="max-w-full transition duration-200 pr-2 cursor-pointer hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg"
|
||||||
|
@click="toggleDropdown">
|
||||||
|
<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)]']">
|
||||||
|
<img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl" class="w-full h-full object-cover" />
|
||||||
|
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
|
||||||
|
{{ activeAgent?.name }}
|
||||||
|
</span>
|
||||||
|
<div class="w-4 h-4 text-[var(--text-secondary)]">
|
||||||
|
<Icon
|
||||||
|
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
|
||||||
|
name="mynaui:chevron-up-down" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- <Dropdown class="overflow-hidden" v-model="agentDropdownOpen" placement="center" width="calc(80% - 1rem)">
|
||||||
<template #trigger="{ toggle }">
|
<template #trigger="{ toggle }">
|
||||||
<button
|
<button
|
||||||
class="flex max-w-full gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg"
|
class="flex max-w-full transition duration-200 gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg"
|
||||||
@click="toggle">
|
@click="toggle">
|
||||||
<div
|
<div
|
||||||
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-highlight-high)]']">
|
: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)]']">
|
||||||
<img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl"
|
<img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl"
|
||||||
class="w-full h-full object-cover" />
|
class="w-full h-full object-cover" />
|
||||||
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
|
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">
|
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
|
||||||
{{ activeAgent?.name }}
|
{{ activeAgent?.name }}
|
||||||
</span>
|
</span>
|
||||||
<div class="w-4 h-4 text-[var(--color-muted)]">
|
<div class="w-4 h-4 text-[var(--text-secondary)]">
|
||||||
<Icon
|
<Icon
|
||||||
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
|
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
|
||||||
name="mynaui:chevron-up-down" />
|
name="mynaui:chevron-up-down" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="flex flex-col gap-1.5 max-h-[calc(2.25rem*4+0.375rem*3)] overflow-y-auto">
|
<div class="flex flex-col gap-1.5 max-h-[calc(2.25rem*4+0.375rem*3)] overflow-y-auto">
|
||||||
<SidenavItem draggable="false" v-for="agent in agents" :to="`/agent/${agent.id}`" :name="agent.name"
|
<SidenavItem draggable="false" v-for="agent in agents" :to="`/agent/${agent.id}`" :name="agent.name"
|
||||||
class="whitespace-nowrap" icon="mynaui:check-hexagon" :key="agent.id"
|
class="whitespace-nowrap" icon="mynaui:check-hexagon" :key="agent.id"
|
||||||
:active="activeAgent?.id === agent.id" />
|
:active="activeAgent?.id === agent.id" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown> -->
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -9,11 +9,11 @@ const props = defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NuxtLink v-if="props.to" v-bind="$attrs" :to="props.to" :aria-label="props.name" :class="[
|
<NuxtLink v-if="props.to" v-bind="$attrs" :to="props.to" :aria-label="props.name" :class="[
|
||||||
'decoration-none text-[var(--color-muted)] flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
|
'decoration-none text-[var(--text-secondary)] flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
|
||||||
props.icon ? 'px-1' : 'px-2',
|
props.icon ? 'px-1' : 'px-2',
|
||||||
props.active
|
props.active
|
||||||
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
|
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
|
||||||
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
|
: 'hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
|
||||||
]">
|
]">
|
||||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||||
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
|
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
|
||||||
@@ -30,8 +30,8 @@ const props = defineProps<{
|
|||||||
'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9',
|
'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9',
|
||||||
props.icon ? 'px-1' : 'px-2',
|
props.icon ? 'px-1' : 'px-2',
|
||||||
props.active
|
props.active
|
||||||
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
|
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
|
||||||
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
|
: 'hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
|
||||||
]">
|
]">
|
||||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||||
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
|
<div v-if="props.icon" class="h-7 w-7 flex items-center justify-center">
|
||||||
|
|||||||
+176
-137
@@ -1,9 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { assert } from '~~/utils/assert';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const topicsListRef = ref<HTMLElement | null>(null);
|
const { openDropdown, dropdownState, closeDropdown } = useDropdown();
|
||||||
const topicsListHeight = ref('auto');
|
|
||||||
const topicsListOpacity = ref(1);
|
|
||||||
const topicsListScale = ref(1);
|
|
||||||
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
|
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
|
||||||
|
|
||||||
const triplit = useTriplitClient();
|
const triplit = useTriplitClient();
|
||||||
@@ -12,97 +11,88 @@ const activeAgent = computed(() => getAgent(route.params.id as string));
|
|||||||
|
|
||||||
const topics = computed(() => {
|
const topics = computed(() => {
|
||||||
if (activeAgent.value === undefined) return [];
|
if (activeAgent.value === undefined) return [];
|
||||||
// return the todos but sorted and in a new array do not add messages or anything to the object, JUST SORT IT
|
return activeAgent.value.topics;
|
||||||
return activeAgent.value.topics
|
|
||||||
.map((topic) => topic)
|
|
||||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
|
||||||
.reverse();
|
|
||||||
});
|
|
||||||
|
|
||||||
const routeParts = computed(() => {
|
|
||||||
return route.path.replace('/agent/', '').split('/');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (routeParts.value.length < 1) navigateTo('/');
|
|
||||||
|
|
||||||
const pageInfo = computed(() => {
|
|
||||||
// `/agent/agent_[uuid]` or `/agent/agent_[uuid]/topic/...`
|
|
||||||
if (route.path.startsWith('/agent/') && !route.path.includes('/profile')) {
|
|
||||||
return 'conversation';
|
|
||||||
}
|
|
||||||
|
|
||||||
// `/agent/agent_[uuid]/profile`
|
|
||||||
if (route.path.endsWith('/profile')) {
|
|
||||||
return 'agent-profile';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const topicsOpen = ref(true);
|
const topicsOpen = ref(true);
|
||||||
|
let activeAutoRenames = reactive(new Map<string, string>());
|
||||||
function easeInOutQuad(x: number): number {
|
|
||||||
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleAgentsList = () => {
|
|
||||||
if (!topicsListRef.value) return;
|
|
||||||
const animationLength = 200;
|
|
||||||
let animationStart: number | null = null;
|
|
||||||
|
|
||||||
let startHeight: number;
|
|
||||||
const startOpacity = topicsListOpacity.value;
|
|
||||||
const startScale = topicsListScale.value;
|
|
||||||
if (topicsListHeight.value === 'auto') {
|
|
||||||
startHeight = topicsListRef.value.clientHeight;
|
|
||||||
} else {
|
|
||||||
startHeight = Number(topicsListHeight.value.replace('px', ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetHeight = topicsOpen.value ? 0 : topicsListRef.value.scrollHeight;
|
|
||||||
const targetOpacity = topicsOpen.value ? 0 : 1;
|
|
||||||
const targetScale = topicsOpen.value ? 0.95 : 1;
|
|
||||||
topicsOpen.value = !topicsOpen.value;
|
|
||||||
|
|
||||||
const animate = (timestamp: number) => {
|
|
||||||
if (!animationStart) animationStart = timestamp;
|
|
||||||
|
|
||||||
const elapsed = timestamp - animationStart;
|
|
||||||
const progress = Math.min(elapsed / animationLength, 1);
|
|
||||||
|
|
||||||
const currentHeight = startHeight + (targetHeight - startHeight) * easeInOutQuad(progress);
|
|
||||||
const currentOpacity = startOpacity + (targetOpacity - startOpacity) * easeInOutQuad(progress);
|
|
||||||
const currentScale = startScale + (targetScale - startScale) * easeInOutQuad(progress);
|
|
||||||
|
|
||||||
topicsListOpacity.value = currentOpacity;
|
|
||||||
topicsListScale.value = currentScale;
|
|
||||||
topicsListHeight.value = `${currentHeight}px`;
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
} else {
|
|
||||||
if (topicsOpen.value) {
|
|
||||||
topicsListHeight.value = 'auto';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoRenameTopic = async (topicId: string) => {
|
const autoRenameTopic = async (topicId: string) => {
|
||||||
const { setPage } = useSettings();
|
const { setPage } = useSettings();
|
||||||
const { autoRename } = useChat(route.params.id as string);
|
const { autoRename, AutoRenameError } = useChat(route.params.id as string);
|
||||||
|
|
||||||
const firstMessage = await triplit.fetchOne(triplit.query('messages').Where('topicId', '=', topicId).Order('createdAt', 'ASC').Limit(1));
|
const firstMessage = await triplit.fetchOne(triplit.query('messages').Where('topicId', '=', topicId).Order('createdAt', 'ASC').Limit(1));
|
||||||
if (!firstMessage) return;
|
if (!firstMessage) return;
|
||||||
|
|
||||||
const success = await autoRename(topicId, firstMessage.content);
|
const res = await autoRename(topicId, firstMessage.content);
|
||||||
if (!success) {
|
if (res.ok) {
|
||||||
setPage('systemAssistants');
|
activeAutoRenames.set(topicId, res.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (res.error) {
|
||||||
|
case AutoRenameError.NoModelSelected:
|
||||||
|
case AutoRenameError.ModelDisabled:
|
||||||
|
case AutoRenameError.AutoRenameDisabled: {
|
||||||
|
setPage('systemAssistants');
|
||||||
|
} break;
|
||||||
|
case AutoRenameError.NoModelFound:
|
||||||
|
case AutoRenameError.DatabaseOperationFailed:
|
||||||
|
case AutoRenameError.FailedToGenerate:
|
||||||
|
case AutoRenameError.FailedToDecryptProviderApiKey: {
|
||||||
|
console.error('Failed to auto-rename:', res.error);
|
||||||
|
|
||||||
|
await triplit.update('topics', topicId, {
|
||||||
|
renaming: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert('flush' in triplit);
|
||||||
|
await triplit.flush();
|
||||||
|
} break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const renameTopic = (topicId: string) => {
|
const cancelAutoRename = async (topicId: string) => {
|
||||||
console.log('renameTopic', topicId);
|
await triplit.update('topics', topicId, {
|
||||||
|
renaming: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renameId = activeAutoRenames.get(topicId);
|
||||||
|
if (!renameId) return;
|
||||||
|
|
||||||
|
await $fetch(`/api/topic/auto-rename/cancel/${renameId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
activeAutoRenames.delete(topicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameTopicId = ref<string | null>(null);
|
||||||
|
const newTopicName = ref('');
|
||||||
|
|
||||||
|
const startRename = (topicId: string, currentName: string) => {
|
||||||
|
renameTopicId.value = topicId;
|
||||||
|
newTopicName.value = currentName;
|
||||||
|
nextTick(() => {
|
||||||
|
const input = document.getElementById('topic-rename-input') as HTMLInputElement | null;
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRename = async () => {
|
||||||
|
if (renameTopicId.value && newTopicName.value.trim()) {
|
||||||
|
await triplit.update('topics', renameTopicId.value, {
|
||||||
|
name: newTopicName.value.trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cancelRename();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRename = () => {
|
||||||
|
renameTopicId.value = null;
|
||||||
|
newTopicName.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteTopic = async (topicId: string) => {
|
const deleteTopic = async (topicId: string) => {
|
||||||
@@ -118,6 +108,70 @@ const deleteTopic = async (topicId: string) => {
|
|||||||
// TODO: deeply delete all messages, generations, and message_parts in the topic
|
// TODO: deeply delete all messages, generations, and message_parts in the topic
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNavClick = (e: MouseEvent) => {
|
||||||
|
const trigger = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
|
||||||
|
if (!trigger) return;
|
||||||
|
|
||||||
|
const topicId = trigger.dataset.topicId || (trigger.closest('[data-topic-id]') as HTMLElement | null)?.dataset.topicId;
|
||||||
|
if (!topicId) return;
|
||||||
|
|
||||||
|
const action = trigger.dataset.action;
|
||||||
|
if (action === 'navigate') {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
return navigateTo(`/agent/${route.params.id}/topic/${topicId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'toggle-dropdown') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (dropdownState.open) {
|
||||||
|
closeDropdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsFactory = () => {
|
||||||
|
const items = [];
|
||||||
|
const isRenaming = activeAutoRenames.has(topicId) && topics.value?.find(t => t.id === topicId)?.renaming;
|
||||||
|
|
||||||
|
if (isRenaming) {
|
||||||
|
items.push({
|
||||||
|
label: 'Cancel Auto Rename',
|
||||||
|
onClick: () => cancelAutoRename(topicId)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
label: 'Auto Rename',
|
||||||
|
onClick: () => autoRenameTopic(topicId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: 'Rename',
|
||||||
|
disabled: isRenaming ?? false,
|
||||||
|
onClick: () => startRename(topicId, topics.value?.find(t => t.id === topicId)?.name || '')
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
onClick: () => deleteTopic(topicId)
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
openDropdown(e, itemsFactory, { minWidth: '120px', placement: 'right' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// simply preload the topic page
|
||||||
|
preloadRouteComponents(`/agent/${route.params.id}/topic/42`);
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
unsubscribeAgents?.();
|
unsubscribeAgents?.();
|
||||||
});
|
});
|
||||||
@@ -128,69 +182,54 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Agent Info Link -->
|
<!-- Agent Info Link -->
|
||||||
<div class="mt-2 flex flex-col">
|
<div class="mt-2 flex flex-col">
|
||||||
<SidenavItem :to="`/agent/${routeParts[0]}/profile`" name="Agent Info" icon="mynaui:info-square"
|
<SidenavItem :to="`/agent/${route.params.id}/profile`" name="Agent Info" icon="mynaui:info-square"
|
||||||
:active="pageInfo === 'agent-profile'" />
|
:active="route.path.endsWith('/profile')" />
|
||||||
|
|
||||||
<!-- Topics Section -->
|
<!-- Topics Section -->
|
||||||
<button @click="toggleAgentsList"
|
<button @click="topicsOpen = !topicsOpen"
|
||||||
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors w-full text-left">
|
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors w-full text-left">
|
||||||
<span class="text-sm font-medium">Topics</span>
|
<span class="text-sm font-medium">Topics</span>
|
||||||
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', topicsOpen ? '' : '-rotate-90']" />
|
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', topicsOpen ? '' : '-rotate-90']" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div ref="topicsListRef" :inert="!topicsOpen" :class="{ 'overflow-y-hidden': !topicsOpen }"
|
<Collapsible :is-open="topicsOpen">
|
||||||
:style="{ height: topicsListHeight, opacity: topicsListOpacity, transform: `scale(${topicsListScale})` }"
|
<div @click="handleNavClick"
|
||||||
class="mt-1 gap-1 flex flex-col transform-origin-center-top">
|
class="mt-1 gap-1 flex flex-col transform-origin-center-top [content-visibility:auto] [contain-intrinsic-size:0_36px]">
|
||||||
<NuxtLink v-if="activeAgent?.topics !== undefined" v-for="topic in topics"
|
<a v-for="topic in topics" :key="topic.id" data-action="navigate" :data-topic-id="topic.id"
|
||||||
:to="`/agent/${activeAgent.id}/topic/${topic.id}`" :aria-label="topic.name" :class="[
|
:href="`/agent/${route.params.id}/topic/${topic.id}`" :aria-label="topic.name" :class="[
|
||||||
'group relative decoration-none text-[var(--color-muted)] flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
|
'group relative decoration-none flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
|
||||||
'px-2',
|
'px-2',
|
||||||
topic.id === route.params.topicId
|
topic.id === route.params.topicId
|
||||||
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
|
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
|
||||||
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
|
: 'text-[var(--text-secondary)] hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]'
|
||||||
]">
|
]">
|
||||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||||
<div v-if="!topic.renaming" class="flex justify-between items-center w-full">
|
<div class="flex justify-between items-center w-full">
|
||||||
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
|
<input v-if="renameTopicId === topic.id && !topic.renaming" id="topic-rename-input"
|
||||||
{{ topic.name }}
|
v-model="newTopicName" @keydown.enter="saveRename" @keydown.escape="cancelRename"
|
||||||
</span>
|
@blur="saveRename"
|
||||||
|
class="flex-1 bg-transparent border-none outline-none text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" />
|
||||||
|
<div v-else-if="topic.renaming" class="flex w-full">
|
||||||
|
<Icon name="svg-spinners:3-dots-fade" class="text-6" />
|
||||||
|
</div>
|
||||||
|
<span v-else
|
||||||
|
class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
{{ topic.name }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<Dropdown class="shrink-0 text-[var(--color-text)]" verticality="descending"
|
<div data-action="toggle-dropdown"
|
||||||
placement="right">
|
class="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-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||||
<template #trigger="{ toggle }">
|
<svg class="pointer-events-none" xmlns="http://www.w3.org/2000/svg" width="18"
|
||||||
<div dots @click.prevent="toggle"
|
height="18" viewBox="0 0 24 24">
|
||||||
class="opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
<path fill="currentColor"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
|
d="M7 12a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0" />
|
||||||
viewBox="0 0 24 24"><!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ -->
|
</svg>
|
||||||
<path fill="currentColor"
|
</div>
|
||||||
d="M7 12a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0" />
|
</div>
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #content="{ toggle }">
|
|
||||||
<div class="shadow-lg rounded p-1 flex flex-col min-w-[120px] gap-1">
|
|
||||||
<button @click.prevent="autoRenameTopic(topic.id); toggle()"
|
|
||||||
class="text-left px-3 py-1.5 text-sm hover:bg-[var(--color-highlight)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
|
||||||
Auto Rename
|
|
||||||
</button>
|
|
||||||
<button @click.prevent="renameTopic(topic.id); toggle()"
|
|
||||||
class="text-left px-3 py-1.5 text-sm hover:bg-[var(--color-highlight)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
|
||||||
Rename
|
|
||||||
</button>
|
|
||||||
<button @click.prevent="deleteTopic(topic.id); toggle()"
|
|
||||||
class="text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-600/20 rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex w-full">
|
</a>
|
||||||
<Icon name="svg-spinners:3-dots-fade" class="text-6" />
|
</div>
|
||||||
</div>
|
</Collapsible>
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,107 +1,54 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { agents, unsubscribe: unsubscribeAgents, createAgent } = await useAgents();
|
const { agents, unsubscribe, createAgent } = await useAgents();
|
||||||
const agentsListRef = ref<HTMLElement | null>(null);
|
|
||||||
const agentsOpen = ref(true);
|
const agentsOpen = ref(true);
|
||||||
const agentsListHeight = ref('auto');
|
|
||||||
const agentsListOpacity = ref(1);
|
|
||||||
const agentsListScale = ref(1);
|
|
||||||
const creatingAgent = ref(false);
|
const creatingAgent = ref(false);
|
||||||
|
|
||||||
function easeInOutQuad(x: number): number {
|
|
||||||
return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleAgentsList = () => {
|
const toggleAgentsList = () => {
|
||||||
if (!agentsListRef.value) return;
|
|
||||||
const animationLength = 200;
|
|
||||||
let animationStart: number | null = null;
|
|
||||||
|
|
||||||
let startHeight: number;
|
|
||||||
const startOpacity = agentsListOpacity.value;
|
|
||||||
const startScale = agentsListScale.value;
|
|
||||||
if (agentsListHeight.value === 'auto') {
|
|
||||||
startHeight = agentsListRef.value.clientHeight;
|
|
||||||
} else {
|
|
||||||
startHeight = Number(agentsListHeight.value.replace('px', ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetHeight = agentsOpen.value ? 0 : agentsListRef.value.scrollHeight;
|
|
||||||
const targetOpacity = agentsOpen.value ? 0 : 1;
|
|
||||||
const targetScale = agentsOpen.value ? 0.95 : 1;
|
|
||||||
agentsOpen.value = !agentsOpen.value;
|
agentsOpen.value = !agentsOpen.value;
|
||||||
|
|
||||||
const animate = (timestamp: number) => {
|
|
||||||
if (!animationStart) animationStart = timestamp;
|
|
||||||
|
|
||||||
const elapsed = timestamp - animationStart;
|
|
||||||
const progress = Math.min(elapsed / animationLength, 1);
|
|
||||||
|
|
||||||
const currentHeight = startHeight + (targetHeight - startHeight) * easeInOutQuad(progress);
|
|
||||||
const currentOpacity = startOpacity + (targetOpacity - startOpacity) * easeInOutQuad(progress);
|
|
||||||
const currentScale = startScale + (targetScale - startScale) * easeInOutQuad(progress);
|
|
||||||
|
|
||||||
agentsListOpacity.value = currentOpacity;
|
|
||||||
agentsListScale.value = currentScale;
|
|
||||||
agentsListHeight.value = `${currentHeight}px`;
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
} else {
|
|
||||||
if (agentsOpen.value) {
|
|
||||||
agentsListHeight.value = 'auto';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const newAgent = async () => {
|
const newAgent = async () => {
|
||||||
const triplit = useTriplitClient();
|
creatingAgent.value = true;
|
||||||
const { user } = useAuth();
|
try {
|
||||||
|
const agent = await createAgent();
|
||||||
if (!user.value) throw new Error('User not logged in');
|
if (agent) return navigateTo(`/agent/${agent.id}`);
|
||||||
|
} finally {
|
||||||
const agent = await createAgent();
|
creatingAgent.value = false;
|
||||||
if (!agent) throw new Error('Failed to create agent');
|
}
|
||||||
|
|
||||||
return navigateTo(`/agent/${agent.id}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => unsubscribe?.());
|
||||||
unsubscribeAgents?.();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="flex flex-col gap-1">
|
<nav class="flex flex-col gap-1">
|
||||||
<SidenavItem name="Search" icon="mynaui:search" />
|
<SidenavItem name="Search" icon="mynaui:search" />
|
||||||
<SidenavItem to="/" :active="true" name="Home" icon="mynaui:home" />
|
<SidenavItem to="/" name="Home" icon="mynaui:home" />
|
||||||
|
|
||||||
<!-- Agents Section -->
|
<!-- Header Toggle -->
|
||||||
<button
|
<button
|
||||||
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors w-full text-left"
|
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg hover:bg-[var(--color-hover)] transition-colors w-full"
|
||||||
@click="toggleAgentsList()">
|
@click="toggleAgentsList">
|
||||||
<span class="text-sm">Agents</span>
|
<span class="text-sm">Agents</span>
|
||||||
<Icon name="mynaui:chevron-down"
|
<Icon name="mynaui:chevron-down" class="w-4 h-4 transition-transform duration-200"
|
||||||
:class="['w-4 h-4 transition-transform duration-250 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center', agentsOpen ? '' : '-rotate-90']" />
|
:class="{ '-rotate-90': !agentsOpen }" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div ref="agentsListRef" :inert="!agentsOpen"
|
<!-- Animated Section -->
|
||||||
:style="{ height: agentsListHeight, opacity: agentsListOpacity, transform: `scale(${agentsListScale})` }"
|
<Collapsible :is-open="agentsOpen">
|
||||||
class="mt-1 gap-1 flex flex-col transform-origin-center-top overflow-y-hidden">
|
<div class="mt-1 gap-1 flex flex-col transform-origin-top pt-1 pb-1 px-1">
|
||||||
<button @click="newAgent" :disabled="creatingAgent"
|
<button @click="newAgent" :disabled="creatingAgent"
|
||||||
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--color-muted)] bg-transparent hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors disabled:opacity-50 w-full">
|
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">
|
<div class="h-7 w-7 flex items-center justify-center">
|
||||||
<Icon v-if="creatingAgent" class="text-4.5" name="svg-spinners:ring-resize" />
|
<Icon v-if="creatingAgent" class="text-4.5" name="svg-spinners:ring-resize" />
|
||||||
<Icon v-else class="text-4.5" name="mynaui:plus" />
|
<Icon v-else class="text-4.5" name="mynaui:plus" />
|
||||||
</div>
|
</div>
|
||||||
<span>New Agent</span>
|
<span>New Agent</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<SidenavItem
|
<SidenavItem v-for="agent in agents" :key="agent.id" :to="`/agent/${agent.id}`" :name="agent.name"
|
||||||
v-for="agent in agents?.map((agent) => agent)?.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())"
|
icon="mynaui:check-hexagon" />
|
||||||
:to="`/agent/${agent.id}`" :key="agent.id" :name="agent.name" icon="mynaui:check-hexagon" />
|
</div>
|
||||||
</div>
|
</Collapsible>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
@@ -68,15 +68,18 @@ const onResizeEnd = () => {
|
|||||||
|
|
||||||
let resizeAnimationFrame: number | null = null;
|
let resizeAnimationFrame: number | null = null;
|
||||||
|
|
||||||
|
const trackInteractionMouse = () => trackInteraction('mouse');
|
||||||
|
const trackInteractionKeyboard = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Tab') trackInteraction('keyboard');
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('mousemove', onResizeMove);
|
document.addEventListener('mousemove', onResizeMove);
|
||||||
document.addEventListener('mouseup', onResizeEnd);
|
document.addEventListener('mouseup', onResizeEnd);
|
||||||
|
|
||||||
// NEW: Track global interactions
|
// NEW: Track global interactions
|
||||||
document.addEventListener('mousedown', () => trackInteraction('mouse'));
|
document.addEventListener('mousedown', trackInteractionMouse);
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', trackInteractionKeyboard);
|
||||||
if (e.key === 'Tab') trackInteraction('keyboard');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -84,10 +87,8 @@ onUnmounted(() => {
|
|||||||
document.removeEventListener('mouseup', onResizeEnd);
|
document.removeEventListener('mouseup', onResizeEnd);
|
||||||
|
|
||||||
// NEW: Cleanup listeners
|
// NEW: Cleanup listeners
|
||||||
document.removeEventListener('mousedown', () => trackInteraction('mouse'));
|
document.removeEventListener('mousedown', trackInteractionMouse);
|
||||||
document.removeEventListener('keydown', (e) => {
|
document.removeEventListener('keydown', trackInteractionKeyboard);
|
||||||
if (e.key === 'Tab') trackInteraction('keyboard');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const navKind = computed(() => {
|
const navKind = computed(() => {
|
||||||
@@ -100,9 +101,9 @@ const navKind = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<aside ref="sidenavRef" :class="[
|
<aside ref="sidenavRef" :class="[
|
||||||
'sidenav',
|
'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 ? 'sidenav--open' : 'sidenav--closed',
|
open ? 'w-full mr-2' : 'w-0 mr-0',
|
||||||
isResizing ? 'sidenav--resizing' : ''
|
isResizing ? 'transition-none' : ''
|
||||||
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="isMouseOver = true"
|
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="isMouseOver = true"
|
||||||
@mouseleave="isMouseOver = false" @focusin="isFocused = true" @focusout="onFocusOut">
|
@mouseleave="isMouseOver = false" @focusin="isFocused = true" @focusout="onFocusOut">
|
||||||
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
|
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
|
||||||
@@ -113,11 +114,11 @@ const navKind = computed(() => {
|
|||||||
<SidenavHeader v-if="navKind === 'home'" />
|
<SidenavHeader v-if="navKind === 'home'" />
|
||||||
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
|
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
|
||||||
|
|
||||||
<div class="flex items-center justify-end text-[var(--color-muted)] gap-0.5">
|
<div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
|
||||||
<div :style="isHovering ? 'width: 32px;' : 'width: 0px;'"
|
<div :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']">
|
: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']">
|
||||||
<button aria-label="close sidebar" @click="closeSidebar" :class="[
|
<button aria-label="close sidebar" @click="closeSidebar" :class="[
|
||||||
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] bg-transparent transition-inherit',
|
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent transition-inherit',
|
||||||
]">
|
]">
|
||||||
<Icon name="mynaui:panel-left-close"
|
<Icon name="mynaui:panel-left-close"
|
||||||
:class="['transition-inherit transform-origin-right-center', isHovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" />
|
:class="['transition-inherit transform-origin-right-center', isHovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" />
|
||||||
@@ -125,7 +126,7 @@ const navKind = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
|
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
|
||||||
<NuxtLink aria-label="Start a new topic" :to="`/agent/${route.params.id}`" :class="[
|
<NuxtLink aria-label="Start a new topic" :to="`/agent/${route.params.id}`" :class="[
|
||||||
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] bg-transparent text-inherit',
|
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent text-inherit',
|
||||||
]">
|
]">
|
||||||
<Icon name="mynaui:book-plus" />
|
<Icon name="mynaui:book-plus" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -134,7 +135,8 @@ const navKind = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Menu -->
|
<!-- Main Menu -->
|
||||||
<div class="max-h-full h-full overflow-auto" style="scrollbar-width: thin;">
|
<div
|
||||||
|
class="max-h-full h-full overflow-auto [scrollbar-color:#888_transparent] [scrollbar-width:thin] [scrollbar-gutter:stable]">
|
||||||
<SidenavNavHome v-if="navKind === 'home'" />
|
<SidenavNavHome v-if="navKind === 'home'" />
|
||||||
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
||||||
</div>
|
</div>
|
||||||
@@ -143,12 +145,11 @@ const navKind = computed(() => {
|
|||||||
<div class="flex justify-between pt-2">
|
<div class="flex justify-between pt-2">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<button @click="toggleSettings()"
|
<button @click="toggleSettings()"
|
||||||
class="flex items-center justify-center h-7 w-7 cursor-pointer hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg transition-colors text-[var(--color-muted)] active:text-[var(--color-text)]">
|
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)]">
|
||||||
<Icon name="mynaui:cog-four" class="text-4" />
|
<Icon name="mynaui:cog-four" class="text-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme Switcher -->
|
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
@@ -161,31 +162,3 @@ const navKind = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.sidenav {
|
|
||||||
height: 100%;
|
|
||||||
max-width: fit-content;
|
|
||||||
background: var(--color-base);
|
|
||||||
overflow: hidden;
|
|
||||||
will-change: width;
|
|
||||||
color: var(--color-muted);
|
|
||||||
user-select: none;
|
|
||||||
transition: width 250ms cubic-bezier(0, 0.55, 0.45, 1),
|
|
||||||
margin 250ms cubic-bezier(0, 0.55, 0.45, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidenav--open {
|
|
||||||
width: 100%;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidenav--closed {
|
|
||||||
width: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidenav--resizing {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -25,62 +25,13 @@ watch(() => props.checked, (newValue) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button :id="id" role="switch" class="vl-toggle-switch"
|
<button :id="id" role="switch"
|
||||||
|
class="vl-toggle-switch flex items-center w-[2.5em] h-[1.4em] rounded-[100px] bg-[var(--bg-container)] py-0.5 px-1 transition-[background-color] duration-300 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
||||||
:aria-disabled="(props.disabled === true) ? 'true' : 'false'" :aria-label="label" :aria-labelledby="id"
|
:aria-disabled="(props.disabled === true) ? 'true' : 'false'" :aria-label="label" :aria-labelledby="id"
|
||||||
:tabindex="(disabled) ? '-1' : '0'" @click="(e) => $emit('click', e)" :aria-checked="active"
|
:tabindex="(disabled) ? '-1' : '0'" @click="(e) => $emit('click', e)" :aria-checked="active"
|
||||||
:data-state="(active) ? 'checked' : 'unchecked'">
|
:data-state="(active) ? 'checked' : 'unchecked'">
|
||||||
<div></div>
|
<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">
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.vl-toggle-switch {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: inherit;
|
|
||||||
border: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 2.5em;
|
|
||||||
height: 1.4em;
|
|
||||||
background: var(--color-highlight);
|
|
||||||
border-radius: 100px;
|
|
||||||
padding: 0.125rem 0.25rem;
|
|
||||||
position: relative;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vl-toggle-switch[aria-disabled="true"] div {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vl-toggle-switch div {
|
|
||||||
transform-origin: center left;
|
|
||||||
will-change: transform;
|
|
||||||
position: relative;
|
|
||||||
left: 0;
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
background: #f7f7f7;
|
|
||||||
border-radius: 9999px;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vl-toggle-switch[data-state="checked"] {
|
|
||||||
background: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vl-toggle-switch[data-state="checked"] div {
|
|
||||||
transform-origin: center right;
|
|
||||||
transform: translateX(calc(2.5em - 1em - 0.5rem));
|
|
||||||
}
|
|
||||||
|
|
||||||
.vl-toggle-switch:active div {
|
|
||||||
width: 1.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vl-toggle-switch[data-state="checked"]:active div {
|
|
||||||
transform: translateX(calc(2.5em - 1.3em - 0.5rem));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -3,34 +3,38 @@ import type { DropdownItem } from '~/types/dropdown';
|
|||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
const colorMode = useColorMode();
|
const { updateSettings, settings } = await useUserSettings();
|
||||||
|
const { openDropdown, dropdownState, closeDropdown } = useDropdown();
|
||||||
|
|
||||||
|
const selectTheme = (colorScheme: Theme) => {
|
||||||
|
updateSettings({ appearance: { colorScheme } });
|
||||||
|
};
|
||||||
|
|
||||||
const themeOptions: DropdownItem[] = [
|
const themeOptions: DropdownItem[] = [
|
||||||
{ value: 'light', label: 'Light', icon: 'mynaui:sun' },
|
{ id: 'light', label: 'Light', icon: 'mynaui:sun', onClick: () => selectTheme('light') },
|
||||||
{ value: 'dark', label: 'Dark', icon: 'mynaui:moon' },
|
{ id: 'dark', label: 'Dark', icon: 'mynaui:moon', onClick: () => selectTheme('dark') },
|
||||||
{ value: 'system', label: 'System', icon: 'mynaui:desktop' },
|
{ id: 'system', label: 'System', icon: 'mynaui:desktop', onClick: () => selectTheme('system') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const currentOption = computed(
|
const currentOption = computed(
|
||||||
() => themeOptions.find((option) => option.value === colorMode.preference) || themeOptions[2],
|
() => themeOptions.find((option) => option.id === settings.value.appearance.colorScheme) || themeOptions[2],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isOpen = ref(false);
|
const toggleDropdown = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
const selectTheme = (item: DropdownItem) => {
|
if (dropdownState.open) {
|
||||||
colorMode.preference = item.value as Theme;
|
closeDropdown();
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openDropdown(e, () => themeOptions, { verticality: 'ascending', placement: 'right' });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dropdown class="relative" v-model="isOpen" @select="selectTheme" :items="themeOptions" verticality="asscending"
|
<button aria-label="Open theme switcher" @click="toggleDropdown"
|
||||||
placement="right" width="140px">
|
class="h-7 w-7 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)]">
|
||||||
<template #trigger="{ toggle, isOpen }">
|
<Icon :name="currentOption!.icon!" class="text-5 pointer-events-none" />
|
||||||
<button aria-label="Open theme switcher" @click="toggle" :class="[isOpen ? 'bg-[var(--color-highlight)] hover:text-[var(--color-subtle)] focus-visible:text-[var(--color-subtle)]' : 'bg-transparent text-[var(--color-muted)]',
|
</button>
|
||||||
'h-7 w-7 flex items-center justify-center rounded-lg hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-colors active:text-[var(--color-text)]'
|
|
||||||
]">
|
|
||||||
<Icon :name="currentOption!.icon!" class="text-4" />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</Dropdown>
|
|
||||||
</template>
|
</template>
|
||||||
@@ -4,7 +4,14 @@ import type { Entity } from "@triplit/client";
|
|||||||
export const useAgents = async () => {
|
export const useAgents = async () => {
|
||||||
const triplit = useTriplitClient();
|
const triplit = useTriplitClient();
|
||||||
|
|
||||||
const { results: agents, unsubscribe } = await useQuery('agents', triplit, triplit.query('agents').Include('topics'));
|
const { results: agents, unsubscribe } = await useQuery(
|
||||||
|
'agents',
|
||||||
|
triplit,
|
||||||
|
triplit
|
||||||
|
.query('agents')
|
||||||
|
.Include('topics', (rel) => rel('topics').Order('createdAt', 'DESC'))
|
||||||
|
.Order('createdAt', 'ASC')
|
||||||
|
);
|
||||||
|
|
||||||
const createAgent = async (): Promise<Readonly<Entity<typeof schema, 'agents'>> | null> => {
|
const createAgent = async (): Promise<Readonly<Entity<typeof schema, 'agents'>> | null> => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|||||||
+88
-41
@@ -1,8 +1,9 @@
|
|||||||
import type schema from "#triplit/schema";
|
import type schema from "#triplit/schema";
|
||||||
import type { Entity } from "@triplit/client";
|
import type { Entity } from "@triplit/client";
|
||||||
import type { ModelMessage } from "ai";
|
import type { ModelMessage } from "ai";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
import { decrypt, base64ToUint8Array } from "~/utils/crypto";
|
import { decrypt, base64ToUint8Array } from "~/utils/crypto";
|
||||||
import { type Result, Ok, Err } from "~~/types/result";
|
import { type Result, Ok, Err, attempt } from "~~/types/result";
|
||||||
import { assert } from "~~/utils/assert";
|
import { assert } from "~~/utils/assert";
|
||||||
|
|
||||||
export type MessageEntity = Entity<typeof schema, 'messages'> & {
|
export type MessageEntity = Entity<typeof schema, 'messages'> & {
|
||||||
@@ -22,6 +23,7 @@ export enum ChatErrorType {
|
|||||||
NoAgent,
|
NoAgent,
|
||||||
NoUser,
|
NoUser,
|
||||||
DatabaseOperationFailed,
|
DatabaseOperationFailed,
|
||||||
|
FailedToDecryptProviderApiKey,
|
||||||
GenerationFailed,
|
GenerationFailed,
|
||||||
MarshallFailed,
|
MarshallFailed,
|
||||||
NoProviderApiKey,
|
NoProviderApiKey,
|
||||||
@@ -195,22 +197,27 @@ export const useChat = (agentId: string) => {
|
|||||||
): Promise<Result<void, ChatErrorType>> => {
|
): Promise<Result<void, ChatErrorType>> => {
|
||||||
let providerApiKey: string | undefined = undefined;
|
let providerApiKey: string | undefined = undefined;
|
||||||
if (provider.config.apiKey !== undefined) {
|
if (provider.config.apiKey !== undefined) {
|
||||||
const key = await crypto.subtle.importKey(
|
try {
|
||||||
"jwk",
|
const key = await crypto.subtle.importKey(
|
||||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
"jwk",
|
||||||
"AES-GCM",
|
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||||
false,
|
"AES-GCM",
|
||||||
["encrypt", "decrypt"]
|
false,
|
||||||
)
|
["encrypt", "decrypt"]
|
||||||
|
)
|
||||||
|
|
||||||
providerApiKey = await decrypt(
|
providerApiKey = await decrypt(
|
||||||
key,
|
key,
|
||||||
base64ToUint8Array(provider.config.apiKey)
|
base64ToUint8Array(provider.config.apiKey)
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to decrypt provider API key:', error);
|
||||||
|
return Err(ChatErrorType.FailedToDecryptProviderApiKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$fetch('/api/chat/generate', {
|
await $fetch('/api/chat/generate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
messages,
|
messages,
|
||||||
@@ -249,14 +256,17 @@ export const useChat = (agentId: string) => {
|
|||||||
return Err(ChatErrorType.NoUser);
|
return Err(ChatErrorType.NoUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageId = nanoid();
|
||||||
const newMessage = await triplit.insert('messages', {
|
const newMessage = await triplit.insert('messages', {
|
||||||
|
id: messageId,
|
||||||
userId: user.value.id,
|
userId: user.value.id,
|
||||||
topicId: topic.id,
|
topicId: topic.id,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
content: message,
|
content: message,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
}).catch(error => {
|
}).catch(async error => {
|
||||||
console.error('Failed to insert message:', error);
|
console.error('Failed to insert message:', error);
|
||||||
|
await triplit.delete('messages', messageId);
|
||||||
return Err(ChatErrorType.DatabaseOperationFailed);
|
return Err(ChatErrorType.DatabaseOperationFailed);
|
||||||
}) as Message;
|
}) as Message;
|
||||||
|
|
||||||
@@ -278,7 +288,14 @@ export const useChat = (agentId: string) => {
|
|||||||
presence_penalty: 0,
|
presence_penalty: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
return startGeneration(messages.data, args, topic, provider, model)
|
return startGeneration(messages.data, args, topic, provider, model).then(async res => {
|
||||||
|
if (res.ok === false) {
|
||||||
|
console.error('Failed to start generation:', res.error);
|
||||||
|
await triplit.delete('messages', messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -373,60 +390,90 @@ export const useChat = (agentId: string) => {
|
|||||||
return startGeneration(messages.data, args, topic, provider, model, parentMessageId);
|
return startGeneration(messages.data, args, topic, provider, model, parentMessageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoRename = async (topicId: string, prompt: string) => {
|
enum AutoRenameError {
|
||||||
|
AutoRenameDisabled = 0,
|
||||||
|
NoModelSelected,
|
||||||
|
NoModelFound,
|
||||||
|
ModelDisabled,
|
||||||
|
DatabaseOperationFailed,
|
||||||
|
FailedToDecryptProviderApiKey,
|
||||||
|
FailedToGenerate,
|
||||||
|
}
|
||||||
|
const autoRename = async (topicId: string, prompt: string): Promise<Result<string, AutoRenameError>> => {
|
||||||
const { settings } = await useUserSettings();
|
const { settings } = await useUserSettings();
|
||||||
|
|
||||||
console.log(settings.value);
|
console.log(settings.value);
|
||||||
|
|
||||||
if (!settings.value.systemAssistants.rename.enabled) {
|
if (!settings.value.systemAssistants.rename.enabled) {
|
||||||
return false;
|
return Err(AutoRenameError.AutoRenameDisabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings.value.systemAssistants.rename.modelId) {
|
if (!settings.value.systemAssistants.rename.modelId) {
|
||||||
return false;
|
return Err(AutoRenameError.NoModelSelected);
|
||||||
}
|
}
|
||||||
|
|
||||||
await triplit.update('topics', topicId, {
|
await triplit.update('topics', topicId, {
|
||||||
renaming: true
|
renaming: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const model = await triplit.fetchOne(triplit.query('models').Where('id', '=', settings.value.systemAssistants.rename.modelId).Include('provider'));
|
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) {
|
if (!model) {
|
||||||
return false;
|
return Err(AutoRenameError.NoModelFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.enabled === false || model.provider?.enabled === false) {
|
||||||
|
return Err(AutoRenameError.ModelDisabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
let providerApiKey: string | undefined = undefined;
|
let providerApiKey: string | undefined = undefined;
|
||||||
if (model.provider!.config.apiKey !== undefined) {
|
if (model.provider!.config.apiKey !== undefined) {
|
||||||
const key = await crypto.subtle.importKey(
|
try {
|
||||||
"jwk",
|
const key = await crypto.subtle.importKey(
|
||||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
"jwk",
|
||||||
"AES-GCM",
|
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||||
false,
|
"AES-GCM",
|
||||||
["encrypt", "decrypt"]
|
false,
|
||||||
)
|
["encrypt", "decrypt"]
|
||||||
|
)
|
||||||
|
|
||||||
providerApiKey = await decrypt(
|
providerApiKey = await decrypt(
|
||||||
key,
|
key,
|
||||||
base64ToUint8Array(model.provider!.config.apiKey)
|
base64ToUint8Array(model.provider!.config.apiKey)
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to decrypt provider API key:', error);
|
||||||
|
return Err(AutoRenameError.FailedToDecryptProviderApiKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await $fetch(`/api/topic/auto-rename`, {
|
try {
|
||||||
method: 'POST',
|
const res = await $fetch(`/api/topic/auto-rename`, {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
modelId: model.id,
|
body: JSON.stringify({
|
||||||
topicId,
|
modelId: model.id,
|
||||||
prompt,
|
topicId,
|
||||||
providerApiKey,
|
prompt,
|
||||||
}),
|
providerApiKey,
|
||||||
});
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return Ok(res.renameId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to auto-rename:', error);
|
||||||
|
return Err(AutoRenameError.FailedToGenerate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
AutoRenameError,
|
||||||
autoRename,
|
autoRename,
|
||||||
regenerateMessage,
|
regenerateMessage,
|
||||||
createTopic,
|
createTopic,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { DropdownItem } from "~/types/dropdown"
|
||||||
|
|
||||||
|
interface DropdownOptions {
|
||||||
|
placement?: 'right' | 'left' | 'center';
|
||||||
|
verticality?: 'ascending' | 'descending';
|
||||||
|
width?: string | number;
|
||||||
|
minWidth?: string;
|
||||||
|
maxWidth?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DropdownState {
|
||||||
|
open: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
itemsFactory: (() => DropdownItem[]) | null;
|
||||||
|
options: DropdownOptions | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropdownState = reactive<DropdownState>({
|
||||||
|
open: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
itemsFactory: null,
|
||||||
|
options: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useDropdown = () => {
|
||||||
|
const openDropdown = (e: MouseEvent, itemsFactory: () => DropdownItem[], options?: DropdownOptions) => {
|
||||||
|
// TODO: take in placement logic and prefered verticality, and measure the space
|
||||||
|
// available in the direction of the prefered verticality, and if it doesnt fit
|
||||||
|
// in the direction of the placement, flip the placement
|
||||||
|
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
||||||
|
|
||||||
|
dropdownState.x = rect.left
|
||||||
|
dropdownState.y = rect.bottom
|
||||||
|
if (options) {
|
||||||
|
switch (options.placement) {
|
||||||
|
case 'right':
|
||||||
|
dropdownState.x = rect.right
|
||||||
|
dropdownState.y = rect.bottom
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
dropdownState.x = rect.left
|
||||||
|
dropdownState.y = rect.bottom
|
||||||
|
break;
|
||||||
|
case 'center':
|
||||||
|
dropdownState.x = rect.left + rect.width / 2
|
||||||
|
dropdownState.y = rect.bottom
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (options.verticality) {
|
||||||
|
case 'ascending':
|
||||||
|
dropdownState.y = rect.top;
|
||||||
|
break;
|
||||||
|
case 'descending':
|
||||||
|
dropdownState.y = rect.bottom;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dropdownState.itemsFactory = itemsFactory
|
||||||
|
dropdownState.options = options ?? null
|
||||||
|
dropdownState.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDropdown = () => {
|
||||||
|
dropdownState.open = false
|
||||||
|
dropdownState.itemsFactory = null
|
||||||
|
dropdownState.options = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dropdownState,
|
||||||
|
openDropdown,
|
||||||
|
closeDropdown,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,10 @@ export const useModels = async () => {
|
|||||||
.query('providers')
|
.query('providers')
|
||||||
.Include('models');
|
.Include('models');
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
nuxtApp._modelsPromise = useQuery('providers', triplit, providersQuery).then((sub) => {
|
nuxtApp._modelsPromise = useQuery('providers', triplit, providersQuery).then((sub) => {
|
||||||
nuxtApp._modelsSubscription = sub;
|
nuxtApp._modelsSubscription = sub;
|
||||||
|
console.log("fetching providers took", Date.now() - start);
|
||||||
return sub;
|
return sub;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const useSidebar = () => {
|
|||||||
const sidebarWidth = useState<number>('sidebar:width', () => {
|
const sidebarWidth = useState<number>('sidebar:width', () => {
|
||||||
return Number(
|
return Number(
|
||||||
useCookie('sidebar:width', {
|
useCookie('sidebar:width', {
|
||||||
default: () => '226',
|
default: () => '250',
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
}).value,
|
}).value,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,9 +13,64 @@ export const useTheme = () => {
|
|||||||
maxAge: 60 * 60 * 24 * 365,
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const colorScheme: { preference: Ref<'light' | 'dark' | 'system'>; value: Ref<'light' | 'dark'>; class: Ref<'light' | 'dark' | undefined> } = {
|
||||||
|
preference: useCookie('colorScheme', {
|
||||||
|
default: () => 'system',
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
}),
|
||||||
|
value: computed(() => {
|
||||||
|
if (colorScheme.preference.value === 'system') {
|
||||||
|
if (import.meta.client) {
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
|
return prefersDark ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
return colorScheme.preference.value as 'light' | 'dark';
|
||||||
|
}),
|
||||||
|
class: ref<undefined | 'light' | 'dark'>(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
let listeningToColorScheme = false;
|
||||||
|
const changeSystemColorScheme = (e: MediaQueryListEvent) => {
|
||||||
|
if (colorScheme.preference.value !== 'system') return;
|
||||||
|
|
||||||
|
colorScheme.class.value = e.matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
watch(colorScheme.preference, () => {
|
||||||
|
if (colorScheme.preference.value === 'system') {
|
||||||
|
if (import.meta.client) {
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
|
if (!listeningToColorScheme) {
|
||||||
|
listeningToColorScheme = true;
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', changeSystemColorScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
colorScheme.class.value = prefersDark ? 'dark' : 'light';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listeningToColorScheme) {
|
||||||
|
listeningToColorScheme = false;
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', changeSystemColorScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback to dark on the server
|
||||||
|
colorScheme.class.value = 'dark';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
colorScheme.class.value = colorScheme.preference.value as 'light' | 'dark';
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accent,
|
accent,
|
||||||
neutral,
|
neutral,
|
||||||
hinting,
|
hinting,
|
||||||
|
colorScheme
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,142 @@
|
|||||||
|
import { schema } from '#triplit/schema';
|
||||||
|
import { type Entity } from '@triplit/client';
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
|
||||||
|
// TODO: most of this code is generated by gemini 3 flash with fixups,
|
||||||
|
// but its still bad code, so I'm going to clean it up later
|
||||||
export const useUserSettings = async () => {
|
export const useUserSettings = async () => {
|
||||||
const user = useAuth().user;
|
const { user, loggedIn } = useAuth();
|
||||||
const triplit = useTriplitClient();
|
const triplit = useTriplitClient();
|
||||||
|
const { accent, neutral, hinting, colorScheme } = useTheme();
|
||||||
|
|
||||||
const { results: settings, unsubscribe } = await useQuery('settings', triplit, triplit.query('settings').Where('userId', '=', user.value!.id));
|
const start = Date.now();
|
||||||
|
const remoteSettings = useState<Entity<typeof schema, 'settings'> | null>('user:settings', () => null);
|
||||||
|
const { results } = await useQuery('settings', triplit, triplit.query('settings'))
|
||||||
|
console.log("fetching settings took", Date.now() - start);
|
||||||
|
|
||||||
return { settings: computed(() => settings.value![0]!), unsubscribe };
|
watch(results, (val) => {
|
||||||
}
|
if (!val) {
|
||||||
|
remoteSettings.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteSettings.value = val[0] as any;
|
||||||
|
}, { immediate: true, deep: true })
|
||||||
|
|
||||||
|
// Sync remote settings to local cookies
|
||||||
|
// This bridges the gap between the server state and the local CSS variable application
|
||||||
|
watch(remoteSettings, (newSettings) => {
|
||||||
|
if (!newSettings?.appearance) return;
|
||||||
|
const { appearance } = newSettings;
|
||||||
|
|
||||||
|
if (appearance.colorScheme && appearance.colorScheme !== colorScheme.preference.value) {
|
||||||
|
colorScheme.preference.value = appearance.colorScheme as 'light' | 'dark' | 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appearance.accent && appearance.accent !== accent.value) {
|
||||||
|
accent.value = appearance.accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appearance.neutral && appearance.neutral !== neutral.value) {
|
||||||
|
neutral.value = appearance.neutral;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appearance.hinting !== undefined && String(appearance.hinting) !== hinting.value) {
|
||||||
|
hinting.value = String(appearance.hinting);
|
||||||
|
}
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// Effective settings with defaults for backwards compatibility
|
||||||
|
const settings = computed(() => {
|
||||||
|
const remote = remoteSettings.value;
|
||||||
|
return {
|
||||||
|
appearance: {
|
||||||
|
colorScheme: remote?.appearance?.colorScheme ?? colorScheme.preference.value,
|
||||||
|
accent: remote?.appearance?.accent ?? accent.value,
|
||||||
|
neutral: remote?.appearance?.neutral ?? neutral.value,
|
||||||
|
hinting: remote?.appearance?.hinting ?? Number(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update settings both locally and on the server
|
||||||
|
*/
|
||||||
|
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) colorScheme.preference.value = updates.appearance.colorScheme;
|
||||||
|
if (updates.appearance.accent) accent.value = updates.appearance.accent;
|
||||||
|
if (updates.appearance.neutral) neutral.value = updates.appearance.neutral;
|
||||||
|
if (updates.appearance.hinting !== undefined) hinting.value = String(updates.appearance.hinting);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loggedIn.value || !user.value?.id) return;
|
||||||
|
|
||||||
|
const current = remoteSettings.value;
|
||||||
|
if (!current) {
|
||||||
|
await triplit.insert('settings', {
|
||||||
|
userId: user.value.id,
|
||||||
|
appearance: {
|
||||||
|
colorScheme: colorScheme.preference.value,
|
||||||
|
accent: accent.value,
|
||||||
|
neutral: neutral.value,
|
||||||
|
hinting: Number(hinting.value),
|
||||||
|
...(updates.appearance || {})
|
||||||
|
},
|
||||||
|
systemAssistants: {
|
||||||
|
rename: {
|
||||||
|
enabled: updates.systemAssistants?.rename?.enabled ?? true,
|
||||||
|
prompt: updates.systemAssistants?.rename?.prompt ?? null,
|
||||||
|
modelId: updates.systemAssistants?.rename?.modelId ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await triplit.update('settings', current.id, (s) => {
|
||||||
|
if (updates.appearance) {
|
||||||
|
s.appearance = {
|
||||||
|
...(s.appearance || {}),
|
||||||
|
...updates.appearance
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (updates.systemAssistants) {
|
||||||
|
// @ts-expect-error
|
||||||
|
s.systemAssistants = {
|
||||||
|
...(s.systemAssistants || {}),
|
||||||
|
...updates.systemAssistants
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
remoteSettings,
|
||||||
|
updateSettings,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full w-full grid place-items-center">
|
<div class="h-full w-full grid place-items-center">
|
||||||
<div
|
<div
|
||||||
class="max-w-xs p-4 bg-[var(--color-neutral)] border border-solid border-[var(--color-highlight)] w-full rounded-lg">
|
class="max-w-xs p-4 bg-[var(--bg-surface)] border border-solid border-[var(--color-border)] w-full rounded-lg">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -9,14 +9,14 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
input {
|
input {
|
||||||
color: var(--color-text);
|
color: var(--text-primary);
|
||||||
padding-inline: calc(var(--spacing) * 4);
|
padding-inline: calc(var(--spacing) * 4);
|
||||||
padding-block: calc(var(--spacing) * 2);
|
padding-block: calc(var(--spacing) * 2);
|
||||||
|
|
||||||
border-radius: calc(var(--spacing) * 1.5);
|
border-radius: calc(var(--spacing) * 1.5);
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--color-input);
|
background-color: var(--bg-container);
|
||||||
|
|
||||||
transition-property: color, border, background-color;
|
transition-property: color, border, background-color;
|
||||||
transition-duration: 300ms;
|
transition-duration: 300ms;
|
||||||
|
|||||||
+11
-2
@@ -2,17 +2,25 @@
|
|||||||
import SettingsDialog from '~/components/Settings/Dialog.vue';
|
import SettingsDialog from '~/components/Settings/Dialog.vue';
|
||||||
|
|
||||||
const { open: sidebarOpen, openSidebar } = useSidebar();
|
const { open: sidebarOpen, openSidebar } = useSidebar();
|
||||||
|
const { colorScheme } = useTheme();
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
htmlAttrs: {
|
||||||
|
class: colorScheme.class
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
useKeyboardShortcuts();
|
useKeyboardShortcuts();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Sidenav />
|
<Sidenav />
|
||||||
<main
|
<main
|
||||||
class="bg-[var(--color-neutral)] flex flex-col h-full w-full border border-solid border-[var(--color-highlight)] rounded-lg overflow-hidden">
|
class="bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden">
|
||||||
<div class="h-14 flex items-center justify-between px-4">
|
<div class="h-14 flex items-center justify-between px-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button v-if="!sidebarOpen" @click="openSidebar"
|
<button v-if="!sidebarOpen" @click="openSidebar"
|
||||||
class="flex p-2 rounded-lg text-[var(--color-text)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors">
|
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors">
|
||||||
<Icon class="text-5" name="mynaui:panel-left-open" />
|
<Icon class="text-5" name="mynaui:panel-left-open" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,4 +34,5 @@ useKeyboardShortcuts();
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<SettingsDialog />
|
<SettingsDialog />
|
||||||
|
<Dropdown />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
import type { ModelWithProvider } from '~/composables/useModels';
|
||||||
|
|
||||||
|
const triplit = useTriplitClient();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const inputValue = ref('');
|
||||||
const pendingMessage = ref<Message | null>(null);
|
const pendingMessage = ref<Message | null>(null);
|
||||||
|
|
||||||
const { createTopic, sendMessage, autoRename } = useChat(route.params.id as string);
|
const { createTopic, sendMessage, autoRename } = useChat(route.params.id as string);
|
||||||
@@ -49,11 +52,38 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
|
|||||||
const topic = await createTopic();
|
const topic = await createTopic();
|
||||||
if (!topic) throw new Error('Failed to create topic');
|
if (!topic) throw new Error('Failed to create topic');
|
||||||
|
|
||||||
autoRename(topic.id, message);
|
autoRename(topic.id, message).then(async res => {
|
||||||
|
if (res.ok === false) {
|
||||||
|
console.error('Failed to auto-rename:', res.error);
|
||||||
|
await triplit.update('topics', topic.id, {
|
||||||
|
renaming: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
|
await navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
|
||||||
|
|
||||||
return sendMessage(message, topic, [], agent.value!, model.provider, model);
|
return sendMessage(message, topic, [], agent.value!, model.provider, model).then(async res => {
|
||||||
|
if (res.ok === false) {
|
||||||
|
console.error('Failed to send message:', res.error);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -64,28 +94,22 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full w-full">
|
<div
|
||||||
<!-- chat pane -->
|
class="chat-scroll-container justify-center w-full h-full [scrollbar-width:thin] [scrollbar-color:#888_transparent] overflow-y-scroll overflow-x-hidden flex justify-center">
|
||||||
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
|
<div class="chatPane max-w-4xl w-full min-h-full flex flex-col px-4">
|
||||||
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
|
<div class="w-full px-px flex flex-col flex-grow gap-2"
|
||||||
<div class="flex-grow w-full flex justify-center">
|
:class="pendingMessage === null ? 'justify-end pb-28' : 'pb-9'">
|
||||||
<div class="flex h-full max-w-4xl w-full flex-col gap-2"
|
<div v-if="pendingMessage === null">
|
||||||
:class="pendingMessage === null ? 'justify-end' : ''">
|
<h1 v-if="agent" class="font-bold">{{ agent.name }}</h1>
|
||||||
<div v-if="pendingMessage === null">
|
<p class="text-[var(--text-secondary)]">Select a topic to continue or create a new one</p>
|
||||||
<h1 v-if="agent" class="font-bold">{{ agent.name }}</h1>
|
</div>
|
||||||
<p class="mb-28 text-[var(--color-muted)]">Select a topic to continue or create a new one</p>
|
<div v-else class="opacity-70">
|
||||||
</div>
|
<Message :message="pendingMessage" />
|
||||||
<div class="opacity-70" v-else>
|
|
||||||
<Message :message="pendingMessage" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
|
||||||
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center">
|
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
|
||||||
<div class="pb-4 w-full max-w-4xl bg-[var(--color-neutral)] rounded-t-2xl">
|
:agent="agent" :providers="providers?.filter(p => p.enabled)" @submit="handleSubmit" />
|
||||||
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" :agent="agent"
|
|
||||||
:providers="providers.filter(p => p.enabled)" @submit="handleSubmit"></ChatInput>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ onUnmounted(() => {
|
|||||||
<Icon v-else name="mynaui:check-hexagon" class="text-16" />
|
<Icon v-else name="mynaui:check-hexagon" class="text-16" />
|
||||||
</div>
|
</div>
|
||||||
<input @input="handleInput" placeholder="Agent Name..."
|
<input @input="handleInput" placeholder="Agent Name..."
|
||||||
class="placeholder:text-[var(--color-highlight)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-highlight-high)] text-12 p-0"
|
class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-border)] text-12 p-0"
|
||||||
type="text" :value="agent?.name" />
|
type="text" :value="agent?.name" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 w-full h-full mb-14">
|
<div class="flex flex-col gap-2 w-full h-full mb-14">
|
||||||
<label class="text-sm text-[var(--color-text-subtle)]">System Message</label>
|
<label class="text-sm text-[var(--text-secondary)]">System Message</label>
|
||||||
<textarea placeholder="You are a helpful assistant."
|
<textarea placeholder="You are a helpful assistant."
|
||||||
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-highlight)]"
|
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-border)]"
|
||||||
:value="agent?.systemPrompt" @input="changeSystemPrompt"></textarea>
|
:value="agent?.systemPrompt" @input="changeSystemPrompt"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Message, MessageEntity } from '~/composables/useChat';
|
import type schema from '#triplit/schema';
|
||||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
import type { Entity } from '@triplit/client';
|
||||||
|
import type { ModelWithProvider } from '~/composables/useModels';
|
||||||
|
|
||||||
|
const rootStart = Date.now();
|
||||||
const triplit = useTriplitClient();
|
const triplit = useTriplitClient();
|
||||||
|
|
||||||
const chatPane = ref<HTMLElement | null>(null);
|
const chatPaneWrapper = ref<HTMLElement | null>(null);
|
||||||
|
const inputValue = ref('');
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { sendMessage, regenerateMessage } = useChat(route.params.id as string);
|
const { sendMessage, regenerateMessage } = useChat(route.params.id as string);
|
||||||
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
|
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
|
||||||
const { providers, unsubscribe: unsubscribeModels, allModels } = await useModels();
|
const { providers, allModels, unsubscribe: unsubscribeModels } = await useModels();
|
||||||
|
|
||||||
const agent = computed(() => {
|
const agent = computed(() => {
|
||||||
if (route.params.id === null || typeof route.params.id !== 'string') {
|
if (route.params.id === null || typeof route.params.id !== 'string') {
|
||||||
@@ -59,39 +62,42 @@ const [
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const topic = computed(() => {
|
const topic = computed(() => {
|
||||||
if (!rawMessages.value || !rawTopic.value || !rawTopic.value[0]) return null;
|
if (!rawMessages.value || !rawTopic.value?.[0]) return null;
|
||||||
|
|
||||||
// Build the messages tree manually for maximum performance
|
|
||||||
const messagesMap = new Map();
|
const messagesMap = new Map();
|
||||||
|
const partsByMessage = new Map<string, Entity<typeof schema, 'message_parts'>[]>();
|
||||||
|
|
||||||
// First pass: Create message objects with parts arrays
|
// Group parts by message ID once
|
||||||
for (const msg of rawMessages.value) {
|
|
||||||
messagesMap.set(msg.id, {
|
|
||||||
...msg,
|
|
||||||
parts: [],
|
|
||||||
children: [],
|
|
||||||
generation: rawGenerations.value?.find(g => g.id === msg.generationId) ?? null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second pass: Attach parts to messages
|
|
||||||
if (rawParts.value) {
|
if (rawParts.value) {
|
||||||
for (const part of rawParts.value) {
|
for (const part of rawParts.value) {
|
||||||
const msg = messagesMap.get(part.messageId);
|
if (!partsByMessage.has(part.messageId)) {
|
||||||
if (msg) {
|
partsByMessage.set(part.messageId, []);
|
||||||
// Filter empty parts here if needed, or just push
|
}
|
||||||
if (part.content !== '' || part.toolCall !== null) {
|
if (part.content !== '' || part.toolCall !== null) {
|
||||||
msg.parts.push(part);
|
partsByMessage.get(part.messageId)!.push(part);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Third pass: Build children relationships
|
const generationsMap = new Map(
|
||||||
const rootMessages = [];
|
rawGenerations.value?.map(g => [g.id, g]) ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Single pass to build messages
|
||||||
|
for (const msg of rawMessages.value) {
|
||||||
|
messagesMap.set(msg.id, {
|
||||||
|
...msg,
|
||||||
|
parts: partsByMessage.get(msg.id) ?? [],
|
||||||
|
children: [],
|
||||||
|
generation: generationsMap.get(msg.generationId!) ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tree
|
||||||
|
const rootMessages: Message[] = [];
|
||||||
for (const msg of messagesMap.values()) {
|
for (const msg of messagesMap.values()) {
|
||||||
if (msg.parentMessageId && messagesMap.has(msg.parentMessageId)) {
|
if (msg.parentMessageId && messagesMap.has(msg.parentMessageId)) {
|
||||||
messagesMap.get(msg.parentMessageId).children.push(msg);
|
messagesMap.get(msg.parentMessageId)!.children.push(msg);
|
||||||
} else {
|
} else {
|
||||||
rootMessages.push(msg);
|
rootMessages.push(msg);
|
||||||
}
|
}
|
||||||
@@ -104,26 +110,7 @@ const topic = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!topic.value) navigateTo(`/agent/${route.params.id}`);
|
const submitMessage = async (message: string, model: ModelWithProvider | null) => {
|
||||||
|
|
||||||
const activeGeneration = computed(() => {
|
|
||||||
if (topic.value === null) return null;
|
|
||||||
return topic.value?.generations?.find((generation) => generation.status === 'pending') ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const { scrollToBottom } = useAutoScroll(chatPane);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
scrollToBottom('instant');
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCancel = async () => {
|
|
||||||
await $fetch(`/api/chat/cancel/${activeGeneration.value!.id}`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (message: string, model: ModelWithProvider | null) => {
|
|
||||||
if (!model) {
|
if (!model) {
|
||||||
console.error('No model selected');
|
console.error('No model selected');
|
||||||
return;
|
return;
|
||||||
@@ -132,6 +119,14 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
|
|||||||
const res = await sendMessage(message, topic.value!, focusedMessageTree.value, agent.value!, model.provider, model);
|
const res = await sendMessage(message, topic.value!, focusedMessageTree.value, agent.value!, model.provider, model);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error('Failed to send message:', res.error);
|
console.error('Failed to send message:', res.error);
|
||||||
|
const chatInput = document.getElementById('chat') as HTMLInputElement | null;
|
||||||
|
console.log("chat input", chatInput, message);
|
||||||
|
if (chatInput) {
|
||||||
|
inputValue.value = message;
|
||||||
|
nextTick(() => {
|
||||||
|
chatInput.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +134,7 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const focusedMessageTree = computed(() => {
|
const focusedMessageTree = computed(() => {
|
||||||
const tree: MessageEntity[] = [];
|
const tree: Readonly<MessageEntity>[] = [];
|
||||||
for (const message of topic.value?.messages || []) {
|
for (const message of topic.value?.messages || []) {
|
||||||
if (message.focusedIndex !== undefined && message.focusedIndex !== null) {
|
if (message.focusedIndex !== undefined && message.focusedIndex !== null) {
|
||||||
if (message.focusedIndex === 0) {
|
if (message.focusedIndex === 0) {
|
||||||
@@ -156,7 +151,7 @@ const focusedMessageTree = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleRegenerate = async (message: Message) => {
|
const handleRegenerate = async (message: Message) => {
|
||||||
if (!agent.value.defaultModelId) {
|
if (!agent.value!.defaultModelId) {
|
||||||
console.error('No model selected');
|
console.error('No model selected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -172,7 +167,7 @@ const handleRegenerate = async (message: Message) => {
|
|||||||
messageId = message.id;
|
messageId = message.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = allModels.value.find(m => m.id === agent.value.defaultModelId);
|
const model = allModels.value.find(m => m.id === agent.value!.defaultModelId);
|
||||||
if (!model) {
|
if (!model) {
|
||||||
console.error('Model not found');
|
console.error('Model not found');
|
||||||
return;
|
return;
|
||||||
@@ -254,36 +249,54 @@ const handleDelete = async (rootMessage: Message) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeGeneration = computed(() => {
|
||||||
|
if (topic.value === null) return null;
|
||||||
|
return topic.value?.generations?.find((generation) => generation.status === 'pending') ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { scrollToBottom } = useAutoScroll(chatPaneWrapper);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
scrollToBottom('instant');
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
await $fetch(`/api/chat/cancel/${activeGeneration.value?.id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("full page render took", Date.now() - rootStart);
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
unsubscribeTopic?.();
|
unsubscribeTopic?.();
|
||||||
unsubscribeAgents?.();
|
|
||||||
unsubscribeMessages?.();
|
unsubscribeMessages?.();
|
||||||
unsubscribeParts?.();
|
unsubscribeParts?.();
|
||||||
unsubscribeGenerations?.();
|
unsubscribeGenerations?.();
|
||||||
|
unsubscribeAgents?.();
|
||||||
unsubscribeModels?.();
|
unsubscribeModels?.();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full w-full">
|
<!-- chat pane -->
|
||||||
<!-- chat pane -->
|
<div ref="chatPaneWrapper"
|
||||||
<div class="flex flex-col w-full px-4 overflow-y-auto h-full"
|
class="chat-scroll-container justify-center w-full h-full [scrollbar-width:thin] [scrollbar-color:#888_transparent] overflow-y-scroll overflow-x-hidden flex justify-center">
|
||||||
style="scrollbar-width: thin; scrollbar-color: #888 transparent;" ref="chatPane">
|
<div class="chatPane max-w-4xl w-full min-h-full flex flex-col px-4">
|
||||||
<div class="flex-grow w-full flex justify-center">
|
<div class="w-full px-px flex flex-col flex-grow gap-2 pb-9">
|
||||||
<div class="max-w-4xl w-full flex flex-col gap-2 pb-9"
|
<Suspense>
|
||||||
v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
|
<template v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
|
||||||
<Message v-for="message in topic.messages" @delete="handleDelete(message)" :key="message.id"
|
<Message v-for="message in topic.messages" :key="message.id" :message="message"
|
||||||
@regenerate="handleRegenerate(message)" :message="message" />
|
v-memo="[message.id, message.parts?.length, message.children, message.focusedIndex, message.content]"
|
||||||
</div>
|
@delete="handleDelete(message)" @regenerate="handleRegenerate(message)" />
|
||||||
|
</template>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sticky max-h-full z-10 bottom-0 w-full flex justify-center">
|
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
|
||||||
<div class="pb-4 w-full max-w-4xl bg-[var(--color-neutral)] rounded-t-2xl">
|
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
|
||||||
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
|
:loading="activeGeneration !== null" :agent="agent" :providers="providers?.filter(p => p.enabled)"
|
||||||
:loading="activeGeneration !== null" :agent="agent"
|
@submit="submitMessage" @cancel="handleCancel" />
|
||||||
:providers="providers?.filter(p => p.enabled)" @submit="handleSubmit" @cancel="handleCancel">
|
|
||||||
</ChatInput>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+32
-27
@@ -3,6 +3,8 @@ import type schema from '#triplit/schema';
|
|||||||
import type { Entity } from '@triplit/client';
|
import type { Entity } from '@triplit/client';
|
||||||
import { assert } from '~~/utils/assert';
|
import { assert } from '~~/utils/assert';
|
||||||
|
|
||||||
|
const triplit = useTriplitClient();
|
||||||
|
|
||||||
const { agents, unsubscribe: unsubscribeAgents, createAgent } = await useAgents();
|
const { agents, unsubscribe: unsubscribeAgents, createAgent } = await useAgents();
|
||||||
const { providers, unsubscribe: unsubscribeModels, getFirstAvailableModel, allModels } = await useModels();
|
const { providers, unsubscribe: unsubscribeModels, getFirstAvailableModel, allModels } = await useModels();
|
||||||
|
|
||||||
@@ -108,9 +110,34 @@ const handleChatSubmit = async (message: string, model: ModelWithProvider | null
|
|||||||
|
|
||||||
await navigateTo(`/agent/${agent.id}/topic/${topic.id}`);
|
await navigateTo(`/agent/${agent.id}/topic/${topic.id}`);
|
||||||
|
|
||||||
autoRename(topic.id, message);
|
autoRename(topic.id, message).then(async res => {
|
||||||
|
if (res.ok === false) {
|
||||||
|
console.error('Failed to auto-rename:', res.error);
|
||||||
|
await triplit.update('topics', topic.id, {
|
||||||
|
renaming: false,
|
||||||
|
});
|
||||||
|
|
||||||
return sendMessage(message, topic, [], agent, model.provider, model);
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendMessage(message, topic, [], agent, model.provider, model).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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -142,7 +169,9 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center pt-12 px-4 h-full gap-12">
|
<div class="flex flex-col items-center pt-12 px-4 h-full gap-12">
|
||||||
<h1 class="text-center text-3xl font-semibold">{{ animatedText }}<span class="cursor"> </span></h1>
|
<h1 class="text-center text-3xl font-semibold">{{ animatedText }}<span
|
||||||
|
class="animate-blink inline-block w-4 h-[0.9em] bg-current align-middle ml-0.5 select-none"> </span>
|
||||||
|
</h1>
|
||||||
<div class="max-w-4xl h-full w-full">
|
<div class="max-w-4xl h-full w-full">
|
||||||
<!-- TODO: view transitions have caused me issues with the page flashing with no content (so just a black or white screen depending on the theme) so I have disabled them for now. -->
|
<!-- TODO: view transitions have caused me issues with the page flashing with no content (so just a black or white screen depending on the theme) so I have disabled them for now. -->
|
||||||
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" :agent="agent"
|
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" :agent="agent"
|
||||||
@@ -150,27 +179,3 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
.cursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 1rem;
|
|
||||||
height: 0.9em;
|
|
||||||
background-color: currentColor;
|
|
||||||
animation: blink 1s step-end infinite;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-left: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -23,6 +23,7 @@ export default defineNuxtPlugin({
|
|||||||
|
|
||||||
assert('updateOptions' in triplit);
|
assert('updateOptions' in triplit);
|
||||||
triplit.updateOptions({
|
triplit.updateOptions({
|
||||||
|
serverUrl: process.env.NUXT_LOCAL_TRIPLIT_URL || process.env.NUXT_PUBLIC_TRIPLIT_URL,
|
||||||
token: session.value.token,
|
token: session.value.token,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
.use(remarkGfm)
|
.use(remarkGfm)
|
||||||
.use(remarkMath)
|
.use(remarkMath)
|
||||||
.use(remarkRehype, { allowDangerousHtml: true })
|
.use(remarkRehype, { allowDangerousHtml: true })
|
||||||
.use(rehypeKatex);
|
.use(rehypeKatex, { output: 'mathml' });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provide: {
|
provide: {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import * as Comlink from 'comlink'
|
||||||
|
import type { ShikiWorker } from '~/workers/shiki'
|
||||||
|
|
||||||
|
let shikiWorker: ShikiWorker | null = null
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(async (nuxtApp) => {
|
||||||
|
if (shikiWorker) {
|
||||||
|
return {
|
||||||
|
provide: {
|
||||||
|
shiki: shikiWorker,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = new Worker(new URL('~/workers/shiki.ts', import.meta.url), { type: 'module' });
|
||||||
|
const shiki = Comlink.wrap<ShikiWorker>(worker);
|
||||||
|
|
||||||
|
await shiki.init();
|
||||||
|
|
||||||
|
return {
|
||||||
|
provide: {
|
||||||
|
shiki,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { createShiki } from '~/utils/shiki'
|
||||||
|
import { type HighlighterCore } from 'shiki/core'
|
||||||
|
|
||||||
|
let shiki: HighlighterCore | null = null
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(async (nuxtApp) => {
|
||||||
|
if (shiki) {
|
||||||
|
return {
|
||||||
|
provide: {
|
||||||
|
shiki,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// to prevent lazy loading, we provide the shiki highlighter
|
||||||
|
// via a plugin.
|
||||||
|
shiki = await createShiki()
|
||||||
|
|
||||||
|
return {
|
||||||
|
provide: {
|
||||||
|
shiki,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
export interface DropdownItem {
|
export interface DropdownItem {
|
||||||
|
id?: string;
|
||||||
|
active?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
value?: string | number;
|
danger?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
divider?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ export const providerBaseUrls = {
|
|||||||
cerebras: 'https://api.cerebras.ai/v1',
|
cerebras: 'https://api.cerebras.ai/v1',
|
||||||
google: 'https://generativelanguage.googleapis.com/v1beta',
|
google: 'https://generativelanguage.googleapis.com/v1beta',
|
||||||
longcat: 'https://api.longcat.chat/openai/v1',
|
longcat: 'https://api.longcat.chat/openai/v1',
|
||||||
cohere: 'https://api.cohere.ai/v1',
|
cohere: 'https://api.cohere.ai/v2',
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Model = Entity<typeof schema, 'models'> & { provider: Entity<typeof schema, 'providers'> };
|
export type Model = Entity<typeof schema, 'models'> & { provider: Entity<typeof schema, 'providers'> };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const hash = async (text: string) => {
|
|||||||
return hashHex;
|
return hashHex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// simple djb2 hash
|
||||||
export const hashSync = (text: string) => {
|
export const hashSync = (text: string) => {
|
||||||
let hash = 5381;
|
let hash = 5381;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|||||||
+29
-13
@@ -1,25 +1,34 @@
|
|||||||
import {
|
import {
|
||||||
|
LogoOpenAI,
|
||||||
|
LogoOpenrouter,
|
||||||
|
LogoOllama,
|
||||||
|
LogoCerebras,
|
||||||
|
LogoLongCat,
|
||||||
|
LogoGoogle,
|
||||||
LogoGrok,
|
LogoGrok,
|
||||||
LogoGemini,
|
LogoGemini,
|
||||||
|
LogoGemma,
|
||||||
|
LogoDeepMind,
|
||||||
|
LogoVertexAI,
|
||||||
|
LogoDeepCogito,
|
||||||
LogoQwen,
|
LogoQwen,
|
||||||
LogoOpenAI,
|
|
||||||
LogoArcee,
|
LogoArcee,
|
||||||
LogoAi2,
|
LogoAi2,
|
||||||
LogoOpenrouter,
|
|
||||||
LogoMoonshot,
|
LogoMoonshot,
|
||||||
LogoZAI,
|
LogoZAI,
|
||||||
LogoGLMV,
|
LogoGLMV,
|
||||||
LogoChatGLM,
|
LogoChatGLM,
|
||||||
LogoNvidia,
|
LogoNvidia,
|
||||||
LogoGemma,
|
|
||||||
LogoMistral,
|
LogoMistral,
|
||||||
LogoMeta,
|
LogoMeta,
|
||||||
LogoPerplexity,
|
LogoPerplexity,
|
||||||
LogoDeepCogito,
|
|
||||||
LogoDeepSeek,
|
LogoDeepSeek,
|
||||||
|
LogoClaude,
|
||||||
LogoAnthropic,
|
LogoAnthropic,
|
||||||
LogoMorph,
|
LogoMorph,
|
||||||
|
LogoNousResearch,
|
||||||
LogoAi21,
|
LogoAi21,
|
||||||
|
LogoWenxin,
|
||||||
LogoBaiduCloud,
|
LogoBaiduCloud,
|
||||||
LogoMinimax,
|
LogoMinimax,
|
||||||
LogoNova,
|
LogoNova,
|
||||||
@@ -31,24 +40,18 @@ import {
|
|||||||
LogoXiaomiMiMo,
|
LogoXiaomiMiMo,
|
||||||
LogoRelace,
|
LogoRelace,
|
||||||
LogoEssentialAI,
|
LogoEssentialAI,
|
||||||
LogoClaude,
|
|
||||||
LogoKwaipilot,
|
LogoKwaipilot,
|
||||||
LogoIBM,
|
LogoIBM,
|
||||||
LogoInternLM,
|
LogoInternLM,
|
||||||
LogoLongCat,
|
|
||||||
LogoWenxin,
|
|
||||||
LogoHunyuan,
|
LogoHunyuan,
|
||||||
LogoInception,
|
LogoInception,
|
||||||
|
LogoAya,
|
||||||
LogoCohere,
|
LogoCohere,
|
||||||
LogoAionLabs,
|
LogoAionLabs,
|
||||||
LogoMicrosoft,
|
LogoMicrosoft,
|
||||||
LogoInflection,
|
LogoInflection,
|
||||||
LogoAya,
|
|
||||||
LogoDeepMind,
|
|
||||||
LogoVertexAI,
|
|
||||||
LogoGoogle
|
|
||||||
} from '#components';
|
} from '#components';
|
||||||
import NousResearch from '~/components/Logo/NousResearch.vue';
|
import { markRaw } from 'vue';
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
Icon: any;
|
Icon: any;
|
||||||
@@ -210,7 +213,7 @@ const MODEL_MAPPINGS: ModelConfig[] = [
|
|||||||
keywords: [/^morph-/, /\/morph-/]
|
keywords: [/^morph-/, /\/morph-/]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: markRaw(NousResearch),
|
Icon: markRaw(LogoNousResearch),
|
||||||
keywords: [/deephermes/, /hermes/, /genstruct/, /minos/]
|
keywords: [/deephermes/, /hermes/, /genstruct/, /minos/]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -311,6 +314,19 @@ const MODEL_MAPPINGS: ModelConfig[] = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const providerIcons: Record<string, any | null> = {
|
||||||
|
ollama: markRaw(LogoOllama),
|
||||||
|
google: markRaw(LogoGoogle),
|
||||||
|
openai: markRaw(LogoOpenAI),
|
||||||
|
azure: null,
|
||||||
|
anthropic: null,
|
||||||
|
cohere: markRaw(LogoCohere),
|
||||||
|
huggingface: null,
|
||||||
|
openrouter: markRaw(LogoOpenrouter),
|
||||||
|
cerebras: markRaw(LogoCerebras),
|
||||||
|
longcat: markRaw(LogoLongCat),
|
||||||
|
};
|
||||||
|
|
||||||
export function getModelConfig(modelId: string) {
|
export function getModelConfig(modelId: string) {
|
||||||
const cleanId = modelId.toLowerCase();
|
const cleanId = modelId.toLowerCase();
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
export const initSettings = async (userId: string) => {
|
export const initSettings = async (userId: string) => {
|
||||||
const triplit = useTriplitClient();
|
const triplit = useTriplitClient();
|
||||||
|
|
||||||
console.log(userId);
|
|
||||||
const settings = await triplit.fetchOne(triplit.query('settings').Where('userId', '=', userId));
|
const settings = await triplit.fetchOne(triplit.query('settings').Where('userId', '=', userId));
|
||||||
console.log(settings);
|
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
console.log('no settings');
|
console.log('no settings');
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { createHighlighterCore } from 'shiki/core'
|
||||||
|
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
|
||||||
|
|
||||||
|
import vitesseDark from '@shikijs/themes/vitesse-dark'
|
||||||
|
import vitesseLight from '@shikijs/themes/vitesse-light'
|
||||||
|
|
||||||
|
import langBash from '@shikijs/langs/bash'
|
||||||
|
import langVue from '@shikijs/langs/vue'
|
||||||
|
import langTsx from '@shikijs/langs/tsx'
|
||||||
|
import langJavascript from '@shikijs/langs/javascript'
|
||||||
|
import langTypescript from '@shikijs/langs/typescript'
|
||||||
|
import langPython from '@shikijs/langs/python'
|
||||||
|
import langCss from '@shikijs/langs/css'
|
||||||
|
import langHtml from '@shikijs/langs/html'
|
||||||
|
import langMarkdown from '@shikijs/langs/markdown'
|
||||||
|
import langJSON from '@shikijs/langs/json'
|
||||||
|
|
||||||
|
export const createShiki = () => createHighlighterCore({
|
||||||
|
themes: [
|
||||||
|
vitesseDark,
|
||||||
|
vitesseLight,
|
||||||
|
],
|
||||||
|
langs: [
|
||||||
|
langTsx,
|
||||||
|
langJavascript,
|
||||||
|
langTypescript,
|
||||||
|
langBash,
|
||||||
|
langVue,
|
||||||
|
langPython,
|
||||||
|
langCss,
|
||||||
|
langHtml,
|
||||||
|
langMarkdown,
|
||||||
|
langJSON
|
||||||
|
],
|
||||||
|
langAlias: {
|
||||||
|
jsx: 'tsx',
|
||||||
|
},
|
||||||
|
engine: createOnigurumaEngine(import('shiki/wasm')),
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const sortByReleaseDate = (a: { releasedAt?: Date | null } & Record<string, unknown>, b: { releasedAt?: Date | null } & Record<string, unknown>) => {
|
||||||
|
if (!a.releasedAt && !b.releasedAt) return 0;
|
||||||
|
if (!a.releasedAt) return 1;
|
||||||
|
if (!b.releasedAt) return -1;
|
||||||
|
|
||||||
|
return b.releasedAt.getTime() - a.releasedAt.getTime();
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createShiki } from '~/utils/shiki'
|
||||||
|
import { type HighlighterCore } from 'shiki/core'
|
||||||
|
|
||||||
|
import { expose } from 'comlink'
|
||||||
|
|
||||||
|
let shiki: HighlighterCore | null = null
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
async init() {
|
||||||
|
if (shiki) return
|
||||||
|
shiki = await createShiki()
|
||||||
|
},
|
||||||
|
async codeToHtml(code: string, options: { lang: string, themes: { light: string, dark: string } }) {
|
||||||
|
if (!shiki) throw new Error('shiki not initialized')
|
||||||
|
return shiki.codeToHtml(code, options)
|
||||||
|
},
|
||||||
|
async getLanguage(lang: string) {
|
||||||
|
if (!shiki) throw new Error('shiki not initialized')
|
||||||
|
const grammar = shiki.getLanguage(lang);
|
||||||
|
return { name: grammar.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expose(api)
|
||||||
|
export type ShikiWorker = typeof api
|
||||||
@@ -16,6 +16,8 @@ export const auth = betterAuth({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
session: {
|
session: {
|
||||||
|
expiresIn: 60 * 60 * 24 * 30, // 1 month
|
||||||
|
updateAge: 60 * 60 * 24 * 1, // 1 day
|
||||||
cookieCache: {
|
cookieCache: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
maxAge: 5 * 60,
|
maxAge: 5 * 60,
|
||||||
|
|||||||
+80
-24
@@ -20,21 +20,63 @@ export default defineNuxtConfig({
|
|||||||
'LLM, AI, Chat, Assistant, Agent, LLMs, OpenAI, GPT, GPT-3, GPT-4, Claude, ChatGPT, Whisper, Bard, Bing, Anthropic, DeepAI, Dolly, StableLM, Vicuna, Llama, Alpaca, ChatGLM, MOSS, MPT, Codex, Codex2, Codex 3, Codex 4, Falcon, Flan-T5, T5, Llama2, LLaMA, LLaMA2, StableLM, OpenAssistant, OpenChat',
|
'LLM, AI, Chat, Assistant, Agent, LLMs, OpenAI, GPT, GPT-3, GPT-4, Claude, ChatGPT, Whisper, Bard, Bing, Anthropic, DeepAI, Dolly, StableLM, Vicuna, Llama, Alpaca, ChatGLM, MOSS, MPT, Codex, Codex2, Codex 3, Codex 4, Falcon, Flan-T5, T5, Llama2, LLaMA, LLaMA2, StableLM, OpenAssistant, OpenChat',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
link: [
|
|
||||||
{
|
|
||||||
rel: 'stylesheet',
|
|
||||||
href: 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
script: [
|
script: [
|
||||||
{
|
{
|
||||||
src: 'https://cdn.jsdelivr.net/npm/eruda',
|
innerHTML: `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
const cookies = document.cookie.split('; ');
|
||||||
|
const colorSchemeCookie = cookies.find(row => row.startsWith('colorScheme='));
|
||||||
|
const colorScheme = colorSchemeCookie ? decodeURIComponent(colorSchemeCookie.split('=')[1]) : 'system';
|
||||||
|
|
||||||
|
if (colorScheme === 'system') {
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(prefersDark ? 'dark' : 'light');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(colorScheme);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
tagPosition: 'head',
|
||||||
|
type: 'text/javascript',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
innerHTML: `
|
innerHTML: `
|
||||||
eruda.init();
|
(function() {
|
||||||
|
const parent = document.createElement("div");
|
||||||
|
parent.setAttribute("style", "width:30px;height:30px;overflow:auto;position:absolute;top:-9999px;");
|
||||||
|
document.documentElement.appendChild(parent);
|
||||||
|
|
||||||
|
const child = document.createElement("div");
|
||||||
|
child.setAttribute("style", "width:100%;height:40px");
|
||||||
|
parent.appendChild(child);
|
||||||
|
|
||||||
|
const scrollbarWidth = parent.offsetWidth - child.offsetWidth;
|
||||||
|
if (scrollbarWidth > 0) {
|
||||||
|
document.documentElement.classList.add("has-obtrusive-scrollbars");
|
||||||
|
document.documentElement.style.setProperty('--scrollbar-width', scrollbarWidth + 'px');
|
||||||
|
|
||||||
|
parent.style.scrollbarWidth = 'thin';
|
||||||
|
const thinWidth = parent.offsetWidth - child.offsetWidth;
|
||||||
|
document.documentElement.style.setProperty('--thin-scrollbar-width', thinWidth + 'px');
|
||||||
|
}
|
||||||
|
document.documentElement.removeChild(parent);
|
||||||
|
})();
|
||||||
`,
|
`,
|
||||||
}
|
tagPosition: 'bodyOpen',
|
||||||
|
type: 'text/javascript',
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// src: 'https://cdn.jsdelivr.net/npm/eruda',
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// innerHTML: `
|
||||||
|
// eruda.init();
|
||||||
|
// `,
|
||||||
|
// }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -45,9 +87,27 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: [
|
include: [
|
||||||
"shiki/themes/vitesse-dark.mjs", "shiki/themes/vitesse-light.mjs", "shiki/themes/min-light.mjs", "shiki/themes/min-dark.mjs", "shiki/langs/js.mjs", "shiki/langs/jsx.mjs", "shiki/langs/json.mjs", "shiki/langs/ts.mjs", "shiki/langs/tsx.mjs", "shiki/langs/vue.mjs", "shiki/langs/css.mjs", "shiki/langs/html.mjs", "shiki/langs/bash.mjs", "shiki/langs/md.mjs", "shiki/langs/mdc.mjs", "shiki/langs/yaml.mjs", "shiki/langs/py.mjs", "shiki/langs/typescript.mjs", "shiki/langs/javascript.mjs",
|
// shiki
|
||||||
|
'shiki/core',
|
||||||
|
'shiki/wasm',
|
||||||
|
'shiki/engine/javascript',
|
||||||
|
'shiki/engine/oniguruma',
|
||||||
|
'@shikijs/themes/vitesse-dark',
|
||||||
|
'@shikijs/themes/vitesse-light',
|
||||||
|
'@shikijs/langs/vue',
|
||||||
|
'@shikijs/langs/javascript',
|
||||||
|
'@shikijs/langs/typescript',
|
||||||
|
'@shikijs/langs/tsx',
|
||||||
|
'@shikijs/langs/python',
|
||||||
|
'@shikijs/langs/css',
|
||||||
|
'@shikijs/langs/html',
|
||||||
|
'@shikijs/langs/markdown',
|
||||||
|
'@shikijs/langs/json',
|
||||||
|
'@shikijs/langs/bash',
|
||||||
|
|
||||||
'@vue/devtools-core',
|
'@vue/devtools-core',
|
||||||
'@vue/devtools-kit',
|
'@vue/devtools-kit',
|
||||||
|
'@sentry/nuxt',
|
||||||
'@nuxt/hints/runtime/hydration/component',
|
'@nuxt/hints/runtime/hydration/component',
|
||||||
'vue-virtual-scroller',
|
'vue-virtual-scroller',
|
||||||
'shiki',
|
'shiki',
|
||||||
@@ -59,9 +119,17 @@ export default defineNuxtConfig({
|
|||||||
'remark-rehype',
|
'remark-rehype',
|
||||||
'remark-math',
|
'remark-math',
|
||||||
'rehype-katex',
|
'rehype-katex',
|
||||||
|
'unist-util-visit',
|
||||||
'@triplit/client',
|
'@triplit/client',
|
||||||
'@triplit/db'
|
'@triplit/db',
|
||||||
|
'comlink'
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
nitro: {
|
||||||
|
experimental: {
|
||||||
|
websocket: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -70,13 +138,7 @@ export default defineNuxtConfig({
|
|||||||
// viewTransition: true,
|
// viewTransition: true,
|
||||||
// },
|
// },
|
||||||
|
|
||||||
modules: ['@nuxt/hints', '@nuxt/icon', '@unocss/nuxt', '@nuxtjs/color-mode', 'triplit-nuxt', 'nuxt-shiki', '@sentry/nuxt/module'],
|
modules: ['@vue-macros/nuxt', '@nuxt/hints', '@nuxt/icon', '@unocss/nuxt', 'triplit-nuxt', '@sentry/nuxt/module', '@nuxt/fonts'],
|
||||||
|
|
||||||
shiki: {
|
|
||||||
bundledThemes: ['vitesse-dark', 'vitesse-light'],
|
|
||||||
bundledLangs: ['js', 'jsx', 'json', 'ts', 'tsx', 'vue', 'css', 'html', 'bash', 'md', 'mdc', 'yaml', 'py'],
|
|
||||||
defaultTheme: 'vitesse-dark',
|
|
||||||
},
|
|
||||||
|
|
||||||
triplit: {
|
triplit: {
|
||||||
schema_path: './triplit/schema.ts',
|
schema_path: './triplit/schema.ts',
|
||||||
@@ -87,12 +149,6 @@ export default defineNuxtConfig({
|
|||||||
autoConnect: false,
|
autoConnect: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
colorMode: {
|
|
||||||
preference: 'system',
|
|
||||||
fallback: 'dark',
|
|
||||||
storage: 'cookie',
|
|
||||||
},
|
|
||||||
|
|
||||||
devtools: {
|
devtools: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
||||||
|
|||||||
+76
-69
@@ -1,71 +1,78 @@
|
|||||||
{
|
{
|
||||||
"name": "veridian",
|
"name": "veridian",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
"triplit": "triplit dev -s sqlite",
|
"triplit": "triplit dev -s sqlite",
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare",
|
||||||
},
|
"knip": "knip"
|
||||||
"dependencies": {
|
},
|
||||||
"@ai-sdk/cerebras": "^2.0.31",
|
"dependencies": {
|
||||||
"@ai-sdk/cohere": "^3.0.20",
|
"@ai-sdk/cerebras": "^2.0.34",
|
||||||
"@ai-sdk/google": "^3.0.23",
|
"@ai-sdk/cohere": "^3.0.21",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.28",
|
"@ai-sdk/google": "^3.0.29",
|
||||||
"@ai-sdk/vue": "^3.0.78",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
"@daveyplate/better-auth-triplit": "^0.2.2",
|
"@daveyplate/better-auth-triplit": "^0.2.2",
|
||||||
"@iconify-json/mynaui": "^1.2.17",
|
"@iconify-json/mynaui": "^1.2.17",
|
||||||
"@nuxt/hints": "1.0.0-alpha.5",
|
"@nuxt/fonts": "0.13.0",
|
||||||
"@nuxt/icon": "2.2.0",
|
"@nuxt/hints": "1.0.0-alpha.5",
|
||||||
"@nuxtjs/color-mode": "4.0.0",
|
"@nuxt/icon": "2.2.0",
|
||||||
"@openrouter/ai-sdk-provider": "^2.1.1",
|
"@openrouter/ai-sdk-provider": "^2.2.3",
|
||||||
"@sentry/nuxt": "^10",
|
"@sentry/nuxt": "^10.39.0",
|
||||||
"@triplit/client": "^1.0.50",
|
"@triplit/client": "^1.0.50",
|
||||||
"@types/big.js": "^6.2.2",
|
"@triplit/server": "^1.1.8",
|
||||||
"ai": "^6.0.78",
|
"@types/big.js": "^6.2.2",
|
||||||
"ai-sdk-ollama": "^3.5.0",
|
"@vue-macros/nuxt": "^3.1.2",
|
||||||
"better-auth": "^1.4.18",
|
"ai": "^6.0.89",
|
||||||
"big.js": "^7.0.1",
|
"ai-sdk-ollama": "^3.7.1",
|
||||||
"dotenv": "^17.2.4",
|
"better-auth": "^1.4.18",
|
||||||
"glob": "^13.0.1",
|
"big.js": "^7.0.1",
|
||||||
"nanoid": "^5.1.6",
|
"comlink": "^4.4.2",
|
||||||
"nuxt": "4.2.2",
|
"glob": "^13.0.5",
|
||||||
"nuxt-shiki": "0.3.2",
|
"nanoid": "^5.1.6",
|
||||||
"rehype-katex": "^7.0.1",
|
"nuxt": "4.2.2",
|
||||||
"remark-gfm": "^4.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"remark-rehype": "^11.1.2",
|
"remark-parse": "^11.0.0",
|
||||||
"triplit-nuxt": "0.3.1-prerelease.5",
|
"remark-rehype": "^11.1.2",
|
||||||
"unified": "^11.0.5",
|
"shiki": "^3.22.0",
|
||||||
"vue": "^3.5.28",
|
"triplit-nuxt": "0.3.1-prerelease.5",
|
||||||
"vue-router": "^4.6.4",
|
"unified": "^11.0.5",
|
||||||
"vue-virtual-scroller": "^2.0.0-beta.8",
|
"vue": "^3.5.28",
|
||||||
"zod": "^4.3.6"
|
"vue-router": "^4.6.4",
|
||||||
},
|
"vue-virtual-scroller": "^2.0.0-beta.8",
|
||||||
"devDependencies": {
|
"zod": "^4.3.6"
|
||||||
"@iconify-json/logos": "^1.2.10",
|
},
|
||||||
"@iconify-json/svg-spinners": "^1.2.4",
|
"devDependencies": {
|
||||||
"@triplit/cli": "^1.0.61",
|
"@iconify-json/logos": "^1.2.10",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@iconify-json/svg-spinners": "^1.2.4",
|
||||||
"@unocss/nuxt": "^66.6.0",
|
"@iconify-json/tabler": "^1.2.26",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"@triplit/cli": "^1.0.61",
|
||||||
"unocss": "^66.6.0"
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
},
|
"@types/node": "^25.3.0",
|
||||||
"trustedDependencies": [
|
"@unocss/nuxt": "^66.6.0",
|
||||||
"@parcel/watcher",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"core-js",
|
"knip": "^5.84.1",
|
||||||
"esbuild",
|
"typescript": "^5.9.3",
|
||||||
"unrs-resolver"
|
"unocss": "^66.6.0"
|
||||||
],
|
},
|
||||||
"patchedDependencies": {
|
"trustedDependencies": [
|
||||||
"@triplit/db@1.1.10": "patches/@triplit%2Fdb@1.1.10.patch",
|
"@parcel/watcher",
|
||||||
"@triplit/client@1.0.50": "patches/@triplit%2Fclient@1.0.50.patch"
|
"core-js",
|
||||||
},
|
"esbuild",
|
||||||
"overrides": {
|
"unrs-resolver"
|
||||||
"@vercel/nft": "^0.27.4"
|
],
|
||||||
}
|
"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.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,77 @@
|
|||||||
|
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
|
diff --git a/dist/db.js b/dist/db.js
|
||||||
index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..9c4d3060894285218d2d1f961930b5dad52d7bc5 100644
|
index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..0ce27b58838db11fd51fcf873a182d528d5823fb 100644
|
||||||
--- a/dist/db.js
|
--- a/dist/db.js
|
||||||
+++ b/dist/db.js
|
+++ b/dist/db.js
|
||||||
@@ -307,9 +307,7 @@ export class DB {
|
@@ -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
|
// TODO call the listeners in the entity store
|
||||||
// Trigger subscription updates
|
// Trigger subscription updates
|
||||||
await this.ivm.bufferChanges(changes);
|
await this.ivm.bufferChanges(changes);
|
||||||
@@ -13,3 +82,104 @@ index 09fcba1b0df49971de59b5d9f11171e5ea88aee7..9c4d3060894285218d2d1f961930b5da
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
async applyChanges(changes, options) {
|
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/index.js b/dist/index.js
|
||||||
|
index 5155e43bb742663d121d2c5a50275398d8b79b36..9fbd6beac969f25612d74a57b845b973ea914637 100644
|
||||||
|
--- a/dist/index.js
|
||||||
|
+++ b/dist/index.js
|
||||||
|
@@ -1,4 +1,3 @@
|
||||||
|
-import './polyfills.js';
|
||||||
|
export * from './codec.js';
|
||||||
|
export * from './db.js';
|
||||||
|
export * from './db-transaction.js';
|
||||||
|
diff --git a/dist/polyfills.d.ts b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/polyfills.d.ts
|
||||||
|
deleted file mode 100644
|
||||||
|
index 825f04fd5dba36fd87c308e87e15b2c3c5be8712..0000000000000000000000000000000000000000
|
||||||
|
diff --git a/dist/polyfills.js b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/polyfills.js
|
||||||
|
deleted file mode 100644
|
||||||
|
index 264a2151aaf63c967a2f8820454d2692f4522c99..0000000000000000000000000000000000000000
|
||||||
|
diff --git a/dist/polyfills.js.map b/home/zoeissleeping/.cache/.bun/install/cache/@triplit/db@1.1.10@@@1/dist/polyfills.js.map
|
||||||
|
deleted file mode 100644
|
||||||
|
index 23685d5015568d42f4273c442fff1dacac3151d4..0000000000000000000000000000000000000000
|
||||||
|
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
|
||||||
|
|||||||
@@ -8,20 +8,19 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const success = cancelPendingGeneration(generationId!);
|
const success = cancelPendingGeneration(generationId!);
|
||||||
|
|
||||||
if (!success) {
|
const generation = await httpClient.fetchOne(httpClient.query('generations').Where('id', '=', generationId!));
|
||||||
const generation = await httpClient.fetchOne(httpClient.query('generations').Where('id', '=', generationId!));
|
if (generation !== null && generation.status === 'pending') {
|
||||||
if (generation !== null && generation.status === 'pending') {
|
await httpClient.update('generations', generationId!, {
|
||||||
await httpClient.update('generations', generationId!, {
|
status: 'cancelled',
|
||||||
status: 'cancelled',
|
});
|
||||||
});
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'Generation not found or already completed',
|
message: 'Generation not found or already completed',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return 'ok';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { httpClient } from '~~/server/lib/triplit';
|
|||||||
import { addPendingGeneration, completeGeneration } from '~~/server/utils/generations';
|
import { addPendingGeneration, completeGeneration } from '~~/server/utils/generations';
|
||||||
import type { schema } from '~~/triplit/schema';
|
import type { schema } from '~~/triplit/schema';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { getGateway, type ModelGateway } from '~~/server/utils/ai-provider';
|
import { GatewayFetchError, getProviderDetails, type ModelGateway } from '~~/server/utils/ai-provider';
|
||||||
|
import { assert } from '~~/utils/assert';
|
||||||
|
|
||||||
export const messagesSchema = z.array(modelMessageSchema);
|
export const messagesSchema = z.array(modelMessageSchema);
|
||||||
|
|
||||||
@@ -73,7 +74,26 @@ export default defineEventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { gateway, streamTransformer: transformer } = await getGateway(provider, model, providerApiKey);
|
const providerDetails = await getProviderDetails(provider, providerApiKey, model);
|
||||||
|
if (!providerDetails.ok) {
|
||||||
|
switch (providerDetails.error) {
|
||||||
|
case GatewayFetchError.NoProviderApiKey: {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: `${provider.type} provider requires an API key`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case GatewayFetchError.NoProviderBaseUrl: {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Invalid provider URL',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { gateway } = providerDetails.data;
|
||||||
|
assert(gateway !== null, 'Invalid gateway');
|
||||||
|
|
||||||
const generationId = nanoid();
|
const generationId = nanoid();
|
||||||
const message = await httpClient.insert('messages', {
|
const message = await httpClient.insert('messages', {
|
||||||
@@ -98,14 +118,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
let logMessage: ((message: string) => void) | undefined;
|
let logMessage: ((message: string) => void) | undefined;
|
||||||
|
|
||||||
if (process.env.GENERATION_DEBUG) {
|
if (process.env.GENERATION_DEBUG) {
|
||||||
logFile = await fs.open(path.join(process.env.LOG_DIR!, `${Date.now()}-${generationId}.log`), 'w');
|
if (process.env.LOG_DIR) {
|
||||||
logMessage = (message: string) => {
|
await fs.mkdir(process.env.LOG_DIR!, { recursive: true });
|
||||||
logFile!.write(message + '\n');
|
|
||||||
};
|
logFile = await fs.open(path.join(process.env.LOG_DIR!, `${Date.now()}-${generationId}.log`), 'w');
|
||||||
|
logMessage = (message: string) => {
|
||||||
|
logFile!.write(message + '\n');
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.warn('Generation debug logging is enabled but GENERATION_DEBUG is not set');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
generateResponse(message, { gateway, model, parameters: args }, generationId, userId, topicId, messages, transformer, logMessage, logFile),
|
generateResponse(message, { gateway: gateway.gateway, model, parameters: args }, generationId, userId, topicId, messages, gateway.streamTransformer, logMessage, logFile),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -755,8 +781,6 @@ async function generateResponse(
|
|||||||
status: 'failed',
|
status: 'failed',
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new Error(error);
|
|
||||||
} break;
|
} break;
|
||||||
case 'finish': {
|
case 'finish': {
|
||||||
let tps;
|
let tps;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import { type Entity } from '@triplit/client';
|
||||||
|
import Big from 'big.js';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { providerBaseUrls, SupportedModalities } from '~/types/model';
|
import { SupportedModalities } from '~/types/model';
|
||||||
import { Providers } from '~/types/model';
|
import { Providers } from '~/types/model';
|
||||||
import { httpClient } from '~~/server/lib/triplit';
|
import { httpClient } from '~~/server/lib/triplit';
|
||||||
|
import { GatewayFetchError, getProviderDetails } from '~~/server/utils/ai-provider';
|
||||||
|
import { getModelsDevData } from '~~/server/utils/models-dev';
|
||||||
|
import { schema } from '~~/triplit/schema';
|
||||||
|
import { Err, Ok, type Result } from '~~/types/result';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await protectRoute(event);
|
await protectRoute(event);
|
||||||
@@ -32,109 +38,130 @@ export default defineEventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let baseUrl;
|
console.log("apiKey", result.data.providerApiKey);
|
||||||
let fetchUrl;
|
|
||||||
let headers;
|
const [providerModelsRes, modelsDevRes] = await Promise.all([
|
||||||
switch (provider.type) {
|
fetchProviderModels(provider, result.data.providerApiKey),
|
||||||
case 'cohere':
|
getModelsDevData(),
|
||||||
case 'cerebras':
|
]);
|
||||||
if (!result.data.providerApiKey) {
|
if (!providerModelsRes.ok) {
|
||||||
|
switch (providerModelsRes.error) {
|
||||||
|
case ProviderFetchError.NoProviderApiKey: {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: `${provider.type} provider requires an API key`,
|
message: `${provider.type} provider requires an API key`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case 'google':
|
case ProviderFetchError.NoProviderBaseUrl: {
|
||||||
case 'openrouter':
|
|
||||||
baseUrl = !!provider.config.apiProxyUrl ? provider.config.apiProxyUrl : providerBaseUrls[provider.type];
|
|
||||||
baseUrl = baseUrl.replace(/\/$/, '');
|
|
||||||
|
|
||||||
if (baseUrl === '') {
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: 'Invalid provider URL',
|
message: 'Invalid provider URL',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fetchUrl = `${baseUrl}/models`;
|
console.log("providerModelsRes.data", providerModelsRes.data);
|
||||||
break;
|
|
||||||
case 'ollama':
|
const normalizedModels = await normalizeResponse(
|
||||||
if (!provider.config.apiProxyUrl) {
|
providerModelsRes.data.data,
|
||||||
throw createError({
|
provider.type,
|
||||||
statusCode: 400,
|
providerModelsRes.data.baseURL,
|
||||||
message: 'Ollama provider requires an API proxy URL',
|
modelsDevRes
|
||||||
});
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
models: normalizedModels,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
enum ProviderFetchError {
|
||||||
|
NoProviderApiKey = 0,
|
||||||
|
NoProviderBaseUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchProviderModels = async (provider: Entity<typeof schema, 'providers'>, providerApiKey: string | undefined): Promise<Result<{ data: Record<string, any>, baseURL: string }, ProviderFetchError>> => {
|
||||||
|
const providerDetails = await getProviderDetails(provider, providerApiKey);
|
||||||
|
if (!providerDetails.ok) {
|
||||||
|
switch (providerDetails.error) {
|
||||||
|
case GatewayFetchError.NoProviderApiKey: {
|
||||||
|
// throw createError({
|
||||||
|
// statusCode: 400,
|
||||||
|
// message: `${provider.type} provider requires an API key`,
|
||||||
|
// });
|
||||||
|
return Err(ProviderFetchError.NoProviderApiKey);
|
||||||
}
|
}
|
||||||
baseUrl = provider.config.apiProxyUrl;
|
case GatewayFetchError.NoProviderBaseUrl: {
|
||||||
baseUrl = baseUrl.replace(/\/$/, '');
|
// throw createError({
|
||||||
|
// statusCode: 400,
|
||||||
|
// message: 'Invalid provider URL',
|
||||||
|
// });
|
||||||
|
return Err(ProviderFetchError.NoProviderBaseUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fetchUrl = `${baseUrl}/api/tags`;
|
const { endpoint: { baseURL, modelsEndpoint, headers } } = providerDetails.data;
|
||||||
break;
|
|
||||||
|
switch (provider.type) {
|
||||||
case 'longcat':
|
case 'longcat':
|
||||||
// longcat doesn't have a model list endpoint so we just hardcode them here,
|
// longcat doesn't have a model list endpoint so we just hardcode them here,
|
||||||
// sry. I talked to Meituan and this is what they said:
|
// sry. I talked to Meituan and this is what they said:
|
||||||
// 后续如果我们新增了这样的接口会及时同步您。
|
// 后续如果我们新增了这样的接口会及时同步您。
|
||||||
// en (approx): If we add an interface like this in the future, we will promptly update you
|
// en (approx): If we add an interface like this in the future, we will promptly update you
|
||||||
return {
|
return Ok({
|
||||||
models: [
|
data: {
|
||||||
{
|
models: [
|
||||||
id: "LongCat-Flash-Chat",
|
{
|
||||||
name: "LongCat Flash Chat",
|
id: "LongCat-Flash-Chat",
|
||||||
attributes: {
|
name: "LongCat Flash Chat",
|
||||||
inputModalities: ['text'],
|
attributes: {
|
||||||
outputModalities: ['text'],
|
inputModalities: ['text'],
|
||||||
capabilities: ['tools'],
|
outputModalities: ['text'],
|
||||||
contextWindow: 256_000,
|
capabilities: ['tools'],
|
||||||
|
contextWindow: 256_000,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "LongCat-Flash-Thinking",
|
||||||
|
name: "LongCat Flash Thinking",
|
||||||
|
attributes: {
|
||||||
|
inputModalities: ['text'],
|
||||||
|
outputModalities: ['text'],
|
||||||
|
capabilities: ['reasoning', 'tools'],
|
||||||
|
contextWindow: 256_000,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "LongCat-Flash-Thinking-2601",
|
||||||
|
name: "LongCat Flash Thinking (2601)",
|
||||||
|
attributes: {
|
||||||
|
inputModalities: ['text'],
|
||||||
|
outputModalities: ['text'],
|
||||||
|
capabilities: ['reasoning', 'tools'],
|
||||||
|
contextWindow: 256_000,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "LongCat-Flash-Lite",
|
||||||
|
name: "LongCat Flash Lite",
|
||||||
|
attributes: {
|
||||||
|
inputModalities: ['text'],
|
||||||
|
outputModalities: ['text'],
|
||||||
|
capabilities: ['tools'],
|
||||||
|
contextWindow: 320_000,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
],
|
||||||
{
|
},
|
||||||
id: "LongCat-Flash-Thinking",
|
baseURL
|
||||||
name: "LongCat Flash Thinking",
|
});
|
||||||
attributes: {
|
|
||||||
inputModalities: ['text'],
|
|
||||||
outputModalities: ['text'],
|
|
||||||
capabilities: ['reasoning', 'tools'],
|
|
||||||
contextWindow: 256_000,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "LongCat-Flash-Thinking-2601",
|
|
||||||
name: "LongCat Flash Thinking (2601)",
|
|
||||||
attributes: {
|
|
||||||
inputModalities: ['text'],
|
|
||||||
outputModalities: ['text'],
|
|
||||||
capabilities: ['reasoning', 'tools'],
|
|
||||||
contextWindow: 256_000,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "LongCat-Flash-Lite",
|
|
||||||
name: "LongCat Flash Lite",
|
|
||||||
attributes: {
|
|
||||||
inputModalities: ['text'],
|
|
||||||
outputModalities: ['text'],
|
|
||||||
capabilities: ['tools'],
|
|
||||||
contextWindow: 320_000,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider.type === 'google') {
|
|
||||||
headers = {
|
|
||||||
'x-goog-api-key': `${result.data.providerApiKey}`
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
headers = {
|
|
||||||
'Authorization': `Bearer ${result.data.providerApiKey}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let res;
|
let res;
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
res = await fetch(fetchUrl, {
|
res = await fetch(`${baseURL}${modelsEndpoint}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers
|
headers
|
||||||
});
|
});
|
||||||
@@ -154,16 +181,89 @@ export default defineEventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return Ok({ data, baseURL });
|
||||||
models: await normalizeResponse(data, provider.type, baseUrl)
|
}
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const normalizeResponse = async (response: Record<string, any>, provider: typeof Providers[number], baseUrl: string) => {
|
function mergeSets(setA: Set<string>, setB: Set<string>): string[] {
|
||||||
|
return Array.from(new Set([...setA, ...setB]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const getModelData = (modelId: string, providerId: string, modelsDevData: any) => {
|
||||||
|
const modelData = modelsDevData[providerId]?.models[modelId];
|
||||||
|
console.log("modelData", modelData);
|
||||||
|
|
||||||
|
if (modelData === undefined) return {
|
||||||
|
cost: {},
|
||||||
|
attributes: {
|
||||||
|
inputModalities: ['text'],
|
||||||
|
outputModalities: ['text'],
|
||||||
|
capabilities: [],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const capabilities = new Set<string>();
|
||||||
|
if (modelData.reasoning) {
|
||||||
|
capabilities.add('reasoning');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelData.tool_call) {
|
||||||
|
capabilities.add('tools');
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputModalities = modelData.modalities.input.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m));
|
||||||
|
let outputModalities = modelData.modalities.output.filter((m: string) => (SupportedModalities as Readonly<string[]>).includes(m));
|
||||||
|
let supportedParameters = [];
|
||||||
|
if (modelData.temperature) {
|
||||||
|
supportedParameters.push('temperature');
|
||||||
|
}
|
||||||
|
|
||||||
|
let contextWindow = modelData.limit?.context || null;
|
||||||
|
let cost: Record<string, string> = {};
|
||||||
|
for (const key in modelData.cost) {
|
||||||
|
switch (key) {
|
||||||
|
case 'input': {
|
||||||
|
cost.prompt = modelData.cost[key].toString();
|
||||||
|
} break;
|
||||||
|
case 'output': {
|
||||||
|
cost.completion = modelData.cost[key].toString();
|
||||||
|
} break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let releasedAt = (new Date(modelData.release_date)).getTime();
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: modelData.name,
|
||||||
|
attributes: {
|
||||||
|
inputModalities,
|
||||||
|
outputModalities,
|
||||||
|
capabilities: modelData.capabilities || [],
|
||||||
|
contextWindow,
|
||||||
|
supported_parameters: modelData.supportedParameters,
|
||||||
|
},
|
||||||
|
cost,
|
||||||
|
releasedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBig = (bigValue: Big) => {
|
||||||
|
let str = bigValue.toString();
|
||||||
|
|
||||||
|
if (!str.includes('.')) return str + '.00';
|
||||||
|
if (str.split('.')[1]!.length === 1) return str + '0';
|
||||||
|
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeResponse = async (response: Record<string, any>, provider: typeof Providers[number], baseUrl: string, modelsDevData: any) => {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case 'cerebras': {
|
case 'cerebras': {
|
||||||
console.log(response);
|
console.log("response.data", response.data);
|
||||||
return response.data.map((model: any) => ({ id: model.id, releasedAt: model.created }));
|
for (const model of response.data) {
|
||||||
|
console.log(model.created);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.map((model: any) => ({ ...getModelData(model.id, provider, modelsDevData), id: model.id }));
|
||||||
}
|
}
|
||||||
case 'openrouter': {
|
case 'openrouter': {
|
||||||
const models = [];
|
const models = [];
|
||||||
@@ -182,18 +282,69 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pricing = {} as Record<string, string>;
|
||||||
|
for (const key of Object.keys(model.pricing)) {
|
||||||
|
// normalize to /1M tokens since models.dev is already in /1M tokens
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'prompt':
|
||||||
|
case 'completion':
|
||||||
|
case 'request':
|
||||||
|
case 'image':
|
||||||
|
case 'audio':
|
||||||
|
case 'discount':
|
||||||
|
pricing[key] = formatBig(Big(model.pricing[key]).mul(1_000_000));
|
||||||
|
break;
|
||||||
|
case 'image_tokens':
|
||||||
|
pricing['imageTokens'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
|
||||||
|
break;
|
||||||
|
case 'image_output':
|
||||||
|
pricing['imageOutput'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
|
||||||
|
break;
|
||||||
|
case 'audio_output':
|
||||||
|
pricing['audioOutput'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
|
||||||
|
break;
|
||||||
|
case 'input_audio_cache':
|
||||||
|
pricing['inputAudioCache'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
|
||||||
|
break;
|
||||||
|
case 'web_search':
|
||||||
|
pricing['webSearch'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
|
||||||
|
break;
|
||||||
|
case 'internal_reasoning':
|
||||||
|
pricing['internalReasoning'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
|
||||||
|
break;
|
||||||
|
case 'input_cache_read':
|
||||||
|
pricing['inputCacheRead'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
|
||||||
|
break;
|
||||||
|
case 'input_cache_write':
|
||||||
|
pricing['inputCacheWrite'] = formatBig(Big(model.pricing[key]).mul(1_000_000));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: do I really need to pull in data from models.dev for OR models?
|
||||||
|
// const modelData = getModelData(model.id, provider, modelsDevData);
|
||||||
|
|
||||||
models.push({
|
models.push({
|
||||||
id: model.id as string,
|
id: model.id as string,
|
||||||
name: model.name as string,
|
name: model.name as string,
|
||||||
pricing: model.pricing,
|
cost: pricing,
|
||||||
attributes: {
|
attributes: {
|
||||||
|
// inputModalities: mergeSets(
|
||||||
|
// new Set(modelData.inputModalities || []),
|
||||||
|
// new Set(model.architecture.input_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality))),
|
||||||
|
// ),
|
||||||
|
// outputModalities: mergeSets(
|
||||||
|
// new Set(modelData.outputModalities || []),
|
||||||
|
// new Set(model.architecture.output_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality))),
|
||||||
|
// ),
|
||||||
inputModalities: model.architecture.input_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)),
|
inputModalities: model.architecture.input_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)),
|
||||||
outputModalities: model.architecture.output_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)),
|
outputModalities: model.architecture.output_modalities.filter((modality: string) => (SupportedModalities as Readonly<string[]>).includes(modality)),
|
||||||
capabilities: Array.from(capabilities),
|
capabilities: Array.from(capabilities),
|
||||||
contextWindow: model.context_length,
|
contextWindow: model.context_length,
|
||||||
supported_parameters: model.supported_parameters,
|
supported_parameters: model.supported_parameters,
|
||||||
},
|
},
|
||||||
created: model.created,
|
releasedAt: model.created * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,12 +406,26 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let normalizedId = model.name;
|
||||||
|
normalizedId = normalizedId.replace(/:cloud$/, '');
|
||||||
|
normalizedId = normalizedId.replace(/-cloud$/, '');
|
||||||
|
normalizedId = normalizedId.replace(/:latest$/, '');
|
||||||
|
const modelData = getModelData(normalizedId, 'ollama-cloud', modelsDevData);
|
||||||
|
|
||||||
models.set(model.name, {
|
models.set(model.name, {
|
||||||
|
...modelData,
|
||||||
id: model.name,
|
id: model.name,
|
||||||
name: model.name,
|
name: model.name,
|
||||||
attributes: {
|
attributes: {
|
||||||
inputModalities: Array.from(inputModalities),
|
...modelData.attributes,
|
||||||
capabilities: Array.from(capabilities),
|
inputModalities: mergeSets(
|
||||||
|
inputModalities,
|
||||||
|
new Set(modelData.attributes?.inputModalities || [])
|
||||||
|
),
|
||||||
|
capabilities: mergeSets(
|
||||||
|
capabilities,
|
||||||
|
new Set(modelData.attributes?.capabilities || [])
|
||||||
|
),
|
||||||
contextWindow,
|
contextWindow,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -272,12 +437,10 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
|
|||||||
return Array.from(models.values());
|
return Array.from(models.values());
|
||||||
}
|
}
|
||||||
case 'google': {
|
case 'google': {
|
||||||
console.log(response);
|
return response.models.map((model: any) => ({ ...getModelData(model.name.replace('models/', ''), provider, modelsDevData), id: model.name.replace('models/', ''), name: model.displayName }));
|
||||||
return response.models.map((model: any) => ({ id: model.name.replace('models/', ''), name: model.displayName }));
|
|
||||||
}
|
}
|
||||||
case 'longcat': {
|
case 'longcat': {
|
||||||
console.log(response);
|
return response.models;
|
||||||
return response.models.map((model: any) => ({ id: model.name, name: model.name }));
|
|
||||||
}
|
}
|
||||||
case 'cohere': {
|
case 'cohere': {
|
||||||
const models = [];
|
const models = [];
|
||||||
@@ -299,13 +462,22 @@ const normalizeResponse = async (response: Record<string, any>, provider: typeof
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelData = getModelData(model.name, provider, modelsDevData);
|
||||||
|
|
||||||
models.push({
|
models.push({
|
||||||
|
...modelData,
|
||||||
id: model.name,
|
id: model.name,
|
||||||
name: model.name,
|
|
||||||
attributes: {
|
attributes: {
|
||||||
contextWindow: model.context_length,
|
...modelData.attributes,
|
||||||
inputModalities: Array.from(inputModalities),
|
inputModalities: mergeSets(
|
||||||
capabilities: Array.from(capabilities),
|
inputModalities,
|
||||||
|
new Set(modelData.attributes?.inputModalities || [])
|
||||||
|
),
|
||||||
|
capabilities: mergeSets(
|
||||||
|
capabilities,
|
||||||
|
new Set(modelData.attributes?.capabilities || [])
|
||||||
|
),
|
||||||
|
contextWindow: model.context_length || modelData.attributes?.contextWindow,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { httpClient } from '~~/server/lib/triplit';
|
||||||
import { cancelPendingRename } from '~~/server/utils/renames';
|
import { cancelPendingRename } from '~~/server/utils/renames';
|
||||||
import { assert } from '~~/utils/assert';
|
import { assert } from '~~/utils/assert';
|
||||||
|
|
||||||
@@ -7,7 +8,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
const { renameId } = event.context.params!;
|
const { renameId } = event.context.params!;
|
||||||
assert(renameId);
|
assert(renameId);
|
||||||
|
|
||||||
if (cancelPendingRename(renameId)) {
|
const [success, pendingRename] = cancelPendingRename(renameId);
|
||||||
|
if (success) {
|
||||||
|
const topic = await httpClient.fetchOne(httpClient.query('topics').Where('id', '=', pendingRename!.topicId));
|
||||||
|
if (topic !== null && topic.renaming) {
|
||||||
|
await httpClient.update('topics', topic.id, {
|
||||||
|
renaming: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { renamePrompt } from '~~/prompts';
|
|||||||
import { schema } from '~~/triplit/schema';
|
import { schema } from '~~/triplit/schema';
|
||||||
import { type Entity } from '@triplit/client';
|
import { type Entity } from '@triplit/client';
|
||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
import { getGateway, type ModelGateway } from '~~/server/utils/ai-provider';
|
import { GatewayFetchError, getProviderDetails, type ModelGateway } from '~~/server/utils/ai-provider';
|
||||||
import { addPendingRename } from '~~/server/utils/renames';
|
import { addPendingRename } from '~~/server/utils/renames';
|
||||||
|
import { assert } from '~~/utils/assert';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await protectRoute(event);
|
await protectRoute(event);
|
||||||
@@ -38,10 +39,31 @@ export default defineEventHandler(async (event) => {
|
|||||||
message: 'Invalid model',
|
message: 'Invalid model',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
assert(model.provider !== null, 'Invalid model provider');
|
||||||
|
|
||||||
const { gateway, textTransformer } = await getGateway(model.provider!, model, providerApiKey);
|
const providerDetails = await getProviderDetails(model.provider, providerApiKey, model);
|
||||||
const [renameId, abortController] = addPendingRename();
|
if (!providerDetails.ok) {
|
||||||
event.waitUntil(autoRename(topicId, abortController, { gateway, model }, textTransformer, prompt));
|
switch (providerDetails.error) {
|
||||||
|
case GatewayFetchError.NoProviderApiKey: {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: `${model.provider.type} provider requires an API key`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case GatewayFetchError.NoProviderBaseUrl: {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Invalid provider URL',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { gateway } = providerDetails.data;
|
||||||
|
assert(gateway !== null, 'Invalid gateway');
|
||||||
|
|
||||||
|
const [renameId, pendingRename] = addPendingRename(topicId);
|
||||||
|
event.waitUntil(autoRename(topicId, renameId, pendingRename.abortController, { gateway: gateway.gateway, model }, gateway.textTransformer, prompt));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -51,6 +73,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const autoRename = async (
|
const autoRename = async (
|
||||||
topicId: string,
|
topicId: string,
|
||||||
|
renameId: string,
|
||||||
abortController: AbortController,
|
abortController: AbortController,
|
||||||
model: {
|
model: {
|
||||||
gateway: ModelGateway,
|
gateway: ModelGateway,
|
||||||
@@ -86,6 +109,7 @@ const autoRename = async (
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to auto-rename:', error);
|
console.error('Failed to auto-rename:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
completeRename(renameId);
|
||||||
await httpClient.update('topics', topicId, {
|
await httpClient.update('topics', topicId, {
|
||||||
renaming: false
|
renaming: false
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ import { schema } from '#triplit/schema';
|
|||||||
|
|
||||||
export const httpClient = new HttpClient({
|
export const httpClient = new HttpClient({
|
||||||
schema,
|
schema,
|
||||||
serverUrl: process.env.NUXT_PUBLIC_TRIPLIT_URL,
|
serverUrl: process.env.NUXT_LOCAL_TRIPLIT_URL || process.env.NUXT_PUBLIC_TRIPLIT_URL,
|
||||||
token: process.env.TRIPLIT_SERVICE_TOKEN,
|
token: process.env.TRIPLIT_SERVICE_TOKEN,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const start = Date.now(); //
|
||||||
|
|
||||||
|
// Hook to run when the response is about to be sent
|
||||||
|
event.node.res.on('finish', () => {
|
||||||
|
const end = Date.now();
|
||||||
|
const duration = end - start;
|
||||||
|
console.log(`Request to ${event.req.url} took ${duration}ms to render.`); //
|
||||||
|
});
|
||||||
|
});
|
||||||
+83
-39
@@ -9,6 +9,7 @@ import { schema } from "~~/triplit/schema";
|
|||||||
import { type StreamTextTransform } from "ai";
|
import { type StreamTextTransform } from "ai";
|
||||||
import { transformCerebrasReasoningStream } from "./cerebras";
|
import { transformCerebrasReasoningStream } from "./cerebras";
|
||||||
import { providerBaseUrls } from "~/types/model";
|
import { providerBaseUrls } from "~/types/model";
|
||||||
|
import { type Result, Err, Ok } from "~~/types/result";
|
||||||
// import { createLongcatTransformer } from "./longcat";
|
// import { createLongcatTransformer } from "./longcat";
|
||||||
|
|
||||||
export type ModelGateway = OpenRouterProvider | OllamaProvider | CerebrasProvider | GoogleGenerativeAIProvider | OpenAICompatibleProvider | CohereProvider;
|
export type ModelGateway = OpenRouterProvider | OllamaProvider | CerebrasProvider | GoogleGenerativeAIProvider | OpenAICompatibleProvider | CohereProvider;
|
||||||
@@ -19,90 +20,133 @@ export interface Gateway {
|
|||||||
textTransformer: ((text: string) => string) | ((text: string) => string)[] | undefined;
|
textTransformer: ((text: string) => string) | ((text: string) => string)[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGateway(provider: Entity<typeof schema, 'providers'>, model: Entity<typeof schema, 'models'>, providerApiKey?: string): Promise<Gateway> {
|
export interface Provider {
|
||||||
let gateway: ModelGateway;
|
gateway: Gateway | null;
|
||||||
let streamTransformer = undefined;
|
endpoint: {
|
||||||
let textTransformer = undefined;
|
baseURL: string;
|
||||||
|
modelsEndpoint: string | null;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum GatewayFetchError {
|
||||||
|
NoProviderApiKey = 0,
|
||||||
|
NoProviderBaseUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProviderDetails(provider: Entity<typeof schema, 'providers'>, providerApiKey?: string, model?: Entity<typeof schema, 'models'>): Promise<Result<Provider, GatewayFetchError>> {
|
||||||
|
let gateway: Gateway = {} as Gateway;
|
||||||
|
|
||||||
let baseURL = undefined;
|
let baseURL = undefined;
|
||||||
|
let modelsEndpoint = undefined;
|
||||||
|
let headers: Record<string, string> = {};
|
||||||
|
|
||||||
if (provider.config.apiProxyUrl && provider.config.apiProxyUrl.trim() !== '') {
|
if (provider.config.apiProxyUrl && provider.config.apiProxyUrl.trim() !== '') {
|
||||||
baseURL = provider.config.apiProxyUrl;
|
baseURL = provider.config.apiProxyUrl;
|
||||||
|
} else {
|
||||||
|
baseURL = providerBaseUrls[provider.type];
|
||||||
}
|
}
|
||||||
|
baseURL = baseURL.replace(/\/$/, '');
|
||||||
|
|
||||||
switch (provider.type) {
|
switch (provider.type) {
|
||||||
case 'openrouter': {
|
case 'openrouter': {
|
||||||
if (providerApiKey === undefined) {
|
if (providerApiKey === undefined) {
|
||||||
throw createError({
|
return Err(GatewayFetchError.NoProviderApiKey);
|
||||||
statusCode: 400,
|
|
||||||
message: 'OpenRouter provider requires an API key',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gateway = createOpenRouter({
|
gateway.gateway = createOpenRouter({
|
||||||
apiKey: providerApiKey,
|
apiKey: providerApiKey,
|
||||||
headers: {
|
headers: {
|
||||||
'HTTP-Referer': 'https://localhost:3000',
|
'HTTP-Referer': 'https://localhost:3000',
|
||||||
'X-Title': 'Veridian',
|
'X-Title': 'Veridian',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
headers['Authorization'] = `Bearer ${providerApiKey}`
|
||||||
}
|
modelsEndpoint = `/models`;
|
||||||
|
} break;
|
||||||
case 'ollama': {
|
case 'ollama': {
|
||||||
if (baseURL === undefined) {
|
if (baseURL === undefined) {
|
||||||
throw createError({
|
return Err(GatewayFetchError.NoProviderBaseUrl);
|
||||||
statusCode: 400,
|
|
||||||
message: 'Ollama provider requires an API proxy URL',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const innerGateway = createOllama({
|
if (providerApiKey !== undefined) {
|
||||||
apiKey: providerApiKey,
|
headers['Authorization'] = `Bearer ${providerApiKey}`
|
||||||
baseURL,
|
}
|
||||||
})
|
modelsEndpoint = `/api/tags`;
|
||||||
|
|
||||||
gateway = ((modelId: string) => innerGateway(modelId, { think: [...model.attributes.capabilities].includes('reasoning') })) as OllamaProvider;
|
if (model !== undefined) {
|
||||||
break;
|
const innerGateway = createOllama({
|
||||||
}
|
apiKey: providerApiKey,
|
||||||
|
baseURL,
|
||||||
|
})
|
||||||
|
|
||||||
|
gateway.gateway = ((modelId: string) => innerGateway(modelId, { think: [...model.attributes.capabilities].includes('reasoning') })) as OllamaProvider;
|
||||||
|
}
|
||||||
|
} break;
|
||||||
case 'cerebras': {
|
case 'cerebras': {
|
||||||
gateway = createCerebras({
|
if (providerApiKey === undefined) {
|
||||||
|
return Err(GatewayFetchError.NoProviderApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway.gateway = createCerebras({
|
||||||
apiKey: providerApiKey,
|
apiKey: providerApiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
})
|
})
|
||||||
|
|
||||||
streamTransformer = transformCerebrasReasoningStream() as StreamTextTransform<{}>;
|
gateway.streamTransformer = transformCerebrasReasoningStream() as StreamTextTransform<{}>;
|
||||||
textTransformer = (text: string) => {
|
gateway.textTransformer = (text: string) => {
|
||||||
return text.split('</think>').at(-1)!.trim()
|
return text.split('</think>').at(-1)!.trim()
|
||||||
};
|
};
|
||||||
break;
|
headers['Authorization'] = `Bearer ${providerApiKey}`
|
||||||
}
|
modelsEndpoint = `/models`;
|
||||||
|
} break;
|
||||||
case 'google': {
|
case 'google': {
|
||||||
gateway = createGoogleGenerativeAI({
|
if (providerApiKey === undefined) {
|
||||||
|
return Err(GatewayFetchError.NoProviderApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway.gateway = createGoogleGenerativeAI({
|
||||||
apiKey: providerApiKey,
|
apiKey: providerApiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
})
|
});
|
||||||
break;
|
headers['x-goog-api-key'] = `${providerApiKey}`
|
||||||
}
|
modelsEndpoint = `/models`;
|
||||||
|
} break;
|
||||||
case 'longcat': {
|
case 'longcat': {
|
||||||
gateway = createOpenAICompatible({
|
if (providerApiKey === undefined) {
|
||||||
|
return Err(GatewayFetchError.NoProviderApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway.gateway = createOpenAICompatible({
|
||||||
name: 'LongCat',
|
name: 'LongCat',
|
||||||
apiKey: providerApiKey,
|
apiKey: providerApiKey,
|
||||||
baseURL: baseURL ?? providerBaseUrls[provider.type],
|
baseURL: baseURL ?? providerBaseUrls[provider.type],
|
||||||
includeUsage: true,
|
includeUsage: true,
|
||||||
})
|
})
|
||||||
|
headers['Authorization'] = `Bearer ${providerApiKey}`
|
||||||
|
modelsEndpoint = null;
|
||||||
// streamTransformer = createLongcatTransformer() as StreamTextTransform<{}>;
|
// streamTransformer = createLongcatTransformer() as StreamTextTransform<{}>;
|
||||||
} break;
|
} break;
|
||||||
case 'cohere': {
|
case 'cohere': {
|
||||||
gateway = createCohere({
|
if (providerApiKey === undefined) {
|
||||||
|
return Err(GatewayFetchError.NoProviderApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway.gateway = createCohere({
|
||||||
apiKey: providerApiKey,
|
apiKey: providerApiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
})
|
});
|
||||||
}
|
headers['Authorization'] = `Bearer ${providerApiKey}`
|
||||||
|
modelsEndpoint = `/models`;
|
||||||
|
} break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return Ok({
|
||||||
gateway,
|
gateway,
|
||||||
streamTransformer,
|
endpoint: {
|
||||||
textTransformer,
|
baseURL,
|
||||||
};
|
modelsEndpoint,
|
||||||
|
headers,
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ToolSet, type TextStreamPart, type ToolCallPart } from 'ai';
|
import { type ToolSet, type TextStreamPart, type ToolCallPart } from 'ai';
|
||||||
|
|
||||||
export function createLongcatTransformer<TOOLS extends ToolSet>(): (options: {
|
export function createLongcatTransformer<TOOLS extends ToolSet>(): (options: {
|
||||||
tools: TOOLS;
|
tools: TOOLS;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
interface ModelsDevCache {
|
||||||
|
data: any;
|
||||||
|
etag: string | null;
|
||||||
|
lastFetched: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let modelsDevCache: ModelsDevCache = {
|
||||||
|
data: null,
|
||||||
|
etag: null,
|
||||||
|
lastFetched: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODELS_DEV_URL = 'https://models.dev/api.json';
|
||||||
|
const CACHE_TTL = 60 * 1000; // 60 seconds
|
||||||
|
|
||||||
|
export async function getModelsDevData(): Promise<any> {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (modelsDevCache.data &&
|
||||||
|
(now - modelsDevCache.lastFetched < CACHE_TTL)) {
|
||||||
|
return modelsDevCache.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
if (modelsDevCache.etag) {
|
||||||
|
headers['If-None-Match'] = modelsDevCache.etag;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(MODELS_DEV_URL, { headers });
|
||||||
|
|
||||||
|
if (response.status === 304) {
|
||||||
|
modelsDevCache.lastFetched = now;
|
||||||
|
return modelsDevCache.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newData = await response.json();
|
||||||
|
const newEtag = response.headers.get('ETag') || null;
|
||||||
|
|
||||||
|
modelsDevCache = {
|
||||||
|
data: newData,
|
||||||
|
etag: newEtag,
|
||||||
|
lastFetched: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
return newData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching models.dev:', error);
|
||||||
|
return modelsDevCache.data || {};
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-11
@@ -1,13 +1,19 @@
|
|||||||
const pendingRenames: Map<string, AbortController> = new Map();
|
interface PendingRename {
|
||||||
|
topicId: string;
|
||||||
|
abortController: AbortController;
|
||||||
|
}
|
||||||
|
|
||||||
export const cancelPendingRename = (renameId: string): boolean => {
|
const pendingRenames: Map<string, PendingRename> = new Map();
|
||||||
const controller = pendingRenames.get(renameId);
|
|
||||||
if (controller) {
|
export const cancelPendingRename = (renameId: string): [boolean, PendingRename?] => {
|
||||||
controller.abort();
|
const pendingRename = pendingRenames.get(renameId);
|
||||||
|
if (pendingRename?.abortController) {
|
||||||
|
pendingRename.abortController.abort();
|
||||||
pendingRenames.delete(renameId);
|
pendingRenames.delete(renameId);
|
||||||
return true;
|
return [true, pendingRename];
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
return [false, undefined];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const completeRename = (renameId: string) => {
|
export const completeRename = (renameId: string) => {
|
||||||
@@ -17,10 +23,11 @@ export const completeRename = (renameId: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const addPendingRename = (): [string, AbortController] => {
|
export const addPendingRename = (topicId: string): [string, PendingRename] => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const controller = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
const pendingRename = { topicId, abortController };
|
||||||
|
|
||||||
pendingRenames.set(id, controller);
|
pendingRenames.set(id, pendingRename);
|
||||||
return [id, controller];
|
return [id, pendingRename];
|
||||||
};
|
};
|
||||||
@@ -17,6 +17,13 @@ export const schema = S.Collections({
|
|||||||
systemAssistants: S.Record({
|
systemAssistants: S.Record({
|
||||||
rename: systemAssistantRecord
|
rename: systemAssistantRecord
|
||||||
}),
|
}),
|
||||||
|
appearance: S.Record({
|
||||||
|
colorScheme: S.String({ nullable: true, default: 'system' }),
|
||||||
|
accent: S.String({ nullable: true }),
|
||||||
|
neutral: S.String({ nullable: true }),
|
||||||
|
hinting: S.Number({ nullable: true }),
|
||||||
|
fontSize: S.String({ nullable: true }),
|
||||||
|
}, { nullable: true }),
|
||||||
}),
|
}),
|
||||||
permissions: {
|
permissions: {
|
||||||
authenticated: {
|
authenticated: {
|
||||||
|
|||||||
+7
-1
@@ -2,7 +2,13 @@
|
|||||||
// https://nuxt.com/docs/guide/concepts/typescript
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
"files": [],
|
"files": [],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noFallthroughCasesInSwitch": true
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
},
|
},
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,3 +19,14 @@ export const Err = <E>(error: E): Err<E> => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type Result<T, E> = Ok<T> | Err<E>;
|
export type Result<T, E> = Ok<T> | Err<E>;
|
||||||
|
|
||||||
|
export async function attempt<T, E = Error>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
): Promise<Result<T, E>> {
|
||||||
|
try {
|
||||||
|
const result = await promise;
|
||||||
|
return Ok(result);
|
||||||
|
} catch (error) {
|
||||||
|
return Err(error as E);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,5 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
presets: [
|
presets: [
|
||||||
presetMini(),
|
presetMini(),
|
||||||
presetWebFonts({
|
|
||||||
provider: 'fontsource',
|
|
||||||
fonts: {
|
|
||||||
sans: 'Geist',
|
|
||||||
mono: 'JetBrains Mono',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user