22 Commits

Author SHA1 Message Date
zoeissleeping ff7797d3f8 feat: admin controls 2026-10-15 10:22:06 -05:00
zoeissleeping b6f4d85ed7 feat: complete admin invite and user creation and recovery flows 2026-10-08 14:24:47 -05:00
zoeissleeping 61f9bb74b4 feat: complete onboarding flow 2026-10-08 14:04:29 -05:00
zoeissleeping fae05ebc4c feat: instance invites 2026-10-08 13:50:44 -05:00
zoeissleeping f6e1bc1f47 feat: basic admin interface 2026-10-08 13:41:02 -05:00
zoeissleeping 7b0800882a feat: setup and roles 2026-10-08 13:08:47 -05:00
zoeissleeping 4bba529ba8 feat: improve account security
Support TOTP 2fa, allow for changing password, add session management
and account removal.
2026-10-08 12:43:22 -05:00
zoeissleeping ff33e9c573 feat: replace settings with a full page 2026-10-08 08:06:06 -05:00
zoeissleeping 2b9f8d64d7 bug fixes, and performance improvements, and drawer 2026-09-27 18:44:51 -05:00
zoeissleeping d48d8163a7 feat: remove search rerank 2026-09-27 09:45:19 -05:00
zoeissleeping bb5795e00d feat: custom tool renderers 2026-09-25 19:49:02 -05:00
zoeissleeping 3f81f96bda feat: expand model icons, guard uploads, and handle seach env better 2026-09-10 04:25:37 -05:00
zoeissleeping 4b99921023 feat(markdown): Migrate to comark for markdown rendering 2026-08-11 03:18:28 -05:00
zoeissleeping c9e48687ef fix: Dramatically better auto scroll 2026-08-04 02:38:36 -05:00
zoeissleeping a836082de8 feat: per-agent tools toggle and better python scratchpad
Add ToolSelector for enabling sandboxed python per agent, drop unsafe
filesystem/bash tools, bundle fetchUrl with web search, and return
Monty's last expression value so agents need not print.
2026-07-29 14:18:37 -05:00
zoeissleeping 5aebd30808 fix: copy tool calls, generations, and timestamps when forking topics
Forked topics dropped toolCall/generation relations and rewrote part
lastUpdatedAt, which broke tool rendering, token stats, and reasoning durations.
2026-07-27 17:37:40 -05:00
zoeissleeping 166052012d fix: preserve nested hard-break content and use timestamptz
remarkSplitBlocks was splicing after-break paragraphs onto the root
tree, which dropped content inside list items and other nested parents.
Insert the split sibling into the actual parent instead.

Also store timestamps as timestamptz so Drizzle no longer treats naive
timestamp values as UTC and shifts displayed times by the server offset.
2026-07-19 14:03:14 -05:00
zoeissleeping 28ef0c8a07 feat: add export to topics dropdown 2026-07-16 14:29:07 -05:00
zoeissleeping 886f385835 feat: add timestamp and forking 2026-07-15 15:49:38 -05:00
zoeissleeping 8ccaa824dd feat: file upload retry, secure file tokens, UI polish
- Add HMAC-based file token auth for secure AI model file access
- Add file upload retry with exponential backoff (max 3 retries)
- File endpoint now requires session auth or signed token
- Support assistant role messages in chat input
- Optimistic UI for attachments on message send
- Verify topic ownership before allowing messages
- Switch web scraping to Firecrawl API
- Agent profile page layout fixes (proper flex overflow)
- Add quick switcher (Ctrl+K) to sidenav
- Clean up longcat.ts and stale comments
2026-06-06 00:16:39 -05:00
zoeissleeping 47009b1f0a refactor: un-hardcode file urls 2026-05-12 10:28:33 -05:00
zoeissleeping 9550d44220 merge: drizzle rewrite into main
Complete migration from Triplit to PostgreSQL + Drizzle ORM with all subsequent feature work and fixes.
2026-05-11 17:44:22 -05:00
197 changed files with 36168 additions and 2890 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ app/ # Nuxt 4 application (SSR)
├── layouts/ # Auth and default layouts ├── layouts/ # Auth and default layouts
├── middleware/ # Global auth middleware ├── middleware/ # Global auth middleware
├── pages/ # File-based routing (/, /auth/*, /agent/*) ├── pages/ # File-based routing (/, /auth/*, /agent/*)
├── plugins/ # Auth plugins (client/server), remark markdown ├── plugins/ # Auth plugins (client/server)
├── types/ # TypeScript interfaces ├── types/ # TypeScript interfaces
└── utils/ # Crypto, model-mapping, search utilities └── utils/ # Crypto, model-mapping, search utilities
+3
View File
@@ -26,6 +26,9 @@ COPY --from=build /app/drizzle/migrations /app/drizzle/migrations
# Copy entrypoint script # Copy entrypoint script
COPY --from=build /app/scripts/start.ts /app/scripts/start.ts COPY --from=build /app/scripts/start.ts /app/scripts/start.ts
# Copy the account recovery script (bun run recover <email>)
COPY --from=build /app/scripts/recover.ts /app/scripts/recover.ts
# Copy package.json and lockfile for production dependency install # Copy package.json and lockfile for production dependency install
COPY --from=build /app/package.json /app/package.json COPY --from=build /app/package.json /app/package.json
COPY --from=build /app/bun.lock* ./ COPY --from=build /app/bun.lock* ./
+1
View File
@@ -4,6 +4,7 @@
- [x] Make dropdowns a singleton component (work in progress) - [x] 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
- [x] Make topics manually renameable - [x] Make topics manually renameable
- [ ] Surface auto-rename failures through the future toast/error UI; add actionable retry and bounded bulk naming for unnamed/provisional topics
- [ ] Improve latex rendering, make single line equations work _well_, and likely make it configurable - [ ] Improve latex rendering, make single line equations work _well_, and likely make it configurable
- [x] Implement appearence settings - [x] Implement appearence settings
- [x] Accent colors - [x] Accent colors
+2 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import '~/assets/css/reset.css'; import '~/assets/css/reset.css';
import '~/assets/css/base.css'; import '~/assets/css/base.css';
import 'katex/dist/katex.min.css';
const { user } = useAuth(); const { user } = useAuth();
const { accent, neutral, hinting, refresh: refreshSettings } = await useUserSettings(); const { accent, neutral, hinting, refresh: refreshSettings } = await useUserSettings();
@@ -24,7 +25,7 @@ watchEffect(() => {
}); });
if (import.meta.client) { if (import.meta.client) {
// force shiki into browser rendering only // Force Shiki into browser rendering only.
window.sessionStorage.setItem('mdc-shiki-highlighter', 'browser'); window.sessionStorage.setItem('mdc-shiki-highlighter', 'browser');
} }
</script> </script>
+10 -5
View File
@@ -81,6 +81,7 @@
--color-border: color-mix(in srgb, rgba(255, 255, 255, 0.1), 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)); --color-border-active: color-mix(in srgb, rgba(255, 255, 255, 0.16), var(--color-accent) var(--accent-hinting));
} }
:root.light { :root.light {
@@ -107,6 +108,7 @@
--color-border: color-mix(in srgb, rgba(0, 0, 0, 0.08), 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)); --color-border-active: color-mix(in srgb, rgba(0, 0, 0, 0.14), var(--color-accent) var(--accent-hinting));
} }
/* :root.dark { /* :root.dark {
@@ -316,16 +318,19 @@ button.accent:hover {
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='18' height='18'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='18' height='18'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0'/%3E%3C/svg%3E");
} }
.reasoning-container.middle { .reasoning-container.top {
mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent) -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 calc(100% - 24px), transparent 100%);
mask-image: linear-gradient(to bottom, #000 0%, #000 calc(100% - 24px), transparent 100%);
} }
.reasoning-container.top { .reasoning-container.middle {
mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent) -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 24px, #000 calc(100% - 24px), transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0%, #000 24px, #000 calc(100% - 24px), transparent 100%);
} }
.reasoning-container.bottom { .reasoning-container.bottom {
mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000) -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 24px, #000 100%);
mask-image: linear-gradient(to bottom, transparent 0%, #000 24px, #000 100%);
} }
/* Markdown renderer */ /* Markdown renderer */
+25 -5
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import ImageViewer from '~/components/ImageViewer.vue'; import ImageViewer from '~/components/ImageViewer.vue';
import { resolveFileUrl } from '~~/utils/url';
const props = defineProps<{ const props = defineProps<{
file: { file: {
@@ -9,14 +10,21 @@ const props = defineProps<{
status?: 'uploading' | 'uploaded' | 'error'; status?: 'uploading' | 'uploaded' | 'error';
url: string; url: string;
progress?: number; progress?: number;
retryCount?: number;
}; };
}>(); }>();
const emit = defineEmits<{
retry: [];
}>();
const isImage = computed(() => props.file.mimeType.startsWith('image/')); const isImage = computed(() => props.file.mimeType.startsWith('image/'));
const isVideo = computed(() => props.file.mimeType.startsWith('video/')); const isVideo = computed(() => props.file.mimeType.startsWith('video/'));
const isAudio = computed(() => props.file.mimeType.startsWith('audio/')); const isAudio = computed(() => props.file.mimeType.startsWith('audio/'));
const isPdf = computed(() => props.file.mimeType === 'application/pdf'); const isPdf = computed(() => props.file.mimeType === 'application/pdf');
const resolvedUrl = computed(() => resolveFileUrl(props.file.url));
const imageViewerOpen = ref(false); const imageViewerOpen = ref(false);
const imageRef = ref<HTMLImageElement | null>(null); const imageRef = ref<HTMLImageElement | null>(null);
const imageViewerOriginRect = ref<DOMRect | null>(null); const imageViewerOriginRect = ref<DOMRect | null>(null);
@@ -45,16 +53,16 @@ function openImageViewer() {
<template> <template>
<div class="relative h-full w-fit flex"> <div class="relative h-full w-fit flex">
<div v-if="isImage"> <div v-if="isImage">
<img ref="imageRef" :src="props.file.url" <img ref="imageRef" :src="resolvedUrl"
class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover cursor-zoom-in hover:opacity-90 transition-opacity" class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover cursor-zoom-in hover:opacity-90 transition-opacity"
@click="openImageViewer" /> @click="openImageViewer" />
<ImageViewer v-if="imageViewerOpen" :src="props.file.url" :alt="props.file.name" <ImageViewer v-if="imageViewerOpen" :src="resolvedUrl" :alt="props.file.name"
:origin-rect="imageViewerOriginRect" :origin-element="imageRef" :origin-rect="imageViewerOriginRect" :origin-element="imageRef"
@close="imageViewerOpen = false" /> @close="imageViewerOpen = false" />
</div> </div>
<video v-else-if="isVideo" controls :src="props.file.url" <video v-else-if="isVideo" controls :src="resolvedUrl"
class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover" /> class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover" />
<audio v-else-if="isAudio" controls :src="props.file.url" class="rounded-lg h-full w-full max-h-36 max-w-64" /> <audio v-else-if="isAudio" controls :src="resolvedUrl" class="rounded-lg h-full w-full max-h-36 max-w-64" />
<div v-else class="flex items-center gap-2 p-2 rounded-lg bg-[var(--color-hover)] min-w-40 max-w-48"> <div v-else class="flex items-center gap-2 p-2 rounded-lg bg-[var(--color-hover)] min-w-40 max-w-48">
<span :class="[fileIcon, 'text-6 text-[var(--color-accent)]']"></span> <span :class="[fileIcon, 'text-6 text-[var(--color-accent)]']"></span>
@@ -68,8 +76,20 @@ function openImageViewer() {
class="absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center"> class="absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center">
<div class="text-center"> <div class="text-center">
<span class="i-svg-spinners-90-ring-with-bg text-6 text-white block mb-1"></span> <span class="i-svg-spinners-90-ring-with-bg text-6 text-white block mb-1"></span>
<span class="text-xs text-white">{{ file.progress || 0 }}%</span> <span class="text-xs text-white">
{{ file.retryCount ? `Retry ${file.retryCount}/3` : `${file.progress || 0}%` }}
</span>
</div> </div>
</div> </div>
<div v-else-if="file.status === 'error'"
class="absolute inset-0 bg-black/60 rounded-lg flex flex-col items-center justify-center gap-1">
<span class="i-mynaui-danger-triangle text-6 text-red-400"></span>
<span class="text-xs text-red-300">Upload failed</span>
<button @click.stop="emit('retry')"
class="mt-1 flex items-center gap-1 px-2 py-0.5 rounded-md bg-white/10 hover:bg-white/20 text-xs text-white transition-colors">
<span class="i-mynaui-refresh text-3"></span>
Retry
</button>
</div>
</div> </div>
</template> </template>
+8 -2
View File
@@ -8,11 +8,13 @@ const props = defineProps<{
mimeType: string; mimeType: string;
status: 'uploading' | 'uploaded' | 'error'; status: 'uploading' | 'uploaded' | 'error';
url: string; url: string;
retryCount?: number;
} }
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
delete: []; delete: [];
retry: [];
}>(); }>();
const handleDelete = async () => { const handleDelete = async () => {
@@ -21,12 +23,16 @@ const handleDelete = async () => {
</script> </script>
<template> <template>
<div class="relative h-full w-fit flex"> <div class="relative h-full w-fit flex flex-shrink-0">
<Display :file="props.file" /> <Display :file="props.file" @retry="emit('retry')" />
<button @click="handleDelete" <button @click="handleDelete"
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 flex items-center justify-center w-4 h-4 rounded-full bg-[var(--bg-base)] border border-[var(--color-border)] text-xs text-[#ff3b3b]"> class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 flex items-center justify-center w-4 h-4 rounded-full bg-[var(--bg-base)] border border-[var(--color-border)] text-xs text-[#ff3b3b]">
<span class="i-mynaui-x text-3"></span> <span class="i-mynaui-x text-3"></span>
</button> </button>
<div v-if="file.status === 'error'"
class="absolute bottom-0 right-0 translate-x-1/4 translate-y-1/4 flex items-center justify-center w-4 h-4 rounded-full bg-red-500">
<span class="i-mynaui-danger-triangle text-2.5 text-white"></span>
</div>
</div> </div>
</template> </template>
+249 -80
View File
@@ -1,14 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import type { BaseMessage } from '~/composables/useChat'; import type { BaseMessage } from '~/composables/useChat';
import { onMounted, ref, watch, onUnmounted, nextTick, type Ref } from 'vue'; import { computed, onMounted, ref, watch, onUnmounted, nextTick, type Ref } from 'vue';
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels'; import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
import type { Agent } from '~/composables/useAgents'; import type { Agent } from '~/composables/useAgents';
import type FileSelector from './FileSelector.vue'; import type FileSelector from './FileSelector.vue';
import { getComposerHeight } from '~/utils/composer-size';
const { allModels } = await useModels(); const { allModels } = await useModels();
const { updateAgent, patchAgentLocally } = await useAgents(); const { updateAgent, patchAgentLocally } = await useAgents();
const inputHeight: Ref<string> = ref('auto'); const isDrawerOpen = useCookie<boolean>("chat:tools-drawer", { default: () => false });
watch(isDrawerOpen, () => {
emit("resize");
});
const inputHeight: Ref<string> = ref('48px');
const fileSelectorRef = ref<InstanceType<typeof FileSelector> | null>(null); const fileSelectorRef = ref<InstanceType<typeof FileSelector> | null>(null);
const inputRef = ref<HTMLTextAreaElement | null>(null); const inputRef = ref<HTMLTextAreaElement | null>(null);
let tempInput = ''; let tempInput = '';
@@ -19,6 +25,7 @@ const files = ref<{
status: 'uploading' | 'uploaded' | 'error'; status: 'uploading' | 'uploaded' | 'error';
url: string; url: string;
progress: number; progress: number;
retryCount: number;
}[]>([]); }[]>([]);
const inputValue = defineModel<BaseMessage>({ required: false, default: { content: '', fileIds: [] } }); const inputValue = defineModel<BaseMessage>({ required: false, default: { content: '', fileIds: [] } });
const textAreaValue = ref(''); const textAreaValue = ref('');
@@ -31,45 +38,72 @@ watch(files, (newFiles) => {
const emit = defineEmits<{ const emit = defineEmits<{
submit: [value: BaseMessage, model: ModelWithProvider | null]; submit: [value: BaseMessage, model: ModelWithProvider | null];
addMessage: [value: BaseMessage, role: 'user' | 'assistant', model: ModelWithProvider | null];
cancel: []; cancel: [];
resize: []; resize: [];
}>(); }>();
const props = defineProps<{ const props = defineProps<{
loading?: boolean; loading?: boolean;
allowManualRole?: boolean;
agent: Readonly<Agent> | null; agent: Readonly<Agent> | null;
providers?: ProviderWithModels[]; providers?: ProviderWithModels[];
}>(); }>();
const searchConfig = ref({ const searchConfig = ref({
enabled: props.agent?.config?.search?.enabled ?? false, enabled: props.agent?.config?.search?.enabled ?? false,
maxResults: props.agent?.config?.search?.maxResults ?? 10, });
rerank: props.agent?.config?.search?.rerank ?? false,
const toolsConfig = ref({
python: props.agent?.config?.tools?.python ?? false,
}); });
watch(() => props.agent?.config?.search, (val) => { watch(() => props.agent?.config?.search, (val) => {
searchConfig.value = { searchConfig.value = {
enabled: val?.enabled ?? false, enabled: val?.enabled ?? false,
maxResults: val?.maxResults ?? 10,
rerank: val?.rerank ?? false,
}; };
}, { deep: true }); }, { deep: true });
const saveSearchConfig = async () => { watch(() => props.agent?.config?.tools, (val) => {
toolsConfig.value = {
python: val?.python ?? false,
};
}, { deep: true });
const saveAgentConfig = async (partial: {
search?: typeof searchConfig.value;
tools?: typeof toolsConfig.value;
}) => {
if (!props.agent) return; if (!props.agent) return;
const currentConfig = props.agent.config ?? {}; const currentConfig = props.agent.config ?? {};
const newConfig = { const newConfig = {
...currentConfig, ...currentConfig,
search: { ...(partial.search !== undefined ? {
enabled: searchConfig.value.enabled, search: {
maxResults: searchConfig.value.maxResults, enabled: partial.search.enabled,
rerank: searchConfig.value.rerank, },
}, } : {}),
...(partial.tools !== undefined ? {
tools: {
python: partial.tools.python,
},
} : {}),
}; };
patchAgentLocally(props.agent.id, { config: newConfig }); patchAgentLocally(props.agent.id, { config: newConfig });
updateAgent(props.agent.id, { config: newConfig }); updateAgent(props.agent.id, { config: newConfig });
}; };
const saveSearchConfig = async () => {
await saveAgentConfig({ search: searchConfig.value });
};
const saveToolsConfig = async () => {
await saveAgentConfig({ tools: toolsConfig.value });
};
const isUploading = computed(() => files.value.some((f) => f.status === 'uploading'));
const hasFailedUploads = computed(() => files.value.some((f) => f.status === 'error'));
const selectedModel = ref<ModelWithProvider | null>(null); const selectedModel = ref<ModelWithProvider | null>(null);
const handlePaste = async (event: ClipboardEvent) => { const handlePaste = async (event: ClipboardEvent) => {
@@ -109,11 +143,18 @@ const initializeModel = () => {
const updateAgentDefaultModel = async (modelId: string) => { const updateAgentDefaultModel = async (modelId: string) => {
if (!props.agent) return; if (!props.agent) return;
try { try {
const currentModelId = props.agent.defaultModelId;
await $fetch(`/api/agent/${props.agent.id}`, { await $fetch(`/api/agent/${props.agent.id}`, {
method: 'PATCH', method: 'PATCH',
body: { body: {
defaultModelId: modelId, defaultModelId: modelId,
}, },
onRequest: () => {
patchAgentLocally(props.agent!.id, { defaultModelId: modelId });
},
onResponseError: () => {
patchAgentLocally(props.agent!.id, { defaultModelId: currentModelId });
},
}); });
} catch (error) { } catch (error) {
console.error('Failed to update agent default model:', error); console.error('Failed to update agent default model:', error);
@@ -137,14 +178,51 @@ watch(() => props.agent?.defaultModelId, (newModelId) => {
} }
}) })
// sending needs a model, and there are none until a provider is set up
const noModels = computed(() => allModels.value.length === 0);
const handleSubmit = () => { const handleSubmit = () => {
if (noModels.value) return;
if (isUploading.value || hasFailedUploads.value) {
return;
}
if (props.loading) { if (props.loading) {
emit('cancel'); emit('cancel');
return; return;
} }
if (inputValue.value.content.trim() || inputValue.value.fileIds.length > 0) { if (inputValue.value.content.trim() || inputValue.value.fileIds.length > 0) {
emit('submit', structuredClone(toRaw(inputValue.value)), selectedModel.value); const message: BaseMessage = {
content: inputValue.value.content,
fileIds: inputValue.value.fileIds,
files: files.value.map(f => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
url: f.url,
})),
};
emit('submit', message, selectedModel.value);
files.value = [];
textAreaValue.value = '';
}
};
const handleAddMessage = (role: 'user' | 'assistant') => {
if (inputValue.value.content.trim() || inputValue.value.fileIds.length > 0) {
const message: BaseMessage = {
content: inputValue.value.content,
fileIds: inputValue.value.fileIds,
files: files.value.map(f => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
url: f.url,
})),
};
emit('addMessage', message, role, selectedModel.value);
files.value = []; files.value = [];
textAreaValue.value = ''; textAreaValue.value = '';
} }
@@ -236,47 +314,57 @@ const handleWindowKeyDown = async (event: KeyboardEvent) => {
}; };
const mirrorRef = ref<HTMLDivElement | null>(null); const mirrorRef = ref<HTMLDivElement | null>(null);
const MIRROR_PROPERTIES = [
'box-sizing', 'font-family', 'font-size', 'font-weight', 'font-style',
'line-height', 'letter-spacing', 'word-spacing', 'text-align', 'text-indent',
'text-transform', 'tab-size', 'padding-top', 'padding-bottom', 'padding-left',
'padding-right', 'border-top-width', 'border-bottom-width', 'border-left-width',
'border-right-width', 'border-style', 'white-space', 'overflow-wrap', 'word-break',
] as const;
let sizingRaf: number | undefined;
let sizingObserver: ResizeObserver | undefined;
let typographyObserver: MutationObserver | undefined;
let sizingDirty = true;
let mirrorWidth = '';
let lineHeight = 24;
let padding = 0;
let borders = 0;
let borderBox = true;
const resizeTextArea = () => { const resizeTextArea = () => {
const textarea = inputRef.value; if (import.meta.server || sizingRaf !== undefined) return;
const mirror = mirrorRef.value; sizingRaf = requestAnimationFrame(() => {
if (!textarea || !mirror) return; sizingRaf = undefined;
const textarea = inputRef.value;
const mirror = mirrorRef.value;
if (!textarea || !mirror) return;
emit('resize'); if (sizingDirty) {
nextTick().then(() => { // Finish style reads before changing the isolated measurement box.
mirror.textContent = (textarea.value || '\u200b') + '\n'; const styles = getComputedStyle(textarea);
const values = MIRROR_PROPERTIES.map(property => styles.getPropertyValue(property));
mirrorWidth = styles.width;
lineHeight = Number.parseFloat(styles.lineHeight) || Number.parseFloat(styles.fontSize) * 1.2;
padding = Number.parseFloat(styles.paddingTop) + Number.parseFloat(styles.paddingBottom);
borders = Number.parseFloat(styles.borderTopWidth) + Number.parseFloat(styles.borderBottomWidth);
borderBox = styles.boxSizing === 'border-box';
MIRROR_PROPERTIES.forEach((property, index) => mirror.style.setProperty(property, values[index]!));
mirror.style.width = mirrorWidth;
sizingDirty = false;
}
const textareaStyles = getComputedStyle(textarea); // Preserve the final empty line without adding an extra line to ordinary text.
mirror.style.width = textareaStyles.width; mirror.textContent = textarea.value + '\u200b';
mirror.style.fontFamily = textareaStyles.fontFamily; const height = `${getComposerHeight(mirror.scrollHeight, lineHeight, padding, borders, borderBox)}px`;
mirror.style.fontSize = textareaStyles.fontSize; if (height === inputHeight.value) return;
mirror.style.lineHeight = textareaStyles.lineHeight;
mirror.style.letterSpacing = textareaStyles.letterSpacing;
mirror.style.wordSpacing = textareaStyles.wordSpacing;
mirror.style.textAlign = textareaStyles.textAlign;
mirror.style.paddingTop = textareaStyles.paddingTop;
mirror.style.paddingBottom = textareaStyles.paddingBottom;
mirror.style.paddingLeft = textareaStyles.paddingLeft;
mirror.style.paddingRight = textareaStyles.paddingRight;
mirror.style.borderTopWidth = textareaStyles.borderTopWidth;
mirror.style.borderBottomWidth = textareaStyles.borderBottomWidth;
mirror.style.borderLeftWidth = textareaStyles.borderLeftWidth;
mirror.style.borderRightWidth = textareaStyles.borderRightWidth;
mirror.style.whiteSpace = 'pre-wrap';
mirror.style.wordWrap = 'break-word';
mirror.style.overflow = 'hidden';
const lineHeight = 24; // Capture bottom-follow intent before applying the new height.
const minLines = 2; emit('resize');
const maxLines = 10; inputHeight.value = height;
const minHeight = minLines * lineHeight;
const maxHeight = maxLines * lineHeight;
const height = Math.min(Math.max(mirror.scrollHeight, minHeight), maxHeight);
inputHeight.value = `${height}px`;
}); });
}; };
watch(textAreaValue, resizeTextArea, { immediate: true }); watch(textAreaValue, resizeTextArea, { flush: 'post' });
let hasCommandKey = false; let hasCommandKey = false;
if (import.meta.server) { if (import.meta.server) {
@@ -290,42 +378,53 @@ onBeforeMount(() => {
tempInput = (document.getElementById('chat') as HTMLInputElement)?.value ?? ''; tempInput = (document.getElementById('chat') as HTMLInputElement)?.value ?? '';
}); });
let resizeRafId: number | undefined;
const handleWindowResize = () => { const handleWindowResize = () => {
if (resizeRafId) return; sizingDirty = true;
resizeRafId = requestAnimationFrame(() => { resizeTextArea();
resizeRafId = undefined; };
resizeTextArea();
});
}
onMounted(() => { onMounted(() => {
textAreaValue.value = tempInput; textAreaValue.value = tempInput;
document.addEventListener('keydown', handleWindowKeyDown); document.addEventListener('keydown', handleWindowKeyDown);
window.addEventListener('resize', handleWindowResize); window.addEventListener('resize', handleWindowResize);
inputRef.value?.addEventListener('paste', handlePaste); inputRef.value?.addEventListener('paste', handlePaste);
let observedWidth: number | undefined;
sizingObserver = new ResizeObserver(([entry]) => {
if (!entry || entry.contentRect.width === observedWidth) return;
observedWidth = entry.contentRect.width;
handleWindowResize();
});
if (inputRef.value) sizingObserver.observe(inputRef.value);
typographyObserver = new MutationObserver(handleWindowResize);
typographyObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] });
document.fonts.addEventListener('loadingdone', handleWindowResize);
handleWindowResize();
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('keydown', handleWindowKeyDown); document.removeEventListener('keydown', handleWindowKeyDown);
window.removeEventListener('resize', handleWindowResize); window.removeEventListener('resize', handleWindowResize);
if (resizeRafId !== undefined) cancelAnimationFrame(resizeRafId); if (sizingRaf !== undefined) cancelAnimationFrame(sizingRaf);
sizingObserver?.disconnect();
typographyObserver?.disconnect();
document.fonts.removeEventListener('loadingdone', handleWindowResize);
inputRef.value?.removeEventListener('paste', handlePaste); inputRef.value?.removeEventListener('paste', handlePaste);
}); });
</script> </script>
<template> <template>
<div :class="['w-full flex max-h-full', $attrs.class]"> <div :class="['w-full flex flex-col max-h-full', $attrs.class]">
<div ref="mirrorRef" aria-hidden="true" <div ref="mirrorRef" data-chat-measurement aria-hidden="true"
class="absolute top-0 left-0 pointer-events-none invisible overflow-hidden h-0"></div> class="fixed top-0 left-0 pointer-events-none invisible overflow-hidden h-0 [contain:layout_style_paint]"></div>
<div class="relative w-full flex flex-shrink-1 flex-col gap-3 p-2 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--bg-container)] <div class="relative z-10 w-full flex flex-shrink-1 flex-col gap-3 p-2 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--bg-container)]
border-[var(--color-border)] focus-within:border-[var(--color-border-active)]"> border-[var(--color-border)] focus-within:border-[var(--color-border-active)]">
<div v-if="files.length > 0" class="flex-1 flex gap-2 pt-2 px-2 pb-1 overflow-x-auto flex-wrap"> <div v-if="files.length > 0"
class="flex-shrink-0 flex flex-nowrap gap-2 pt-2 px-2 pb-1 overflow-x-auto overflow-y-hidden [scrollbar-width:thin]">
<!-- TODO: show attachment previews --> <!-- TODO: show attachment previews -->
<AttachmentPreview v-for="file in files" @delete="files = files.filter((f) => f.id !== file.id)" <AttachmentPreview v-for="file in files" @delete="files = files.filter((f) => f.id !== file.id)"
:key="file.id" :file="file" /> @retry="fileSelectorRef?.retryFile(file.id)" :key="file.id" :file="file"></AttachmentPreview>
</div> </div>
<div class="flex-1 min-w-0 max-h-full"> <div class="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 --> <!-- 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="textAreaValue" ref="inputRef" <textarea data-gramm="false" id="chat" v-model="textAreaValue" ref="inputRef"
@@ -336,27 +435,97 @@ onUnmounted(() => {
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<!-- TODO: since we dont want to model selector dropdown to potentially overflow, it has max-width: 100%, so, we need to maake the trigger large enough to fit the entire width of the dropdown --> <!-- TODO: since we dont want to model selector dropdown to potentially overflow, it has max-width: 100%, so, we need to maake the trigger large enough to fit the entire width of the dropdown -->
<div class="flex flex-1 gap-1 min-w-0"> <div class="flex flex-1 items-center gap-1 min-w-0">
<button v-if="selectedModel?.capabilities.includes('tools')"
type="button"
@click="isDrawerOpen = !isDrawerOpen"
class="sm:hidden relative flex items-center justify-center h-8.5 w-8.5 shrink-0 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200"
:aria-label="isDrawerOpen ? 'Close tools drawer' : 'Open tools drawer'"
:aria-expanded="isDrawerOpen">
<span class="i-mynaui-chevron-down text-5 text-[var(--text-secondary)] transition-transform duration-200"
:class="{ 'rotate-180': isDrawerOpen }"></span>
<span v-if="!isDrawerOpen && (searchConfig.enabled || toolsConfig.python)"
class="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-[var(--color-accent)]"></span>
</button>
<ModelSelector v-if="providers !== undefined" :add-hotkey="true" v-model="selectedModel" <ModelSelector v-if="providers !== undefined" :add-hotkey="true" v-model="selectedModel"
:providers="providers" /> :providers="providers"></ModelSelector>
<SearchSelector v-if="selectedModel?.capabilities.includes('tools')" :enabled="searchConfig.enabled" <div v-if="selectedModel?.capabilities.includes('tools')" class="hidden sm:flex items-center gap-1">
:max-results="searchConfig.maxResults" :rerank="searchConfig.rerank" <SearchSelector :enabled="searchConfig.enabled"
@update:enabled="(v: boolean) => { searchConfig.enabled = v; saveSearchConfig() }" @update:enabled="(v: boolean) => { searchConfig.enabled = v; saveSearchConfig(); }"></SearchSelector>
@update:max-results="(v: number) => { searchConfig.maxResults = v; saveSearchConfig() }" <ToolSelector :python="toolsConfig.python"
@update:rerank="(v: boolean) => { searchConfig.rerank = v; saveSearchConfig() }" /> @update:python="(v: boolean) => { toolsConfig.python = v; saveToolsConfig(); }"></ToolSelector>
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files" /> </div>
</div> </div>
<button aria-label="Send message" @click="handleSubmit" <div class="flex items-center gap-1 shrink-0">
:disabled="(!inputValue.content.trim() && files.length === 0) && !loading" :class="[ <FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files"></FileSelector>
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent', <div v-if="allowManualRole" class="flex items-center">
(inputValue.content.trim() || files.length > 0) && !loading <button :aria-label="noModels ? 'Send (add a provider first)' : 'Send message'" @click="handleSubmit"
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)]' :disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads || noModels"
: 'text-[var(--text-dim)]', :class="[
loading && 'bg-[var(--color-hover)] @hover:bg-[var(--color-active)]', 'h-8 w-8 rounded-l-lg rounded-r-none transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
]"> (inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
<span v-if="loading" class="text-6.5 i-mynaui-stop-solid"></span> ? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)]'
<span v-else class="text-5 i-mynaui-send-solid"></span> : 'text-[var(--text-dim)]',
</button> loading && 'bg-[var(--color-hover)] @hover:bg-[var(--color-active)]',
]">
<span v-if="loading" class="text-6.5 i-mynaui-stop-solid"></span>
<span v-else class="text-5 i-mynaui-send-solid"></span>
</button>
<Dropdown placement="bottom-end">
<template #default="{ setRef, isOpen, toggle }">
<button :ref="setRef" aria-label="Message options" @click="toggle"
:disabled="(!inputValue.content.trim() && files.length === 0) || loading || isUploading || hasFailedUploads"
:class="[
'h-8 w-6 rounded-r-lg rounded-l-none transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed border-l border-[var(--text-dim)] disabled:border-transparent',
(inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)] disabled:bg-transparent'
: 'text-[var(--text-dim)]',
loading && 'bg-[var(--color-hover)] @hover:bg-[var(--color-active)]',
]">
<span class="text-4.5 i-mynaui-chevron-down" :class="{ 'rotate-180': isOpen }"></span>
</button>
</template>
<template #dropdown="{ close }">
<button
class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm text-[var(--text-primary)] @hover:bg-[var(--color-hover)] transition-colors whitespace-nowrap"
@click="handleAddMessage('user'); close()">
<span class="text-4 i-mynaui-user"></span>
Add as User
</button>
<button
class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm text-[var(--text-primary)] @hover:bg-[var(--color-hover)] transition-colors whitespace-nowrap"
@click="handleAddMessage('assistant'); close()">
<span class="text-4 i-mynaui-sparkles"></span>
Add as Assistant
</button>
</template>
</Dropdown>
</div>
<button v-else :aria-label="noModels ? 'Send (add a provider first)' : 'Send message'" @click="handleSubmit"
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads || noModels"
:class="[
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
(inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)]'
: 'text-[var(--text-dim)]',
loading && 'bg-[var(--color-hover)] @hover:bg-[var(--color-active)]',
]">
<span v-if="loading" class="text-6.5 i-mynaui-stop-solid"></span>
<span v-else class="text-5 i-mynaui-send-solid"></span>
</button>
</div>
</div>
</div>
<div v-if="selectedModel?.capabilities.includes('tools')"
class="sm:hidden relative z-0 mx-auto w-[calc(100%-4px)] transition-[height] duration-250 ease-[cubic-bezier(0.4,_0,_0.2,_1)]"
:class="isDrawerOpen ? 'h-[44px]' : 'h-0'">
<div class="absolute -top-4 left-0 right-0 pt-5 px-3 pb-1.5 rounded-b-2xl border-x border-b border-[var(--color-border)] bg-[var(--bg-surface)] flex items-center gap-1.5 transition-[transform,opacity] duration-250 ease-[cubic-bezier(0.4,_0,_0.2,_1)]"
:class="isDrawerOpen ? 'translate-y-0 opacity-100' : '-translate-y-[44px] opacity-0 pointer-events-none'">
<SearchSelector :enabled="searchConfig.enabled"
@update:enabled="(v: boolean) => { searchConfig.enabled = v; saveSearchConfig(); }"></SearchSelector>
<ToolSelector :python="toolsConfig.python"
@update:python="(v: boolean) => { toolsConfig.python = v; saveToolsConfig(); }"></ToolSelector>
</div> </div>
</div> </div>
</div> </div>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
const LENGTH = 6;
// the code typed so far, always a run of digits with no gaps
const code = defineModel<string>({ default: '' });
const props = defineProps<{
large?: boolean;
disabled?: boolean;
}>();
const emit = defineEmits<{
complete: [code: string];
}>();
const inputs = ref<HTMLInputElement[]>([]);
const digits = computed(() => Array.from({ length: LENGTH }, (_, i) => code.value[i] ?? ''));
const focusAt = (index: number) => {
inputs.value[Math.max(0, Math.min(LENGTH - 1, index))]?.focus();
};
const setCode = (value: string) => {
const next = value.slice(0, LENGTH);
code.value = next;
// code.value still reads the old value until the parent re-renders, so
// this has to check what was just written
if (next.length === LENGTH) {
emit('complete', next);
}
};
const handleInput = (index: number, event: Event) => {
const el = event.target as HTMLInputElement;
// more than one digit arrives at once when the browser autofills the code
const typed = el.value.replace(/\D/g, '');
if (typed !== '') {
const next = code.value.slice(0, index) + typed + code.value.slice(index + typed.length);
setCode(next);
}
// the box only ever shows its own digit, whatever was typed into it
nextTick(() => {
el.value = digits.value[index]!;
if (typed !== '') focusAt(index + typed.length);
});
};
const handleKeydown = (index: number, event: KeyboardEvent) => {
if (event.key === 'Backspace') {
event.preventDefault();
if (digits.value[index] !== '') {
code.value = code.value.slice(0, index) + code.value.slice(index + 1);
} else if (index > 0) {
code.value = code.value.slice(0, index - 1) + code.value.slice(index);
focusAt(index - 1);
}
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
focusAt(index - 1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
focusAt(index + 1);
}
};
const handlePaste = (event: ClipboardEvent) => {
event.preventDefault();
const pasted = (event.clipboardData?.getData('text') ?? '').replace(/\D/g, '');
if (pasted === '') return;
setCode(pasted);
focusAt(pasted.length);
};
const handleFocus = (index: number, event: FocusEvent) => {
// boxes fill left to right, so never leave the cursor past the first empty one
if (index > code.value.length) {
focusAt(code.value.length);
return;
}
(event.target as HTMLInputElement).select();
};
defineExpose({
focus: () => focusAt(code.value.length),
});
</script>
<template>
<div class="flex gap-2">
<input v-for="(digit, index) in digits" :key="index" :ref="(el) => { inputs[index] = el as HTMLInputElement; }"
type="text" inputmode="numeric" pattern="[0-9]*" :aria-label="`Digit ${index + 1}`"
:autocomplete="index === 0 ? 'one-time-code' : 'off'" :value="digit" :disabled="props.disabled" :class="[
'flex-1 min-w-0 w-full p-0 text-center font-mono rounded-lg bg-[var(--bg-container)] text-[var(--text-primary)] border border-[var(--color-border)] outline-none transition-[border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:ring-3 focus:ring-[var(--color-accent)]/20 disabled:opacity-50',
props.large ? 'h-14 text-2xl' : 'h-13 text-[22px]',
]" @input="handleInput(index, $event)" @keydown="handleKeydown(index, $event)" @paste="handlePaste"
@focus="handleFocus(index, $event)" />
</div>
</template>
+243
View File
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { Err, Ok, type Result } from '~~/types/result';
import { base64ToUint8Array, decrypt, deriveKey, encryptData, uint8ArrayToBase64 } from '~/utils/crypto';
const emit = defineEmits(['confirm', 'cancel']);
const { user } = useAuth();
const { providers, refresh } = await useModels();
const INPUT_CLASS = 'w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]';
type StepState = 'pending' | 'active' | 'done' | 'failed';
const form = reactive({
current: '',
next: '',
confirm: '',
});
const busy = ref(false);
const done = ref(false);
const error = ref('');
const savedKeyCount = computed(() => providers.value.filter(p => !!p.config.apiKey).length);
const steps = reactive<{ label: string; hint?: string; state: StepState }[]>([
{ label: 'Unlock your saved API keys in this browser', state: 'pending' },
{ label: 'Re-encrypt them with the new password', state: 'pending' },
{ label: 'Save the new password and keys together', state: 'pending' },
{ label: 'Sign out your other devices', state: 'pending' },
]);
const started = computed(() => steps.some(s => s.state !== 'pending'));
const fail = (step: number, message: string) => {
steps[step]!.state = 'failed';
error.value = message;
busy.value = false;
};
const importKey = (jwk: JsonWebKey) =>
crypto.subtle.importKey('jwk', jwk, 'AES-GCM', false, ['encrypt', 'decrypt']);
// API keys are stored encrypted with a key derived from the password, so they
// have to be decrypted with the old password and encrypted again with the new
// one. The server never sees the plaintext keys.
const decryptProviderKeys = async (
oldJwk: JsonWebKey,
): Promise<Result<{ id: string; originalApiKey: string; plaintext: string }[], string[]>> => {
const oldKey = await importKey(oldJwk);
const decrypted: { id: string; originalApiKey: string; plaintext: string }[] = [];
const failed: string[] = [];
for (const provider of providers.value) {
if (!provider.config.apiKey) continue;
try {
const originalApiKey = provider.config.apiKey;
const plaintext = await decrypt(oldKey, base64ToUint8Array(originalApiKey));
decrypted.push({ id: provider.id, originalApiKey, plaintext });
} catch {
failed.push(provider.name);
}
}
if (failed.length > 0) {
return Err(failed);
}
return Ok(decrypted);
};
const submit = async () => {
if (busy.value || done.value || !user.value) return;
error.value = '';
for (const step of steps) step.state = 'pending';
if (form.next !== form.confirm) {
error.value = 'The new passwords do not match.';
return;
}
if (form.next === form.current) {
error.value = 'The new password is the same as the current one.';
return;
}
busy.value = true;
const userId = user.value.id;
steps[0]!.state = 'active';
const oldJwk = await deriveKey(form.current, userId);
const decrypted = await decryptProviderKeys(oldJwk);
if (decrypted.ok === false) {
fail(0, `Your saved API keys could not be decrypted with that password. Check your current password. If it is right, re-enter the key for ${decrypted.error.join(', ')} under AI Providers first.`);
return;
}
steps[0]!.state = 'done';
steps[1]!.state = 'active';
const newJwk = await deriveKey(form.next, userId);
const newKey = await importKey(newJwk);
const providerKeys: { id: string; originalApiKey: string; apiKey: string }[] = [];
for (const { id, originalApiKey, plaintext } of decrypted.data) {
providerKeys.push({ id, originalApiKey, apiKey: uint8ArrayToBase64(await encryptData(newKey, plaintext)) });
}
steps[1]!.state = 'done';
steps[2]!.state = 'active';
try {
await $fetch('/api/account/password', {
method: 'POST',
body: {
currentPassword: form.current,
newPassword: form.next,
providerKeys,
},
});
} catch (err: any) {
const code = err?.data?.data?.code as string | undefined;
switch (code) {
case 'INVALID_PASSWORD':
fail(2, 'Your current password is not right. Nothing was changed.');
break;
case 'PASSWORD_TOO_SHORT':
case 'PASSWORD_TOO_LONG':
fail(2, 'The new password has to be between 8 and 128 characters.');
break;
case 'PROVIDER_KEYS_OUT_OF_DATE':
fail(2, 'Your providers changed in another tab. Reload the page and try again.');
break;
default:
fail(2, 'Your password could not be changed. Nothing was saved, try again.');
break;
}
return;
}
// from here on this browser has to use the new key
localStorage.setItem('encryptionKey', JSON.stringify(newJwk));
await refresh();
// the server signs the other devices out as part of the same request
steps[2]!.state = 'done';
steps[3]!.state = 'done';
form.current = '';
form.next = '';
form.confirm = '';
busy.value = false;
done.value = true;
};
</script>
<template>
<form class="flex flex-col gap-5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
aria-labelledby="change-password-title" @submit.prevent="submit">
<header class="flex flex-col gap-1">
<h2 id="change-password-title" class="text-lg font-semibold">Change password</h2>
<p class="text-sm text-[var(--text-secondary)]">
<template v-if="savedKeyCount > 0">
Your {{ savedKeyCount }} saved API {{ savedKeyCount === 1 ? 'key is' : 'keys are' }}
re-encrypted with the new password and kept. You will automatically be logged out of
all other devices.
</template>
<template v-else>
Your password also encrypts the API keys you save in this browser.
</template>
</p>
</header>
<!-- hidden while it runs, and brought back with what was typed if a step fails -->
<div v-if="!busy && !done" class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<label class="text-sm" for="current-password">Current password</label>
<input id="current-password" v-model="form.current" type="password" required :disabled="busy"
autocomplete="current-password" :class="INPUT_CLASS" />
</div>
<div class="flex flex-wrap gap-3">
<div class="flex flex-col gap-1 flex-1 min-w-40">
<label class="text-sm" for="new-password">New password</label>
<input id="new-password" v-model="form.next" type="password" required minlength="8" maxlength="128"
:disabled="busy" autocomplete="new-password" :class="INPUT_CLASS" />
</div>
<div class="flex flex-col gap-1 flex-1 min-w-40">
<label class="text-sm" for="confirm-password">Confirm</label>
<input id="confirm-password" v-model="form.confirm" type="password" required minlength="8"
maxlength="128" :disabled="busy" autocomplete="new-password" :class="INPUT_CLASS" />
</div>
</div>
</div>
<ol v-if="started" aria-label="Progress" class="flex flex-col gap-2.5 text-sm">
<li v-for="step in steps" :key="step.label" class="flex items-start gap-2.5"
:class="step.state === 'pending' ? 'text-[var(--text-tertiary)]' : ''">
<span class="text-4.5 mt-0.25 flex-shrink-0" :class="{
'i-mynaui-circle': step.state === 'pending',
'i-svg-spinners-180-ring-with-bg': step.state === 'active',
'i-mynaui-check-circle text-[var(--color-accent)]': step.state === 'done',
'i-mynaui-x-circle text-red-400': step.state === 'failed',
}"></span>
<span class="flex flex-col">
<span>{{ step.label }}</span>
<span v-if="step.hint && step.state === 'active'" class="text-xs text-[var(--text-secondary)]">
{{ step.hint }}
</span>
</span>
</li>
</ol>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<p v-else-if="done" role="status" class="text-sm text-[var(--text-secondary)]">
Your password is changed. Use the new one the next time you sign in.
</p>
<p v-else-if="busy" class="text-xs text-[var(--text-secondary)]">
Keep this tab open until it finishes. Plain-text keys never leave this browser.
</p>
<footer class="flex justify-end gap-2 mt-auto md:mt-0">
<button v-if="done" type="button"
class="flex items-center justify-center h-9 px-3 text-sm font-medium rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)] transition-[background-color] duration-200"
@click="emit('confirm', true)">
Done
</button>
<template v-else>
<button type="button" :disabled="busy"
class="flex items-center justify-center h-9 px-3 text-sm rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
@click="emit('cancel')">
Cancel
</button>
<button type="submit" :disabled="busy"
class="flex items-center justify-center h-9 px-3 text-sm font-medium rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200">
{{ busy ? 'Changing password…' : 'Change password' }}
</button>
</template>
</footer>
</form>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
defineProps<{
title?: string;
message?: string;
confirmLabel?: string;
danger?: boolean;
}>();
const emit = defineEmits(['confirm', 'cancel']);
</script>
<template>
<div class="flex flex-col gap-4.5 p-6 w-screen md:w-110 max-w-full h-full md:h-auto" role="alertdialog"
aria-labelledby="confirm-title" aria-describedby="confirm-message">
<h2 id="confirm-title" class="text-lg font-semibold">{{ title ?? 'Are you sure?' }}</h2>
<p v-if="message" id="confirm-message" class="text-sm leading-normal text-[var(--text-secondary)]">
{{ message }}
</p>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] transition-[background-color] duration-200"
@click="emit('cancel')">
Cancel
</button>
<button type="button" class="flex items-center h-10 px-4 rounded-md font-semibold"
:class="danger ? 'bg-red-600 text-white' : 'bg-[var(--color-accent)] text-[var(--color-accent-text)]'"
@click="emit('confirm', true)">
{{ confirmLabel ?? 'Confirm' }}
</button>
</footer>
</div>
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
const MAX_USES = 1000;
const DURATIONS = [
{ hours: 1, label: '1 hour' },
{ hours: 24, label: '1 day' },
{ hours: 24 * 7, label: '7 days' },
{ hours: 24 * 30, label: '30 days' },
];
const emit = defineEmits(['confirm', 'cancel']);
const hours = ref(24 * 7);
const maxUses = ref(1);
const busy = ref(false);
const error = ref('');
// set once the link exists, the dialog then shows it instead of the options
const link = ref('');
const copied = ref(false);
const usesValid = computed(() => Number.isInteger(maxUses.value) && maxUses.value >= 1 && maxUses.value <= MAX_USES);
const copyLink = async () => {
try {
await navigator.clipboard.writeText(link.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy invite link:', err);
}
};
const submit = async () => {
if (busy.value || !usesValid.value) return;
busy.value = true;
error.value = '';
try {
const invite = await $fetch<{ token: string }>('/api/admin/invites', {
method: 'POST',
body: {
maxUses: maxUses.value,
expiresInHours: hours.value,
},
});
link.value = `${window.location.origin}/auth/register?invite=${invite.token}`;
} catch (err) {
console.error('Failed to create invite:', err);
error.value = 'The invite link could not be created. Try again.';
}
busy.value = false;
};
</script>
<template>
<form class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
aria-labelledby="create-invite-title" @submit.prevent="submit">
<header class="flex flex-col gap-1">
<h2 id="create-invite-title" class="text-lg font-semibold">
{{ link ? 'Invite link created' : 'New invite link' }}
</h2>
<p class="text-sm text-[var(--text-secondary)]">
{{ link
? 'Send it to whoever should join. You can copy it again or revoke it under Instance.'
: 'Anyone with the link can create an account until it runs out.' }}
</p>
</header>
<template v-if="link">
<div class="flex items-center rounded-md bg-[var(--bg-container)]">
<input type="text" readonly :value="link" aria-label="Invite link" @focus="($event.target as HTMLInputElement).select()"
class="flex-1 min-w-0 h-9 px-2.5 text-[13px] font-mono bg-transparent outline-none" />
<button type="button" :aria-label="copied ? 'Copied' : 'Copy invite link'" @click="copyLink"
class="flex items-center justify-center w-9 h-9 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
<span class="text-4.5" :class="copied ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
</button>
</div>
<footer class="flex justify-end mt-auto">
<button type="button"
class="flex items-center h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)]"
@click="emit('confirm', true)">
Done
</button>
</footer>
</template>
<template v-else>
<fieldset class="flex flex-col">
<legend class="mb-1.5 text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Valid for
</legend>
<div class="flex gap-0.5 p-0.5 rounded-lg bg-[var(--bg-container)] self-start">
<button v-for="d in DURATIONS" :key="d.hours" type="button" :aria-pressed="hours === d.hours"
:disabled="busy" @click="hours = d.hours"
class="flex items-center h-7.5 px-3 text-sm rounded-md disabled:cursor-not-allowed transition-colors duration-200"
:class="hours === d.hours
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] font-medium'
: 'text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)]'">
{{ d.label }}
</button>
</div>
</fieldset>
<div class="flex flex-col gap-1.5">
<label for="create-invite-uses"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Number of uses
</label>
<input id="create-invite-uses" v-model.number="maxUses" type="number" required min="1"
:max="MAX_USES" step="1" :disabled="busy"
class="w-28 h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
<p class="text-xs leading-normal text-[var(--text-secondary)]">
How many people can register with this link, up to {{ MAX_USES }}.
</p>
</div>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button" :disabled="busy"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
@click="emit('cancel')">
Cancel
</button>
<button type="submit" :disabled="busy || !usesValid"
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
Create link
</button>
</footer>
</template>
</form>
</template>
+159
View File
@@ -0,0 +1,159 @@
<script setup lang="ts">
const emit = defineEmits(['confirm', 'cancel']);
const form = reactive({
name: '',
email: '',
});
const busy = ref(false);
const error = ref('');
// set once the account exists, the dialog then shows its setup link
const link = ref('');
const copied = ref(false);
const nameInputEl = ref<HTMLInputElement | null>(null);
onMounted(() => {
nameInputEl.value?.focus();
});
const ready = computed(() => form.name.trim() !== '' && form.email.trim() !== '');
const copyLink = async () => {
try {
await navigator.clipboard.writeText(link.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy setup link:', err);
}
};
const submit = async () => {
if (busy.value || !ready.value) return;
busy.value = true;
error.value = '';
try {
const { token } = await $fetch<{ token: string }>('/api/admin/users', {
method: 'POST',
body: {
name: form.name.trim(),
email: form.email.trim(),
},
});
link.value = `${window.location.origin}/auth/recover/${token}`;
} catch (err: any) {
busy.value = false;
switch (err?.data?.data?.code as string | undefined) {
case 'USER_ALREADY_EXISTS':
case 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL':
error.value = 'An account with this email already exists.';
break;
case 'INVALID_EMAIL':
error.value = 'That email address is not valid.';
break;
case 'INVALID_BODY':
error.value = 'Check the name and the email address.';
break;
default:
console.error('Failed to create user:', err);
error.value = 'The account could not be created. Try again.';
break;
}
return;
}
busy.value = false;
};
</script>
<template>
<form class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
aria-labelledby="create-user-title" @submit.prevent="submit">
<header class="flex flex-col gap-1">
<h2 id="create-user-title" class="text-lg font-semibold">
{{ link ? 'Account created' : 'Create user' }}
</h2>
<p class="text-sm text-[var(--text-secondary)]">
{{ link
? `Send ${form.name.trim()} this link. They open it, choose a password and are in.`
: 'Creates the account without a password. You get a link for them to choose their own, so you never know it.' }}
</p>
</header>
<template v-if="link">
<div class="flex flex-col gap-1.5">
<label for="create-user-link"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Setup link
</label>
<div class="flex items-center rounded-md bg-[var(--bg-container)]">
<input id="create-user-link" type="text" readonly :value="link"
@focus="($event.target as HTMLInputElement).select()"
class="flex-1 min-w-0 h-9 px-2.5 text-[13px] font-mono bg-transparent outline-none" />
<button type="button" :aria-label="copied ? 'Copied' : 'Copy setup link'" @click="copyLink"
class="flex items-center justify-center w-9 h-9 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
<span class="text-4.5" :class="copied ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
</button>
</div>
<span class="text-xs leading-normal text-[var(--text-secondary)]">
Works once. Expires in 7 days. Shown only now; if it gets lost, make a new one from the
user's menu. Nobody can sign in to the account until it is used.
</span>
</div>
<footer class="flex justify-end mt-auto">
<button type="button"
class="flex items-center h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)]"
@click="emit('confirm', true)">
Done
</button>
</footer>
</template>
<template v-else>
<div class="flex flex-col gap-1.5">
<label for="create-user-name"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Name
</label>
<input id="create-user-name" ref="nameInputEl" v-model="form.name" type="text" required
maxlength="128" :disabled="busy" autocomplete="off"
class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
</div>
<div class="flex flex-col gap-1.5">
<label for="create-user-email"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Email
</label>
<input id="create-user-email" v-model="form.email" type="email" required :disabled="busy"
autocomplete="off" class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
</div>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button" :disabled="busy"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
@click="emit('cancel')">
Cancel
</button>
<button type="submit" :disabled="busy || !ready"
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
Create user
</button>
</footer>
</template>
</form>
</template>
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
import CodeInput from '~/components/CodeInput.vue';
const emit = defineEmits(['confirm', 'cancel']);
const { user, session } = useAuth();
const password = ref('');
// accounts with two-factor also have to prove the second factor
const needsCode = ref(!!user.value?.twoFactorEnabled);
const code = ref('');
const useBackupCode = ref(false);
const codeReady = computed(() => {
if (!needsCode.value) return true;
return useBackupCode.value ? code.value.trim() !== '' : code.value.length === 6;
});
const toggleBackupCode = () => {
useBackupCode.value = !useBackupCode.value;
code.value = '';
error.value = '';
};
const busy = ref(false);
const error = ref('');
const passwordInputEl = ref<HTMLInputElement | null>(null);
onMounted(() => {
passwordInputEl.value?.focus();
});
const submit = async () => {
if (busy.value || password.value === '' || !codeReady.value) return;
busy.value = true;
error.value = '';
try {
await $fetch('/api/account', {
method: 'DELETE',
body: {
password: password.value,
code: needsCode.value ? code.value.trim() : undefined,
backupCode: needsCode.value ? useBackupCode.value : undefined,
},
});
} catch (err: any) {
switch (err?.data?.data?.code as string | undefined) {
case 'INVALID_PASSWORD':
error.value = 'That password is not right. Nothing was deleted.';
password.value = '';
break;
case 'INVALID_CODE':
error.value = 'That code is not right. Check your authenticator app. Nothing was deleted.';
code.value = '';
break;
case 'INVALID_BACKUP_CODE':
error.value = 'That backup code is not valid, or it was already used. Nothing was deleted.';
code.value = '';
break;
case 'TWO_FACTOR_REQUIRED':
// two-factor was turned on somewhere else since this page loaded
needsCode.value = true;
error.value = 'This account has two-factor sign-in on. Enter a code to continue.';
break;
case 'LAST_ADMIN':
error.value = 'You are the only admin of this instance. Make someone else an admin first. Nothing was deleted.';
break;
default:
error.value = 'Your account could not be deleted. Nothing was deleted, try again.';
break;
}
busy.value = false;
return;
}
// nothing of this account is left, so drop every local trace of it too
localStorage.removeItem('encryptionKey');
user.value = null;
session.value = null;
clearNuxtData();
emit('confirm', true);
await navigateTo('/auth/login');
};
</script>
<template>
<form class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
aria-labelledby="delete-account-title" @submit.prevent="submit">
<header class="flex flex-col gap-1">
<h2 id="delete-account-title" class="text-lg font-semibold">Delete your account</h2>
<p class="text-sm text-[var(--text-secondary)]">
This can't be undone.
</p>
</header>
<p class="text-sm leading-normal">
Deleting <strong class="font-medium">{{ user?.email }}</strong> removes your agents, topics and
messages, your providers with their saved API keys, your file list, and your settings from this
instance. You are signed out everywhere.
</p>
<div class="flex flex-col gap-1.5">
<label for="delete-account-password"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Password
</label>
<input id="delete-account-password" ref="passwordInputEl" v-model="password" type="password" required
:disabled="busy" autocomplete="current-password"
class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
</div>
<div v-if="needsCode && useBackupCode" class="flex flex-col gap-1.5">
<label for="delete-account-backup-code"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Backup code
</label>
<input id="delete-account-backup-code" v-model="code" type="text" required :disabled="busy"
autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="xxxxx-xxxxx"
class="w-full h-9 px-2.5 text-sm font-mono rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]" />
</div>
<fieldset v-else-if="needsCode" class="flex flex-col">
<legend class="mb-2 text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
6-digit code from your authenticator app
</legend>
<CodeInput v-model="code" :disabled="busy" />
</fieldset>
<button v-if="needsCode" type="button" :disabled="busy" @click="toggleBackupCode"
class="self-start -mt-2 text-sm text-[var(--color-accent)] font-medium @hover:underline">
{{ useBackupCode ? 'Use your authenticator app instead' : 'Use a backup code instead' }}
</button>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button" :disabled="busy"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
@click="emit('cancel')">
Cancel
</button>
<button type="submit" :disabled="busy || password === '' || !codeReady"
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-red-600 text-white disabled:opacity-50 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
Delete account
</button>
</footer>
</form>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
const props = defineProps<{
user: {
id: string;
name: string;
email: string;
image?: string | null;
twoFactorEnabled?: boolean;
// has no password yet, so this makes a setup link and nothing is lost
pending?: boolean;
};
}>();
const emit = defineEmits(['confirm', 'cancel']);
// also turn two-factor off when the link is used, every device is signed out
// either way
const resetSecurity = ref(true);
const busy = ref(false);
const error = ref('');
// set once the link exists, the dialog then shows it instead of the options
const link = ref('');
const copied = ref(false);
const revoked = ref(false);
const copyLink = async () => {
try {
await navigator.clipboard.writeText(link.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy recovery link:', err);
}
};
const create = async () => {
if (busy.value) return;
busy.value = true;
error.value = '';
try {
const { token } = await $fetch<{ token: string }>(`/api/admin/users/${props.user.id}/recovery`, {
method: 'POST',
body: { resetSecurity: resetSecurity.value },
});
link.value = `${window.location.origin}/auth/recover/${token}`;
} catch (err) {
console.error('Failed to create recovery link:', err);
error.value = 'The recovery link could not be created. Try again.';
}
busy.value = false;
};
const revoke = async () => {
if (busy.value) return;
busy.value = true;
error.value = '';
try {
await $fetch(`/api/admin/users/${props.user.id}/recovery`, { method: 'DELETE' });
link.value = '';
revoked.value = true;
} catch (err) {
console.error('Failed to revoke recovery link:', err);
error.value = 'The link could not be revoked. Try again.';
}
busy.value = false;
};
</script>
<template>
<form class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
aria-labelledby="recover-account-title" @submit.prevent="create">
<header class="flex items-center gap-3">
<UserAvatar :user="user" class="w-10 h-10 rounded-lg"></UserAvatar>
<div class="flex flex-col min-w-0">
<h2 id="recover-account-title" class="text-lg font-semibold">
{{ user.pending ? 'Account setup link' : 'Recover account' }}
</h2>
<p class="text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis whitespace-nowrap">
{{ user.name }} · {{ user.email }}
</p>
</div>
</header>
<p v-if="user.pending && !link" class="text-sm leading-normal text-[var(--text-secondary)]">
This account has no password yet. A new link lets them choose one, and replaces the link they
were given before.
</p>
<div v-if="!user.pending" role="note"
class="flex gap-2.5 px-3 py-2.5 rounded-lg border border-red-400/40 bg-red-600/12 text-sm leading-normal">
<span class="i-tabler-alert-triangle text-4.5 mt-0.5 flex-shrink-0 text-red-400" aria-hidden="true"></span>
<div class="flex flex-col gap-0.5">
<strong class="font-semibold">This deletes their saved API keys</strong>
<span class="text-[13px] text-[var(--text-secondary)]">
The keys are encrypted with the old password and nobody can decrypt them without it. They
are removed when the link is used, not when it is created. Chats, agents and files are
not deleted. Using the link signs them out on all devices.
</span>
</div>
</div>
<template v-if="link">
<div class="flex flex-col gap-1.5">
<label for="recover-account-link"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
{{ user.pending ? 'Setup link' : 'Recovery link' }}
</label>
<div class="flex items-center rounded-md bg-[var(--bg-container)]">
<input id="recover-account-link" type="text" readonly :value="link"
@focus="($event.target as HTMLInputElement).select()"
class="flex-1 min-w-0 h-9 px-2.5 text-[13px] font-mono bg-transparent outline-none" />
<button type="button" :aria-label="copied ? 'Copied' : 'Copy recovery link'" @click="copyLink"
class="flex items-center justify-center w-9 h-9 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
<span class="text-4.5" :class="copied ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
</button>
</div>
<span class="text-xs leading-normal text-[var(--text-secondary)]">
Works once. Expires in {{ user.pending ? '7 days' : '24 hours' }}. Shown only now; closing
this dialog hides it for good.
Send it to them yourself, over a channel you trust.
</span>
</div>
</template>
<template v-else>
<label v-if="!user.pending" class="flex items-start gap-2.5 text-sm cursor-pointer">
<input v-model="resetSecurity" type="checkbox" :disabled="busy" class="mt-0.75 flex-shrink-0"
style="accent-color: var(--color-accent)" />
<span>Also reset two-factor sign-in</span>
</label>
<p v-if="revoked" role="status" class="text-sm text-[var(--text-secondary)]">
The link was revoked and no longer works.
</p>
</template>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<footer class="flex justify-between gap-2 mt-auto">
<template v-if="link">
<button type="button" :disabled="busy" @click="revoke"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-red-400 @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200">
Revoke link
</button>
<button type="button"
class="flex items-center h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)]"
@click="emit('confirm', true)">
Done
</button>
</template>
<template v-else>
<button type="button" :disabled="busy"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
@click="emit('cancel')">
{{ revoked ? 'Close' : 'Cancel' }}
</button>
<button type="submit" :disabled="busy"
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
{{ user.pending ? 'Create setup link' : 'Create recovery link' }}
</button>
</template>
</footer>
</form>
</template>
-138
View File
@@ -1,138 +0,0 @@
<script setup lang="ts">
import GeneralSettings from '~/components/Settings/GeneralSettings.vue';
import ProviderSettings from '~/components/Settings/ProviderSettings.vue';
import ProviderSidebar from '~/components/Settings/ProviderSidebar.vue';
import SystemAssistants from '~/components/Settings/SystemAssistants.vue';
import AppearanceSettings from '~/components/Settings/AppearanceSettings.vue';
import AIServiceProvider from '~/components/Settings/AIServiceProvider.vue';
const { providers } = await useModels();
const PAGES_CONFIG = {
general: {
label: 'General',
icon: 'i-mynaui-cog-four',
component: GeneralSettings
},
appearance: {
label: 'Appearance',
icon: 'i-tabler-palette',
component: AppearanceSettings
},
providers: {
label: 'AI Providers',
icon: 'i-mynaui-api',
component: ProviderSettings,
sidebar: ProviderSidebar
},
systemAssistants: {
label: 'System Assistants',
icon: 'i-mynaui-sparkles',
component: SystemAssistants
},
} as const;
const props = defineProps<{
page: keyof typeof PAGES_CONFIG;
params?: string;
}>();
const { close, setOptions } = useDialog();
const mobileNavOpen = ref(false);
onMounted(() => {
window.addEventListener('resize', () => {
if (window.innerWidth >= 768) {
mobileNavOpen.value = false;
}
})
});
const handleNavigate = (page: string, params?: string) => {
setOptions({ page: page as keyof typeof PAGES_CONFIG, params });
mobileNavOpen.value = false;
};
const runtimePage = computed(() => {
// 1. Get the base config (e.g., 'providers' or 'general')
const config = PAGES_CONFIG[props.page as keyof typeof PAGES_CONFIG] || PAGES_CONFIG.general;
// 2. Determine the actual component to show
let component = config.component;
let label = config.label as string;
if (props.page === 'providers' && props.params) {
component = AIServiceProvider;
const providerId = props.params;
const provider = providers.value!.find(p => p.id === providerId);
label = provider ? provider.name : 'Unknown Provider';
}
return {
...config,
label,
component,
params: props.params,
} as {
label: string;
icon: string;
component: Component;
sidebar?: Component;
params: string;
};
});
</script>
<template>
<div class="p-2 flex flex-col md:flex-row h-screen md:h-[70vh] md:h-[70dvh] w-screen md:w-[85vw]">
<nav :class="[
'w-full md:w-64 flex-col gap-1 mr-2 overflow-y-auto text-xl md:text-base',
// Mobile: hidden by default, shown as fixed overlay
'hidden md:flex',
mobileNavOpen ? '!flex fixed inset-y-0 left-0 z-15 bg-[var(--bg-base)] p-2 shadow-xl' : ''
]">
<!-- Mobile close button -->
<button
class="md:hidden flex items-center justify-center h-9 w-full rounded-lg @hover:bg-[var(--color-hover)] text-[var(--text-secondary)]"
@click="mobileNavOpen = false">
<span class="i-mynaui-x-solid mr-2"></span>
Close
</button>
<!-- If the page has a custom sidebar (for nested lists), show it; otherwise show default nav -->
<component v-if="runtimePage?.sidebar" :is="runtimePage.sidebar" @navigate="handleNavigate"
:params="props.params" />
<button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="handleNavigate(id)"
:class="[page === id ? 'bg-[var(--color-hover)]' : '@hover:bg-[var(--color-hover)]', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9']">
<div class="flex items-center gap-2 max-w-full flex-1">
<span :class="['w-5 h-5 text-5', config.icon]"></span>
{{ config.label }}
</div>
</button>
</nav>
<!-- DYNAMIC CONTENT -->
<main
class="flex-1 flex flex-col overflow-y-hidden max-h-full h-full px-3 bg-[var(--bg-surface)] border rounded-none md:rounded-lg border-[var(--color-border)]">
<header class="flex items-center justify-between pl-2 pb-2 pt-2 ">
<div class="flex items-center gap-2">
<!-- Mobile menu button -->
<button
class="md:hidden flex items-center justify-center h-8 w-8 rounded-lg @hover:bg-[var(--color-hover)]"
@click="mobileNavOpen = true">
<span class="i-mynaui-menu text-5"></span>
</button>
<h2 class="text-lg font-semibold m-0 case-capital">{{ runtimePage.label }}</h2>
</div>
<button
class="@hover:bg-[var(--color-hover)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]"
@click="close">
<span class="i-mynaui-x-solid"></span>
</button>
</header>
<component @navigate="handleNavigate" :is="runtimePage.component" :params="props.params" />
</main>
</div>
</template>
+359
View File
@@ -0,0 +1,359 @@
<script setup lang="ts">
import { renderSVG } from 'uqr';
import CodeInput from '~/components/CodeInput.vue';
type Action = 'enable' | 'disable' | 'regenerate';
type Step = 'password' | 'verify' | 'codes' | 'check';
const props = defineProps<{
action?: Action;
}>();
const emit = defineEmits(['confirm', 'cancel']);
const { client: authClient, user } = useAuth();
const action = computed<Action>(() => props.action ?? 'enable');
const FLOWS: Record<Action, { title: string; steps: Step[] }> = {
enable: { title: 'Set up two-factor sign-in', steps: ['password', 'verify', 'codes', 'check'] },
regenerate: { title: 'New backup codes', steps: ['password', 'codes', 'check'] },
disable: { title: 'Turn off two-factor sign-in', steps: ['password'] },
};
const STEP_LABELS: Record<Step, string> = {
password: 'Confirm your password',
verify: 'Scan and confirm',
codes: 'Save your backup codes',
check: 'Check what you saved',
};
const INPUT_CLASS = 'w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]';
const CANCEL_CLASS = 'flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200';
const PRIMARY_CLASS = 'flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200';
const DANGER_CLASS = 'flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-red-600 text-white disabled:opacity-50 disabled:cursor-not-allowed';
const SECONDARY_CLASS = 'flex items-center gap-2 h-8.5 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] transition-colors duration-200';
const flow = computed(() => FLOWS[action.value]);
const step = ref<Step>('password');
const stepIndex = computed(() => flow.value.steps.indexOf(step.value));
const password = ref('');
const code = ref('');
const busy = ref(false);
const error = ref('');
const totpUri = ref('');
const backupCodes = ref<string[]>([]);
const copied = ref<'secret' | 'codes' | null>(null);
// last step: ask for one of the codes back, to make sure they were really saved
const checkIndex = ref(0);
const checkInput = ref('');
const checkInputEl = ref<HTMLInputElement | null>(null);
const passwordInputEl = ref<HTMLInputElement | null>(null);
const codeInputEl = ref<InstanceType<typeof CodeInput> | null>(null);
onMounted(() => {
passwordInputEl.value?.focus();
});
const totpSecret = computed(() => {
if (!totpUri.value) return '';
return new URL(totpUri.value).searchParams.get('secret') ?? '';
});
const qrCode = computed(() => totpUri.value ? renderSVG(totpUri.value, { border: 2 }) : '');
type AuthClientError = { code?: string; message?: string; status?: number } | null;
const describeError = (err: AuthClientError, fallback: string): string => {
if (err?.status === 429) return 'Too many attempts. Wait a few seconds and try again.';
if (err?.code === 'INVALID_PASSWORD') return 'That password is not right.';
if (err?.code === 'ACCOUNT_TEMPORARILY_LOCKED') return 'Too many wrong codes. Try again later.';
if (err?.code === 'INVALID_CODE') return 'That code is not right. Check your authenticator app and try again.';
return fallback;
};
const submitPassword = async () => {
if (busy.value || password.value === '') return;
busy.value = true;
error.value = '';
const body = { password: password.value };
if (action.value === 'enable') {
const { data, error: err } = await authClient.twoFactor.enable(body);
if (err || !data || data.method !== 'totp') {
error.value = describeError(err, 'Two-factor could not be set up. Try again.');
} else {
// nothing is turned on until a code from the app is verified
totpUri.value = data.totpURI;
backupCodes.value = data.backupCodes;
step.value = 'verify';
await nextTick();
codeInputEl.value?.focus();
}
} else if (action.value === 'regenerate') {
const { data, error: err } = await authClient.twoFactor.generateBackupCodes(body);
if (err || !data) {
error.value = describeError(err, 'New backup codes could not be made. Try again.');
} else {
backupCodes.value = data.backupCodes;
step.value = 'codes';
}
} else {
const { error: err } = await authClient.twoFactor.disable(body);
if (err) {
error.value = describeError(err, 'Two-factor could not be turned off. Try again.');
} else {
if (user.value) user.value = { ...user.value, twoFactorEnabled: false };
emit('confirm', true);
}
}
password.value = '';
busy.value = false;
};
const verifyCode = async () => {
if (busy.value || code.value.length !== 6) return;
busy.value = true;
error.value = '';
const { error: err } = await authClient.twoFactor.verifyTotp({ code: code.value });
if (err) {
error.value = describeError(err, 'That code could not be verified. Try again.');
code.value = '';
busy.value = false;
await nextTick();
codeInputEl.value?.focus();
} else {
if (user.value) user.value = { ...user.value, twoFactorEnabled: true };
step.value = 'codes';
}
busy.value = false;
};
// the numbers are part of what gets saved, the last step asks for a code by number
const numberedBackupCodes = computed(() =>
backupCodes.value.map((backupCode, index) => `${index + 1}. ${backupCode}`).join('\n')
);
const startCheck = async () => {
// a different code every time
checkIndex.value = Math.floor(Math.random() * backupCodes.value.length);
checkInput.value = '';
error.value = '';
step.value = 'check';
await nextTick();
checkInputEl.value?.focus();
};
const backToCodes = () => {
error.value = '';
step.value = 'codes';
};
// forgiving about spacing and the dash, since the code may be read off paper
const normalizeBackupCode = (value: string) => value.replace(/[\s-]/g, '');
const submitCheck = () => {
const expected = backupCodes.value[checkIndex.value] ?? '';
if (normalizeBackupCode(checkInput.value) !== normalizeBackupCode(expected)) {
error.value = `That is not code ${checkIndex.value + 1}. Capital letters matter. Go back and check what you saved.`;
return;
}
emit('confirm', true);
};
const copyText = async (text: string, what: 'secret' | 'codes') => {
try {
await navigator.clipboard.writeText(text);
copied.value = what;
setTimeout(() => {
if (copied.value === what) copied.value = null;
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const downloadBackupCodes = () => {
const text = `Veridian backup codes for ${user.value?.email ?? 'your account'}\nEach code works once.\n\n${numberedBackupCodes.value}\n`;
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
const link = document.createElement('a');
link.href = url;
link.download = 'veridian-backup-codes.txt';
link.click();
URL.revokeObjectURL(url);
};
</script>
<template>
<section class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
aria-labelledby="two-factor-title">
<header class="flex items-start justify-between gap-3">
<div class="flex flex-col gap-1">
<h2 id="two-factor-title" class="text-lg font-semibold">{{ flow.title }}</h2>
<p class="text-sm text-[var(--text-secondary)]">
<template v-if="flow.steps.length > 1">
Step {{ stepIndex + 1 }} of {{ flow.steps.length }} ·
</template>
{{ STEP_LABELS[step] }}
</p>
</div>
<div v-if="flow.steps.length > 1" aria-hidden="true" class="flex gap-1 pt-2">
<span v-for="(_, index) in flow.steps" :key="index" class="w-5 h-1 rounded-sm"
:class="index <= stepIndex ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-border-active)]'"></span>
</div>
</header>
<!-- STEP: PASSWORD -->
<form v-if="step === 'password'" class="flex flex-col gap-4.5 flex-1" @submit.prevent="submitPassword">
<div class="flex flex-col gap-1.5">
<label for="two-factor-password"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Password
</label>
<input id="two-factor-password" ref="passwordInputEl" v-model="password" type="password" required
:disabled="busy" autocomplete="current-password" :class="INPUT_CLASS" />
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
</div>
<ol class="flex flex-col gap-1.5 text-[13px] text-[var(--text-secondary)]">
<li v-if="action === 'regenerate'">
Your current backup codes stop working as soon as the new ones are made.
</li>
</ol>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button" :disabled="busy" :class="CANCEL_CLASS" @click="emit('cancel')">Cancel</button>
<button type="submit" :disabled="busy" :class="action === 'disable' ? DANGER_CLASS : PRIMARY_CLASS">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
{{ action === 'disable' ? 'Turn off' : 'Continue' }}
</button>
</footer>
</form>
<!-- STEP: SCAN AND CONFIRM -->
<form v-else-if="step === 'verify'" class="flex flex-col gap-4.5 flex-1" @submit.prevent="verifyCode">
<div class="flex flex-wrap gap-4 p-4 rounded-lg bg-[var(--bg-surface)] border border-[var(--color-border)]">
<div class="w-37 h-37 flex-shrink-0 rounded-lg overflow-hidden bg-white" role="img"
aria-label="QR code for your authenticator app" v-html="qrCode"></div>
<div class="flex flex-col gap-2.5 flex-1 min-w-40">
<p class="text-sm leading-normal">
Scan this with an authenticator app such as Ente, KeepassXC, or Authy.
</p>
<div class="flex flex-col gap-1">
<span class="text-xs text-[var(--text-secondary)]">Can't scan? Enter this key by hand</span>
<div class="flex items-center gap-1 pl-2.5 rounded-md bg-[var(--bg-container)]">
<span
class="flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[13px] select-all">
{{ totpSecret }}
</span>
<button type="button" :aria-label="copied === 'secret' ? 'Copied' : 'Copy setup key'"
class="flex items-center justify-center w-8.5 h-8.5 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)] transition-colors"
@click="copyText(totpSecret, 'secret')">
<span class="text-4"
:class="copied === 'secret' ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
</button>
</div>
</div>
</div>
</div>
<fieldset class="flex flex-col gap-2">
<legend class="mb-2 text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Enter the 6-digit code to verify that enrollment was successful
</legend>
<CodeInput ref="codeInputEl" v-model="code" :disabled="busy" @complete="verifyCode" />
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
</fieldset>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button" :disabled="busy" :class="CANCEL_CLASS" @click="emit('cancel')">Cancel</button>
<button type="submit" :disabled="busy || code.length !== 6" :class="PRIMARY_CLASS">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
Verify code
</button>
</footer>
</form>
<!-- STEP: BACKUP CODES -->
<div v-else-if="step === 'codes'" class="flex flex-col gap-4.5 flex-1">
<p class="text-sm leading-normal">
<template v-if="action === 'enable'">Two-factor sign-in is on. </template>
Save these codes somewhere safe. Each one signs you in once if you lose your authenticator app.
They are not shown again.
</p>
<ul
class="grid grid-cols-2 gap-x-6 gap-y-1.5 p-4 rounded-lg bg-[var(--bg-surface)] border border-[var(--color-border)] font-mono text-sm [list-style-type:none]">
<li v-for="(backupCode, index) in backupCodes" :key="backupCode" class="flex gap-2">
<span class="w-5 text-right text-[var(--text-tertiary)] select-none">{{ index + 1 }}.</span>
<span>{{ backupCode }}</span>
</li>
</ul>
<div class="flex flex-wrap gap-2">
<button type="button" :class="SECONDARY_CLASS" @click="copyText(numberedBackupCodes, 'codes')">
<span class="text-4" :class="copied === 'codes' ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
{{ copied === 'codes' ? 'Copied' : 'Copy' }}
</button>
<button type="button" :class="SECONDARY_CLASS" @click="downloadBackupCodes">
<span class="i-mynaui-download text-4"></span>
Download
</button>
</div>
<footer class="flex justify-end gap-2 mt-auto">
<button type="button" :class="PRIMARY_CLASS" @click="startCheck">I saved them</button>
</footer>
</div>
<!-- STEP: CHECK A SAVED CODE -->
<form v-else class="flex flex-col gap-4.5 flex-1" @submit.prevent="submitCheck">
<p class="text-sm leading-normal">
One last check that your codes made it somewhere safe. Find them where you saved them and enter
code {{ checkIndex + 1 }} of {{ backupCodes.length }}.
</p>
<div class="flex flex-col gap-1.5">
<label for="two-factor-check"
class="text-[13px] font-medium tracking-wider text-[var(--text-secondary)]">
Backup code {{ checkIndex + 1 }} of {{ backupCodes.length }}
</label>
<input id="two-factor-check" ref="checkInputEl" v-model="checkInput" type="text" required
autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="xxxxx-xxxxx"
:class="[INPUT_CLASS, 'font-mono']" />
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
</div>
<ol class="flex flex-col gap-1.5 text-[13px] text-[var(--text-secondary)] [list-style-type:none]">
<li>Entering it here does not use the code up.</li>
</ol>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button" :class="CANCEL_CLASS" @click="backToCodes">Back to the codes</button>
<button type="submit" :class="PRIMARY_CLASS">Finish</button>
</footer>
</form>
</section>
</template>
+20 -5
View File
@@ -1,7 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import Textbox from './Textbox.vue'; import Textbox from './Textbox.vue';
import Settings from './Settings.vue';
import QuickSwitcher from './QuickSwitcher.vue'; import QuickSwitcher from './QuickSwitcher.vue';
import ChangePassword from './ChangePassword.vue';
import TwoFactor from './TwoFactor.vue';
import DeleteAccount from './DeleteAccount.vue';
import Confirm from './Confirm.vue';
import CreateUser from './CreateUser.vue';
import CreateInvite from './CreateInvite.vue';
import RecoverAccount from './RecoverAccount.vue';
import { DialogType } from '~/composables/useDialog'; import { DialogType } from '~/composables/useDialog';
@@ -11,7 +17,7 @@ const { addShortcut } = useKeyboardShortcuts();
const unbindSettingsShortcut = addShortcut(['ctrl', ','], (event) => { const unbindSettingsShortcut = addShortcut(['ctrl', ','], (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
openDialog(DialogType.Settings); navigateTo('/settings');
}); });
const unbindQuickSwitcherShortcut = addShortcut(['ctrl', 'k'], (event) => { const unbindQuickSwitcherShortcut = addShortcut(['ctrl', 'k'], (event) => {
@@ -57,12 +63,21 @@ onUnmounted(() => {
<div <div
class="absolute w-full h-full md:w-auto md:h-auto max-w-6xl bg-[var(--bg-base)] rounded-none md:rounded-2xl shadow-2xl border border-[var(--color-border)] flex"> class="absolute w-full h-full md:w-auto md:h-auto max-w-6xl bg-[var(--bg-base)] rounded-none md:rounded-2xl shadow-2xl border border-[var(--color-border)] flex">
<KeepAlive> <KeepAlive>
<Settings v-if="page === DialogType.Settings" :page="data.page" :params="data.params" /> <Textbox v-if="page === DialogType.Textbox" v-bind="data" @confirm="confirm" @cancel="close" />
<Textbox v-else-if="page === DialogType.Textbox" v-bind="data" @confirm="confirm" @cancel="close" />
<QuickSwitcher v-else-if="page === DialogType.QuickSwitcher" @close="close" /> <QuickSwitcher v-else-if="page === DialogType.QuickSwitcher" @close="close" />
</KeepAlive> </KeepAlive>
<!-- kept out of KeepAlive so passwords never linger after it closes -->
<ChangePassword v-if="page === DialogType.ChangePassword" @confirm="confirm" @cancel="close" />
<TwoFactor v-else-if="page === DialogType.TwoFactor" :action="data.action" @confirm="confirm"
@cancel="close" />
<DeleteAccount v-else-if="page === DialogType.DeleteAccount" @confirm="confirm" @cancel="close" />
<CreateUser v-else-if="page === DialogType.CreateUser" @confirm="confirm" @cancel="close" />
<CreateInvite v-else-if="page === DialogType.CreateInvite" @confirm="confirm" @cancel="close" />
<RecoverAccount v-else-if="page === DialogType.RecoverAccount" :user="data.user" @confirm="confirm"
@cancel="close" />
<Confirm v-else-if="page === DialogType.Confirm" v-bind="data" @confirm="confirm" @cancel="close" />
</div> </div>
</div> </div>
</Transition> </Transition>
+115 -50
View File
@@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
const MAX_RETRIES = 3;
const RETRY_BASE_DELAY_MS = 1000;
const props = defineProps<{ const props = defineProps<{
selectedModel?: ModelWithProvider | null; selectedModel?: ModelWithProvider | null;
}>(); }>();
@@ -15,10 +18,12 @@ const files = defineModel<{
status: 'uploading' | 'uploaded' | 'error'; status: 'uploading' | 'uploaded' | 'error';
url: string; url: string;
progress: number; progress: number;
retryCount: number;
}[]>({ required: false, default: [] }); }[]>({ required: false, default: [] });
const rawFiles = ref<File[]>([]); const rawFiles = ref<File[]>([]);
const activeUploads = ref<Map<string, XMLHttpRequest>>(new Map()); const activeUploads = ref<Map<string, XMLHttpRequest>>(new Map());
const pendingFiles = ref<Map<string, File>>(new Map());
const uploadWithProgress = (file: File, url: string, id: string) => { const uploadWithProgress = (file: File, url: string, id: string) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -39,12 +44,16 @@ const uploadWithProgress = (file: File, url: string, id: string) => {
xhr.onload = () => { xhr.onload = () => {
activeUploads.value.delete(id); activeUploads.value.delete(id);
resolve(xhr); if (xhr.status >= 400) {
reject(new Error(`Upload failed with status ${xhr.status}`));
} else {
resolve(xhr);
}
}; };
xhr.onerror = () => { xhr.onerror = () => {
activeUploads.value.delete(id); activeUploads.value.delete(id);
reject(xhr); reject(new Error('Network error during upload'));
}; };
xhr.onabort = () => { xhr.onabort = () => {
@@ -57,68 +66,122 @@ const uploadWithProgress = (file: File, url: string, id: string) => {
}); });
}; };
const uploadFile = async (file: File) => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
try {
const id = nanoid();
const fileName = file.name || `${id}.png`;
const fileType = file.type || 'application/octet-stream';
files.value.push({ const uploadFileAttempt = async (file: File, id: string, fileName: string, fileType: string) => {
const { url: uploadUrl, assetUrl } = await $fetch('/api/upload/presigned', {
method: 'POST',
body: {
file: {
name: fileName,
mimeType: fileType,
}
},
}) as { url: string; assetUrl: string };
await uploadWithProgress(file, uploadUrl, id);
await $fetch('/api/file', {
method: 'POST',
body: {
id, id,
name: fileName, name: fileName,
mimeType: fileType, mimeType: fileType,
status: 'uploading', size: file.size,
url: URL.createObjectURL(file), url: assetUrl,
progress: 0 },
}); });
};
const { url: uploadUrl, assetUrl } = await $fetch('/api/upload/presigned', { const uploadFile = async (file: File) => {
method: 'POST', const id = nanoid();
body: { const fileName = file.name || `${id}.png`;
file: { const fileType = file.type || 'application/octet-stream';
name: fileName,
mimeType: fileType,
}
},
}) as { url: string; assetUrl: string };
await uploadWithProgress(file, uploadUrl, id); files.value.push({
id,
name: fileName,
mimeType: fileType,
status: 'uploading',
url: URL.createObjectURL(file),
progress: 0,
retryCount: 0,
});
pendingFiles.value.set(id, file);
await $fetch('/api/file', { await attemptUpload(file, id, fileName, fileType);
method: 'POST', };
body: {
id, const attemptUpload = async (file: File, id: string, fileName: string, fileType: string, attempt = 0) => {
name: fileName, try {
mimeType: fileType, await uploadFileAttempt(file, id, fileName, fileType);
size: file.size,
url: assetUrl,
},
})
files.value = files.value.map(f => { files.value = files.value.map(f => {
if (f.name === file.name) { if (f.id === id) {
return { return { ...f, status: 'uploaded' as const, progress: 100 };
...f,
status: 'uploaded',
progress: 100
};
} }
return f; return f;
}); });
pendingFiles.value.delete(id);
} catch (error) { } catch (error) {
files.value = files.value.map(f => { if (error instanceof Error && error.message === 'Upload aborted') {
if (f.name === file.name) { pendingFiles.value.delete(id);
return { return;
...f, }
status: 'error'
};
}
return f;
});
}
}
defineExpose({ uploadFile }); const nextAttempt = attempt + 1;
if (nextAttempt < MAX_RETRIES) {
files.value = files.value.map(f => {
if (f.id === id) {
return { ...f, retryCount: nextAttempt, progress: 0 };
}
return f;
});
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
await sleep(delay);
const stillExists = files.value.some(f => f.id === id);
if (!stillExists) return;
files.value = files.value.map(f => {
if (f.id === id) {
return { ...f, status: 'uploading' as const };
}
return f;
});
await attemptUpload(file, id, fileName, fileType, nextAttempt);
} else {
files.value = files.value.map(f => {
if (f.id === id) {
return { ...f, status: 'error' as const };
}
return f;
});
}
}
};
const retryFile = async (id: string) => {
const file = pendingFiles.value.get(id);
if (!file) return;
const entry = files.value.find(f => f.id === id);
if (!entry) return;
files.value = files.value.map(f => {
if (f.id === id) {
return { ...f, status: 'uploading' as const, progress: 0, retryCount: 0 };
}
return f;
});
await attemptUpload(file, id, entry.name, entry.mimeType);
};
defineExpose({ uploadFile, retryFile });
watch(rawFiles, async (newFiles, oldFiles) => { watch(rawFiles, async (newFiles, oldFiles) => {
const diff = newFiles.filter(f => const diff = newFiles.filter(f =>
@@ -146,6 +209,8 @@ watch(files, async (newFiles, oldFiles) => {
activeUploads.value.delete(removedFile.id); activeUploads.value.delete(removedFile.id);
} }
pendingFiles.value.delete(removedFile.id);
if (removedFile.url.startsWith('blob:')) { if (removedFile.url.startsWith('blob:')) {
URL.revokeObjectURL(removedFile.url); URL.revokeObjectURL(removedFile.url);
} }
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { useId } from "vue";
withDefaults(defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>(), {
size: 24,
});
const titleId = useId();
const BACKGROUND_COLOR = "#a3e6d9";
const AVATAR_SCALE = 0.7;
</script>
<template>
<div class="inline-flex items-center justify-center"
:style="{ width: `${size}px`, height: `${size}px`, backgroundColor: avatar ? BACKGROUND_COLOR : undefined, borderRadius: avatar ? '0.375rem' : undefined }">
<!-- Artwork from https://github.com/lobehub/lobe-icons/pull/396 (MIT; see LICENSE). -->
<svg xmlns="http://www.w3.org/2000/svg" :width="size" :height="size"
viewBox="0 0 24 24" :fill="avatar ? '#000' : 'currentColor'" fill-rule="evenodd"
role="img" :aria-labelledby="titleId" style="flex: none; line-height: 1;"
:style="{ transform: avatar ? `scale(${AVATAR_SCALE})` : undefined }">
<title :id="titleId">Dots Studio</title>
<path d="M8.429 23.18a67 67 0 0 1-.423-2.074c0-.024 2.298-.042 5.106-.042 4.836 0 5.104.005 5.077.098a19 19 0 0 0-.148.714c-.066.34-.158.787-.204.996l-.084.378h-4.651c-3.671 0-4.656-.015-4.673-.07m1.89-6.37-6.351-1.1-.054-.195c-.05-.177-.025-.247.286-.836 1.975-3.727 5.2-7.438 8.824-10.153 1.898-1.422 4.002-2.647 6.013-3.502.733-.311.744-.314.897-.218l.17.106c.01.005-.728 3.81-1.64 8.457-.91 4.645-1.655 8.47-1.655 8.5s-.032.052-.07.048-2.928-.502-6.42-1.107m4.799-1.675c.293-1.376 2.09-10.679 2.067-10.701-.017-.017-.354.172-.75.418-2.186 1.362-3.772 2.637-5.599 4.501a27.4 27.4 0 0 0-3.302 4.03c-.367.545-.419.648-.331.668.25.056 7.628 1.337 7.737 1.343.101.006.13-.037.178-.26z"></path>
</svg>
</div>
</template>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { useId } from "vue";
withDefaults(defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>(), {
size: 24,
});
const titleId = useId();
const BACKGROUND_COLOR = "#fff";
const AVATAR_SCALE = 0.75;
</script>
<template>
<div class="inline-flex items-center justify-center"
:style="{ width: `${size}px`, height: `${size}px`, backgroundColor: avatar ? BACKGROUND_COLOR : undefined, borderRadius: avatar ? '0.375rem' : undefined }">
<svg xmlns="http://www.w3.org/2000/svg" :width="size" :height="size"
viewBox="0 0 24 24" :fill="avatar || color ? '#000' : 'currentColor'" role="img" :aria-labelledby="titleId"
style="flex: none; line-height: 1;"
:style="{ transform: avatar ? `scale(${AVATAR_SCALE})` : undefined }">
<title :id="titleId">Nex AGI</title>
<g transform="matrix(.02777778 0 0 .02777778 -2.222222 -2.222222)">
<path d="M634.153 529.915C634.153 536.056 631.023 541.773 625.849 545.081C620.674 548.388 614.17 548.83 608.597 546.251L367.982 434.957L367.983 749.839C367.983 755.934 364.899 761.615 359.787 764.935L137.804 909.096C132.269 912.69 125.211 912.968 119.411 909.819C113.611 906.67 110 900.599 110 894V156.359C110 150.25 113.099 144.558 118.229 141.242C123.361 137.926 129.823 137.438 135.394 139.948L623.547 359.872C630.002 362.78 634.153 369.203 634.153 376.284V529.915Z"></path>
<path d="M389.847 493.085C389.847 486.944 392.977 481.227 398.151 477.919C403.326 474.612 409.83 474.17 415.403 476.749L656.018 588.043L656.017 273.161C656.017 267.066 659.101 261.385 664.213 258.065L886.196 113.904C891.731 110.31 898.789 110.032 904.589 113.181C910.389 116.33 914 122.401 914 129L914 866.641C914 872.75 910.901 878.442 905.771 881.758C900.639 885.074 894.177 885.562 888.606 883.052L400.453 663.128C393.998 660.22 389.847 653.797 389.847 646.716V493.085Z"></path>
</g>
</svg>
</div>
</template>
+3 -4
View File
@@ -16,17 +16,16 @@ const [a, b, c, d] = useFillIds(TITLE, 4);
<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;` : '']">
<!-- TODO: scale to 24px -->
<svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']" <svg :style="[`width: ${size}px; height: ${size}px; flex: none; line-height: 1;`, avatar ? `transform: scale(${AVATAR_SCALE});` : '']"
style="flex: none; line-height: 1;" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> style="flex: none; line-height: 1;" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>{{ TITLE }}</title> <title>{{ TITLE }}</title>
<mask :id="d!.id" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="32" height="32"> <mask :id="d!.id" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="32" height="32">
<rect width="32" height="32" :fill="a!.fill" style="" /> <rect width="32" height="32" :fill="a!.fill" style="" />
<rect width="32" height="32" :fill="b!.fill" style="" /> <rect width="32" height="32" :fill="b!.fill" style="" />
<rect width="32" height="32" :fill="c!.fill" style="" /> <rect width="32" height="32" :fill="c!.fill" style="" />
</mask> </mask>
<g :mask="d!.fill"> <g transform="scale(0.75)">
<path <path :mask="d!.fill"
d="M8.98976 30.3815C6.02045 28.9329 3.60852 26.6447 2.01266 23.7643C0.453682 20.9498 -0.219858 17.775 0.0629571 14.5827C0.126988 13.8674 0.757216 13.3392 1.47289 13.4024C2.18722 13.466 2.71622 14.0968 2.65309 14.8126C2.41613 17.4865 2.98128 20.1467 4.28757 22.5053C5.41219 24.536 7.023 26.2153 8.9891 27.4192L15.1043 14.8784C12.7106 14.0812 10.7169 14.4282 10.5375 14.4631C10.5112 14.4692 10.4858 14.4735 10.4599 14.4786C9.86386 14.5784 9.28735 14.2527 9.05527 13.7111C8.73499 13.1131 7.77341 11.6515 6.60959 11.0837C5.44577 10.516 3.65213 10.6991 3.07471 10.8246C2.58789 10.931 2.08122 10.7494 1.77371 10.358C1.4662 9.9665 1.40726 9.43259 1.62554 8.98496C5.48998 1.05529 15.087 -2.24965 23.0153 1.61816C30.9437 5.48598 34.2453 15.0756 30.3875 23.003C30.3836 23.0111 30.3796 23.0192 30.3752 23.0282C26.504 30.949 16.9145 34.2476 8.98976 30.3815ZM17.44 16.0179L11.3253 28.5578C17.4968 30.8631 24.5196 28.3019 27.731 22.4803C27.2796 21.7817 26.4875 20.78 25.5709 20.3328C24.3864 19.7549 22.6509 19.9442 22.0547 20.0695C21.9566 20.0928 21.8588 20.1041 21.7604 20.105C21.5842 20.1059 21.4054 20.071 21.2339 19.9962C21.056 19.9183 20.8952 19.8009 20.7654 19.6497C20.6912 19.5623 20.6299 19.4667 20.5806 19.3648C20.5422 19.2882 19.5939 17.4481 17.4391 16.0175L17.44 16.0179ZM7.74737 8.74582C8.74313 9.2316 9.5548 10.0037 10.1608 10.7321C12.0348 7.86545 14.578 5.70023 16.5425 4.3042C17.2726 3.78579 18.0284 3.30111 18.7563 2.88291C13.6429 1.80923 8.28375 3.83577 5.1764 8.10245C5.99889 8.15876 6.90528 8.33501 7.74737 8.74582ZM24.6415 5.75507C24.7602 6.58628 24.8441 7.47954 24.8847 8.37503C24.9943 10.7762 24.8555 14.1022 23.7588 17.3369C24.6735 17.3614 25.7372 17.5232 26.7105 17.998C27.5795 18.4219 28.3081 19.0633 28.8814 19.7035C30.3453 14.6184 28.6441 9.13194 24.6411 5.75597L24.6415 5.75507ZM17.4489 13.0336C19.2472 13.9109 20.5471 15.078 21.4282 16.0842C22.8614 11.427 22.2366 6.21527 21.7149 4.28506C19.873 5.06221 15.3815 7.77795 12.5957 11.7753C13.9322 11.8499 15.6515 12.1568 17.4489 13.0336Z" d="M8.98976 30.3815C6.02045 28.9329 3.60852 26.6447 2.01266 23.7643C0.453682 20.9498 -0.219858 17.775 0.0629571 14.5827C0.126988 13.8674 0.757216 13.3392 1.47289 13.4024C2.18722 13.466 2.71622 14.0968 2.65309 14.8126C2.41613 17.4865 2.98128 20.1467 4.28757 22.5053C5.41219 24.536 7.023 26.2153 8.9891 27.4192L15.1043 14.8784C12.7106 14.0812 10.7169 14.4282 10.5375 14.4631C10.5112 14.4692 10.4858 14.4735 10.4599 14.4786C9.86386 14.5784 9.28735 14.2527 9.05527 13.7111C8.73499 13.1131 7.77341 11.6515 6.60959 11.0837C5.44577 10.516 3.65213 10.6991 3.07471 10.8246C2.58789 10.931 2.08122 10.7494 1.77371 10.358C1.4662 9.9665 1.40726 9.43259 1.62554 8.98496C5.48998 1.05529 15.087 -2.24965 23.0153 1.61816C30.9437 5.48598 34.2453 15.0756 30.3875 23.003C30.3836 23.0111 30.3796 23.0192 30.3752 23.0282C26.504 30.949 16.9145 34.2476 8.98976 30.3815ZM17.44 16.0179L11.3253 28.5578C17.4968 30.8631 24.5196 28.3019 27.731 22.4803C27.2796 21.7817 26.4875 20.78 25.5709 20.3328C24.3864 19.7549 22.6509 19.9442 22.0547 20.0695C21.9566 20.0928 21.8588 20.1041 21.7604 20.105C21.5842 20.1059 21.4054 20.071 21.2339 19.9962C21.056 19.9183 20.8952 19.8009 20.7654 19.6497C20.6912 19.5623 20.6299 19.4667 20.5806 19.3648C20.5422 19.2882 19.5939 17.4481 17.4391 16.0175L17.44 16.0179ZM7.74737 8.74582C8.74313 9.2316 9.5548 10.0037 10.1608 10.7321C12.0348 7.86545 14.578 5.70023 16.5425 4.3042C17.2726 3.78579 18.0284 3.30111 18.7563 2.88291C13.6429 1.80923 8.28375 3.83577 5.1764 8.10245C5.99889 8.15876 6.90528 8.33501 7.74737 8.74582ZM24.6415 5.75507C24.7602 6.58628 24.8441 7.47954 24.8847 8.37503C24.9943 10.7762 24.8555 14.1022 23.7588 17.3369C24.6735 17.3614 25.7372 17.5232 26.7105 17.998C27.5795 18.4219 28.3081 19.0633 28.8814 19.7035C30.3453 14.6184 28.6441 9.13194 24.6411 5.75597L24.6415 5.75507ZM17.4489 13.0336C19.2472 13.9109 20.5471 15.078 21.4282 16.0842C22.8614 11.427 22.2366 6.21527 21.7149 4.28506C19.873 5.06221 15.3815 7.77795 12.5957 11.7753C13.9322 11.8499 15.6515 12.1568 17.4489 13.0336Z"
:fill="!avatar && color ? '#4137FF' : 'currentColor'" style="fill-opacity:1;" /> :fill="!avatar && color ? '#4137FF' : 'currentColor'" style="fill-opacity:1;" />
</g> </g>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { useId } from "vue";
withDefaults(defineProps<{
size?: string | number;
color?: boolean;
avatar?: boolean;
}>(), {
size: 24,
});
const titleId = useId();
</script>
<template>
<div class="inline-flex items-center justify-center"
:style="{ width: `${size}px`, height: `${size}px`, borderRadius: avatar ? '0.375rem' : undefined, overflow: avatar ? 'hidden' : undefined }">
<svg xmlns="http://www.w3.org/2000/svg" :width="size" :height="size"
viewBox="0 0 24 24" role="img" :aria-labelledby="titleId"
style="flex: none; line-height: 1;">
<title :id="titleId">Thinking Machines Lab</title>
<rect width="24" height="24" rx="2.6666666666666665"
:fill="color ? '#e6e7e8' : 'currentColor'" :fill-opacity="color ? 1 : 0.15"></rect>
<rect x="4" y="4" width="16" height="16" rx="1.3333333333333333"
:fill="color ? '#31373d' : 'currentColor'"></rect>
</svg>
</div>
</template>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { renderMath } from 'comark/plugins/math';
const props = defineProps<{
content: string;
display: boolean;
}>();
const html = computed(() => renderMath(props.content, props.display, { throwOnError: false }));
</script>
<template>
<span v-if="!display" class="math-inline" v-html="html"></span>
<div v-else class="math-block" v-html="html"></div>
</template>
+77 -83
View File
@@ -1,105 +1,99 @@
<script setup lang="ts"> <script setup lang="ts">
import { h, Text, computed } from 'vue'; import type { MarkdownAstNode } from '~/utils/markdown';
import { agentParser, userParser, getNodeAttributes, getNodeChildren, getNodeTag, splitParagraphBreaks, textContent, toVueAttributes } from '~/utils/markdown';
import { createTextVNode, h, shallowRef, watch } from 'vue';
import type { SearchSource } from '~/utils/tool-rendering';
import SourceCitation from './SourceCitation.vue';
import MarkdownMath from './Math.vue';
import MarkdownShikiHighlight from './ShikiHighlight.vue'; import MarkdownShikiHighlight from './ShikiHighlight.vue';
const props = defineProps<{ const props = defineProps<{
content: string; content: string;
finished: boolean; finished: boolean;
id: string; id: string;
sources?: ReadonlyMap<string, SearchSource>;
citations?: boolean;
}>(); }>();
const { $remark } = useNuxtApp(); const parser = props.citations ? agentParser : userParser;
const parse = async (content: string, finished: boolean) => {
const tree = await parser(content, { streaming: !finished });
// Copy-on-write: br-free trees return by reference with no allocation.
return splitParagraphBreaks(tree);
};
// this function effectively removes lazy concetenation from the markdown // Parsed trees are replaced, never mutated. Avoid proxying every AST node.
// normally in markdown, if you have two lines that are separated by only const parsed = shallowRef(await parse(props.content, props.finished));
// one new line. E.g.: let parseVersion = 0;
//
// This is some markdown text but I split
// it into two lines so it's easier to read in the editor
//
// they are *usually* concatenated into one line, however,
// since we are a web editor, with line wrapping, that behavior
// in undesirable, so pre preserve newlines by just maiking
// every new line two newlines (as long as we arent in a codeblock)
// function preprocessMarkdown(doc: string) {
// const lines = doc.split('\n');
// let inCode = false;
// let result: string[] = [];
// for (let i = 0; i < lines.length; i++) { watch(
// const fullLine = lines[i]!; [() => props.content, () => props.finished],
async ([content, finished]) => {
// const match = fullLine.match(/^( {0,3})(.*)/); const version = ++parseVersion;
// const content = match?.[2] || ''; const tree = await parse(content, finished);
if (version === parseVersion) {
// if (content.startsWith('```')) { parsed.value = tree;
// if (!inCode) {
// inCode = true;
// } else {
// inCode = false;
// }
// result.push(fullLine);
// continue;
// }
// if (inCode) {
// result.push(fullLine);
// } else {
// if (content.trim().length === 0) {
// result.push('');
// } else {
// if (result.length > 0 && result[result.length - 1] !== '') {
// result.push('');
// }
// result.push(fullLine);
// }
// }
// }
// return result.join('\n');
// }
const ast = computed(() => {
const mdast = $remark.parse(props.content);
return $remark.runSync(mdast);
});
const renderNode = (node: any, index: number): any => {
if (node.type === 'text' || node.type === 'raw') return h(Text, 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(MarkdownShikiHighlight, {
key: `code-${index}`,
code: node.children[0].value,
language: node.properties?.className?.[0]?.replace('language-', '') || 'text'
});
}
} }
},
);
const children = node.children?.map((child: any, i: number) => renderNode(child, i)) || []; const renderNode = (node: MarkdownAstNode, path: string): any => {
if (typeof node === 'string') {
return h( return createTextVNode(node);
node.tagName,
{ ...node.properties, key: `${node.tagName}-${index}` },
children
);
} }
return null;
if (!Array.isArray(node)) {
return null;
}
if (node[0] === null) {
return null;
}
const tag = getNodeTag(node);
const attributes = getNodeAttributes(node);
if (tag === 'source-cite') {
const ids = typeof attributes.ids === "string" ? attributes.ids.split(",") : [];
return h(SourceCitation, { key: path, ids, sources: props.sources });
}
if (tag === 'pre') {
const language = typeof attributes.language === 'string' ? attributes.language : 'text';
return h(MarkdownShikiHighlight, {
key: path,
code: textContent(node),
language,
});
}
if (tag === 'math') {
return h(MarkdownMath, {
key: path,
content: typeof attributes.content === 'string' ? attributes.content : textContent(node),
display: typeof attributes.class === 'string' && attributes.class.includes('block'),
});
}
const children = getNodeChildren(node)
.map((child, index) => renderNode(child, `${path}.${index}`))
.filter((child) => child !== null);
return h(
tag,
{ ...toVueAttributes(attributes), key: path },
children,
);
}; };
const render = () => { const render = () => {
const children = ast.value?.children?.flatMap(renderNode) || []; const children = parsed.value.nodes
.map((node, index) => renderNode(node, `${props.id}.${index}`))
.filter((child) => child !== null);
return h('div', { class: 'prose-wrapper' }, [ return h('article', { class: 'prose-wrapper markdown-body' }, children);
h('article', { class: 'markdown-body' }, children) };
]);
}
</script> </script>
<template> <template>
<render /> <render />
</template> </template>
@@ -3,8 +3,6 @@ import { type Grammar } from 'shiki';
import { hashSync } from '~/utils/hash'; import { hashSync } from '~/utils/hash';
const props = defineProps<{ code: string; language: string }>(); const props = defineProps<{ code: string; language: string }>();
const start = Date.now();
const MAX_LENGTH = 15000; const MAX_LENGTH = 15000;
const renderId = `${useId()}-${hashSync(props.code + props.language)}`; const renderId = `${useId()}-${hashSync(props.code + props.language)}`;
@@ -76,8 +74,6 @@ onUnmounted(() => {
if (copyTimeout) clearTimeout(copyTimeout); if (copyTimeout) clearTimeout(copyTimeout);
clear(); clear();
}); });
console.log("shiki codeblock rendered in", Date.now() - start);
</script> </script>
<template> <template>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { SearchSource } from "~/utils/tool-rendering";
import { autoUpdate, flip, offset, shift, useFloating } from "@floating-ui/vue";
import { computed, onBeforeUnmount, ref, useId } from "vue";
import { getFaviconUrl } from "~/utils/tool-rendering";
const props = defineProps<{
ids: string[];
sources?: ReadonlyMap<string, SearchSource>;
}>();
const entries = computed(() => [...new Set(props.ids)].map(id => ({ id, source: props.sources?.get(id) })));
const firstSource = computed(() => entries.value.find(entry => entry.source)?.source);
const label = computed(() => {
const first = firstSource.value?.hostname ?? "Source unavailable";
return entries.value.length > 1 ? `${first} +${entries.value.length - 1}` : first;
});
const popupId = useId();
const trigger = ref<HTMLElement | null>(null);
const preview = ref<HTMLElement | null>(null);
const open = ref(false);
const failedFavicons = ref(new Set<string>());
const { floatingStyles, placement } = useFloating(trigger, preview, {
open,
strategy: "fixed",
placement: "top-start",
middleware: [offset(8), flip(), shift({ padding: 12 })],
whileElementsMounted: autoUpdate,
transform: false,
});
const transformOrigin = computed(() =>
placement.value.startsWith("top")
? "transform-origin-bottom-center"
: "transform-origin-top-center"
);
let closeTimer: ReturnType<typeof setTimeout> | undefined;
let pointerInside = false;
const cancelClose = () => {
clearTimeout(closeTimer);
closeTimer = undefined;
};
const showPreview = () => {
cancelClose();
open.value = true;
};
const scheduleClose = () => {
cancelClose();
// Allow the pointer to cross the gap between the badge and the preview.
closeTimer = setTimeout(() => {
closeTimer = undefined;
const focused = document.activeElement;
if (!pointerInside && !trigger.value?.contains(focused) && !preview.value?.contains(focused)) {
open.value = false;
}
}, 150);
};
const onPointerEnter = (event: PointerEvent) => {
if (event.pointerType === "touch") {
return;
}
pointerInside = true;
showPreview();
};
const onPointerLeave = (event: PointerEvent) => {
if (event.pointerType === "touch") {
return;
}
pointerInside = false;
scheduleClose();
};
const dismissPreview = () => {
cancelClose();
if (preview.value?.contains(document.activeElement)) {
trigger.value?.focus();
}
open.value = false;
};
onBeforeUnmount(cancelClose);
</script>
<template>
<a ref="trigger" :href="firstSource?.url" target="_blank" rel="noopener noreferrer"
:tabindex="firstSource ? undefined : 0" :aria-expanded="open" :aria-controls="open ? popupId : undefined"
@pointerenter="onPointerEnter" @pointerleave="onPointerLeave" @focusin="showPreview" @focusout="scheduleClose"
@keydown.esc.stop.prevent="dismissPreview"
class="inline-flex items-center gap-1 max-w-64 mx-1 px-2 border border-[var(--color-border)] rounded-full bg-[var(--bg-container)] hover:bg-[var(--color-hover)] transition-colors text-[var(--text-secondary)] text-xs align-baseline cursor-pointer focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--text-secondary)] focus-visible:outline-offset-2 decoration-none">
<img v-if="firstSource && !failedFavicons.has(firstSource.hostname)" :src="getFaviconUrl(firstSource.hostname)"
alt="" loading="lazy" class="rounded-[4px] w-[0.9rem] h-[0.9rem] m-0"
@error="failedFavicons.add(firstSource.hostname)">
{{ label }}
</a>
<Transition enter-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 translate-y-1">
<span v-if="open" :id="popupId" ref="preview" @pointerenter="onPointerEnter" @pointerleave="onPointerLeave"
@focusin="showPreview" @focusout="scheduleClose" @keydown.esc.stop.prevent="dismissPreview"
class="z-2 inset-auto m-0 p-[0.85rem] w-[min(22rem,calc(100vw_-_24px))] max-h-[min(24rem,70vh)] overflow-y-auto border border-[var(--color-border)] rounded-xl bg-[var(--bg-surface)] text-[var(--text-primary)] shadow-md text-sm leading-normal"
:class="transformOrigin" :style="floatingStyles">
<span v-for="(entry, index) in entries" :key="entry.id"
class="flex flex-col gap-[0.4rem] [overflow-wrap:anywhere]"
:class="{ 'mt-[0.8rem] pt-[0.8rem] border-t border-[var(--color-border)]': index > 0 }">
<template v-if="entry.source">
<span class="text-xs text-[var(--text-secondary)]">{{ entry.source.hostname }} · Search
excerpt</span>
<a :href="entry.source.url" target="_blank" rel="noopener noreferrer">{{ entry.source.title }}</a>
<span v-if="entry.source.snippet" class="whitespace-pre-wrap text-[var(--text-secondary)]">{{
entry.source.snippet }}</span>
</template>
<span v-else>Source unavailable ({{ entry.id }})</span>
</span>
</span>
</Transition>
</template>
+5 -38
View File
@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MessagePart } from '~/composables/useChat'; import type { MessagePart } from '~/composables/useChat';
import ReasoningBody from './ReasoningBody.vue';
const props = defineProps<{ const props = defineProps<{
part: Readonly<MessagePart>; part: Readonly<MessagePart>;
}>(); }>();
const reasoningOpen = ref(!props.part.finished); const reasoningOpen = ref(!props.part.finished);
const bodyRef = ref<InstanceType<typeof ReasoningBody> | null>(null);
watch( watch(
() => props.part.finished, () => props.part.finished,
@@ -14,44 +16,14 @@ watch(
}, },
); );
const containerRef: Ref<HTMLDivElement | null> = ref(null);
const scrollState = ref('middle');
const { scrollToBottom } = useAutoScroll(containerRef);
const handleScroll = () => {
if (!containerRef.value) return;
const container = containerRef.value;
const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
const topThreshold = 0.02 * clientHeight;
if (scrollHeight <= clientHeight) {
// the container does not have enough content to scroll
scrollState.value = '';
return;
}
if (scrollTop <= topThreshold) {
scrollState.value = 'top';
} else if (scrollTop + 100 >= scrollHeight - clientHeight) {
scrollState.value = 'bottom';
} else {
scrollState.value = 'middle';
}
};
const toggleReasoning = async () => { const toggleReasoning = async () => {
if (!props.part.finished) return; if (!props.part.finished) return;
reasoningOpen.value = !reasoningOpen.value; reasoningOpen.value = !reasoningOpen.value;
if (!reasoningOpen.value) return; if (!reasoningOpen.value) return;
await nextTick(); await nextTick();
scrollToBottom('instant'); bodyRef.value?.scrollToBottom('instant');
handleScroll(); bodyRef.value?.handleScroll();
}; };
const formatDuration = (ms: number) => { const formatDuration = (ms: number) => {
@@ -91,11 +63,6 @@ const formatDuration = (ms: number) => {
<span class="i-mynaui-chevron-down w-4 h-4 text-[var(--text-secondary)]" <span class="i-mynaui-chevron-down w-4 h-4 text-[var(--text-secondary)]"
:class="reasoningOpen ? '' : '-rotate-90'"></span> :class="reasoningOpen ? '' : '-rotate-90'"></span>
</button> </button>
<div v-if="reasoningOpen" ref="containerRef" @scroll="handleScroll" <ReasoningBody v-if="reasoningOpen" ref="bodyRef" :part="part" />
:class="['reasoning-container max-h-[min(40vh,320px)] overflow-y-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]', scrollState]">
<div class="p-2">
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content!" />
</div>
</div>
</div> </div>
</template> </template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import type { MessagePart } from '~/composables/useChat';
import { nextTick, onMounted, ref, watch, type Ref } from 'vue';
const props = defineProps<{
part: Readonly<MessagePart>;
}>();
const containerRef: Ref<HTMLDivElement | null> = ref(null);
const scrollState = ref('');
const { scrollToBottom } = useAutoScroll(containerRef);
const handleScroll = () => {
if (!containerRef.value) {
return;
}
const container = containerRef.value;
const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
if (scrollHeight <= clientHeight) {
scrollState.value = '';
return;
}
const threshold = Math.max(6, 0.02 * clientHeight);
if (scrollTop <= threshold) {
scrollState.value = 'top';
} else if (scrollTop >= scrollHeight - clientHeight - threshold) {
scrollState.value = 'bottom';
} else {
scrollState.value = 'middle';
}
};
onMounted(() => {
nextTick(() => {
handleScroll();
});
});
watch(
() => props.part.content,
() => {
nextTick(() => {
handleScroll();
});
},
);
defineExpose({ scrollToBottom, handleScroll });
</script>
<template>
<div ref="containerRef" @scroll="handleScroll"
:class="['reasoning-container max-h-[min(40vh,320px)] overflow-y-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]', scrollState]">
<div class="p-2">
<MarkdownRenderer :finished="props.part.finished" :id="props.part.id" :content="props.part.content!" />
</div>
</div>
</template>
-11
View File
@@ -1,11 +0,0 @@
<script lang="ts" setup>
import type { MessagePart } from '~/composables/useChat';
const props = defineProps<{
part: Readonly<MessagePart>;
}>();
</script>
<template>
<MarkdownRenderer :finished="part.finished" :id="part.id" :content="part.content!" />
</template>
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useNuxtApp } from "#app";
const props = defineProps<{
code: string;
lang?: string;
}>();
const { $shiki } = useNuxtApp();
const html = ref("");
const targetLang = computed(() => props.lang || "json");
const lineNumberWidth = computed(() => {
const lines = props.code ? props.code.split("\n").length : 1;
return lines.toString().length;
});
watch(
[() => props.code, targetLang],
async ([newCode, newLang]) => {
if (!newCode) {
html.value = "";
return;
}
let resolvedLang = newLang;
try {
if ($shiki?.getLanguage) {
await $shiki.getLanguage(resolvedLang);
}
} catch {
resolvedLang = "text";
}
try {
if ($shiki?.codeToHtml) {
html.value = await $shiki.codeToHtml(newCode, {
lang: resolvedLang,
themes: {
dark: "vitesse-dark",
light: "vitesse-light",
},
});
} else {
html.value = "";
}
} catch {
html.value = "";
}
},
{ immediate: true },
);
</script>
<template>
<div
class="tool-code-container overflow-auto max-h-80 scrollbar-thin text-xs font-mono"
:style="`--line-number-width: ${lineNumberWidth}ch;`"
>
<div v-if="html" class="tool-code-highlight" v-html="html"></div>
<pre v-else class="tool-code-fallback m-0 p-3.5 text-[var(--text-secondary)] whitespace-pre-wrap break-words leading-relaxed">{{ code }}</pre>
</div>
</template>
<style scoped>
.tool-code-container {
background-color: var(--shiki-dark-bg, #121019);
}
:deep(.tool-code-highlight > pre) {
margin: 0;
padding: 0.875rem 1rem;
font-size: 0.75rem;
line-height: 1.625;
scrollbar-width: thin;
overflow-x: auto;
counter-reset: lines;
min-height: 100%;
box-sizing: border-box;
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
:deep(.tool-code-highlight > pre > code .line::before) {
counter-increment: lines;
content: counter(lines);
width: var(--line-number-width, 2ch);
margin-right: 1.25rem;
display: inline-block;
text-align: right;
color: rgba(255, 255, 255, 0.25);
user-select: none;
}
:deep(html.light .tool-code-container) {
background-color: #ffffff;
}
:deep(html.light .tool-code-highlight > pre > code .line::before) {
color: rgba(0, 0, 0, 0.25);
}
</style>
+7 -6
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ToolCall } from '~/composables/useChat'; import type { ToolCall } from '~/composables/useChat';
import { formatToolValue, parseToolValue } from '~/utils/tool-rendering';
const props = defineProps<{ const props = defineProps<{
toolCall: Readonly<ToolCall>; toolCall: Readonly<ToolCall>;
@@ -28,21 +29,21 @@ const input: ComputedRef<string> = computed(() => {
case 'input': case 'input':
if (props.toolCall.input === null || props.toolCall.input === undefined) return ''; if (props.toolCall.input === null || props.toolCall.input === undefined) return '';
if (props.toolCall.input!.type === 'json') { if (props.toolCall.input!.type === 'json') {
return JSON.stringify(JSON.parse(props.toolCall.input!.value), null, 2); return formatToolValue(props.toolCall.input);
} }
return props.toolCall.input!.value; return props.toolCall.input!.value;
case 'output': case 'output':
if (props.toolCall.output === null || props.toolCall.output === undefined) return ''; if (props.toolCall.output === null || props.toolCall.output === undefined) return '';
if (props.toolCall.output!.type === 'json') { if (props.toolCall.output!.type === 'json') {
return JSON.stringify(JSON.parse(props.toolCall.output!.value), null, 2); return formatToolValue(props.toolCall.output);
} }
return props.toolCall.output!.value; return props.toolCall.output!.value;
case 'error': case 'error':
if (props.toolCall.error === null || props.toolCall.error === undefined) return ''; if (props.toolCall.error === null || props.toolCall.error === undefined) return '';
if (props.toolCall.error!.type === 'json') { if (props.toolCall.error!.type === 'json') {
return JSON.stringify(JSON.parse(props.toolCall.error!.value), null, 2); return formatToolValue(props.toolCall.error);
} }
return props.toolCall.error!.value; return props.toolCall.error!.value;
@@ -55,7 +56,7 @@ const input: ComputedRef<string> = computed(() => {
traceObj.input = traceObj.input.value; traceObj.input = traceObj.input.value;
break; break;
case 'json': case 'json':
traceObj.input = JSON.parse(traceObj.input.value); traceObj.input = parseToolValue(props.toolCall.input);
break; break;
} }
@@ -64,7 +65,7 @@ const input: ComputedRef<string> = computed(() => {
traceObj.output = traceObj.output.value; traceObj.output = traceObj.output.value;
break; break;
case 'json': case 'json':
traceObj.output = JSON.parse(traceObj.output.value); traceObj.output = parseToolValue(props.toolCall.output);
break; break;
} }
@@ -73,7 +74,7 @@ const input: ComputedRef<string> = computed(() => {
traceObj.error = traceObj.error.value; traceObj.error = traceObj.error.value;
break; break;
case 'json': case 'json':
traceObj.error = JSON.parse(traceObj.error.value); traceObj.error = parseToolValue(props.toolCall.error);
break; break;
} }
@@ -0,0 +1,324 @@
<script setup lang="ts">
import type { MessagePart, ToolCall } from "~/composables/useChat";
import { computed, onUnmounted, ref, useId, watch } from "vue";
import CodeViewer from "./CodeViewer.vue";
import {
formatDuration,
formatToolValue,
getToolDisplayInfo,
parseToolValue,
} from "~/utils/tool-rendering";
export interface CustomToolTab {
id: string;
label: string;
count?: string;
copyContent?: () => string;
}
const props = defineProps<{
toolCall: Readonly<ToolCall>;
part?: MessagePart;
title?: string;
subtitle?: string;
icon?: string;
customTab?: CustomToolTab;
duration?: string;
}>();
const emit = defineEmits<{
retry: [];
}>();
const isOpen = ref(false);
const contentId = useId();
const copied = ref(false);
let copyTimeout: ReturnType<typeof setTimeout> | null = null;
const displayInfo = computed(() => getToolDisplayInfo(props.toolCall));
const displayTitle = computed(() => props.title || displayInfo.value.verb);
const displaySubtitle = computed(() => props.subtitle !== undefined ? props.subtitle : displayInfo.value.target);
const displayIcon = computed(() => props.icon || displayInfo.value.icon);
const calculatedDuration = computed(() => {
if (props.duration) {
return props.duration;
}
if (props.toolCall.status === "pending") {
return null;
}
const start = props.part?.createdAt || props.toolCall.createdAt;
const end = props.part?.lastUpdatedAt;
return formatDuration(start, end);
});
interface TabItem {
id: string;
label: string;
}
const availableTabs = computed<TabItem[]>(() => {
const tabs: TabItem[] = [];
if (props.customTab) {
tabs.push({ id: props.customTab.id, label: props.customTab.label });
}
if (props.toolCall.status === "failed" || props.toolCall.error) {
tabs.push({ id: "error", label: "Error" });
}
if (props.toolCall.input) {
tabs.push({ id: "input", label: "Input" });
}
if (props.toolCall.output) {
tabs.push({ id: "output", label: "Output" });
}
tabs.push({ id: "trace", label: "Trace" });
return tabs;
});
const resolveDefaultTab = (): string => {
if (props.toolCall.status === "failed" || props.toolCall.error) {
return "error";
}
if (props.customTab && props.toolCall.status === "completed") {
return props.customTab.id;
}
if (props.toolCall.output) {
return "output";
}
if (props.toolCall.input) {
return "input";
}
return "trace";
};
const activeTab = ref(resolveDefaultTab());
watch(
() => props.toolCall.status,
(newStatus) => {
if (newStatus === "failed") {
activeTab.value = "error";
} else if (newStatus === "completed" && props.customTab) {
activeTab.value = props.customTab.id;
}
},
);
const formattedError = computed(() => {
if (!props.toolCall.error) {
return "";
}
const parsed = parseToolValue(props.toolCall.error);
if (typeof parsed === "string") {
return parsed;
}
return JSON.stringify(parsed, null, 2);
});
const currentTabCode = computed(() => {
switch (activeTab.value) {
case "input":
return formatToolValue(props.toolCall.input);
case "output":
return formatToolValue(props.toolCall.output);
case "error":
return formattedError.value;
case "trace": {
const traceObj = {
id: props.toolCall.id,
toolName: props.toolCall.toolName,
status: props.toolCall.status,
input: parseToolValue(props.toolCall.input),
output: parseToolValue(props.toolCall.output),
error: parseToolValue(props.toolCall.error),
createdAt: props.toolCall.createdAt,
};
return JSON.stringify(traceObj, null, 2);
}
default:
return "";
}
});
const currentTabLang = computed(() => {
if (activeTab.value === "trace") {
return "json";
}
if (activeTab.value === "input") {
return props.toolCall.input?.type === "json" ? "json" : "text";
}
if (activeTab.value === "output") {
return props.toolCall.output?.type === "json" ? "json" : "text";
}
return "text";
});
const footerInfo = computed(() => {
if (props.customTab && activeTab.value === props.customTab.id) {
return props.customTab.count || "";
}
const lines = currentTabCode.value ? currentTabCode.value.split("\n").length : 0;
const lineLabel = lines === 1 ? "line" : "lines";
const lang = currentTabLang.value;
return `${lines} ${lineLabel} · ${lang}`;
});
const canRetry = computed(() => props.toolCall.status === "failed");
const copyActiveTab = async () => {
let textToCopy = "";
if (props.customTab && activeTab.value === props.customTab.id && props.customTab.copyContent) {
textToCopy = props.customTab.copyContent();
} else {
textToCopy = currentTabCode.value;
}
if (!textToCopy) {
return;
}
try {
await navigator.clipboard.writeText(textToCopy);
copied.value = true;
if (copyTimeout) {
clearTimeout(copyTimeout);
}
copyTimeout = setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error("Failed to copy tool call content:", err);
}
};
const toggle = () => {
isOpen.value = !isOpen.value;
};
onUnmounted(() => {
if (copyTimeout) {
clearTimeout(copyTimeout);
}
});
</script>
<template>
<div class="tool-disclosure flex flex-col min-w-0 text-[var(--text-tertiary)]">
<button
type="button"
class="w-full @hover:bg-[var(--color-hover)] rounded-lg p-1 flex justify-between items-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] cursor-pointer select-none text-left"
:aria-expanded="isOpen"
:aria-controls="contentId"
@click="toggle"
>
<span class="flex items-center gap-1.5 min-w-0 overflow-hidden">
<span class="w-6 h-6 flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
<span
:class="[
'w-3 h-3 text-3 transition-colors',
displayIcon,
toolCall.status === 'failed' ? 'text-red-400' : 'text-[var(--text-secondary)]'
]"
aria-hidden="true"
></span>
</span>
<span class="text-sm truncate">
<span :class="toolCall.status === 'failed' ? 'text-red-400' : 'text-[var(--text-secondary)]'">
{{ displayTitle }}
</span>
<span v-if="displaySubtitle" class="text-[var(--text-tertiary)] ml-1.5 font-normal truncate">
{{ displaySubtitle }}
</span>
</span>
</span>
<span class="flex items-center gap-2 shrink-0 ml-2">
<slot name="summary"></slot>
<span
v-if="toolCall.status === 'pending'"
class="i-svg-spinners-180-ring-with-bg text-3 text-[var(--color-accent)]"
aria-hidden="true"
></span>
<span v-else-if="toolCall.status === 'failed'" class="text-xs text-red-400 font-medium">Failed</span>
<span v-else-if="toolCall.status === 'cancelled'" class="text-xs text-[var(--text-tertiary)]">Cancelled</span>
<span v-else-if="calculatedDuration" class="text-xs text-[var(--text-tertiary)] font-mono">{{ calculatedDuration }}</span>
<span
class="i-mynaui-chevron-down w-4 h-4 text-[var(--text-secondary)] transition-transform duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
:class="isOpen ? '' : '-rotate-90'"
aria-hidden="true"
></span>
</span>
</button>
<div
v-show="isOpen"
:id="contentId"
class="mt-1 border border-[var(--color-border)] rounded-xl bg-[var(--bg-surface)] overflow-hidden text-xs"
>
<div class="flex items-center gap-1 px-3 pt-2.5 pb-2 overflow-x-auto scrollbar-none border-b border-[var(--color-border)]/40">
<button
v-for="tab in availableTabs"
:key="tab.id"
type="button"
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer shrink-0"
:class="[
activeTab === tab.id
? (tab.id === 'error'
? 'bg-red-500/15 text-red-300'
: 'bg-[var(--color-hover)] text-[var(--text-primary)]')
: 'text-[var(--text-tertiary)] @hover:text-[var(--text-secondary)] @hover:bg-[var(--color-hover)]/50'
]"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<div class="min-h-0">
<div v-if="customTab && activeTab === customTab.id">
<slot :name="customTab.id">
<slot></slot>
</slot>
</div>
<div v-else-if="activeTab === 'error'">
<CodeViewer :code="formattedError" lang="json"></CodeViewer>
</div>
<div v-else-if="['input', 'output', 'trace'].includes(activeTab)">
<CodeViewer :code="currentTabCode" :lang="currentTabLang"></CodeViewer>
</div>
</div>
<div class="flex items-center justify-between px-3 py-2 border-t border-[var(--color-border)]/40 text-xs text-[var(--text-tertiary)] bg-[var(--color-hover)]/20">
<div>
<span class="font-mono text-[11px] text-[var(--text-tertiary)]">{{ footerInfo }}</span>
</div>
<div class="flex items-center gap-2">
<slot name="actions"></slot>
<button
v-if="canRetry"
type="button"
class="flex items-center gap-1.5 px-2 py-1 rounded text-[var(--text-tertiary)] @hover:text-red-400 @hover:bg-[var(--color-hover)] transition-colors cursor-pointer text-xs"
@click="emit('retry')"
>
<span class="i-mynaui-refresh w-3.5 h-3.5" aria-hidden="true"></span>
<span>Retry</span>
</button>
<button
type="button"
class="flex items-center gap-1.5 px-2 py-1 rounded text-[var(--text-tertiary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] transition-colors cursor-pointer text-xs"
@click="copyActiveTab"
>
<span :class="copied ? 'i-mynaui-check text-emerald-400' : 'i-mynaui-copy'" class="w-3.5 h-3.5" aria-hidden="true"></span>
<span>{{ copied ? 'Copied' : 'Copy' }}</span>
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { MessagePart, ToolCall } from "~/composables/useChat";
import Disclosure from "./Disclosure.vue";
defineProps<{
toolCall: Readonly<ToolCall>;
part?: MessagePart;
}>();
const emit = defineEmits<{
retry: [];
}>();
</script>
<template>
<Disclosure :tool-call="toolCall" :part="part" @retry="emit('retry')"></Disclosure>
</template>
@@ -0,0 +1,200 @@
<script setup lang="ts">
import type { MessagePart, ToolCall } from "~/composables/useChat";
import { computed, ref } from "vue";
import Disclosure from "./Disclosure.vue";
import {
domainInitial,
getDomainColor,
getFaviconUrl,
parseToolValue,
readSearchSources,
readSearchWarnings,
} from "~/utils/tool-rendering";
const props = defineProps<{
toolCall: Readonly<ToolCall>;
part?: MessagePart;
}>();
const emit = defineEmits<{
retry: [];
}>();
const output = computed(() => parseToolValue(props.toolCall.output));
const sources = computed(() => readSearchSources(output.value));
const warnings = computed(() => readSearchWarnings(output.value));
const warningSummary = computed(() => warnings.value.map(warning => `${warning.engine}: ${warning.reason}`).join("; "));
const query = computed(() => {
const input = parseToolValue(props.toolCall.input);
if (typeof input === "object" && input !== null && "query" in input && typeof input.query === "string") {
return input.query;
}
return "";
});
const sitesCount = computed(() => {
return sources.value?.length ?? 0;
});
const topDomains = computed(() => {
return [...new Set((sources.value ?? []).map(s => s.hostname))].slice(0, 4);
});
const failedFavicons = ref(new Set<string>());
const onFaviconError = (domain: string) => {
failedFavicons.value.add(domain);
};
const getDomainBadgeStyle = (idx: number) => {
const zIndex = topDomains.value.length - idx;
if (idx === 0) {
return { zIndex };
}
return {
zIndex,
maskImage: "radial-gradient(circle 12.5px at -4px 50%, transparent 12.5px, #000 13px)",
WebkitMaskImage: "radial-gradient(circle 12.5px at -4px 50%, transparent 12.5px, #000 13px)",
};
};
const title = computed(() => {
if (props.toolCall.status === "pending") {
return "Searching the web…";
}
if (props.toolCall.status === "failed") {
return "Search failed";
}
if (props.toolCall.status === "cancelled") {
return "Search cancelled";
}
const count = sitesCount.value;
if (count > 0) {
return `Searched ${count} ${count === 1 ? "site" : "sites"}`;
}
return "Searched the web";
});
const formattedQuery = computed(() => {
return query.value ? `"${query.value}"` : "";
});
const customTab = computed(() => {
const count = sources.value?.length ?? 0;
return {
id: "sources",
label: "Sources",
count: `${count} ${count === 1 ? "source" : "sources"}`,
copyContent: () => {
if (!sources.value?.length) {
return "";
}
return sources.value.map(s => `${s.title}\n${s.url}`).join("\n\n");
},
};
});
</script>
<template>
<Disclosure
:tool-call="toolCall"
:part="part"
:title="title"
:subtitle="formattedQuery"
icon="i-mynaui-search"
:custom-tab="customTab"
@retry="emit('retry')"
>
<template #summary>
<Tooltip
v-if="warnings.length"
class="flex items-center text-amber-500 mr-2"
:text="`Some search engines failed: ${warningSummary}`"
>
<span
class="i-mynaui-danger-triangle w-4 h-4"
role="img"
:aria-label="`Some search engines failed: ${warningSummary}`"
></span>
</Tooltip>
<div v-if="topDomains.length && toolCall.status !== 'pending'" class="flex items-center mr-1" aria-hidden="true">
<span
v-for="(domain, idx) in topDomains"
:key="domain"
class="relative w-[22px] h-[22px] rounded-full overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center shrink-0"
:class="idx > 0 ? '-ml-[7px]' : ''"
:style="getDomainBadgeStyle(idx)"
:title="domain"
>
<img
v-if="!failedFavicons.has(domain)"
:src="getFaviconUrl(domain)"
:alt="domain"
class="w-[15px] h-[15px] rounded-full object-contain"
loading="lazy"
@error="onFaviconError(domain)"
>
<span
v-else
class="w-full h-full flex items-center justify-center text-[10px] font-bold text-white"
:style="{ backgroundColor: getDomainColor(domain) }"
>
{{ domainInitial(domain) }}
</span>
</span>
</div>
</template>
<template #sources>
<div class="flex flex-col gap-2 max-h-80 overflow-y-auto scrollbar-thin p-3">
<p v-if="warnings.length" class="flex items-start gap-2 text-xs text-amber-500 m-0">
<span class="i-mynaui-danger-triangle w-4 h-4 shrink-0" aria-hidden="true"></span>
<span>Some search engines failed: {{ warningSummary }}</span>
</p>
<a
v-for="source in sources"
:key="source.url"
:href="source.url"
target="_blank"
rel="noopener noreferrer"
class="flex flex-col gap-1.5 p-3 rounded-xl bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] transition-colors cursor-pointer text-left no-underline border border-transparent @hover:border-[var(--color-border)]"
>
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-2.5 min-w-0">
<span class="w-6 h-6 rounded-lg overflow-hidden flex items-center justify-center shrink-0 bg-[var(--bg-surface)]">
<img
v-if="!failedFavicons.has(source.hostname)"
:src="getFaviconUrl(source.hostname)"
:alt="source.hostname"
class="w-4 h-4 rounded-md object-contain"
loading="lazy"
@error="onFaviconError(source.hostname)"
>
<span
v-else
class="w-full h-full flex items-center justify-center text-xs font-bold text-white"
:style="{ backgroundColor: getDomainColor(source.hostname) }"
>
{{ domainInitial(source.hostname) }}
</span>
</span>
<span class="text-sm font-medium text-[var(--text-primary)] truncate">
{{ source.title }}
</span>
</div>
<span class="text-[11px] font-mono text-[var(--text-dim)] truncate pl-0.5">
{{ source.url }}
</span>
</div>
<p v-if="source.snippet" class="text-xs text-[var(--text-secondary)] leading-relaxed m-0 line-clamp-2">
{{ source.snippet }}
</p>
</a>
<p v-if="!sources?.length" class="text-xs text-[var(--text-tertiary)] m-0">
{{ toolCall.status === "pending" ? "Waiting for results…" : "No usable sources returned." }}
</p>
</div>
</template>
</Disclosure>
</template>
+16 -32
View File
@@ -1,44 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ToolCall } from '~/composables/useChat'; import type { MessagePart, ToolCall } from "~/composables/useChat";
import Debug from './Debug.vue'; import { computed } from "vue";
import Generic from "./Generic.vue";
import { resolveToolRenderer } from "./renderers";
const props = defineProps<{ const props = defineProps<{
toolCall: Readonly<ToolCall>; toolCall: Readonly<ToolCall>;
part?: MessagePart;
}>(); }>();
const deubgToolCallOpen = ref(false); const emit = defineEmits<{
retry: [];
}>();
const toggleDebugToolCall = () => { const renderer = computed(() => resolveToolRenderer(props.toolCall.toolName));
deubgToolCallOpen.value = !deubgToolCallOpen.value;
};
</script> </script>
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col min-w-0">
<div <NuxtErrorBoundary :key="toolCall.id + ':' + toolCall.toolName">
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)]"> <component :is="renderer" :tool-call="toolCall" :part="part" @retry="emit('retry')"></component>
<div class="flex items-center justify-between w-full"> <template #error>
<div class="flex items-center gap-1"> <Generic :tool-call="toolCall" :part="part" @retry="emit('retry')"></Generic>
<div </template>
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center"> </NuxtErrorBoundary>
<span class="w-3 h-3" :class="{
'i-svg-spinners-180-ring-with-bg': toolCall.status === 'pending',
'i-mynaui-x-solid text-[#ff3b3b]': toolCall.status === 'failed',
'i-mynaui-tool': toolCall.status !== 'pending' && toolCall.status !== 'failed',
}"></span>
</div>
{{ toolCall.toolName }}
</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)]">
<button @click="toggleDebugToolCall"
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden @hover:bg-[var(--color-hover)] flex items-center justify-center transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-search w-3 h-3 text-[var(--text-secondary)]"></span>
</button>
</div>
</div>
</div>
<Debug v-if="deubgToolCallOpen" :toolCall="toolCall" />
</div> </div>
</template> </template>
@@ -0,0 +1,15 @@
import type { Component } from "vue";
import Generic from "./Generic.vue";
import Search from "./Search.vue";
// Renderers receive a readonly toolCall prop and own their presentation only.
const renderers: Readonly<Record<string, Component>> = Object.freeze({
search: Search,
});
export const resolveToolRenderer = (toolName: string): Component => {
if (Object.hasOwn(renderers, toolName)) {
return renderers[toolName] ?? Generic;
}
return Generic;
};
+21 -9
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { collectCitationSources } from '~/utils/citations';
import type { SearchSource } from '~/utils/tool-rendering';
import Error from './Error.vue'; import Error from './Error.vue';
import Reasoning from './Reasoning.vue'; import Reasoning from './Reasoning.vue';
import Text from './Text.vue';
import Tool from './Tool/index.vue'; import Tool from './Tool/index.vue';
import type { MessageEntity } from '~/composables/useChat'; import type { MessageEntity } from '~/composables/useChat';
@@ -9,6 +10,14 @@ const props = defineProps<{
message: MessageEntity message: MessageEntity
}>(); }>();
const EMPTY_SOURCES: ReadonlyMap<string, SearchSource> = new Map();
// Citation markers are rare; avoid JSON-parsing every tool output when none exist.
const sources = computed(() => {
const parts = props.message.parts ?? [];
const hasCitations = parts.some(part => part.type === 'text' && part.content?.includes('[[cite:') === true);
return hasCitations ? collectCitationSources(parts) : EMPTY_SOURCES;
});
const triggerRef = ref<HTMLElement | null>(null); const triggerRef = ref<HTMLElement | null>(null);
const { open, close, isOpen } = useTokenDropdown(); const { open, close, isOpen } = useTokenDropdown();
@@ -25,14 +34,17 @@ const toggleTokenDropdown = () => {
<template> <template>
<div class="flex flex-col w-full gap-2"> <div class="flex flex-col w-full gap-2">
<div v-for="part in message.parts?.filter(p => p.content?.trim() !== '' || p.toolCall)" :key="part.id"> <template v-for="part in message.parts" :key="part.id">
<Reasoning v-if="part.type === 'reasoning'" :part="part" /> <template v-if="part.content?.trim() !== '' || part.toolCall">
<Text v-else-if="part.type === 'text'" :part="part" /> <Reasoning v-if="part.type === 'reasoning'" :part="part"></Reasoning>
<Tool v-else-if="part.type === 'tool-call'" :toolCall="part.toolCall!" /> <MarkdownRenderer v-else-if="part.type === 'text'" :finished="part.finished" :id="part.id"
<div v-else> :content="part.content!" :sources="sources" :citations="true"></MarkdownRenderer>
Unhandled part type: {{ part.type }} {{ part }} <Tool v-else-if="part.type === 'tool-call'" :tool-call="part.toolCall!" :part="part"></Tool>
</div> <div v-else>
</div> Unhandled part type: {{ part.type }} {{ part }}
</div>
</template>
</template>
<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">
+13 -8
View File
@@ -7,14 +7,19 @@ defineProps<{
</script> </script>
<template> <template>
<div class="flex flex-col gap-2 max-w-full bg-[var(--bg-container)] py-2 px-3 rounded-xl"> <div class="flex flex-col items-end gap-1 max-w-full">
<MarkdownRenderer v-if="message.content" :finished="true" :content="message.content" :id="message.id" /> <time class="text-[10px] text-[var(--text-secondary)] opacity-60 select-none pr-1">
<div v-if="message.attachments && message.attachments.length > 0" class="flex flex-col gap-2"> {{ new Date(message.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}
<div v-for="attachment in message.attachments" :key="attachment.id" </time>
class="flex flex-wrap items-center gap-2"> <div class="flex flex-col gap-2 max-w-full bg-[var(--bg-container)] py-2 px-3 rounded-xl">
<div <MarkdownRenderer v-if="message.content" :finished="true" :content="message.content" :id="message.id" />
class="flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center"> <div v-if="message.attachments && message.attachments.length > 0" class="flex flex-col gap-2">
<AttachmentDisplay :file="attachment.file" /> <div v-for="attachment in message.attachments" :key="attachment.id"
class="flex flex-wrap items-center gap-2">
<div
class="flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
<AttachmentDisplay :file="attachment.file" />
</div>
</div> </div>
</div> </div>
</div> </div>
+112 -75
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Message } from '~/composables/useChat'; import type { Message } from '~/composables/useChat';
import { getActiveMessage, getCurrentChildIndex, getMessageCount, getNextActiveChildId } from '~~/utils/message';
const { openDialog } = useDialog(); const { openDialog } = useDialog();
@@ -12,6 +13,7 @@ const emit = defineEmits<{
delete: []; delete: [];
edit: [value: string]; edit: [value: string];
patch: [updates: Partial<Message>]; patch: [updates: Partial<Message>];
fork: [];
}>(); }>();
let reqAbortController: AbortController | null = null; let reqAbortController: AbortController | null = null;
@@ -34,7 +36,9 @@ watch(() => message.activeChildId, async (newValue) => {
}); });
watch(() => message.children.length, (newCount, oldCount) => { watch(() => message.children.length, (newCount, oldCount) => {
if (newCount === oldCount) return; if (newCount === oldCount) {
return;
}
if (newCount === 0) { if (newCount === 0) {
emit('patch', { activeChildId: null }); emit('patch', { activeChildId: null });
@@ -42,7 +46,7 @@ watch(() => message.children.length, (newCount, oldCount) => {
} }
if (oldCount > newCount) { if (oldCount > newCount) {
const activeChild = message.children.find(c => c.id === message.activeChildId); const activeChild = message.children.find(c => c?.id === message.activeChildId);
if (!activeChild) { if (!activeChild) {
emit('patch', { activeChildId: message.children[newCount - 1]?.id ?? null }); emit('patch', { activeChildId: message.children[newCount - 1]?.id ?? null });
} }
@@ -55,19 +59,17 @@ watch(() => message.children.length, (newCount, oldCount) => {
} }
}); });
const activeMessage = computed(() => { watch(() => message.deleted, (isDeleted) => {
if (message.activeChildId && message.children.length > 0) { if (isDeleted && message.children.length > 0) {
const child = message.children.find(c => c.id === message.activeChildId); const activeChild = message.children.find(c => c?.id === message.activeChildId);
if (child) return child; if (!activeChild) {
emit('patch', { activeChildId: message.children[0]?.id ?? null });
}
} }
if (message.deleted === true && message.children.length > 0) {
return message.children[0];
}
return message;
}); });
const activeMessage = computed(() => getActiveMessage(message));
const copied = ref(false); const copied = ref(false);
const copyMessage = async () => { const copyMessage = async () => {
if (activeMessage.value!.role === 'user') { if (activeMessage.value!.role === 'user') {
@@ -126,91 +128,126 @@ const handleEdit = async () => {
}); });
}; };
const forkMessage = () => {
emit('fork');
};
const deleteMessage = () => { const deleteMessage = () => {
emit('delete'); emit('delete');
}; };
const messageCount = computed(() => { const messageCount = computed(() => getMessageCount(message));
if (message.deleted === true) {
return message.children.length;
}
return message.children.length + 1; const currentChildIndex = computed(() => getCurrentChildIndex(message));
});
const currentChildIndex = computed(() => {
if (!message.activeChildId) return 0;
const idx = message.children.findIndex(c => c.id === message.activeChildId);
return idx >= 0 ? idx + 1 : 0;
});
const navigateChild = (direction: -1 | 1) => { const navigateChild = (direction: -1 | 1) => {
const allItems = message.deleted === true const nextActiveChildId = getNextActiveChildId(message, direction);
? message.children emit('patch', { activeChildId: nextActiveChildId });
: [null, ...message.children]; };
const currentIdx = allItems.findIndex(item => const { show: showTooltip, hide: hideTooltip } = useTooltip();
item === null ? !message.activeChildId : item.id === message.activeChildId
);
const newIdx = Math.max(0, Math.min(allItems.length - 1, currentIdx + direction)); let hasCursor: boolean | null = null;
const newItem = allItems[newIdx]; const checkHasCursor = () => {
if (hasCursor === null && typeof window !== 'undefined') {
hasCursor = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
}
return hasCursor ?? false;
};
emit('patch', { activeChildId: newItem?.id ?? null }); const handleTooltipHover = (e: MouseEvent) => {
if (!checkHasCursor()) {
return;
}
const target = (e.target as HTMLElement).closest('[data-hotkey], [data-tooltip]') as HTMLElement | null;
if (!target) {
return;
}
const hotkey = target.dataset.hotkey ? target.dataset.hotkey.split(',') : undefined;
const text = target.dataset.tooltip || undefined;
showTooltip(target, hotkey, text);
};
const handleTooltipLeave = () => {
hideTooltip();
};
const handleAction = (e: MouseEvent) => {
const trigger = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
if (!trigger) {
return;
}
switch (trigger.dataset.action) {
case 'prev-child':
navigateChild(-1);
break;
case 'next-child':
navigateChild(1);
break;
case 'regenerate':
regenerateMessage();
break;
case 'edit':
handleEdit();
break;
case 'copy':
copyMessage();
break;
case 'fork':
forkMessage();
break;
case 'delete':
deleteMessage();
break;
}
}; };
</script> </script>
<template> <template>
<div v-if="activeMessage" class="max-w-full mb-4 group flex flex-col"> <div v-if="activeMessage" class="max-w-full mb-4 group flex flex-col">
<div :class="[message.role === 'user' ? 'pl-9 flex justify-end' : '']"> <MessageUser v-if="message.role === 'user'" class="pl-9 self-end" :message="activeMessage!"></MessageUser>
<MessageUser v-if="message.role === 'user'" :message="activeMessage!" /> <MessageAgent v-else :message="activeMessage"></MessageAgent>
<MessageAgent v-else :message="activeMessage" /> <div class="flex justify-between items-center" @click="handleAction">
</div> <div v-if="messageCount > 1" class="flex gap-1"
<div class="flex justify-between items-center"> @mouseover="handleTooltipHover" @mouseout="handleTooltipLeave">
<div> <button data-action="prev-child" :data-hotkey="currentChildIndex > 0 ? 'alt,[' : undefined"
<div v-if="messageCount > 1" class="flex gap-1"> class="@hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<Tooltip :inert="currentChildIndex === 0" :hotkey="['alt', '[']"> <span class="i-mynaui-chevron-left w-4 h-4 text-[var(--text-secondary)] pointer-events-none"
<button @click="navigateChild(-1)" :class="currentChildIndex === 0 ? 'opacity-0' : ''"></span>
class="@hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> </button>
<span class="i-mynaui-chevron-left w-4 h-4 text-[var(--text-secondary)]" <span class="text-xs text-[var(--text-secondary)]">
:class="currentChildIndex === 0 ? 'opacity-0' : ''"></span> {{ currentChildIndex + 1 }} / {{ messageCount }}
</button> </span>
</Tooltip> <button data-action="next-child" :data-hotkey="currentChildIndex + 1 < messageCount ? 'alt,]' : undefined"
<span class="text-xs text-[var(--text-secondary)]"> class="@hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
{{ currentChildIndex + 1 }} / {{ messageCount }} <span class="i-mynaui-chevron-right w-4 h-4 text-[var(--text-secondary)] pointer-events-none"
</span> :class="currentChildIndex + 1 === messageCount ? 'opacity-0' : ''"></span>
<Tooltip :inert="currentChildIndex + 1 === messageCount" :hotkey="['alt', ']']"> </button>
<button @click="navigateChild(1)"
class="@hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-chevron-right w-4 h-4 text-[var(--text-secondary)]"
:class="currentChildIndex + 1 === messageCount ? 'opacity-0' : ''"></span>
</button>
</Tooltip>
</div>
</div> </div>
<div v-show="activeMessage.generation?.status !== 'pending'" <div v-show="activeMessage.generation?.status !== 'pending'"
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)]"> @mouseover="handleTooltipHover" @mouseout="handleTooltipLeave"
<Tooltip :hotkey="['ctrl', 'shift', 'enter']"> class="ml-auto 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 data-action="regenerate" data-hotkey="ctrl,shift,enter"
class="flex justify-center items-center w-7 h-6 @hover:bg-[var(--color-hover)]">
<span class="i-mynaui-refresh text-4.5"></span>
</button>
</Tooltip>
<button v-if="message.role === 'user'" @click="handleEdit"
class="flex justify-center items-center w-7 h-6 @hover:bg-[var(--color-hover)]"> class="flex justify-center items-center w-7 h-6 @hover:bg-[var(--color-hover)]">
<span class="i-mynaui-pencil text-4.5"></span> <span class="i-mynaui-refresh text-4.5 pointer-events-none"></span>
</button> </button>
<button @click="copyMessage" :class="{ 'text-emerald-500': copied }" <button v-if="message.role === 'user'" data-action="edit"
class="flex justify-center items-center w-7 h-6 @hover:bg-[var(--color-hover)]"> class="flex justify-center items-center w-7 h-6 @hover:bg-[var(--color-hover)]">
<span :class="copied ? 'i-mynaui-check text-emerald-500' : 'i-mynaui-copy text-4.5'"></span> <span class="i-mynaui-pencil text-4.5 pointer-events-none"></span>
</button>
<button data-action="copy" :class="{ 'text-emerald-500': copied }"
class="flex justify-center items-center w-7 h-6 @hover:bg-[var(--color-hover)]">
<span :class="copied ? 'i-mynaui-check text-emerald-500' : 'i-mynaui-copy text-4.5'" class="pointer-events-none"></span>
</button>
<button data-action="fork"
class="flex justify-center items-center w-7 h-6 @hover:bg-[var(--color-hover)]">
<span class="i-mynaui-git-branch text-4.5 pointer-events-none"></span>
</button>
<button data-action="delete" data-hotkey="ctrl,shift,backspace"
class="flex justify-center items-center w-7 h-6 text-red-500 @hover:bg-[var(--color-hover)]">
<span class="i-mynaui-trash text-5 pointer-events-none"></span>
</button> </button>
<Tooltip :hotkey="['ctrl', 'shift', 'backspace']">
<button @click="deleteMessage"
class="flex justify-center items-center w-7 h-6 text-red-500 @hover:bg-[var(--color-hover)]">
<span class="i-mynaui-trash text-5"></span>
</button>
</Tooltip>
</div> </div>
</div> </div>
</div> </div>
+30 -8
View File
@@ -1,12 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'; import { ref, computed, watch, nextTick } from 'vue';
import { useFloating, offset, flip, shift, autoUpdate, size, hide } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, size, hide } from '@floating-ui/vue';
import { DialogType } from '~/composables/useDialog';
import type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels'; import type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels';
import { sortByReleaseDate } from '~/utils/sort'; import { sortByReleaseDate } from '~/utils/sort';
import RowVirtualizerFixed from './RowVirtualizerFixed.vue'; import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
const { openDialog } = useDialog();
const { allModels } = await useModels(); const { allModels } = await useModels();
const { addShortcut } = useKeyboardShortcuts(); const { addShortcut } = useKeyboardShortcuts();
@@ -17,7 +15,16 @@ const props = defineProps<{
const selectedModel = defineModel<ModelWithProvider | null>(); const selectedModel = defineModel<ModelWithProvider | null>();
const openProviderSettings = async (providerId?: string) => {
closeDropdown();
await navigateTo(providerId ? `/settings/providers/${providerId}` : '/settings/providers');
};
const isOpen = ref(false); const isOpen = ref(false);
// nothing to pick from until a provider is set up, so the picker explains that
const noModels = computed(() => allModels.value.length === 0);
const dropdownButton = ref<HTMLElement | null>(null); const dropdownButton = ref<HTMLElement | null>(null);
const dropdownContent = ref<HTMLElement | null>(null); const dropdownContent = ref<HTMLElement | null>(null);
const searchInputRef = ref<HTMLInputElement | null>(null); const searchInputRef = ref<HTMLInputElement | null>(null);
@@ -217,11 +224,13 @@ onUnmounted(() => {
isOpen isOpen
? 'bg-[var(--color-hover)] text-[var(--text-primary)]' ? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)]', : 'text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)]',
noModels ? 'border border-dashed border-[var(--color-border-active)] !text-[var(--text-primary)]' : '',
]"> ]">
<ModelIcon v-if="selectedModel" class="text-white" :avatar="true" variant="color" <span v-if="noModels" class="i-mynaui-api text-4.5" aria-hidden="true"></span>
<ModelIcon v-if="selectedModel && !noModels" class="text-white" :avatar="true" variant="color"
:model-id="selectedModel.externalId" size="22" /> :model-id="selectedModel.externalId" size="22" />
<span class="max-w-full truncate"> <span class="max-w-full truncate">
{{ selectedModel ? selectedModel.name : 'Select a model' }} {{ noModels ? 'No models yet' : selectedModel ? selectedModel.name : 'Select a model' }}
</span> </span>
<span class="i-mynaui-chevron-down text-3.5 transition-transform duration-200" <span class="i-mynaui-chevron-down text-3.5 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"></span> :class="{ 'rotate-180': isOpen }"></span>
@@ -242,9 +251,21 @@ onUnmounted(() => {
? 'hidden' ? 'hidden'
: 'visible', : 'visible',
}" }"
class="fixed z-100 w-full max-w-[420px] min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden" :class="[transformOrigin, noModels ? 'max-w-80' : 'max-w-[420px]']"
:class="transformOrigin"> class="fixed z-100 w-full min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden">
<div v-if="noModels" class="flex flex-col gap-2.5 p-4">
<div class="font-semibold">No models yet</div>
<p class="text-sm leading-normal text-[var(--text-secondary)]">
Veridian uses your own API keys. Add a provider and its models show up here.
</p>
<button type="button" @click="openProviderSettings()"
class="self-start flex items-center h-8.5 px-3 text-sm font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)]">
Add a provider
</button>
</div>
<template v-else>
<div class="relative"> <div class="relative">
<span <span
class="i-mynaui-search absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--text-secondary)]"></span> class="i-mynaui-search absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--text-secondary)]"></span>
@@ -269,7 +290,7 @@ onUnmounted(() => {
class="px-4 py-1.5 text-[13px] h-10.5 items-end font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between"> class="px-4 py-1.5 text-[13px] h-10.5 items-end font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between">
{{ item.provider.name }} {{ item.provider.name }}
<button <button
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers', params: item.provider.id })" @click="openProviderSettings(item.provider.id)"
class="flex h-5 w-5 items-center justify-center @hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="flex h-5 w-5 items-center justify-center @hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-cog-four text-4"></span> <span class="i-mynaui-cog-four text-4"></span>
</button> </button>
@@ -295,12 +316,13 @@ onUnmounted(() => {
<div class="p-1 border-t border-[var(--color-border)]"> <div class="p-1 border-t border-[var(--color-border)]">
<button <button
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150" class="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers' });"> @click="openProviderSettings()">
<span class="i-mynaui-cog-four text-4.5"></span> <span class="i-mynaui-cog-four text-4.5"></span>
<span>Manage Providers</span> <span>Manage Providers</span>
<span class="i-mynaui-arrow-right text-4 ml-auto"></span> <span class="i-mynaui-arrow-right text-4 ml-auto"></span>
</button> </button>
</div> </div>
</template>
</div> </div>
</KeepAlive> </KeepAlive>
</Transition> </Transition>
+246
View File
@@ -0,0 +1,246 @@
<script setup lang="ts">
import type { Model } from '~/composables/useModels';
import { encryptData, uint8ArrayToBase64 } from '~/utils/crypto';
import { providerIcons } from '~/utils/model-mapping';
import { sortByReleaseDate } from '~/utils/sort';
// the providers offered up front, the rest sit behind "More…"
const FEATURED_PROVIDERS = ['openrouter', 'anthropic', 'openai', 'google', 'ollama'];
// these run on your own machine and are reached by address, not by key
const LOCAL_PROVIDERS = ['ollama', 'vllm'];
// A provider can list hundreds of models, so only the newest few are turned on
// to start with. The rest are a click away in the provider's settings.
const MODELS_ENABLED_AT_START = 8;
const PROVIDER_LABELS: Record<string, string> = {
openrouter: 'OpenRouter',
anthropic: 'Anthropic',
openai: 'OpenAI',
google: 'Google',
ollama: 'Ollama',
};
const KEY_PLACEHOLDERS: Record<string, string> = {
openrouter: 'sk-or-…',
anthropic: 'sk-ant-…',
openai: 'sk-…',
};
// Tint of the chosen option. UnoCSS can't put an opacity on a CSS variable, so
// "bg-[var(--color-accent)]/10" comes out as the solid accent colour.
const SELECTED_STYLE = {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 10%, transparent)',
};
defineProps<{
submitLabel: string;
// shown next to skip, for flows that have a step before this one
showBack?: boolean;
}>();
// done: a provider was connected or the step was skipped
const emit = defineEmits(['done', 'back']);
const { providers } = await useModels();
const showAll = ref(false);
const selectedType = ref(FEATURED_PROVIDERS.find(type => providers.value.some(p => p.type === type))
?? providers.value[0]?.type ?? '');
const featured = computed(() => FEATURED_PROVIDERS
.map(type => providers.value.find(p => p.type === type))
.filter(p => p !== undefined));
const others = computed(() => providers.value.filter(p => !FEATURED_PROVIDERS.includes(p.type)));
const shownProviders = computed(() => showAll.value ? [...featured.value, ...others.value] : featured.value);
const selected = computed(() => providers.value.find(p => p.type === selectedType.value));
const isLocal = computed(() => LOCAL_PROVIDERS.includes(selectedType.value));
const apiKey = ref('');
const apiKeyVisible = ref(false);
const baseUrl = ref('');
const busy = ref(false);
const error = ref('');
const ready = computed(() => isLocal.value ? baseUrl.value.trim() !== '' : apiKey.value.trim() !== '');
const selectProvider = (type: string) => {
selectedType.value = type;
error.value = '';
};
const submit = async () => {
const provider = selected.value;
if (busy.value || !ready.value || !provider) return;
busy.value = true;
error.value = '';
const storedKey = localStorage.getItem('encryptionKey');
if (storedKey === null) {
error.value = 'This browser has no encryption key for your account. Log out and back in, then add the provider under Settings.';
busy.value = false;
return;
}
const key = apiKey.value.trim();
const config = { ...provider.config };
try {
if (isLocal.value) {
config.apiProxyUrl = baseUrl.value.trim();
} else {
const cryptoKey = await crypto.subtle.importKey('jwk', JSON.parse(storedKey), 'AES-GCM', false, ['encrypt', 'decrypt']);
config.apiKey = uint8ArrayToBase64(await encryptData(cryptoKey, key));
}
const saved = await $fetch(`/api/provider/${provider.id}`, {
method: 'PATCH',
body: { config, enabled: true, expectedUpdatedAt: new Date(provider.updatedAt).toISOString() },
});
providers.value = providers.value.map(p => p.id === provider.id
? { ...p, config, enabled: true, updatedAt: new Date(saved.updatedAt) }
: p);
} catch (err) {
console.error('Failed to save provider:', err);
error.value = 'The provider could not be saved. Try again.';
busy.value = false;
return;
}
let models: Model[];
try {
const response = await $fetch<{ models: Model[] }>(`/api/provider/${provider.id}/models`, {
method: 'POST',
body: { providerApiKey: isLocal.value ? undefined : key },
});
models = response.models;
} catch (err) {
console.error('Failed to fetch models:', err);
error.value = isLocal.value
? 'No models could be fetched from that address. Check that it is right and reachable from the server.'
: 'No models could be fetched with that key. Check that it is right and try again.';
busy.value = false;
return;
}
const toEnable = models
.filter(m => m.outputModalities.includes('text'))
.sort(sortByReleaseDate)
.slice(0, MODELS_ENABLED_AT_START);
if (toEnable.length === 0) {
error.value = 'The provider answered, but has no chat models to offer. Try another provider.';
busy.value = false;
return;
}
// a model that fails to turn on here can still be turned on in settings
const results = await Promise.allSettled(toEnable.map(m => $fetch(`/api/model/${m.id}`, {
method: 'PATCH',
body: { enabled: true },
})));
const enabledIds = new Set(toEnable.filter((_, i) => results[i]!.status === 'fulfilled').map(m => m.id));
providers.value = providers.value.map(p => p.id === provider.id
? { ...p, config, enabled: true, models: models.map(m => ({ ...m, enabled: m.enabled || enabledIds.has(m.id) })) }
: p);
if (!models.some(model => model.outputModalities.includes("text") && (model.enabled || enabledIds.has(model.id)))) {
error.value = "The models were fetched, but none could be enabled. Try again.";
busy.value = false;
return;
}
emit('done');
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="submit">
<fieldset class="flex flex-col">
<legend
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Provider
</legend>
<div class="grid grid-cols-3 gap-2">
<button v-for="p in shownProviders" :key="p.id" type="button"
:aria-pressed="selectedType === p.type" :disabled="busy" @click="selectProvider(p.type)"
class="flex items-center justify-center gap-2 h-11 px-2 min-w-0 text-sm rounded-lg border disabled:cursor-not-allowed transition-colors duration-200"
:style="selectedType === p.type ? SELECTED_STYLE : undefined" :class="selectedType === p.type
? 'border-[var(--color-accent)] font-medium'
: 'border-[var(--color-border)] enabled:@hover:bg-[var(--color-hover)]'">
<component v-if="providerIcons[p.type]" :is="providerIcons[p.type]" :color="true"
class="w-5.5 h-5.5 flex-shrink-0 text-[var(--text-primary)]" />
<span class="case-capital overflow-hidden text-ellipsis whitespace-nowrap">
{{ PROVIDER_LABELS[p.type] ?? p.name }}
</span>
</button>
<button v-if="!showAll && others.length > 0" type="button" :disabled="busy"
@click="showAll = true"
class="flex items-center justify-center h-11 text-sm rounded-lg border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:cursor-not-allowed transition-colors duration-200">
More…
</button>
</div>
</fieldset>
<div v-if="isLocal" class="flex flex-col gap-1.5">
<label for="baseurl"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
Server address
</label>
<input id="baseurl" v-model="baseUrl" type="url" :disabled="busy" autocomplete="off"
spellcheck="false" :placeholder="selected?.defaultBaseUrl || 'http://localhost:11434'"
class="text-sm border border-[var(--color-border)]" />
</div>
<div v-else class="flex flex-col gap-1.5">
<label for="apikey"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
API key
</label>
<div
class="flex items-center rounded-md bg-[var(--bg-container)] border border-[var(--color-border)]">
<input id="apikey" v-model="apiKey" :type="apiKeyVisible ? 'text' : 'password'"
:disabled="busy" autocomplete="off" spellcheck="false"
:placeholder="KEY_PLACEHOLDERS[selectedType] ?? 'API key'"
class="flex-1 min-w-0 text-sm !bg-transparent" />
<button type="button" :aria-label="apiKeyVisible ? 'Hide API key' : 'Show API key'"
@click="apiKeyVisible = !apiKeyVisible"
class="flex items-center justify-center w-10 h-10 flex-shrink-0 text-[var(--text-secondary)]">
<span class="text-4.5" :class="apiKeyVisible ? 'i-mynaui-eye' : 'i-mynaui-eye-slash'"></span>
</button>
</div>
</div>
<p v-if="!isLocal" class="flex gap-2 text-xs leading-normal text-[var(--text-secondary)]">
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
<span>
Encrypted in this browser with AES-GCM before it is stored. Nobody else on this instance,
including admins, can read it.
</span>
</p>
<slot name="note"></slot>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<div class="flex gap-2">
<button v-if="showBack" type="button" :disabled="busy" @click="emit('back')"
class="flex items-center justify-center h-11 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
Back
</button>
<button type="button" :disabled="busy" @click="emit('done')"
class="flex items-center justify-center h-11 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
Skip for now
</button>
<button type="submit" :disabled="busy || !ready"
class="accent flex-1 h-11 flex items-center justify-center font-semibold disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
<span v-else>{{ submitLabel }}</span>
</button>
</div>
</form>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
const STEPS = ['Admin account', 'Instance', 'Provider'];
defineProps<{
// 1 based, the steps before it show as done
current: number;
}>();
</script>
<template>
<ol class="flex items-center gap-2 text-sm">
<template v-for="(step, index) in STEPS" :key="step">
<li v-if="index > 0" aria-hidden="true" class="flex-1 h-px bg-[var(--color-border)] [list-style-type:none]">
</li>
<li class="flex items-center gap-2 whitespace-nowrap [list-style-type:none]"
:aria-current="index + 1 === current ? 'step' : undefined"
:class="index + 1 === current ? 'font-medium text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
<span class="flex items-center justify-center w-6 h-6 rounded-md text-xs" :class="index + 1 <= current
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] font-semibold'
: 'border border-[var(--color-border-active)]'">
<span v-if="index + 1 < current" class="i-mynaui-check text-3.5" aria-label="Done"></span>
<template v-else>{{ index + 1 }}</template>
</span>
{{ step }}
</li>
</template>
</ol>
</template>
+4 -83
View File
@@ -1,39 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { DialogType } from '~/composables/useDialog'; defineProps<{ enabled: boolean }>();
const { openDialog } = await useDialog();
const props = defineProps<{
enabled: boolean;
maxResults: number;
rerank: boolean;
}>();
const emit = defineEmits<{ const emit = defineEmits<{
'update:enabled': [value: boolean]; 'update:enabled': [value: boolean];
'update:maxResults': [value: number];
'update:rerank': [value: boolean];
}>(); }>();
const adjustMaxResults = (delta: number) => {
const next = Math.min(50, Math.max(1, props.maxResults + delta));
emit('update:maxResults', next);
};
const { settings } = await useUserSettings();
const systemAssistantsRerank = computed(() => {
return settings.value.systemAssistants?.rerank ?? null;
});
const isRerankConfigured = computed(() => {
const sa = systemAssistantsRerank.value;
return sa?.enabled === true && sa?.modelId != null && sa.modelId !== '';
});
const openSettings = () => {
openDialog(DialogType.Settings, undefined, { page: 'systemAssistants' });
};
</script> </script>
<template> <template>
@@ -46,10 +16,8 @@ const openSettings = () => {
</button> </button>
</template> </template>
<template #dropdown="{ close }"> <template #dropdown>
<div class="flex p-1 gap-4"> <div class="flex flex-col gap-1 p-1">
<!-- Left: mode toggle group -->
<div class="flex flex-col gap-1">
<button @click="emit('update:enabled', false)" <button @click="emit('update:enabled', false)"
class="flex items-start gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-150" class="flex items-start gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-150"
:class="!enabled :class="!enabled
@@ -83,57 +51,10 @@ const openSettings = () => {
</span> </span>
<span class="text-xs leading-snug" <span class="text-xs leading-snug"
:class="enabled ? 'text-[var(--text-secondary)]' : 'text-[var(--text-dim)]'"> :class="enabled ? 'text-[var(--text-secondary)]' : 'text-[var(--text-dim)]'">
Search the web automatically when needed Search the web and fetch URLs when needed
</span> </span>
</div> </div>
</button> </button>
</div>
<!-- Right: settings (only when enabled) -->
<div v-if="enabled" class="flex flex-col gap-3 border-l border-[var(--color-border)] pl-4">
<!-- Max results stepper -->
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
Max results
</span>
<div class="flex items-center gap-2">
<button @click="adjustMaxResults(-1)"
class="h-7 w-7 flex items-center justify-center rounded-lg @hover:bg-[var(--color-hover)] text-[var(--text-secondary)] @hover:text-[var(--text-primary)] transition-colors duration-150">
<span class="text-sm i-mynaui-minus"></span>
</button>
<span class="w-8 text-center text-sm font-medium tabular-nums text-[var(--text-primary)]">
{{ maxResults }}
</span>
<button @click="adjustMaxResults(1)"
class="h-7 w-7 flex items-center justify-center rounded-lg @hover:bg-[var(--color-hover)] text-[var(--text-secondary)] @hover:text-[var(--text-primary)] transition-colors duration-150">
<span class="text-sm i-mynaui-plus-solid"></span>
</button>
</div>
</div>
<!-- Rerank toggle -->
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-[var(--color-tertiary)] uppercase tracking-wider">
Rerank
</span>
<div v-if="!isRerankConfigured"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)]">
<button @click="() => { openSettings(); close() }"
class="flex items-center gap-2 text-xs text-[var(--text-dim)] hover:text-[var(--text-primary)] transition-colors duration-150">
<span class="i-mynaui-sparkles text-3.5"></span>
<span>Setup reranking</span>
<span class="i-mynaui-arrow-right text-3 ml-auto"></span>
</button>
</div>
<label v-else
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] cursor-pointer @hover:border-[var(--color-hover)] transition-colors duration-150">
<input type="checkbox" :checked="props.rerank"
@change="emit('update:rerank', ($event.target as HTMLInputElement).checked)"
class="w-4 h-4 rounded border-[var(--color-border)] bg-transparent text-[var(--color-accent)] focus:ring-[var(--color-accent)] focus:ring-offset-0 cursor-pointer" />
<span class="text-xs text-[var(--text-secondary)]">Rerank results</span>
</label>
</div>
</div>
</div> </div>
</template> </template>
</Dropdown> </Dropdown>
@@ -1,11 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
params?: string;
}>();
defineEmits(['navigate']);
</script>
<template>
hi
</template>
@@ -1,47 +0,0 @@
<script setup lang="ts">
import { providerIcons } from '~/utils/model-mapping';
defineProps<{
params?: string;
}>();
const { providers } = await useModels();
defineEmits(['navigate']);
</script>
<template>
<div class="flex flex-col gap-1 overflow-auto">
<button @click="$emit('navigate', 'general')"
class="flex items-center gap-2 p-2 rounded-lg @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-chevron-left md:text-4"></span> Back to General
</button>
<button @click="$emit('navigate', 'providers')"
class="flex items-center gap-2 p-2 rounded-lg @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-envelope-open md:text-4"></span> All
</button>
<div class="px-2 py-4 font-bold md:text-xs text-sm uppercase opacity-50">Enabled Providers</div>
<button v-for="p in providers?.filter(p => p.enabled)" :key="p.id" @click="$emit('navigate', 'providers', p.id)"
:class="['case-capital flex items-center justify-between p-2 @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
<div class="flex items-center gap-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
class="h-6 w-6 md:w-4 md:h-4 text-[var(--text-primary)]" />
<span>{{ p.name }}</span>
</div>
</button>
<div class="px-2 py-4 font-bold md:text-xs text-sm uppercase opacity-50">Disabled Providers</div>
<button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
@click="$emit('navigate', 'providers', p.id)"
:class="['case-capital flex items-center justify-between p-2 @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
<div class="flex items-center gap-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
class="h-6 w-6 md:w-4 md:h-4 h-full text-[var(--text-primary)]" />
<span>{{ p.name }}</span>
</div>
</button>
</div>
</template>
+8 -10
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { DialogType } from '~/composables/useDialog';
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
// to prevent the user details from going blank for a // to prevent the user details from going blank for a
@@ -16,10 +14,14 @@ watch(user, () => {
cachedUser.value = user.value; cachedUser.value = user.value;
}) })
const { openDialog } = useDialog();
const { isHovered } = useSidenavContext(); const { isHovered } = useSidenavContext();
const openSettings = async () => {
dropdownOpen.value = false;
await navigateTo('/settings');
};
const handleLogout = async () => { const handleLogout = async () => {
await signOut(); await signOut();
@@ -31,11 +33,7 @@ const handleLogout = async () => {
<header class="flex items-center justify-between overflow-hidden"> <header class="flex items-center justify-between overflow-hidden">
<button @click="dropdownOpen = !dropdownOpen" <button @click="dropdownOpen = !dropdownOpen"
class="flex gap-1 pr-1 items-center @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="flex gap-1 pr-1 items-center @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<div <UserAvatar :user="cachedUser" class="w-9 h-9 md:w-7 md:h-7 rounded-lg md:text-sm" />
:class="['w-9 h-9 md:w-7 md:h-7 flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-border)]']">
<img v-if="cachedUser?.image" :src="cachedUser.image" class="w-full h-full object-cover" />
<span v-else class="w-5.5 h-5.5 md:h-4 md:w-4 i-mynaui-user text-[var(--text-secondary)]"></span>
</div>
<span <span
class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap"> class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
{{ cachedUser?.name }} {{ cachedUser?.name }}
@@ -55,7 +53,7 @@ const handleLogout = async () => {
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="dropdownOpen" v-click-outside="() => dropdownOpen = false" <div v-if="dropdownOpen" v-click-outside="() => dropdownOpen = false"
class="w-full top-full text-sm mt-1 absolute z-30 bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 flex flex-col gap-2"> class="w-full top-full text-sm mt-1 absolute z-30 bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 flex flex-col gap-2">
<button @click="dropdownOpen = false; openDialog(DialogType.Settings)" <button @click="openSettings"
class="text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"> class="text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-cog-four text-4.5"></span> <span class="i-mynaui-cog-four text-4.5"></span>
<span>Settings</span> <span>Settings</span>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
const returnTo = useState('settings:returnTo', () => '/');
</script>
<template>
<header class="flex items-center rounded-lg overflow-hidden flex-1 min-w-0">
<NuxtLink :to="returnTo" aria-label="Back"
class="flex items-center gap-1 p-1.5 pr-2.5 @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg decoration-none transition-inherit">
<span class="i-mynaui-chevron-left text-5.5 md:text-4.5 text-[var(--text-secondary)]"></span>
<span
class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
Settings
</span>
</NuxtLink>
</header>
</template>
+136 -11
View File
@@ -1,11 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue'; import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
import { DialogType } from '~/composables/useDialog';
import { exportTopicToJson, exportTopicToMarkdown } from '~~/utils/export';
const { openDialog } = useDialog();
const dropdownOpen = ref(false); const dropdownOpen = ref(false);
const dropdownTrigger = ref<HTMLElement | null>(null); const dropdownTrigger = ref<HTMLElement | null>(null);
const dropdownContent = ref(null); const dropdownContent = ref(null);
const activeMenuTopicId = ref<string | null>(null); const activeMenuTopicId = ref<string | null>(null);
const exportSubmenuOpen = ref(false);
const exportTrigger = ref<HTMLElement | null>(null);
const exportContent = ref(null);
const exportInProgress = ref(false);
const route = useRoute(); const route = useRoute();
const { getAgent, patchTopicLocally, deleteTopic: deleteAgentTopic } = await useAgents(); const { getAgent, patchTopicLocally, deleteTopic: deleteAgentTopic } = await useAgents();
@@ -32,16 +39,51 @@ const { floatingStyles, placement, middlewareData } = useFloating(dropdownTrigge
transform: false, transform: false,
}); });
const {
floatingStyles: exportFloatingStyles,
placement: exportPlacement,
middlewareData: exportMiddlewareData,
} = useFloating(exportTrigger, exportContent, {
placement: 'right-start',
whileElementsMounted: autoUpdate,
middleware: [offset(6), flip({ fallbackPlacements: ['left-start', 'right-end', 'left-end'] }), shift({ padding: 10 }), hide()],
transform: false,
});
const transformOrigin = computed(() => const transformOrigin = computed(() =>
placement.value.startsWith('top') placement.value.startsWith('top')
? 'transform-origin-bottom-center' ? 'transform-origin-bottom-center'
: 'transform-origin-top-center' : 'transform-origin-top-center'
); );
const exportTransformOrigin = computed(() => {
switch (exportPlacement.value.split('-')[0]) {
case 'left':
return 'transform-origin-right-center';
case 'right':
return 'transform-origin-left-center';
case 'top':
return 'transform-origin-bottom-center';
case 'bottom':
return 'transform-origin-top-center';
default:
return 'transform-origin-left-center';
}
});
const closeExportSubmenu = () => {
exportSubmenuOpen.value = false;
};
const closeDropdown = () => { const closeDropdown = () => {
dropdownOpen.value = false; dropdownOpen.value = false;
activeMenuTopicId.value = null; activeMenuTopicId.value = null;
dropdownTrigger.value = null; dropdownTrigger.value = null;
closeExportSubmenu();
};
const openExportSubmenu = () => {
exportSubmenuOpen.value = true;
}; };
// Computed to get the topic data for the currently open menu // Computed to get the topic data for the currently open menu
@@ -49,6 +91,35 @@ const menuTopic = computed(() =>
topics.value.find(t => t.id === activeMenuTopicId.value) topics.value.find(t => t.id === activeMenuTopicId.value)
); );
const handleExport = async (format: 'json' | 'markdown') => {
if (!menuTopic.value || exportInProgress.value) return;
exportInProgress.value = true;
try {
if (format === 'json') {
await exportTopicToJson(menuTopic.value.id);
} else {
await exportTopicToMarkdown(menuTopic.value.id);
}
} finally {
exportInProgress.value = false;
closeDropdown();
}
};
const handleDropdownClickOutside = (event?: Event) => {
const target = event?.target as Node | null;
if (target) {
if (exportContent.value && (exportContent.value as HTMLElement).contains(target)) {
return;
}
if (exportTrigger.value && exportTrigger.value.contains(target)) {
return;
}
}
closeDropdown();
};
const topicsOpen = ref(true); const topicsOpen = ref(true);
const autoRenameTopic = async (topicId: string) => { const autoRenameTopic = async (topicId: string) => {
@@ -56,10 +127,12 @@ const autoRenameTopic = async (topicId: string) => {
} }
const cancelAutoRename = async (topicId: string) => { const cancelAutoRename = async (topicId: string) => {
// TODO: make this more optimistic const result = await $fetch(`/api/topic/${topicId}/auto-rename/cancel`, {
await $fetch(`/api/topic/${topicId}/auto-rename/cancel`, { method: "POST",
method: 'POST',
}); });
if (result.ok) {
patchTopicLocally(topicId, { renaming: false });
}
} }
const renameTopicId = ref<string | null>(null); const renameTopicId = ref<string | null>(null);
@@ -133,6 +206,7 @@ const handleNavClick = (e: MouseEvent) => {
activeMenuTopicId.value = topicId; activeMenuTopicId.value = topicId;
dropdownTrigger.value = trigger; dropdownTrigger.value = trigger;
dropdownOpen.value = true; dropdownOpen.value = true;
closeExportSubmenu();
break; break;
} }
} }
@@ -151,6 +225,17 @@ onMounted(() => {
<SidenavItem :to="`/agent/${agentId}/profile`" name="Agent Info" icon="i-mynaui-info-square" <SidenavItem :to="`/agent/${agentId}/profile`" name="Agent Info" icon="i-mynaui-info-square"
:active="route.path.endsWith('/profile')" /> :active="route.path.endsWith('/profile')" />
<SidenavItem @click="openDialog(DialogType.QuickSwitcher)" name="Search" icon="i-mynaui-search">
<div class="flex items-center gap-1">
<span class="flex bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
<kbd class="font-mono text-[10px] case-capital">ctrl</kbd>
</span>
<span class="flex bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
<kbd class="font-mono text-[10px] case-capital">k</kbd>
</span>
</div>
</SidenavItem>
<!-- Topics Section --> <!-- Topics Section -->
<button @click="topicsOpen = !topicsOpen" <button @click="topicsOpen = !topicsOpen"
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors w-full text-left"> class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors w-full text-left">
@@ -173,12 +258,13 @@ onMounted(() => {
v-model="newTopicName" @keydown.enter="saveRename" @keydown.escape="cancelRename" v-model="newTopicName" @keydown.enter="saveRename" @keydown.escape="cancelRename"
@blur="saveRename" @blur="saveRename"
class="flex-1 bg-transparent border-none outline-none md:text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" /> class="flex-1 bg-transparent border-none outline-none md:text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" />
<div v-else-if="topic.renaming" class="flex w-full"> <div v-else class="flex items-center gap-2 min-w-0">
<span class="i-svg-spinners-3-dots-fade text-7 md:text-6"></span> <span class="md:text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
{{ topic.name }}
</span>
<span v-if="topic.renaming" aria-label="Renaming"
class="i-svg-spinners-3-dots-fade shrink-0 text-4"></span>
</div> </div>
<span v-else class="md:text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
{{ topic.name }}
</span>
<div data-action="toggle-dropdown" <div data-action="toggle-dropdown"
class="text-[var(--text-secondary)] shrink-0 w-0 max-w-0 opacity-0 overflow-hidden p-1 max-md:w-auto max-md:max-w-none max-md:opacity-100 flex items-center justify-center rounded-md @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]" class="text-[var(--text-secondary)] shrink-0 w-0 max-w-0 opacity-0 overflow-hidden p-1 max-md:w-auto max-md:max-w-none max-md:opacity-100 flex items-center justify-center rounded-md @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]"
@@ -198,36 +284,49 @@ onMounted(() => {
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100 translate-y-0" leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-1"> leave-to-class="opacity-0 scale-95 translate-y-1">
<div v-if="dropdownOpen" ref="dropdownContent" :style="{ <div v-if="dropdownOpen" ref="dropdownContent" v-click-outside="handleDropdownClickOutside" :style="{
...floatingStyles, ...floatingStyles,
visibility: middlewareData.hide?.referenceHidden visibility: middlewareData.hide?.referenceHidden
? 'hidden' ? 'hidden'
: 'visible', : 'visible',
}" class="fixed z-20" :class="transformOrigin"> }" class="fixed z-20" :class="transformOrigin">
<div v-click-outside="closeDropdown" <div
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-40"> class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-40">
<!-- Dynamic content based on menuTopic --> <!-- Dynamic content based on menuTopic -->
<template v-if="menuTopic"> <template v-if="menuTopic">
<button v-if="menuTopic.renaming" @click="cancelAutoRename(menuTopic.id); closeDropdown()" <button v-if="menuTopic.renaming" @click="cancelAutoRename(menuTopic.id); closeDropdown()"
@mouseenter="closeExportSubmenu"
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50"> class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
Cancel Auto Rename Cancel Auto Rename
</button> </button>
<button v-else @click="autoRenameTopic(menuTopic.id); closeDropdown()" <button v-else @click="autoRenameTopic(menuTopic.id); closeDropdown()"
@mouseenter="closeExportSubmenu"
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50"> class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
Auto Rename Auto Rename
</button> </button>
<button :disabled="menuTopic.renaming ?? false" <button :disabled="menuTopic.renaming ?? false"
@click="startRename(menuTopic.id, menuTopic.name); closeDropdown()" @click="startRename(menuTopic.id, menuTopic.name); closeDropdown()"
@mouseenter="closeExportSubmenu"
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50"> class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
Rename Rename
</button> </button>
<button ref="exportTrigger" @click.stop="exportSubmenuOpen = !exportSubmenuOpen"
@mouseenter="openExportSubmenu"
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 flex items-center justify-between gap-3"
:class="{ 'bg-[var(--color-hover)]': exportSubmenuOpen }"
:disabled="exportInProgress">
<span>Export</span>
<span class="i-mynaui-chevron-right text-4 shrink-0"
:class="exportPlacement.startsWith('left') ? 'rotate-180' : ''"></span>
</button>
<div class="h-px bg-[var(--color-border)] my-1" /> <div class="h-px bg-[var(--color-border)] my-1" />
<button @click="deleteTopic(menuTopic.id); closeDropdown()" <button @click="deleteTopic(menuTopic.id); closeDropdown()"
@mouseenter="closeExportSubmenu"
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 text-red-500"> class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 text-red-500">
Delete Delete
</button> </button>
@@ -235,6 +334,32 @@ onMounted(() => {
</div> </div>
</div> </div>
</Transition> </Transition>
<Transition
enter-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
<div v-if="dropdownOpen && exportSubmenuOpen" ref="exportContent" :style="{
...exportFloatingStyles,
visibility: exportMiddlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}" class="fixed z-30" :class="exportTransformOrigin"
@mouseenter="openExportSubmenu">
<div
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-44">
<button :disabled="exportInProgress" @click="handleExport('json')"
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 whitespace-nowrap">
Export to JSON
</button>
<button :disabled="exportInProgress" @click="handleExport('markdown')"
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 whitespace-nowrap">
Export to Markdown
</button>
</div>
</div>
</Transition>
</Teleport> </Teleport>
</nav> </nav>
</template> </template>
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { providerIcons } from '~/utils/model-mapping';
const route = useRoute();
const { providers } = await useModels();
const { isAdmin } = useAuth();
const ADMIN_GROUP = {
label: 'Instance',
adminOnly: true,
items: [
{ to: '/settings/instance/users', name: 'Users', icon: 'i-mynaui-users' },
{ to: '/settings/instance', name: 'Instance', icon: 'i-tabler-server' },
],
};
const BASE_GROUPS = [
{
label: 'Account',
adminOnly: false,
items: [
{ to: '/settings/profile', name: 'Profile', icon: 'i-mynaui-user' },
{ to: '/settings/security', name: 'Security', icon: 'i-mynaui-shield' },
],
},
{
label: 'App',
adminOnly: false,
items: [
{ to: '/settings/general', name: 'General', icon: 'i-mynaui-cog-four' },
{ to: '/settings/appearance', name: 'Appearance', icon: 'i-tabler-palette' },
{ to: '/settings/providers', name: 'AI Providers', icon: 'i-mynaui-api' },
{ to: '/settings/assistants', name: 'System Assistants', icon: 'i-mynaui-sparkles' },
],
},
];
const groups = computed(() => isAdmin.value ? [...BASE_GROUPS, ADMIN_GROUP] : BASE_GROUPS);
const inProviders = computed(() => route.path.startsWith('/settings/providers'));
// enabled providers first, same order the provider list uses
const sortedProviders = computed(() => [
...providers.value.filter(p => p.enabled),
...providers.value.filter(p => !p.enabled),
]);
</script>
<template>
<nav aria-label="Settings" class="flex flex-col gap-1 overflow-auto">
<template v-for="group in groups" :key="group.label">
<div
class="flex items-center justify-between px-2 pt-3 pb-0.5 text-sm md:text-xs font-medium uppercase tracking-wider">
{{ group.label }}
<span v-if="group.adminOnly"
class="px-1.5 py-0.25 rounded border border-[var(--color-border-active)] text-[10px] tracking-wide">
Admin only
</span>
</div>
<template v-for="item in group.items" :key="item.to">
<SidenavItem :to="item.to" :name="item.name" :icon="item.icon" :active="route.path === item.to" />
<!-- the providers are listed under their section while it is open -->
<div v-if="item.to === '/settings/providers' && inProviders" class="flex flex-col gap-1 pl-4">
<NuxtLink v-for="p in sortedProviders" :key="p.id" :to="`/settings/providers/${p.id}`" :class="[
'case-capital decoration-none flex items-center gap-2 shrink-0 px-2 h-9 rounded-lg transition-colors',
route.path === `/settings/providers/${p.id}`
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
: 'text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]',
p.enabled ? '' : 'opacity-50',
]">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
class="h-6 w-6 md:w-4 md:h-4 flex-shrink-0 text-[var(--text-primary)]" />
<span class="md:text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
{{ p.name }}
</span>
</NuxtLink>
</div>
</template>
</template>
</nav>
</template>
+17 -7
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { DialogType } from '~/composables/useDialog';
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar(); const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar();
const route = useRoute(); const route = useRoute();
@@ -37,8 +35,6 @@ const onFocusOut = (e: FocusEvent) => {
} }
}; };
const { openDialog } = useDialog();
const onResizeStart = (event: MouseEvent) => { const onResizeStart = (event: MouseEvent) => {
isResizing.value = true; isResizing.value = true;
startX.value = event.clientX; startX.value = event.clientX;
@@ -97,8 +93,18 @@ const navKind = computed(() => {
if (route.path === '/') return 'home'; if (route.path === '/') return 'home';
if (route.path.startsWith('/agent/')) return 'agent'; if (route.path.startsWith('/agent/')) return 'agent';
if (route.path === '/resources') return 'resources'; if (route.path === '/resources') return 'resources';
if (route.path.startsWith('/settings')) return 'settings';
return null; return null;
}); });
// remember where the user was so the settings back button can return there
const settingsReturnTo = useState('settings:returnTo', () => '/');
watch(() => route.fullPath, (path) => {
if (!path.startsWith('/settings')) {
settingsReturnTo.value = path;
}
}, { immediate: true });
</script> </script>
<template> <template>
@@ -126,6 +132,7 @@ const navKind = computed(() => {
<SidenavHeader v-if="navKind === 'home'" /> <SidenavHeader v-if="navKind === 'home'" />
<SidenavHeaderResources v-else-if="navKind === 'resources'" /> <SidenavHeaderResources v-else-if="navKind === 'resources'" />
<SidenavHeaderAgent v-else-if="navKind === 'agent'" /> <SidenavHeaderAgent v-else-if="navKind === 'agent'" />
<SidenavHeaderSettings v-else-if="navKind === 'settings'" />
<div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5"> <div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
<div :class="[ <div :class="[
@@ -158,15 +165,18 @@ const navKind = computed(() => {
<SidenavNavHome v-if="navKind === 'home'" /> <SidenavNavHome v-if="navKind === 'home'" />
<SidenavNavAgent v-else-if="navKind === 'agent'" /> <SidenavNavAgent v-else-if="navKind === 'agent'" />
<SidenavNavResources v-else-if="navKind === 'resources'" /> <SidenavNavResources v-else-if="navKind === 'resources'" />
<SidenavNavSettings v-else-if="navKind === 'settings'" />
</div> </div>
<div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2"> <div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2">
<div class="flex"> <div class="flex">
<Tooltip :hotkey="['ctrl', ',']"> <Tooltip :hotkey="['ctrl', ',']">
<button @click="openDialog(DialogType.Settings)" <NuxtLink to="/settings" aria-label="Settings" :class="[
class="flex items-center justify-center h-9 w-9 md:h-7 md:w-7 cursor-pointer @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors text-[var(--text-secondary)] active:text-[var(--text-primary)]"> 'flex items-center justify-center h-9 w-9 md:h-7 md:w-7 cursor-pointer @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors active:text-[var(--text-primary)]',
navKind === 'settings' ? 'text-[var(--text-primary)] bg-[var(--color-hover)]' : 'text-[var(--text-secondary)]',
]">
<span class="i-mynaui-cog-four text-6 md:text-5"></span> <span class="i-mynaui-cog-four text-6 md:text-5"></span>
</button> </NuxtLink>
</Tooltip> </Tooltip>
</div> </div>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
const props = defineProps<{
python: boolean;
}>();
const emit = defineEmits<{
'update:python': [value: boolean];
}>();
const anyEnabled = computed(() => props.python);
</script>
<template>
<Dropdown dropdownClass="text-sm" placement="top">
<template #default="{ toggle, setRef }">
<button :ref="setRef" @click="toggle"
class="flex items-center justify-center h-8.5 w-8.5 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="pointer-events-none i-mynaui-archive text-5 transition-colors duration-200"
:class="anyEnabled ? 'text-[var(--color-accent)]' : 'text-[var(--text-secondary)]'"></span>
</button>
</template>
<template #dropdown>
<div class="flex flex-col gap-1 p-1 min-w-48">
<label
class="flex items-start gap-3 rounded-xl px-3 py-2.5 text-left cursor-pointer transition-colors duration-150 @hover:bg-[var(--color-hover)]"
:class="python ? 'bg-[var(--color-active)]' : ''">
<input type="checkbox" :checked="python"
@change="emit('update:python', ($event.target as HTMLInputElement).checked)"
class="mt-0.5 w-4 h-4 rounded border-[var(--color-border)] bg-transparent text-[var(--color-accent)] focus:ring-[var(--color-accent)] focus:ring-offset-0 cursor-pointer shrink-0" />
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium"
:class="python ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
Python
</span>
<span class="text-xs leading-snug"
:class="python ? 'text-[var(--text-secondary)]' : 'text-[var(--text-dim)]'">
Run code in a sandbox
</span>
</div>
</label>
</div>
</template>
</Dropdown>
</template>
+10 -7
View File
@@ -1,20 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ const props = defineProps<{
hotkey?: string[]; hotkey?: string[];
text?: string;
}>(); }>();
const { show, hide } = useTooltip(); const { show, hide } = useTooltip();
const triggerRef = ref<HTMLElement | null>(null); const triggerRef = ref<HTMLElement | null>(null);
const hasCursor = ref(false); let hasCursor: boolean | null = null;
const checkHasCursor = () => {
onMounted(() => { if (hasCursor === null && typeof window !== 'undefined') {
hasCursor.value = window.matchMedia('(hover: hover) and (pointer: fine)').matches; hasCursor = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
}); }
return hasCursor ?? false;
};
const onMouseEnter = () => { const onMouseEnter = () => {
if (triggerRef.value && hasCursor.value) { if (triggerRef.value && checkHasCursor()) {
show(triggerRef.value, props.hotkey); show(triggerRef.value, props.hotkey, props.text);
} }
}; };
</script> </script>
+3 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
const { activeElement, activeHotkey, isVisible } = useTooltip(); const { activeElement, activeHotkey, activeText, isVisible } = useTooltip();
const tooltipRef = ref<HTMLElement | null>(null); const tooltipRef = ref<HTMLElement | null>(null);
@@ -61,7 +61,8 @@ const transformOrigin = computed(() => placement.value.includes('top') ? 'transf
} }
]" :class="transformOrigin" ]" :class="transformOrigin"
class="pointer-events-none fixed z-35 flex flex-col bg-[var(--bg-surface)] rounded-lg shadow-xl px-1.5 py-1 text-xs"> class="pointer-events-none fixed z-35 flex flex-col bg-[var(--bg-surface)] rounded-lg shadow-xl px-1.5 py-1 text-xs">
<div class="flex gap-1 items-center"> <span v-if="activeText" class="max-w-xs whitespace-normal">{{ activeText }}</span>
<div v-if="displayHotkey?.length" class="flex gap-1 items-center">
<span v-for="key in displayHotkey" :key="key" <span v-for="key in displayHotkey" :key="key"
class="bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]"> class="bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
<kbd class="font-mono text-[10px] case-capital">{{ key }}</kbd> <kbd class="font-mono text-[10px] case-capital">{{ key }}</kbd>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { avatarColor, avatarInitial } from '~/utils/avatar';
const props = defineProps<{
user: {
id: string;
name?: string | null;
image?: string | null;
} | null | undefined;
}>();
// fall back to the letter placeholder if the image fails to load
const imageFailed = ref(false);
watch(() => props.user?.image, () => {
imageFailed.value = false;
});
const showImage = computed(() => !!props.user?.image && !imageFailed.value);
</script>
<template>
<div class="flex-shrink-0 overflow-hidden flex items-center justify-center font-semibold text-white select-none"
:style="showImage ? {} : { backgroundColor: avatarColor(user?.id ?? '') }">
<img v-if="showImage" :src="user!.image!" alt="" class="w-full h-full object-cover"
@error="imageFailed = true" />
<span v-else aria-hidden="true">{{ avatarInitial(user?.name) }}</span>
</div>
</template>
+39 -2
View File
@@ -1,4 +1,6 @@
import type { BaseMessage } from "~/composables/useChat";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { provisionalTopicTitle } from "~~/utils/topic-title";
import { attempt } from "~~/types/result"; import { attempt } from "~~/types/result";
import * as schema from '~~/drizzle/schema'; import * as schema from '~~/drizzle/schema';
@@ -153,11 +155,11 @@ export const useAgents = async () => {
// TODO: since topics are contained within each agent struct, all topic // TODO: since topics are contained within each agent struct, all topic
// actions must be done through the agents composable // actions must be done through the agents composable
const createTopic = async (agentId: string) => { const createTopic = async (agentId: string, message?: BaseMessage) => {
const topicId = nanoid(); const topicId = nanoid();
const topic = { const topic = {
id: topicId, id: topicId,
name: 'New Topic', name: provisionalTopicTitle(message),
agentId, agentId,
}; };
@@ -198,11 +200,46 @@ export const useAgents = async () => {
}); });
} }
const forkTopic = async (topicId: string, messageId: string): Promise<string | null> => {
try {
const result = await $fetch<{ ok: boolean; topicId: string; name: string }>(`/api/topic/${topicId}/fork`, {
method: 'POST',
body: { messageId },
});
if (result.ok) {
const agent = agents.value.find(a => a.topics.some(t => t.id === topicId));
if (agent) {
const newTopic: Topic = {
id: result.topicId,
name: result.name,
agentId: agent.id,
userId: agent.userId,
renaming: false,
renameAttemptId: null,
renameExpiresAt: null,
titleSource: "manual",
createdAt: new Date(),
};
agents.value = agents.value.map(a =>
a.id === agent.id ? { ...a, topics: [newTopic, ...a.topics] } as AgentWithTopics : a
);
}
return result.topicId;
}
return null;
} catch (error) {
console.error('Failed to fork topic:', error);
return null;
}
};
return { return {
agents, agents,
refresh, refresh,
createAgent, createAgent,
createTopic, createTopic,
forkTopic,
getAgent, getAgent,
patchAgentLocally, patchAgentLocally,
patchTopicLocally, patchTopicLocally,
+93 -10
View File
@@ -1,4 +1,5 @@
import type { BetterAuthClientOptions, InferSessionFromClient, InferUserFromClient, User } from 'better-auth/client'; import type { BetterAuthClientOptions, InferSessionFromClient, InferUserFromClient, User } from 'better-auth/client';
import { adminClient, twoFactorClient } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/vue'; import { createAuthClient } from 'better-auth/vue';
import { Err, Ok, type Result } from '~~/types/result'; import { Err, Ok, type Result } from '~~/types/result';
import { assert } from '~~/utils/assert'; import { assert } from '~~/utils/assert';
@@ -8,19 +9,34 @@ export enum AuthError {
SignInFailed = 'SIGN_IN_FAILED', SignInFailed = 'SIGN_IN_FAILED',
SignUpFailed = 'SIGN_UP_FAILED', SignUpFailed = 'SIGN_UP_FAILED',
SignOutFailed = 'SIGN_OUT_FAILED', SignOutFailed = 'SIGN_OUT_FAILED',
UpdateFailed = 'UPDATE_FAILED',
NetworkError = 'NETWORK_ERROR', NetworkError = 'NETWORK_ERROR',
} }
type sessionData = { export type AuthUser = InferUserFromClient<BetterAuthClientOptions> & {
session: InferSessionFromClient<BetterAuthClientOptions> | null; twoFactorEnabled?: boolean | null;
user: InferUserFromClient<BetterAuthClientOptions> | null; role?: string | null;
}; };
type sessionData = {
session: InferSessionFromClient<BetterAuthClientOptions> | null;
user: AuthUser | null;
};
// Signing in either finishes right away, or stops at the second factor when
// the account has two-factor authentication turned on.
export type SignInResult =
| { twoFactorRequired: false; user: User; token: string }
| { twoFactorRequired: true };
export const useAuth = () => { export const useAuth = () => {
const authClient = createAuthClient({}); const authClient = createAuthClient({
plugins: [twoFactorClient(), adminClient()],
});
const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null); const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null);
const user = useState<InferUserFromClient<BetterAuthClientOptions> | null>('auth:user', () => null); const user = useState<AuthUser | null>('auth:user', () => null);
const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false); const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false);
const isAdmin = computed(() => user.value?.role === 'admin');
let sessionPromise: Promise<Result<sessionData, AuthError>> | null = null; let sessionPromise: Promise<Result<sessionData, AuthError>> | null = null;
@@ -35,7 +51,7 @@ export const useAuth = () => {
sessionPromise = promise; sessionPromise = promise;
let data: { let data: {
session: InferSessionFromClient<BetterAuthClientOptions>; session: InferSessionFromClient<BetterAuthClientOptions>;
user: InferUserFromClient<BetterAuthClientOptions>; user: AuthUser;
} | null = null; } | null = null;
try { try {
@@ -44,7 +60,7 @@ export const useAuth = () => {
( (
await useFetch<{ await useFetch<{
session: InferSessionFromClient<BetterAuthClientOptions>; session: InferSessionFromClient<BetterAuthClientOptions>;
user: InferUserFromClient<BetterAuthClientOptions>; user: AuthUser;
}>('/api/auth/get-session') }>('/api/auth/get-session')
).data.value ?? null; ).data.value ?? null;
} else { } else {
@@ -74,7 +90,7 @@ export const useAuth = () => {
const signIn = async ( const signIn = async (
email: string, email: string,
password: string password: string
): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => { ): Promise<Result<SignInResult, { error: AuthError, data?: any }>> => {
sessionFetching.value = true; sessionFetching.value = true;
try { try {
@@ -92,11 +108,16 @@ export const useAuth = () => {
return Err({ error: AuthError.SignInFailed }); return Err({ error: AuthError.SignInFailed });
} }
// no session exists yet, verifyTwoFactor() finishes the sign in
if ('twoFactorRedirect' in data && data.twoFactorRedirect) {
return Ok({ twoFactorRequired: true });
}
user.value = data.user; user.value = data.user;
clearNuxtData(); clearNuxtData();
return Ok({ user: data.user, token: data.token }); return Ok({ twoFactorRequired: false, user: data.user, token: data.token });
} catch (err) { } catch (err) {
console.error('Sign in error:', err); console.error('Sign in error:', err);
return Err({ error: AuthError.NetworkError }); return Err({ error: AuthError.NetworkError });
@@ -105,10 +126,45 @@ export const useAuth = () => {
} }
}; };
const verifyTwoFactor = async (
code: string,
options: { backupCode?: boolean; trustDevice?: boolean } = {}
): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => {
sessionFetching.value = true;
try {
const body = { code, trustDevice: options.trustDevice ?? false };
const { data, error } = options.backupCode
? await authClient.twoFactor.verifyBackupCode(body)
: await authClient.twoFactor.verifyTotp(body);
if (error) {
return Err({ error: AuthError.SignInFailed, data: error });
}
if (!data || !data.token) {
return Err({ error: AuthError.SignInFailed });
}
user.value = data.user as AuthUser;
clearNuxtData();
return Ok({ user: data.user as User, token: data.token });
} catch (err) {
console.error('Two-factor verification error:', err);
return Err({ error: AuthError.NetworkError });
} finally {
sessionFetching.value = false;
}
};
const signUp = async ( const signUp = async (
email: string, email: string,
password: string, password: string,
name: string name: string,
// needed while the instance only takes invited people
inviteToken?: string,
): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => { ): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => {
sessionFetching.value = true; sessionFetching.value = true;
@@ -117,6 +173,8 @@ export const useAuth = () => {
email, email,
password, password,
name, name,
}, {
headers: inviteToken ? { 'x-invite-token': inviteToken } : undefined,
}); });
if (error) { if (error) {
@@ -166,14 +224,39 @@ export const useAuth = () => {
} }
}; };
const updateProfile = async (
updates: { name?: string; image?: string | null }
): Promise<Result<void, AuthError>> => {
try {
const { error } = await authClient.updateUser(updates);
if (error) {
console.error('Profile update failed:', error);
return Err(AuthError.UpdateFailed);
}
if (user.value) {
user.value = { ...user.value, ...updates };
}
return Ok(undefined);
} catch (err) {
console.error('Profile update error:', err);
return Err(AuthError.NetworkError);
}
};
return { return {
client: authClient, client: authClient,
session, session,
user, user,
isAdmin,
loggedIn: computed(() => !!session.value), loggedIn: computed(() => !!session.value),
signIn, signIn,
verifyTwoFactor,
signUp, signUp,
signOut, signOut,
updateProfile,
fetchSession, fetchSession,
}; };
}; };
+264 -49
View File
@@ -3,80 +3,295 @@ import { ref, watch, onUnmounted, type Ref } from 'vue';
export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: { export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
threshold?: number; threshold?: number;
} = {}) { } = {}) {
const { threshold = 80 } = options; const { threshold = 30 } = options;
const isUserScrollingUp = ref(false); /** User intentionally left the bottom — stop following new content */
const unhooked = ref(false);
/** Back-compat alias for UI that keys off "user scrolled away" */
const isUserScrollingUp = unhooked;
const shouldAutoScroll = ref(true); const shouldAutoScroll = ref(true);
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { let isProgrammatic = false;
let lastScrollTop = 0;
let mutationRaf: number | null = null;
let anchorRaf: number | null = null;
let anchorDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
let observer: MutationObserver | null = null;
let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0;
let anchor: { element: Element; offsetFromTop: number } | null = null;
const isAtBottom = (el: HTMLElement) =>
el.scrollHeight - el.scrollTop - el.clientHeight <= threshold;
const setUnhooked = (value: boolean) => {
unhooked.value = value;
shouldAutoScroll.value = !value;
if (!value) anchor = null;
};
const scrollToBottom = (behavior: ScrollBehavior = 'instant') => {
const el = elementRef.value; const el = elementRef.value;
if (!el) return; if (!el) {
return;
}
const bottom = Math.max(0, el.scrollHeight - el.clientHeight);
if (Math.abs(el.scrollTop - bottom) < 0.5) {
setUnhooked(false);
lastScrollTop = el.scrollTop;
return;
}
isProgrammatic = true;
el.scrollTo({ el.scrollTo({
top: el.scrollHeight, top: bottom,
behavior, behavior,
}); });
shouldAutoScroll.value = true;
requestAnimationFrame(() => {
isProgrammatic = false;
if (elementRef.value && isAtBottom(elementRef.value)) {
setUnhooked(false);
lastScrollTop = elementRef.value.scrollTop;
}
});
};
const captureAnchor = (el: HTMLElement) => {
if (!unhooked.value) {
anchor = null;
return;
}
const rect = el.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const offsets = [4, 24, 48, 80, 120];
for (const y of offsets) {
const target = document.elementFromPoint(x, rect.top + y);
if (!target || target === el || !el.contains(target)) {
continue;
}
anchor = {
element: target,
offsetFromTop: target.getBoundingClientRect().top - rect.top,
};
return;
}
anchor = null;
};
const requestAnchorCapture = () => {
if (!unhooked.value || anchorRaf !== null) return;
anchorRaf = requestAnimationFrame(() => {
anchorRaf = null;
const el = elementRef.value;
if (el) captureAnchor(el);
});
};
const scheduleAnchorCapture = () => {
const el = elementRef.value;
// Native scrollend is the primary path; debounce is only a fallback.
if (!el || 'onscrollend' in el || !unhooked.value) return;
if (anchorDebounceTimeout !== null) {
clearTimeout(anchorDebounceTimeout);
}
anchorDebounceTimeout = setTimeout(() => {
anchorDebounceTimeout = null;
requestAnchorCapture();
}, 150);
};
const handleScrollEnd = () => {
if (anchorDebounceTimeout !== null) {
clearTimeout(anchorDebounceTimeout);
anchorDebounceTimeout = null;
}
requestAnchorCapture();
};
const restoreAnchor = (el: HTMLElement) => {
if (!anchor || !el.contains(anchor.element)) {
return;
}
const top = el.getBoundingClientRect().top;
const delta =
anchor.element.getBoundingClientRect().top - top - anchor.offsetFromTop;
if (Math.abs(delta) < 0.5) {
return;
}
isProgrammatic = true;
el.scrollTop += delta;
requestAnimationFrame(() => {
isProgrammatic = false;
});
};
const handleWheel = (event: WheelEvent) => {
isProgrammatic = false;
// Intent to leave bottom — unhook before content mutations can re-stick
if (event.deltaY < 0) {
setUnhooked(true);
}
}; };
const handleScroll = () => { const handleScroll = () => {
const el = elementRef.value; const el = elementRef.value;
if (!el) return; if (!el) {
return;
const { scrollTop, scrollHeight, clientHeight } = el;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
if (distanceFromBottom <= threshold) {
if (isUserScrollingUp.value) {
isUserScrollingUp.value = false;
shouldAutoScroll.value = true;
}
} else {
isUserScrollingUp.value = true;
shouldAutoScroll.value = false;
} }
const scrollingUp = el.scrollTop < lastScrollTop;
lastScrollTop = el.scrollTop;
if (isProgrammatic) {
if (isAtBottom(el)) {
setUnhooked(false);
}
return;
}
// If the user is scrolling up, they are deliberately navigating away from the bottom.
// Unhook immediately so we never fight the scroll or snap back down.
if (scrollingUp) {
setUnhooked(true);
scheduleAnchorCapture();
return;
}
// Only re-hook when scrolling down into the bottom threshold.
if (isAtBottom(el)) {
setUnhooked(false);
return;
}
scheduleAnchorCapture();
}; };
let observer: MutationObserver | null = null; const followIfHooked = () => {
let timeout: NodeJS.Timeout | null = null; if (unhooked.value) {
return;
watch(elementRef, (newEl, oldEl) => {
if (oldEl) {
oldEl.removeEventListener('scroll', handleScroll);
observer?.disconnect();
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
} }
scrollToBottom('instant');
};
if (newEl) { const handleMutation = (records: MutationRecord[]) => {
newEl.addEventListener('scroll', handleScroll, { passive: true }); if (unhooked.value || mutationRaf !== null) {
return;
}
// The isolated sizing mirror changes on every keystroke, but cannot
// change scroll height. Keep observing real composer content (files,
// tool controls, etc.) as well as message content.
if (records.every(record => {
const target = record.target instanceof Element ? record.target : record.target.parentElement;
return target?.closest('[data-chat-measurement]');
})) return;
mutationRaf = requestAnimationFrame(() => {
mutationRaf = null;
followIfHooked();
});
};
observer = new MutationObserver(() => { const attach = (el: HTMLElement) => {
// Only auto-scroll if user hasn't scrolled up and is near bottom el.style.overflowAnchor = 'none';
if (!isUserScrollingUp.value && shouldAutoScroll.value) { lastScrollTop = el.scrollTop;
lastWidth = el.clientWidth;
setUnhooked(false);
el.addEventListener('wheel', handleWheel, { passive: true });
el.addEventListener('scroll', handleScroll, { passive: true });
el.addEventListener('scrollend', handleScrollEnd, { passive: true });
observer = new MutationObserver(handleMutation);
observer.observe(el, {
childList: true,
subtree: true,
characterData: true,
});
resizeObserver = new ResizeObserver(() => {
const current = elementRef.value;
if (!current) {
return;
}
const width = current.clientWidth;
if (width === lastWidth) {
// Height-only growth (streaming layout): stay stuck if hooked
if (!unhooked.value) {
scrollToBottom('instant'); scrollToBottom('instant');
} }
}); return;
}
observer.observe(newEl, { lastWidth = width;
childList: true,
subtree: true, if (!unhooked.value) {
characterData: true, scrollToBottom('instant');
}); return;
}
if (!anchor) {
captureAnchor(current);
}
restoreAnchor(current);
});
resizeObserver.observe(el);
// Content already taller than the viewport on mount — catch up once
requestAnimationFrame(() => {
if (!unhooked.value) {
scrollToBottom('instant');
}
});
};
const detach = (el: HTMLElement | null) => {
if (el) {
el.removeEventListener('wheel', handleWheel);
el.removeEventListener('scroll', handleScroll);
el.removeEventListener('scrollend', handleScrollEnd);
} }
}); observer?.disconnect();
observer = null;
resizeObserver?.disconnect();
resizeObserver = null;
if (mutationRaf !== null) {
cancelAnimationFrame(mutationRaf);
mutationRaf = null;
}
if (anchorRaf !== null) {
cancelAnimationFrame(anchorRaf);
anchorRaf = null;
}
if (anchorDebounceTimeout !== null) {
clearTimeout(anchorDebounceTimeout);
anchorDebounceTimeout = null;
}
anchor = null;
};
watch(elementRef, (newEl, oldEl) => {
detach(oldEl ?? null);
if (newEl) {
attach(newEl);
}
}, { immediate: true });
onUnmounted(() => { onUnmounted(() => {
elementRef.value?.removeEventListener('scroll', handleScroll); detach(elementRef.value);
observer?.disconnect();
if (timeout) {
clearTimeout(timeout);
}
}); });
return { return {
scrollToBottom, scrollToBottom,
isUserScrollingUp, // expose for UI feedback (optional) isUserScrollingUp,
shouldAutoScroll,
isAtBottom: () => {
const el = elementRef.value;
return el ? isAtBottom(el) : true;
},
}; };
} }
+276 -105
View File
@@ -1,11 +1,20 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { toRaw } from 'vue';
import * as schema from '~~/drizzle/schema'; import * as schema from '~~/drizzle/schema';
import { Err, Ok, type Result } from '~~/types/result'; import { Err, Ok, type Result } from '~~/types/result';
import { buildMessageTree } from '~~/utils/message'; import { buildMessageTree } from '~~/utils/message';
import { appendTextDelta, serializeTopicSnapshot } from '~~/utils/topic-sync';
export type BaseMessage = { export type BaseMessage = {
content: string; content: string;
fileIds: string[]; fileIds: string[];
files?: {
id: string;
name: string;
mimeType: string;
url: string;
size?: number;
}[];
} }
export type ToolCall = typeof schema.toolCalls.$inferSelect export type ToolCall = typeof schema.toolCalls.$inferSelect
@@ -31,7 +40,7 @@ export enum ChatErrorType {
Unimplemented, Unimplemented,
} }
type Event = { type: string; payload: any; timestamp: number }; type Event = { type: string; payload: any; timestamp: number; replay?: boolean };
const TARGET_UPDATES_PER_SECOND = 24; const TARGET_UPDATES_PER_SECOND = 24;
@@ -39,6 +48,10 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
let data = useState<Topic & { messages: Message[] } | undefined>('useChat:data', () => undefined); let data = useState<Topic & { messages: Message[] } | undefined>('useChat:data', () => undefined);
let sse: EventSource | undefined; let sse: EventSource | undefined;
let disposed = false;
let connectionAttempt = 0;
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined;
const { promise: connectionReady, resolve: markConnected } = Promise.withResolvers<void>();
const textDeltaBuffer: Map<string, Map<string, { const textDeltaBuffer: Map<string, Map<string, {
content: string; content: string;
@@ -57,7 +70,7 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
for (const [partId, content] of parts) { for (const [partId, content] of parts) {
const part = msg.parts?.find(p => p.id === partId); const part = msg.parts?.find(p => p.id === partId);
if (part) { if (part) {
part.content += content.content; part.content = content.content;
part.lastUpdatedAt = new Date(content.lastUpdatedAt); part.lastUpdatedAt = new Date(content.lastUpdatedAt);
} }
} }
@@ -86,6 +99,23 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
flushTimeout = setTimeout(flushTextDeltas, 1000 / TARGET_UPDATES_PER_SECOND); flushTimeout = setTimeout(flushTextDeltas, 1000 / TARGET_UPDATES_PER_SECOND);
}; };
const resetTextDeltas = () => {
if (flushTimeout !== undefined) clearTimeout(flushTimeout);
flushTimeout = undefined;
textDeltaBuffer.clear();
flushCbs.clear();
};
const scheduleReconnect = (forceSnapshot = false) => {
sse?.close();
sse = undefined;
if (disposed || reconnectTimeout !== undefined) return;
reconnectTimeout = setTimeout(() => {
reconnectTimeout = undefined;
void connectSSE(forceSnapshot);
}, 1500);
};
const processEvent = (event: Event) => { const processEvent = (event: Event) => {
if (!data.value) return; if (!data.value) return;
@@ -95,12 +125,17 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
case 'MESSAGE_CREATED': { case 'MESSAGE_CREATED': {
// Push the new message if it doesn't exist (prevents duplicates from HTTP vs SSE) // Push the new message if it doesn't exist (prevents duplicates from HTTP vs SSE)
if (!data.value.messages.find(m => m.id === payload.id)) { if (!data.value.messages.find(m => m.id === payload.id)) {
data.value.messages.push({ ...payload, parts: [] }); data.value.messages.push({ ...payload, parts: payload.parts ?? [], attachments: payload.attachments ?? [] });
} else { } else {
// replace the existing data with the new data // replace the existing data with the new data
const index = data.value.messages.findIndex(m => m.id === payload.id); const index = data.value.messages.findIndex(m => m.id === payload.id);
if (index !== -1) { if (index !== -1 && !event.replay) {
data.value.messages[index] = { ...payload, parts: [] }; const existing = data.value.messages[index]!;
data.value.messages[index] = {
...payload,
parts: payload.parts ?? [],
attachments: payload.attachments ?? existing.attachments ?? [],
};
} }
} }
break; break;
@@ -123,7 +158,9 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
const msg = data.value.messages.find(m => m.id === payload.messageId); const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg) { if (msg) {
if (!msg.parts) msg.parts = []; if (!msg.parts) msg.parts = [];
msg.parts.push(payload.part); if (!msg.parts.some(part => part.id === payload.part.id)) {
msg.parts.push(payload.part);
}
} }
break; break;
} }
@@ -131,25 +168,42 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
case 'text-delta': { case 'text-delta': {
const msg = data.value.messages.find(m => m.id === payload.messageId); const msg = data.value.messages.find(m => m.id === payload.messageId);
if (!msg) break; if (!msg) break;
const part = msg.parts?.find(part => part.id === payload.partId);
if (!part || part.finished) break;
if (!textDeltaBuffer.has(payload.messageId)) { if (!textDeltaBuffer.has(payload.messageId)) {
textDeltaBuffer.set(payload.messageId, new Map()); textDeltaBuffer.set(payload.messageId, new Map());
} }
const messageBuffer = textDeltaBuffer.get(payload.messageId)!; const messageBuffer = textDeltaBuffer.get(payload.messageId)!;
const existing = messageBuffer.get(payload.partId)?.content || ''; const existing = messageBuffer.get(payload.partId)?.content ?? part.content ?? '';
messageBuffer.set(payload.partId, { content: existing + payload.content, lastUpdatedAt: payload.lastUpdatedAt }); if (typeof payload.offset === 'number' && payload.offset > existing.length) {
scheduleReconnect(true);
break;
}
const content = appendTextDelta(existing, payload.content, payload.offset);
if (content === existing) break;
messageBuffer.set(payload.partId, { content, lastUpdatedAt: payload.lastUpdatedAt });
scheduleFlush(); scheduleFlush();
break; break;
} }
case 'text-delete': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg?.parts) msg.parts = msg.parts.filter(part => part.id !== payload.partId);
textDeltaBuffer.get(payload.messageId)?.delete(payload.partId);
break;
}
case 'text-end': { case 'text-end': {
const msg = data.value.messages.find(m => m.id === payload.messageId); const msg = data.value.messages.find(m => m.id === payload.messageId);
const part = msg?.parts?.find(p => p.id === payload.partId); const part = msg?.parts?.find(p => p.id === payload.partId);
if (part) { if (part && part.type !== 'tool-call') {
nextFlush(() => { nextFlush(() => {
part.content = payload.content; part.content = payload.content;
part.lastUpdatedAt = payload.lastUpdatedAt; if (payload.lastUpdatedAt) {
part.lastUpdatedAt = payload.lastUpdatedAt;
}
part.finished = true; part.finished = true;
}); });
} }
@@ -159,8 +213,16 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
case 'tool-call-start': { case 'tool-call-start': {
const msg = data.value.messages.find(m => m.id === payload.messageId); const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg) { if (msg) {
if (!msg.parts) msg.parts = []; if (!msg.parts) {
msg.parts.push(payload.part); msg.parts = [];
}
const part = { ...payload.part };
if (!part.createdAt) {
part.createdAt = new Date().toISOString();
}
if (!msg.parts.some(existing => existing.id === part.id)) {
msg.parts.push(part);
}
} }
break; break;
} }
@@ -169,10 +231,22 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
const msg = data.value.messages.find(m => m.id === payload.messageId); const msg = data.value.messages.find(m => m.id === payload.messageId);
const part = msg?.parts?.find(p => p.toolCallId === payload.toolCallId); const part = msg?.parts?.find(p => p.toolCallId === payload.toolCallId);
if (part?.toolCall) { if (part?.toolCall) {
if (payload.input) part.toolCall.input = payload.input; if (payload.input) {
if (payload.output) part.toolCall.output = payload.output; part.toolCall.input = payload.input;
if (payload.error) part.toolCall.error = payload.error; }
if (payload.status) part.toolCall.status = payload.status; if (payload.output) {
part.toolCall.output = payload.output;
}
if (payload.error) {
part.toolCall.error = payload.error;
}
if (payload.status) {
part.toolCall.status = payload.status;
if (payload.status === 'completed' || payload.status === 'failed') {
part.lastUpdatedAt = payload.lastUpdatedAt || new Date().toISOString();
part.finished = true;
}
}
} }
break; break;
} }
@@ -202,55 +276,79 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
} }
} }
const connectSSE = async () => { const connectSSE = async (forceSnapshot = false) => {
const { promise, resolve } = Promise.withResolvers<void>(); if (disposed) return;
const attempt = ++connectionAttempt;
const id = unref(topicId); const id = unref(topicId);
const lastUpdate = data.value?.messages.at(-1)?.updatedAt || '0'; sse?.close();
const msgCount = data.value?.messages.length || 0; if (flushTimeout !== undefined) {
clearTimeout(flushTimeout);
if (sse && sse.readyState !== EventSource.CLOSED) { flushTextDeltas();
if (!(new URL(sse.url).pathname.startsWith(`/api/topic/${id}`))) { }
sse.close(); let offeredSnapshot: string | undefined;
} else { const query = new URLSearchParams();
return; if (!forceSnapshot && data.value?.id === id && globalThis.crypto?.subtle) {
try {
offeredSnapshot = serializeTopicSnapshot(toRaw(data.value));
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(offeredSnapshot));
query.set('snapshot', Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''));
} catch {
// Fingerprinting is an optimization; a full snapshot remains safe.
offeredSnapshot = undefined;
} }
} }
if (disposed || attempt !== connectionAttempt) return;
sse = new EventSource(`/api/topic/${id}?lastUpdate=${lastUpdate}&count=${msgCount}`); const source = new EventSource(`/api/topic/${id}?${query}`);
sse = source;
source.addEventListener('error', () => {
if (sse === source) scheduleReconnect();
});
sse.addEventListener("error", (e) => { source.addEventListener('message', (e) => {
console.error('sse error', e); if (disposed || sse !== source) return;
setTimeout(connectSSE, 1500);
})
sse.addEventListener("message", (e) => {
const raw = JSON.parse(e.data); const raw = JSON.parse(e.data);
const events: Event[] = Array.isArray(raw) ? raw : [raw]; const events: Event[] = Array.isArray(raw) ? raw : [raw];
for (const { type, payload, timestamp } of events) { for (const event of events) {
const { type, payload } = event;
if (!type || !payload) continue; if (!type || !payload) continue;
if (type === 'initial_state') { if (type === 'synced') {
data.value = payload; // Optimistic edits can occur while the handshake is in flight.
resolve() // Only retain the exact state that we offered to the server.
if (offeredSnapshot !== serializeTopicSnapshot(toRaw(data.value))) {
scheduleReconnect(true);
return;
}
offeredSnapshot = undefined;
markConnected();
continue; continue;
} }
processEvent({ type, payload, timestamp }); if (type === 'initial_state') {
resetTextDeltas();
data.value = payload;
offeredSnapshot = undefined;
markConnected();
continue;
}
processEvent(event);
} }
}); });
await promise; await connectionReady;
} };
if (connect) { if (connect) {
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (flushTimeout) { disposed = true;
clearTimeout(flushTimeout); connectionAttempt++;
flushTextDeltas(); if (reconnectTimeout !== undefined) clearTimeout(reconnectTimeout);
} resetTextDeltas();
markConnected();
if (sse) { if (sse) {
console.log('[SSE] Closing connection'); console.log('[SSE] Closing connection');
sse.close(); sse.close();
@@ -263,12 +361,6 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
const { data: ssrData } = await useFetch<Topic & { messages: Message[] }>(`/api/topic/${id}`); const { data: ssrData } = await useFetch<Topic & { messages: Message[] }>(`/api/topic/${id}`);
data.value = ssrData.value; data.value = ssrData.value;
} else { } else {
// if (data.value === undefined) {
// const id = unref(topicId);
// const { data: ssrData } = await useFetch<Topic & { messages: Message[] }>(`/api/topic/${id}`);
// data.value = ssrData.value;
// }
await connectSSE(); await connectSSE();
} }
} }
@@ -305,6 +397,7 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
const sendMessage = async ( const sendMessage = async (
baseMessage: BaseMessage, baseMessage: BaseMessage,
onRequest?: () => void, onRequest?: () => void,
role: 'user' | 'assistant' = 'user',
): Promise<Result<void, ChatErrorType>> => { ): Promise<Result<void, ChatErrorType>> => {
const { user } = useAuth(); const { user } = useAuth();
if (!user.value) return Err(ChatErrorType.NoUser); if (!user.value) return Err(ChatErrorType.NoUser);
@@ -312,34 +405,94 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
try { try {
const message = { const message = {
id: nanoid(), id: nanoid(),
role: 'user', role,
content: baseMessage.content,
fileIds: baseMessage.fileIds,
} }
await $fetch(`/api/topic/${unref(topicId)}/message`, { await $fetch(`/api/topic/${unref(topicId)}/message`, {
method: 'POST', method: 'POST',
body: { body: {
message message: {
...message,
content: baseMessage.content,
fileIds: baseMessage.fileIds,
}
}, },
onRequest() { onRequest() {
if (data.value) { if (data.value) {
data.value!.messages.push({ switch (role) {
topicId: unref(topicId), case 'user': {
userId: user.value!.id, const optimisticAttachments = baseMessage.files?.map(file => ({
// TODO id: nanoid(),
attachments: [], userId: user.value!.id,
parts: undefined, topicId: unref(topicId),
generation: null, messageId: message.id,
parentMessageId: null, fileId: file.id,
generationId: null, createdAt: new Date(),
activeChildId: null, file: {
deleted: null, id: file.id,
createdAt: new Date(), userId: user.value!.id,
updatedAt: new Date(), name: file.name,
children: [], mimeType: file.mimeType,
...message size: file.size ?? 0,
} as Message); url: file.url,
createdAt: new Date(),
},
})) ?? [];
data.value!.messages.push({
topicId: unref(topicId),
userId: user.value!.id,
attachments: optimisticAttachments,
parts: undefined,
generation: null,
parentMessageId: null,
generationId: null,
activeChildId: null,
children: [],
deleted: false,
content: baseMessage.content,
createdAt: new Date(),
updatedAt: new Date(),
...message
});
break;
}
case 'assistant': {
data.value!.messages.push({
topicId: unref(topicId),
userId: user.value!.id,
// TODO
attachments: [],
generation: null,
parentMessageId: null,
generationId: null,
activeChildId: null,
children: [],
deleted: false,
content: null,
createdAt: new Date(),
updatedAt: new Date(),
parts: [
{
id: 'bogus-id',
userId: user.value!.id,
topicId: unref(topicId),
messageId: message.id,
type: 'text',
content: baseMessage.content,
finished: true,
createdAt: new Date(),
lastUpdatedAt: new Date(),
providerOptions: null,
toolCallId: null,
toolCall: null,
}
],
...message
});
break;
}
}
} }
onRequest?.(); onRequest?.();
}, },
@@ -368,31 +521,11 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
return Err(ChatErrorType.NoAgent); return Err(ChatErrorType.NoAgent);
} }
const { settings } = await useUserSettings();
let rerank = undefined;
if (agent.config?.search?.rerank && settings.value.systemAssistants.rerank.enabled) {
const rerankModel = await useModels().then(m => m.allModels.value.find(m => m.id === settings.value.systemAssistants.rerank.modelId));
if (rerankModel) {
const rerankProviderApiKeyRes = await getProviderAPIKey(rerankModel.provider);
if (rerankProviderApiKeyRes.ok === false) {
return rerankProviderApiKeyRes;
}
const rerankProviderApiKey = rerankProviderApiKeyRes.data;
rerank = {
modelId: rerankModel.id,
providerApiKey: rerankProviderApiKey,
};
}
}
await $fetch(`/api/topic/${unref(topicId)}/chat`, { await $fetch(`/api/topic/${unref(topicId)}/chat`, {
method: 'POST', method: 'POST',
body: { body: {
modelId: model.id, modelId: model.id,
providerApiKey, providerApiKey,
rerank,
parentMessageId, parentMessageId,
}, },
}); });
@@ -463,38 +596,76 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
} }
const deleteMessagesFocusedDescendant = async (messageId: string): Promise<Result<void, Error>> => { const deleteMessagesFocusedDescendant = async (messageId: string): Promise<Result<void, Error>> => {
if (!data.value) return Err(new Error('No data')); if (!data.value) {
return Err(new Error("No data"));
}
const msg = data.value.messages.find(m => m.id === messageId); const msg = data.value.messages.find((m) => {
if (!msg) return Err(new Error('Message not found')); return m.id === messageId;
});
if (!msg) {
return Err(new Error("Message not found"));
}
const previousMessages = data.value!.messages; const previousMessages = data.value.messages;
const res = await $fetch(`/api/messages/${messageId}`, { const res = await $fetch(`/api/messages/${messageId}`, {
method: 'DELETE', method: "DELETE",
onRequest() { onRequest() {
if (!msg.activeChildId && (msg.parentMessageId === null || msg.parentMessageId === undefined)) { if (!data.value) {
msg.deleted = true;
return; return;
} }
const parent = data.value!.messages.find(m => m.id === msg.parentMessageId); if (msg.parentMessageId) {
data.value!.messages = data.value!.messages.filter(m => m.id !== msg.id); const parent = data.value.messages.find((m) => {
if (parent?.deleted) { return m.id === msg.parentMessageId;
data.value!.messages = data.value!.messages.filter(m => m.id !== parent.id); });
data.value.messages = data.value.messages.filter((m) => {
return m.id !== msg.id;
});
if (parent) {
const remainingChildren = data.value.messages.filter((m) => {
return m.parentMessageId === parent.id;
});
if (parent.deleted && remainingChildren.length === 0) {
data.value.messages = data.value.messages.filter((m) => {
return m.id !== parent.id;
});
} else if (parent.activeChildId === msg.id) {
parent.activeChildId = remainingChildren.length > 0
? (parent.deleted ? remainingChildren[0]!.id : remainingChildren[remainingChildren.length - 1]!.id)
: null;
}
}
return;
} }
const children = data.value.messages.filter((m) => {
return m.parentMessageId === msg.id;
});
if (children.length === 0) {
data.value.messages = data.value.messages.filter((m) => {
return m.id !== msg.id;
});
return;
}
msg.deleted = true;
msg.activeChildId = children[0]?.id ?? null;
}, },
onResponseError() { onResponseError() {
data.value!.messages = previousMessages; if (data.value) {
data.value.messages = previousMessages;
}
}, },
}); });
if (!res.ok) { if (!res.ok) {
return Err(new Error('Failed to delete message')); return Err(new Error("Failed to delete message"));
} }
return Ok(undefined); return Ok(undefined);
} };
return { return {
topic, topic,
+6 -1
View File
@@ -1,10 +1,15 @@
import { Err, Ok, type Result } from "~~/types/result"; import { Err, Ok, type Result } from "~~/types/result";
export enum DialogType { export enum DialogType {
Settings = 'settings',
Textbox = 'textbox', Textbox = 'textbox',
QuickSwitcher = 'quick-switcher', QuickSwitcher = 'quick-switcher',
Confirm = 'confirm', Confirm = 'confirm',
ChangePassword = 'change-password',
TwoFactor = 'two-factor',
DeleteAccount = 'delete-account',
CreateUser = 'create-user',
CreateInvite = 'create-invite',
RecoverAccount = 'recover-account',
} }
interface DialogOptions { interface DialogOptions {
+8 -3
View File
@@ -134,7 +134,7 @@ export const useModels = async () => {
}); });
} }
const updateProvider = async (id: string, updates: Partial<Provider>) => { const updateProvider = async (id: string, updates: Partial<Provider>, expectedUpdatedAt?: 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;
@@ -142,10 +142,14 @@ export const useModels = async () => {
await $fetch(`/api/provider/${id}`, { await $fetch(`/api/provider/${id}`, {
method: 'PATCH', method: 'PATCH',
body: updates, body: {
...updates,
expectedUpdatedAt: expectedUpdatedAt ?? new Date(original.updatedAt).toISOString(),
},
onRequest() { onRequest() {
// a config update only carries the fields that changed
providers.value = providers.value.map(p => providers.value = providers.value.map(p =>
p.id === id ? { ...p, ...updates } : p p.id === id ? { ...p, ...updates, config: { ...p.config, ...updates.config } } : p
); );
}, },
onRequestError() { onRequestError() {
@@ -166,6 +170,7 @@ export const useModels = async () => {
return { return {
providers, providers,
refresh,
allModels, allModels,
getModel, getModel,
getProvider, getProvider,
+3 -2
View File
@@ -1,5 +1,6 @@
import { attempt } from "~~/types/result"; import { attempt } from "~~/types/result";
import * as schema from '~~/drizzle/schema'; import * as schema from '~~/drizzle/schema';
import { resolveFileUrl } from '~~/utils/url';
export type ResourceFile = typeof schema.files.$inferSelect; export type ResourceFile = typeof schema.files.$inferSelect;
@@ -118,12 +119,12 @@ export const useResources = async () => {
}; };
const copyLink = async (file: ResourceFile) => { const copyLink = async (file: ResourceFile) => {
const url = file.url; const url = resolveFileUrl(file.url);
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
}; };
const downloadFile = (file: ResourceFile) => { const downloadFile = (file: ResourceFile) => {
const url = file.url; const url = resolveFileUrl(file.url);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = file.name; a.download = file.name;
-2
View File
@@ -8,8 +8,6 @@ export const useTokenDropdown = () => {
const triggerElement = useState<HTMLElement | null>('token-dropdown:trigger', () => null); const triggerElement = useState<HTMLElement | null>('token-dropdown:trigger', () => null);
const open = (generation: Generation, trigger: HTMLElement) => { const open = (generation: Generation, trigger: HTMLElement) => {
console.log("open", generation, trigger);
activeGeneration.value = generation; activeGeneration.value = generation;
triggerElement.value = trigger; triggerElement.value = trigger;
isOpen.value = true; isOpen.value = true;
+5 -1
View File
@@ -3,12 +3,13 @@ const persistTimeout = 150;
export const useTooltip = () => { export const useTooltip = () => {
const activeElement = useState<HTMLElement | null>('tooltip:activeElement', () => null); const activeElement = useState<HTMLElement | null>('tooltip:activeElement', () => null);
const activeText = useState<string | null>('tooltip:activeText', () => null);
const activeHotkey = useState<string[] | null>('tooltip:activeHotkey', () => null); const activeHotkey = useState<string[] | null>('tooltip:activeHotkey', () => null);
const isVisible = useState<boolean>('tooltip:isVisible', () => false); const isVisible = useState<boolean>('tooltip:isVisible', () => false);
const dwellTimeoutId = useState<NodeJS.Timeout | null>('tooltip:dwellTimeoutId', () => null); const dwellTimeoutId = useState<NodeJS.Timeout | null>('tooltip:dwellTimeoutId', () => null);
const closeTimeoutId = useState<NodeJS.Timeout | null>('tooltip:closeTimeout', () => null); const closeTimeoutId = useState<NodeJS.Timeout | null>('tooltip:closeTimeout', () => null);
const show = (el: HTMLElement, hotkey?: string[]) => { const show = (el: HTMLElement, hotkey?: string[], text?: string) => {
if (closeTimeoutId.value) { if (closeTimeoutId.value) {
clearTimeout(closeTimeoutId.value); clearTimeout(closeTimeoutId.value);
} }
@@ -16,10 +17,12 @@ export const useTooltip = () => {
if (isVisible.value) { if (isVisible.value) {
activeElement.value = el; activeElement.value = el;
activeHotkey.value = hotkey || null; activeHotkey.value = hotkey || null;
activeText.value = text || null;
} else { } else {
dwellTimeoutId.value = setTimeout(() => { dwellTimeoutId.value = setTimeout(() => {
activeElement.value = el; activeElement.value = el;
activeHotkey.value = hotkey || null; activeHotkey.value = hotkey || null;
activeText.value = text || null;
isVisible.value = true; isVisible.value = true;
}, dwellTimeout); }, dwellTimeout);
} }
@@ -44,6 +47,7 @@ export const useTooltip = () => {
return { return {
activeElement: readonly(activeElement), activeElement: readonly(activeElement),
activeHotkey: readonly(activeHotkey), activeHotkey: readonly(activeHotkey),
activeText: readonly(activeText),
isVisible: readonly(isVisible), isVisible: readonly(isVisible),
show, show,
hide, hide,
+9 -20
View File
@@ -10,7 +10,7 @@ export const useTopic = () => {
FailedToDecryptProviderApiKey, FailedToDecryptProviderApiKey,
FailedToGenerate, FailedToGenerate,
} }
const autoRename = async (topicId: string): Promise<Result<string, AutoRenameError>> => { const autoRename = async (topicId: string): Promise<Result<void, AutoRenameError>> => {
const { settings } = await useUserSettings(); const { settings } = await useUserSettings();
if (!settings.value.systemAssistants.rename.enabled) { if (!settings.value.systemAssistants.rename.enabled) {
@@ -54,7 +54,7 @@ export const useTopic = () => {
} }
} }
const { agents } = await useAgents(); const { patchTopicLocally, refresh } = await useAgents();
try { try {
// @ts-ignore - excessive stack depth // @ts-ignore - excessive stack depth
@@ -65,31 +65,20 @@ export const useTopic = () => {
providerApiKey, providerApiKey,
}), }),
onRequest() { onRequest() {
const topics = agents.value.flatMap(agent => agent.topics); patchTopicLocally(topicId, { renaming: true });
const topic = topics.find(topic => topic.id === topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: true } : t) } : agent;
});
}
}, },
onRequestError() { });
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(topic => topic.id === topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: false } : t) } : agent;
});
}
},
}) as { ok: true, renameId: string } | { ok: false, code: string };
if (!res.ok) { if (!res.ok) {
patchTopicLocally(topicId, { renaming: false });
await refresh();
console.error('Failed to auto-rename:', res.code); console.error('Failed to auto-rename:', res.code);
return Err(AutoRenameError.FailedToGenerate); return Err(AutoRenameError.FailedToGenerate);
} }
return Ok(res.renameId); return Ok(undefined);
} catch (error) { } catch (error) {
patchTopicLocally(topicId, { renaming: false });
await refresh();
console.error('Failed to auto-rename:', error); console.error('Failed to auto-rename:', error);
return Err(AutoRenameError.FailedToGenerate); return Err(AutoRenameError.FailedToGenerate);
} }
+38 -7
View File
@@ -1,11 +1,14 @@
export const useUserEvents = () => { export const useUserEvents = () => {
const eventSource = ref<EventSource | null>(null); const eventSource = ref<EventSource | null>(null);
const agents = useState<AgentWithTopics[]>("agents_state", () => []);
const isConnected = useState('userEvents:connected', () => false); const isConnected = useState('userEvents:connected', () => false);
const lastEventTimestamp = useState<number>('userEvents:lastTimestamp', () => 0); const lastEventTimestamp = useState<number>('userEvents:lastTimestamp', () => 0);
const reconnectAttempts = ref(0); const reconnectAttempts = ref(0);
const connectionStartTime = ref<number>(0); const connectionStartTime = ref<number>(0);
const pendingReconnect = ref<NodeJS.Timeout | null>(null); const pendingReconnect = ref<NodeJS.Timeout | null>(null);
let reconciliationTimer: ReturnType<typeof setInterval> | undefined;
let reconciling = false;
const BASE_DELAY = 1000; const BASE_DELAY = 1000;
const MAX_DELAY = 30000; const MAX_DELAY = 30000;
@@ -50,7 +53,6 @@ export const useUserEvents = () => {
cleanup(); cleanup();
connectionStartTime.value = Date.now(); connectionStartTime.value = Date.now();
reconnectAttempts.value = 0;
const source = new EventSource('/api/events'); const source = new EventSource('/api/events');
eventSource.value = source; eventSource.value = source;
@@ -59,6 +61,7 @@ export const useUserEvents = () => {
console.log('User events connected'); console.log('User events connected');
isConnected.value = true; isConnected.value = true;
reconnectAttempts.value = 0; reconnectAttempts.value = 0;
void reconcile();
}; };
source.onerror = () => { source.onerror = () => {
@@ -164,20 +167,48 @@ export const useUserEvents = () => {
}; };
const reconcile = async () => { const reconcile = async () => {
console.log('Reconciling user state...'); if (reconciling) {
const { data } = await useFetch<AgentWithTopics[]>('/api/agents'); return;
if (data.value) { }
const { agents } = await useAgents(); reconciling = true;
agents.value = data.value; try {
agents.value = await $fetch<AgentWithTopics[]>("/api/agents");
} catch (error) {
console.error("Failed to reconcile topics:", error);
} finally {
reconciling = false;
} }
}; };
const resume = () => {
if (document.visibilityState !== "visible") {
return;
}
if (!isConnected.value) {
reconnectAttempts.value = 0;
void connect();
}
void reconcile();
};
onMounted(() => { onMounted(() => {
connect(); void connect();
document.addEventListener("visibilitychange", resume);
window.addEventListener("online", resume);
window.addEventListener("pageshow", resume);
reconciliationTimer = setInterval(() => {
if (document.visibilityState === "visible" && agents.value.some(agent => agent.topics.some(topic => topic.renaming))) {
void reconcile();
}
}, 15000);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
cleanup(); cleanup();
clearInterval(reconciliationTimer);
document.removeEventListener("visibilitychange", resume);
window.removeEventListener("online", resume);
window.removeEventListener("pageshow", resume);
}); });
return { return {
+2 -3
View File
@@ -18,8 +18,7 @@ useHead({
}); });
if (import.meta.client) { if (import.meta.client) {
const { connect } = useUserEvents(); useUserEvents();
connect();
} }
</script> </script>
@@ -28,7 +27,7 @@ if (import.meta.client) {
<div class="p-2 w-full h-full max-w-full overflow-x-hidden"> <div class="p-2 w-full h-full max-w-full overflow-x-hidden">
<main <main
class="bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden"> class="bg-[var(--bg-surface)] flex flex-col h-full w-full border border-solid border-[var(--color-border)] rounded-lg overflow-hidden">
<div class="flex-1 overflow-y-auto"> <div class="flex-1 min-h-0 flex flex-col">
<slot /> <slot />
</div> </div>
</main> </main>
+25
View File
@@ -22,6 +22,31 @@ export default defineNuxtRouteMiddleware(async (to) => {
return await navigateTo((to.query.to as string) ?? "/"); return await navigateTo((to.query.to as string) ?? "/");
} }
// A fresh instance has no accounts, so there is nothing to log in to yet
// and the first visitor creates the admin account instead.
if (!loggedIn.value) {
const setupNeeded = useState<boolean | null>('instance:setupNeeded', () => null);
const isSetupPage = to.path.toLowerCase() === '/auth/setup';
// once an account exists the answer never changes back
if (setupNeeded.value !== false) {
try {
setupNeeded.value = (await $fetch<{ setupNeeded: boolean }>('/api/instance')).setupNeeded;
} catch (error) {
console.error('Failed to check instance setup:', error);
setupNeeded.value = null;
}
}
if (setupNeeded.value === true && !isSetupPage) {
return await navigateTo('/auth/setup');
}
if (setupNeeded.value !== true && isSetupPage) {
return await navigateTo('/auth/login');
}
}
// If not authenticated, and not on a signin/signup page, redirect to login page // If not authenticated, and not on a signin/signup page, redirect to login page
if (!loggedIn.value && !isAuthPage) { if (!loggedIn.value && !isAuthPage) {
return await navigateTo(`/auth/login?to=${to.path}`); return await navigateTo(`/auth/login?to=${to.path}`);
+45 -5
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { nanoid } from 'nanoid';
import type { BaseMessage } from '~/composables/useChat'; import type { BaseMessage } from '~/composables/useChat';
import type { ModelWithProvider } from '~/composables/useModels'; import type { ModelWithProvider } from '~/composables/useModels';
@@ -43,13 +44,29 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
children: [], children: [],
generationId: null, generationId: null,
activeChildId: null, activeChildId: null,
attachments: [], attachments: message.files?.map(file => ({
id: nanoid(),
userId: user.value!.id,
topicId: '',
messageId: '',
fileId: file.id,
createdAt: new Date(),
file: {
id: file.id,
userId: user.value!.id,
name: file.name,
mimeType: file.mimeType,
size: file.size ?? 0,
url: file.url,
createdAt: new Date(),
},
})) ?? [],
deleted: false, deleted: false,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
} }
const topic = await createTopic(agent.value!.id); const topic = await createTopic(agent.value!.id, message);
if (!topic) throw new Error('Failed to create topic'); if (!topic) throw new Error('Failed to create topic');
const { sendMessage, startGeneration } = await useChat(topic.id, false); const { sendMessage, startGeneration } = await useChat(topic.id, false);
@@ -75,12 +92,34 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
return startGeneration(model, undefined, route.params.id as string); return startGeneration(model, undefined, route.params.id as string);
}; };
const handleAddMessage = async (message: BaseMessage, role: 'user' | 'assistant', _model: ModelWithProvider | null) => {
const user = useAuth().user;
if (!user) {
console.error('No user');
return;
}
const topic = await createTopic(agent.value!.id, message);
if (!topic) throw new Error('Failed to create topic');
const { sendMessage } = await useChat(topic.id, false);
const res = await sendMessage(message, undefined, role);
if (res.ok === false) {
console.error('Failed to send message:', res.error);
return;
}
await navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
};
</script> </script>
<template> <template>
<div class="flex flex-col h-full w-full max-w-full overflow-x-hidden"> <div class="flex flex-col h-full w-full max-w-full overflow-x-hidden">
<div class="h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)]"> <div
class="z-5 h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)] bg-[var(--bg-surface)]">
<div class="flex items-center gap-2 max-w-full"> <div class="flex items-center gap-2 max-w-full">
<button v-if="!sidebarOpen" @click="openSidebar" <button v-if="!sidebarOpen" @click="openSidebar"
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors"> class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
@@ -93,7 +132,7 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
</div> </div>
<div ref="chatPaneWrapper" <div ref="chatPaneWrapper"
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 pt-4"> class="chat-scroll-container justify-center w-full flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#888_transparent] overflow-y-scroll overflow-x-hidden flex justify-center pt-4">
<div class="chatPane max-w-4xl w-full min-h-full flex flex-col px-4"> <div class="chatPane max-w-4xl w-full min-h-full flex flex-col px-4">
<div class="w-full px-px flex flex-col flex-grow gap-2" <div class="w-full px-px flex flex-col flex-grow gap-2"
:class="pendingMessage === null ? 'justify-end pb-28' : 'pb-9'"> :class="pendingMessage === null ? 'justify-end pb-28' : 'pb-9'">
@@ -108,7 +147,8 @@ const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | nul
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl"> <div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out" <ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
:agent="agent" :providers="providers?.filter(p => p.enabled)" @submit="handleSubmit" /> :allow-manual-role="true" :agent="agent" :providers="providers?.filter(p => p.enabled)"
@submit="handleSubmit" @add-message="handleAddMessage" />
</div> </div>
</div> </div>
</div> </div>
+25 -23
View File
@@ -55,30 +55,32 @@ const changeSystemPrompt = async (e: Event) => {
</script> </script>
<template> <template>
<div class="h-14 flex items-center justify-between px-4"> <div class="h-full flex flex-col overflow-hidden pb-4">
<div class="flex items-center gap-2"> <div class="h-14 flex items-center justify-between px-4 shrink-0">
<button v-if="!sidebarOpen" @click="openSidebar" <div class="flex items-center gap-2">
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors"> <button v-if="!sidebarOpen" @click="openSidebar"
<span class="i-mynaui-panel-left-open text-5"></span> class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
</button> <span class="i-mynaui-panel-left-open text-5"></span>
</div> </button>
</div>
<div class="flex flex-col gap-4 px-14 w-full h-full">
<div class="flex items-center gap-4">
<div>
<img v-if="agent?.imageUrl" :src="agent.imageUrl" class="w-16 h-16 rounded-full object-cover" />
<span class="text-16 i-mynaui-check-hexagon"></span>
</div> </div>
<input v-model="name" @input="handleNameInput" placeholder="Agent Name..."
class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-border)] text-12 p-0"
type="text" />
</div> </div>
<div class="flex flex-col gap-2 w-full h-full mb-14">
<label class="text-sm text-[var(--text-secondary)]">System Message</label> <div class="flex flex-col gap-4 px-4 md:px-14 w-full flex-1 min-h-0">
<textarea v-model="systemPrompt" placeholder="You are a helpful assistant." <div class="flex items-center gap-4">
class="p-4 w-full h-full resize-none bg-transparent rounded-lg border border-[var(--color-border)]" <div>
@input="changeSystemPrompt"></textarea> <img v-if="agent?.imageUrl" :src="agent.imageUrl" class="w-16 h-16 rounded-full object-cover" />
<span class="text-16 i-mynaui-check-hexagon"></span>
</div>
<input v-model="name" @input="handleNameInput" placeholder="Agent Name..."
class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent rounded-none border-b-4 border-b-[var(--color-border)] text-12 p-0"
type="text" />
</div>
<div class="flex flex-col gap-2 w-full flex-1 min-h-0">
<label class="text-sm text-[var(--text-secondary)]">System Message</label>
<textarea v-model="systemPrompt" placeholder="You are a helpful assistant."
class="p-4 w-full flex-1 min-h-0 resize-none bg-transparent rounded-lg border border-[var(--color-border)]"
@input="changeSystemPrompt"></textarea>
</div>
</div> </div>
</div> </div>
</template> </template>
+96 -100
View File
@@ -1,14 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { type BaseMessage, type Message, ChatErrorType } from '~/composables/useChat'; import { type BaseMessage, type Message, ChatErrorType } from '~/composables/useChat';
import type { ModelWithProvider } from '~/composables/useModels'; import type { ModelWithProvider } from '~/composables/useModels';
import { buildFocusedMessageTree } from '~~/utils/message'; import { buildFocusedMessageTree, getActiveMessage, getNextActiveChildId } from '~~/utils/message';
const rootStart = Date.now();
const chatPaneWrapper = ref<HTMLElement | null>(null); const chatPaneWrapper = ref<HTMLElement | null>(null);
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] }); const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
const route = useRoute(); const route = useRoute();
const { getAgent, deleteTopic } = await useAgents(); const { getAgent, deleteTopic, forkTopic } = await useAgents();
const { open: sidebarOpen, openSidebar } = useSidebar(); const { open: sidebarOpen, openSidebar } = useSidebar();
const { providers, allModels } = await useModels(); const { providers, allModels } = await useModels();
const { addShortcut } = useKeyboardShortcuts(); const { addShortcut } = useKeyboardShortcuts();
@@ -54,17 +52,37 @@ const submitMessage = async (message: BaseMessage, model: ModelWithProvider | nu
startGeneration(model); startGeneration(model);
}; };
const handleAddMessage = async (message: BaseMessage, role: 'user' | 'assistant', _model: ModelWithProvider | null) => {
inputValue.value = { content: '', fileIds: [] };
const res = await sendMessage(message, async () => {
await nextTick();
setTimeout(() => {
scrollToBottom('instant');
});
}, role);
if (!res.ok) {
console.error('Failed to add message:', res.error);
const chatInput = document.getElementById('chat') as HTMLInputElement | null;
if (chatInput) {
inputValue.value = message;
nextTick(() => {
chatInput.focus();
});
}
}
};
const handleRegenerate = async (message: Message) => { const handleRegenerate = async (message: Message) => {
if (!agent.value!.defaultModelId) { if (!agent.value!.defaultModelId) {
console.error('No model selected'); console.error('No model selected');
return; return;
} }
let messageId: string; const activeMsg = getActiveMessage(message);
if (message.activeChildId && message.children.length > 0) { if (!activeMsg) {
messageId = message.activeChildId; console.error('No active message');
} else { return;
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);
@@ -73,35 +91,54 @@ const handleRegenerate = async (message: Message) => {
return; return;
} }
const res = await regenerateMessage(messageId, buildFocusedMessageTree(topic.value!.messages), model); const res = await regenerateMessage(activeMsg.id, buildFocusedMessageTree(topic.value!.messages), model);
if (!res.ok) { if (!res.ok) {
console.error('Failed to regenerate message:', ChatErrorType[res.error]); console.error('Failed to regenerate message:', ChatErrorType[res.error]);
return; return;
} }
} };
const flatMessages = computed(() => { const flatMessages = computed(() => {
const messages: Message[] = []; const messages: Message[] = [];
for (const message of topic.value?.messages ?? []) { for (const message of topic.value?.messages ?? []) {
messages.push(message); if (!message.deleted) {
messages.push(message);
}
if (message.children.length > 0) { if (message.children.length > 0) {
messages.push(...message.children as Message[]); for (const child of message.children) {
if (child && !child.deleted) {
messages.push(child as Message);
}
}
} }
} }
return messages; return messages;
}); });
const handleDelete = async (rootMessage: Message) => { const handleFork = async (message: Message) => {
let messageId: string; const activeMsg = getActiveMessage(message);
if (rootMessage.activeChildId && rootMessage.children.length > 0) { if (!activeMsg) {
messageId = rootMessage.activeChildId; console.error('No active message');
} else { return;
messageId = rootMessage.id;
} }
const result = await deleteMessagesFocusedDescendant(messageId); const newTopicId = await forkTopic(topicId.value, activeMsg.id);
if (newTopicId) {
await navigateTo(`/agent/${route.params.id}/topic/${newTopicId}`);
} else {
console.error('Failed to fork topic');
}
};
const handleDelete = async (rootMessage: Message) => {
const activeMsg = getActiveMessage(rootMessage);
if (!activeMsg) {
return;
}
const result = await deleteMessagesFocusedDescendant(activeMsg.id);
if (!result.ok) { if (!result.ok) {
console.error('Failed to delete message:', result.error); console.error('Failed to delete message:', result.error);
return; return;
@@ -111,21 +148,19 @@ const handleDelete = async (rootMessage: Message) => {
await navigateTo(`/agent/${route.params.id}`); await navigateTo(`/agent/${route.params.id}`);
deleteTopic(route.params.id as string, topicId.value); deleteTopic(route.params.id as string, topicId.value);
} }
} };
const { scrollToBottom, isAtBottom } = useAutoScroll(chatPaneWrapper);
const handleResize = () => { const handleResize = () => {
const el = chatPaneWrapper.value; if (!isAtBottom()) return;
if (!el) return;
const atBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) <= 80;
if (!atBottom) return;
nextTick().then(() => { nextTick().then(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
scrollToBottom('instant'); scrollToBottom('instant');
}); });
}); });
} };
const activeGeneration = computed(() => { const activeGeneration = computed(() => {
if (topic.value === null) return null; if (topic.value === null) return null;
@@ -133,8 +168,6 @@ const activeGeneration = computed(() => {
return generations?.find((generation) => generation?.status === 'pending') ?? null; return generations?.find((generation) => generation?.status === 'pending') ?? null;
}); });
const { scrollToBottom } = useAutoScroll(chatPaneWrapper);
addShortcut(['ctrl', 'alt', 'n'], (event) => { addShortcut(['ctrl', 'alt', 'n'], (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -168,76 +201,39 @@ addShortcut(['ctrl', 'shift', 'c'], (event) => {
handleCancel(); handleCancel();
}) })
addShortcut(['alt', '['], async (event) => { const navigateLastMessage = (direction: -1 | 1) => {
const lastMessage = topic.value?.messages?.at(-1);
if (!lastMessage || lastMessage.children.length === 0) {
return;
}
const nextActiveChildId = getNextActiveChildId(lastMessage, direction);
patchMessageLocally(lastMessage.id, { activeChildId: nextActiveChildId });
};
addShortcut(['alt', '['], (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
navigateLastMessage(-1);
});
const lastMessage = topic.value?.messages?.at(-1); addShortcut(['ctrl', 'alt', 'arrowleft'], (event) => {
if (lastMessage && lastMessage.children.length > 0) {
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
const prevIdx = Math.max(0, currentIdx);
if (currentIdx > 0) {
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[prevIdx - 1]!.id });
}
}
})
addShortcut(['alt', ']'], async (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
navigateLastMessage(-1);
});
const lastMessage = topic.value?.messages?.at(-1); addShortcut(['alt', ']'], (event) => {
if (lastMessage && lastMessage.children.length > 0) { event.preventDefault();
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId); event.stopPropagation();
const maxIdx = lastMessage.deleted ? lastMessage.children.length - 1 : lastMessage.children.length; navigateLastMessage(1);
const nextIdx = Math.min(maxIdx, currentIdx + 1); });
if (lastMessage.deleted) { addShortcut(['ctrl', 'alt', 'arrowright'], (event) => {
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx]?.id ?? null }); event.preventDefault();
} else { event.stopPropagation();
if (nextIdx === 0) { navigateLastMessage(1);
patchMessageLocally(lastMessage.id, { activeChildId: null }); });
} else {
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx - 1]?.id ?? null });
}
}
}
})
addShortcut(['ctrl', 'alt', 'arrowleft'], async (event) => {
const lastMessage = topic.value?.messages?.at(-1);
if (lastMessage && lastMessage.children.length > 0) {
event.preventDefault();
event.stopPropagation();
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
if (currentIdx > 0) {
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[currentIdx - 1]!.id });
}
}
})
addShortcut(['ctrl', 'alt', 'arrowright'], async (event) => {
const lastMessage = topic.value?.messages?.at(-1);
if (lastMessage && lastMessage.children.length > 0) {
event.preventDefault();
event.stopPropagation();
const currentIdx = lastMessage.children.findIndex(c => c?.id === lastMessage.activeChildId);
const maxIdx = lastMessage.deleted ? lastMessage.children.length - 1 : lastMessage.children.length;
const nextIdx = Math.min(maxIdx, currentIdx + 1);
if (lastMessage.deleted) {
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx]?.id ?? null });
} else {
if (nextIdx === 0) {
patchMessageLocally(lastMessage.id, { activeChildId: null });
} else {
patchMessageLocally(lastMessage.id, { activeChildId: lastMessage.children[nextIdx - 1]?.id ?? null });
}
}
}
})
addShortcut(['ctrl', 'alt', 'backspace'], async (event) => { addShortcut(['ctrl', 'alt', 'backspace'], async (event) => {
event.preventDefault(); event.preventDefault();
@@ -257,13 +253,12 @@ const handleCancel = async () => {
method: 'POST', method: 'POST',
}); });
}; };
console.log("full page render took", Date.now() - rootStart);
</script> </script>
<template> <template>
<div class="flex flex-col h-full w-full max-w-full overflow-x-hidden"> <div class="flex flex-col h-full w-full max-w-full overflow-x-hidden">
<div class="h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)]"> <div
class="z-5 h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)] bg-[var(--bg-surface)]">
<div class="flex items-center gap-2 max-w-full"> <div class="flex items-center gap-2 max-w-full">
<button v-if="!sidebarOpen" @click="openSidebar" <button v-if="!sidebarOpen" @click="openSidebar"
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors"> class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
@@ -277,7 +272,7 @@ console.log("full page render took", Date.now() - rootStart);
<!-- chat pane --> <!-- chat pane -->
<div ref="chatPaneWrapper" <div ref="chatPaneWrapper"
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 pt-4"> class="chat-scroll-container justify-center w-full flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#888_transparent] overflow-y-scroll overflow-x-hidden flex justify-center pt-4">
<div class="chatPane max-w-4xl w-full min-h-full flex flex-col px-4"> <div class="chatPane max-w-4xl w-full min-h-full flex flex-col px-4">
<div class="w-full px-px flex flex-col flex-grow gap-2 pb-9"> <div class="w-full px-px flex flex-col flex-grow gap-2 pb-9">
<Suspense> <Suspense>
@@ -285,16 +280,17 @@ console.log("full page render took", Date.now() - rootStart);
<Message v-for="message in topic.messages" :key="message.id" :message="message" <Message v-for="message in topic.messages" :key="message.id" :message="message"
@edit="(value) => patchMessageLocally(message.id, { content: value })" @edit="(value) => patchMessageLocally(message.id, { content: value })"
@patch="(updates) => patchMessageLocally(message.id, updates)" @patch="(updates) => patchMessageLocally(message.id, updates)"
@delete="handleDelete(message)" @regenerate="handleRegenerate(message)" /> @delete="handleDelete(message)" @regenerate="handleRegenerate(message)"
@fork="handleFork(message)" />
</template> </template>
</Suspense> </Suspense>
</div> </div>
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl"> <div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out" <ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
:loading="activeGeneration !== null" :agent="agent" :loading="activeGeneration !== null" :allow-manual-role="true" :agent="agent"
:providers="providers?.filter(p => p.enabled)" @submit="submitMessage" @cancel="handleCancel" :providers="providers?.filter(p => p.enabled)" @submit="submitMessage"
@resize="handleResize" /> @add-message="handleAddMessage" @cancel="handleCancel" @resize="handleResize" />
</div> </div>
</div> </div>
</div> </div>
+194 -7
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import CodeInput from '~/components/CodeInput.vue';
import { deriveKey } from '~/utils/crypto'; import { deriveKey } from '~/utils/crypto';
const { client: authClient, signIn, session } = useAuth(); const { client: authClient, signIn, verifyTwoFactor, session } = useAuth();
definePageMeta({ definePageMeta({
layout: 'auth', layout: 'auth',
@@ -9,11 +10,16 @@ definePageMeta({
const to = useRoute().query.to as string | undefined; const to = useRoute().query.to as string | undefined;
// set by the recovery page after a new password was chosen: 'keys' when saved
// API keys were deleted along the way, anything else when there were none
const recovered = useRoute().query.recovered as string | undefined;
if (session.value !== null) { if (session.value !== null) {
await navigateTo(to ?? '/'); await navigateTo(to ?? '/');
} }
const config = useRuntimeConfig(); // who may register is an instance setting, admins change it while it runs
const { data: instance } = await useFetch('/api/instance', { key: 'instance' });
const form = reactive({ const form = reactive({
email: '', email: '',
@@ -21,6 +27,27 @@ const form = reactive({
}); });
const loading = ref(false); const loading = ref(false);
// second step, only shown for accounts with two-factor authentication
const step = ref<'credentials' | 'twoFactor'>('credentials');
const twoFactor = reactive({
code: '',
useBackupCode: false,
trustDevice: false,
error: '',
});
const codeInputEl = ref<InstanceType<typeof CodeInput> | null>(null);
const backupCodeInputEl = ref<HTMLInputElement | null>(null);
const focusCodeInput = async () => {
await nextTick();
if (twoFactor.useBackupCode) {
backupCodeInputEl.value?.focus();
} else {
codeInputEl.value?.focus();
}
};
const emailInputEl = ref<HTMLInputElement | null>(null); const emailInputEl = ref<HTMLInputElement | null>(null);
const passwordInputEl = ref<HTMLInputElement | null>(null); const passwordInputEl = ref<HTMLInputElement | null>(null);
@@ -88,6 +115,10 @@ const submit = async () => {
emailInputEl.value!.setCustomValidity(res.error.data.message!); emailInputEl.value!.setCustomValidity(res.error.data.message!);
emailInputEl.value!.reportValidity(); emailInputEl.value!.reportValidity();
break; break;
case 'BANNED_USER':
emailInputEl.value!.setCustomValidity('This account has been disabled by an admin of this instance.');
emailInputEl.value!.reportValidity();
break;
default: default:
console.log(res.error); console.log(res.error);
alert(`Something went wrong. ${res.error.data.message}`); alert(`Something went wrong. ${res.error.data.message}`);
@@ -97,11 +128,94 @@ const submit = async () => {
return; return;
} }
const key = await deriveKey(form.password, res.data.user.id); if (res.data.twoFactorRequired) {
twoFactor.code = '';
twoFactor.error = '';
twoFactor.useBackupCode = false;
step.value = 'twoFactor';
await focusCodeInput();
return;
}
return finishSignIn(res.data.user.id);
};
// The password is still in memory at this point, which is the only time we
// can derive the key that encrypts the user's API keys.
const finishSignIn = async (userId: string) => {
const key = await deriveKey(form.password, userId);
localStorage.setItem('encryptionKey', JSON.stringify(key)); localStorage.setItem('encryptionKey', JSON.stringify(key));
return navigateTo(to ?? '/'); return navigateTo(to ?? '/');
}; };
const backToCredentials = (error = '') => {
step.value = 'credentials';
twoFactor.code = '';
twoFactor.error = '';
if (error) {
passwordInputEl.value!.setCustomValidity(error);
passwordInputEl.value!.reportValidity();
}
};
const toggleBackupCode = async () => {
twoFactor.useBackupCode = !twoFactor.useBackupCode;
twoFactor.code = '';
twoFactor.error = '';
await focusCodeInput();
};
const submitTwoFactor = async () => {
const code = twoFactor.code.trim();
if (code === '' || loading.value) return;
loading.value = true;
twoFactor.error = '';
const res = await verifyTwoFactor(code, {
backupCode: twoFactor.useBackupCode,
trustDevice: twoFactor.trustDevice,
});
if (!res.ok) {
loading.value = false;
if (res.error.data?.status === 429) {
twoFactor.error = 'Too many attempts. Wait a few seconds and try again.';
return;
}
switch (res.error.data?.code) {
case 'INVALID_CODE':
twoFactor.error = 'That code is not right. Check your authenticator app and try again.';
break;
case 'INVALID_BACKUP_CODE':
twoFactor.error = 'That backup code is not valid, or it was already used.';
break;
case 'ACCOUNT_TEMPORARILY_LOCKED':
twoFactor.error = 'Too many wrong codes. Two-factor sign-in is locked for a while, try again later.';
break;
case 'INVALID_TWO_FACTOR_COOKIE':
// the 10 minute window to enter a code ran out
backToCredentials('Sign in timed out. Enter your password again.');
break;
default:
twoFactor.error = 'Something went wrong. Try again.';
break;
}
twoFactor.code = '';
await focusCodeInput();
return;
}
await finishSignIn(res.data.user.id);
loading.value = false;
};
</script> </script>
<template> <template>
@@ -117,15 +231,35 @@ const submit = async () => {
<div class="z-10 max-w-sm w-full"> <div class="z-10 max-w-sm w-full">
<div class="flex flex-col items-center mb-4 text-center"> <div class="flex flex-col items-center mb-4 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1> <h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
<p class="text-[var(--text-secondary)]">Sign in to your account</p> <p class="text-[var(--text-secondary)]">
{{ step === 'credentials'
? 'Sign in to your account'
: twoFactor.useBackupCode
? 'Enter one of your backup codes'
: 'Enter the code from your authenticator app' }}
</p>
</div>
<div v-if="recovered !== undefined && step === 'credentials'" role="status"
class="flex gap-2.5 mb-4 px-3.5 py-3 rounded-lg bg-green-600/12 border border-green-400/40 text-sm leading-normal">
<span class="i-tabler-circle-check text-4.5 mt-0.5 flex-shrink-0 text-green-400" aria-hidden="true"></span>
<span>
<strong class="font-semibold">Your password is changed.</strong>
<span class="text-[var(--text-secondary)]">
Log in with the new one.
<template v-if="recovered === 'keys'">
Your saved API keys were deleted, add them again under Settings → AI Providers.
</template>
</span>
</span>
</div> </div>
<div class="rounded-2xl"> <div class="rounded-2xl">
<form class="flex flex-col gap-2" @submit.prevent="submit"> <form v-show="step === 'credentials'" class="flex flex-col gap-2" @submit.prevent="submit">
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label for="email" <label for="email"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label> class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label>
<input required pattern=".{1,}@.{1,}\..{2,3}" ref="emailInputEl" type="email" <input required pattern=".+@.+\..+" ref="emailInputEl" type="email"
autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com" autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" /> class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div> </div>
@@ -144,9 +278,62 @@ const submit = async () => {
<span v-else>Sign In</span> <span v-else>Sign In</span>
</button> </button>
</form> </form>
<form v-if="step === 'twoFactor'" class="flex flex-col gap-3" @submit.prevent="submitTwoFactor">
<div v-if="twoFactor.useBackupCode" class="flex flex-col gap-1.5">
<label for="two-factor-code"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
Backup code
</label>
<input required ref="backupCodeInputEl" type="text" autocomplete="off" autocapitalize="off"
spellcheck="false" id="two-factor-code" v-model="twoFactor.code"
placeholder="xxxxx-xxxxx"
class="font-mono focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<fieldset v-else>
<legend
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
6-digit code
</legend>
<CodeInput ref="codeInputEl" v-model="twoFactor.code" large :disabled="loading"
@complete="submitTwoFactor" />
</fieldset>
<p v-if="twoFactor.error" role="alert" class="text-sm text-red-400 ml-1">
{{ twoFactor.error }}
</p>
<label class="flex items-center gap-2.5 text-sm text-[var(--text-secondary)] cursor-pointer">
<input type="checkbox" v-model="twoFactor.trustDevice"
class="w-4 h-4 p-0 m-0 accent-[var(--color-accent)]" />
<span>Trust this browser for 30 days</span>
</label>
<button class="accent w-full h-11 flex items-center justify-center font-semibold" type="submit">
<span v-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
<span v-else>Verify</span>
</button>
<div class="flex flex-col items-center gap-2 mt-2 text-sm text-[var(--text-secondary)]">
<button type="button" @click="toggleBackupCode"
class="text-[var(--color-accent)] font-medium @hover:underline">
{{ twoFactor.useBackupCode ? 'Use your authenticator app instead' : 'Use a backup code instead' }}
</button>
<button type="button" @click="backToCredentials()" class="@hover:underline">
Back to log in
</button>
</div>
</form>
</div> </div>
<p v-if="!config.public.disableSignup" class="text-sm text-center mt-3 text-[var(--text-secondary)]"> <p v-if="step === 'credentials'" class="text-sm text-center mt-3">
<NuxtLink to="/auth/recover"
class="text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:underline">
Forgot your password?
</NuxtLink>
</p>
<p v-if="instance?.registration === 'open' && step === 'credentials'" class="text-sm text-center mt-1.5 text-[var(--text-secondary)]">
New to Veridian? New to Veridian?
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'" <NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
class="text-[var(--color-accent)] font-medium @hover:underline"> class="text-[var(--color-accent)] font-medium @hover:underline">
+239
View File
@@ -0,0 +1,239 @@
<script setup lang="ts">
import { deriveKey } from '~/utils/crypto';
definePageMeta({
layout: 'auth',
});
const token = useRoute().params.token as string;
const { signIn } = useAuth();
const { data: link } = await useAsyncData(`recovery_${token}`, () =>
$fetch(`/api/recover/${encodeURIComponent(token)}`));
// set when the link turns out to be used or expired on submit
const rejected = ref(false);
const valid = computed(() => !rejected.value && link.value?.valid ? link.value : null);
// An account an admin created has no password yet. Its owner picks the first
// one here, and nothing is lost by that.
const setup = computed(() => valid.value?.newAccount ? valid.value : null);
// someone who forgot their password, which costs them their saved API keys
const recovery = computed(() => valid.value && !valid.value.newAccount ? valid.value : null);
const keyCount = computed(() => recovery.value?.keyProviders.length ?? 0);
const form = reactive({
password: '',
confirm: '',
});
const understood = ref(false);
const busy = ref(false);
const error = ref('');
const confirmInputEl = ref<HTMLInputElement | null>(null);
// nothing to agree to when there are no keys to lose
const ready = computed(() => keyCount.value === 0 || understood.value);
const submit = async () => {
if (busy.value || !ready.value) return;
if (form.password !== form.confirm) {
confirmInputEl.value!.setCustomValidity('Passwords do not match');
confirmInputEl.value!.reportValidity();
return;
}
busy.value = true;
error.value = '';
try {
await $fetch(`/api/recover/${encodeURIComponent(token)}`, {
method: 'POST',
body: { password: form.password },
});
} catch (err: any) {
busy.value = false;
if (err?.data?.data?.code === 'RECOVERY_LINK_INVALID') {
rejected.value = true;
return;
}
console.error('Failed to reset password:', err);
error.value = setup.value
? 'The password could not be set. Try again.'
: 'The password could not be changed. Nothing was deleted, try again.';
return;
}
// a key left in this browser was derived from some other password
localStorage.removeItem('encryptionKey');
// A new account goes straight in: the password is still in memory, which is
// the only time the key for their API keys can be derived.
if (setup.value) {
const res = await signIn(setup.value.email, form.password);
if (res.ok && !res.data.twoFactorRequired) {
const key = await deriveKey(form.password, res.data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key));
return navigateTo('/welcome');
}
// the password is set, so logging in by hand still works
return navigateTo('/auth/login');
}
// the login page confirms the change, there is nothing left to do here
return navigateTo(`/auth/login?recovered=${keyCount.value > 0 ? 'keys' : 'done'}`);
};
</script>
<template>
<div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<!-- Background decorative blobs -->
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
</div>
<div
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
<p class="text-[var(--text-secondary)]">
<template v-if="setup">Welcome, {{ setup.name }}</template>
<template v-else-if="recovery">Set a new password for {{ recovery.email }}</template>
<template v-else>This link doesn't work</template>
</p>
</div>
<template v-if="!valid">
<p role="alert"
class="px-3 py-2.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] text-sm leading-snug text-[var(--text-secondary)]">
This link doesn't work anymore. It was used already, has expired, or was replaced by a
newer one. Ask whoever runs this instance for a new one.
</p>
<p class="text-sm text-center text-[var(--text-secondary)]">
<NuxtLink to="/auth/login" class="text-[var(--color-accent)] font-medium @hover:underline">
Back to log in
</NuxtLink>
</p>
</template>
<template v-else>
<p v-if="setup" class="text-sm leading-normal text-center text-[var(--text-secondary)]">
An account was created for you as <strong class="font-medium text-[var(--text-primary)]">{{
setup.email }}</strong>. Choose a password to start using it.
</p>
<div v-if="recovery" role="note"
class="flex flex-col gap-2 px-3 py-3 rounded-lg border text-sm leading-normal"
:class="keyCount > 0 ? 'border-red-400/40 bg-red-600/12' : 'border-[var(--color-border)]'">
<template v-if="keyCount > 0">
<div class="flex items-center gap-2">
<span class="i-tabler-alert-triangle text-4.5 flex-shrink-0 text-red-400"
aria-hidden="true"></span>
<strong class="font-semibold">
Your {{ keyCount }} saved API {{ keyCount === 1 ? 'key' : 'keys' }} will be deleted
</strong>
</div>
<p class="text-[13px] text-[var(--text-secondary)]">
They were encrypted with your old password. Without it nobody can decrypt them, not
you, not an admin, not the server. There is no way to get them back.
</p>
</template>
<p v-else class="text-[13px] text-[var(--text-secondary)]">
You have no saved API keys, so nothing of yours is lost.
</p>
<ul class="flex flex-col gap-1 text-[13px]">
<li v-if="keyCount > 0" class="flex items-start gap-2">
<span class="i-tabler-trash text-4 mt-0.5 flex-shrink-0 text-red-400" aria-hidden="true"></span>
<span>Deleted: API keys for <span class="case-capital">{{ recovery.keyProviders.join(', ') }}</span></span>
</li>
<li class="flex items-start gap-2">
<span class="i-tabler-logout text-4 mt-0.5 flex-shrink-0 text-[var(--text-secondary)]"
aria-hidden="true"></span>
<span>Signed out: every device that is signed in to this account</span>
</li>
<li v-if="recovery.resetSecurity" class="flex items-start gap-2">
<span class="i-tabler-shield-off text-4 mt-0.5 flex-shrink-0 text-[var(--text-secondary)]"
aria-hidden="true"></span>
<span>Reset: two-factor sign-in</span>
</li>
<li class="flex items-start gap-2">
<span class="i-tabler-circle-check text-4 mt-0.5 flex-shrink-0 text-[var(--text-secondary)]"
aria-hidden="true"></span>
<span>Kept: chats, agents, files, provider and model lists, settings</span>
</li>
</ul>
</div>
<form class="flex flex-col gap-2" @submit.prevent="submit">
<div class="flex flex-col gap-1.5">
<label for="password"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">{{
setup ? 'Password' : 'New password' }}</label>
<input required minlength="8" maxlength="128" type="password" autocomplete="new-password"
id="password" v-model="form.password" placeholder="••••••••" :disabled="busy"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<div class="flex flex-col gap-1.5">
<label for="confirmPassword"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Confirm
password</label>
<input required minlength="8" maxlength="128" ref="confirmInputEl" type="password"
autocomplete="new-password" id="confirmPassword" v-model="form.confirm"
placeholder="••••••••" :disabled="busy" @input="confirmInputEl?.setCustomValidity('')"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<label v-if="keyCount > 0" class="flex items-start gap-2.5 mt-1 text-[13px] leading-normal cursor-pointer">
<!-- a plain checkbox: the auth layout sizes every input as a text field -->
<input v-model="understood" type="checkbox" :disabled="busy"
class="!w-auto !p-0 mt-0.75 flex-shrink-0" style="accent-color: var(--color-accent)" />
<span>
I understand my API keys will be permanently deleted and I'll need to add them again.
</span>
</label>
<p v-if="setup" class="flex gap-2 mt-1 text-xs leading-normal text-[var(--text-secondary)]">
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
<span>
Your password also encrypts your API keys in the browser. If you forget it, saved
keys can't be recovered and have to be re-entered.
</span>
</p>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<button :disabled="busy || !ready" type="submit"
class="accent w-full mt-2 h-11 flex items-center justify-center font-semibold disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
<span v-else>{{ setup ? 'Set password and continue' : keyCount > 0 ? 'Reset password and delete keys' : 'Reset password' }}</span>
</button>
</form>
<p v-if="recovery" class="text-sm text-center text-[var(--text-secondary)]">
Remember your old password after all?
<NuxtLink to="/auth/login" class="text-[var(--color-accent)] font-medium @hover:underline">
Log in instead
</NuxtLink>
and change it from Settings to keep your keys.
</p>
</template>
</div>
</div>
</template>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
definePageMeta({
layout: 'auth',
});
</script>
<template>
<div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<!-- Background decorative blobs -->
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
</div>
<div
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
<p class="text-[var(--text-secondary)]">Forgot your password?</p>
</div>
<p class="text-sm leading-normal text-[var(--text-secondary)]">
This instance doesn't send email. An administrator can create a one-time recovery link for
you.
</p>
<ol class="flex flex-col gap-2.5 text-sm">
<li v-for="(step, index) in [
'Contact whoever runs this instance.',
'They send you a link that works once and expires in 24 hours.',
'Open it here and choose a new password.',
]" :key="step" class="flex items-start gap-3">
<span aria-hidden="true"
class="flex items-center justify-center w-6 h-6 flex-shrink-0 rounded-md border border-[var(--color-border-active)] text-xs text-[var(--text-secondary)]">
{{ index + 1 }}
</span>
<span class="mt-0.5">{{ step }}</span>
</li>
</ol>
<p class="text-[13px] leading-normal text-[var(--text-secondary)]">
Operators locked out of the only admin account can run
<code class="px-1.5 py-0.5 rounded bg-[var(--bg-container)] font-mono text-xs text-[var(--text-primary)] whitespace-nowrap">bun run recover &lt;email&gt;</code>
on the server to print a link.
</p>
<div role="note"
class="flex gap-2.5 px-3.5 py-3 rounded-lg bg-red-600/12 border border-red-400/40 text-sm leading-normal">
<span class="i-tabler-alert-triangle text-4.5 mt-0.5 flex-shrink-0 text-red-400"
aria-hidden="true"></span>
<span>
<strong class="font-semibold">Recovery deletes your saved API keys.</strong>
<span class="text-[var(--text-secondary)]">
They are encrypted with your current password and can't be read without it. Your
chats, agents and files are kept.
</span>
</span>
</div>
<p class="text-sm text-center text-[var(--text-secondary)]">
Remembered it?
<NuxtLink to="/auth/login" class="text-[var(--color-accent)] font-medium @hover:underline">
Back to log in
</NuxtLink>
</p>
</div>
</div>
</template>
+76 -6
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { deriveKey } from '~/utils/crypto'; import { deriveKey } from '~/utils/crypto';
import { timeLeft } from '~/utils/time';
const { client: authClient, signUp, session } = useAuth(); const { client: authClient, signUp, session } = useAuth();
@@ -12,12 +13,38 @@ if (session.value !== null) {
await navigateTo(to ?? '/'); await navigateTo(to ?? '/');
} }
const config = useRuntimeConfig(); // who may register is an instance setting, admins change it while it runs
const { data: instance } = await useFetch('/api/instance', { key: 'instance' });
if (config.public.disableSignup) { if (instance.value?.registration === 'closed') {
await navigateTo(to ? `/auth/login?to=${to}` : '/auth/login'); await navigateTo(to ? `/auth/login?to=${to}` : '/auth/login');
} }
const inviteOnly = computed(() => instance.value?.registration === 'invite');
// invite links look like /auth/register?invite=<token>
const inviteToken = typeof useRoute().query.invite === 'string' ? useRoute().query.invite as string : undefined;
const { data: invite } = await useAsyncData(`invite_${inviteToken ?? 'none'}`, async () => {
if (inviteToken === undefined) return null;
return await $fetch(`/api/invite/${encodeURIComponent(inviteToken)}`);
});
// set when the invite turns out to be used up or expired on submit
const inviteRejected = ref(false);
const validInvite = computed(() => !inviteRejected.value && invite.value?.valid ? invite.value : null);
// an invite only instance takes nobody without a working link
const blocked = computed(() => inviteOnly.value && validInvite.value === null);
const inviteValidFor = computed(() => {
if (!validInvite.value) return '';
return `Invite valid for ${timeLeft(validInvite.value.expiresAt)} more${validInvite.value.singleUse ? ' · single use' : ''}`;
});
const form = reactive({ const form = reactive({
name: '', name: '',
email: '', email: '',
@@ -55,6 +82,9 @@ onMounted(() => {
form.confirmPassword = tempForm.confirmPassword; form.confirmPassword = tempForm.confirmPassword;
hydrated.value = true; hydrated.value = true;
// there is no form when the invite does not work
if (blocked.value) return;
nameInputEl.value!.addEventListener('input', () => { nameInputEl.value!.addEventListener('input', () => {
nameInputEl.value!.setCustomValidity(''); nameInputEl.value!.setCustomValidity('');
}); });
@@ -94,7 +124,7 @@ const submit = async () => {
loading.value = true; loading.value = true;
const res = await signUp(form.email, form.password, form.name); const res = await signUp(form.email, form.password, form.name, inviteOnly.value ? inviteToken : undefined);
loading.value = false; loading.value = false;
if (!res.ok) { if (!res.ok) {
if (!res.error.data) { if (!res.error.data) {
@@ -121,6 +151,14 @@ const submit = async () => {
passwordInputEl.value!.setCustomValidity(res.error.data.message!); passwordInputEl.value!.setCustomValidity(res.error.data.message!);
passwordInputEl.value!.reportValidity(); passwordInputEl.value!.reportValidity();
break; break;
case 'INVITE_INVALID':
// used by someone else or expired while this page was open
inviteRejected.value = true;
break;
case 'REGISTRATION_CLOSED':
// an admin closed registration while this page was open
alert('Registration is closed on this instance. Ask an admin for an account.');
break;
default: default:
console.log(res.error); console.log(res.error);
alert('Something went wrong: ' + res.error.data.message); alert('Something went wrong: ' + res.error.data.message);
@@ -133,7 +171,8 @@ const submit = async () => {
const key = await deriveKey(form.password, res.data.user.id); const key = await deriveKey(form.password, res.data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key)); localStorage.setItem('encryptionKey', JSON.stringify(key));
return navigateTo(to ?? '/'); // connecting a provider comes next, there is nothing to chat with before that
return navigateTo(to ? `/welcome?to=${to}` : '/welcome');
}; };
</script> </script>
@@ -153,7 +192,30 @@ const submit = async () => {
<p class="text-[var(--text-secondary)]">Create your account</p> <p class="text-[var(--text-secondary)]">Create your account</p>
</div> </div>
<div class="rounded-2xl"> <p v-if="blocked" role="alert"
class="px-3 py-2.5 mb-1 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] text-sm leading-snug text-[var(--text-secondary)]">
<template v-if="inviteToken === undefined">
This instance is invite only. Ask an admin for an invite link to create an account.
</template>
<template v-else>
This invite link doesn't work anymore. It was used already, has expired, or was revoked.
Ask an admin for a new one.
</template>
</p>
<div v-if="inviteOnly && validInvite"
class="flex items-center gap-3 px-3 py-2.5 mb-5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)]">
<UserAvatar :user="validInvite.inviter" class="w-8 h-8 rounded-lg text-sm"></UserAvatar>
<div class="flex flex-col gap-0.25 text-sm min-w-0">
<span class="overflow-hidden text-ellipsis">
<strong class="font-semibold">{{ validInvite.inviter.name }}</strong> invited you to this
instance
</span>
<span class="text-xs text-[var(--text-secondary)]">{{ inviteValidFor }}</span>
</div>
</div>
<div v-if="!blocked" class="rounded-2xl">
<form class="flex flex-col gap-2" @submit.prevent="submit"> <form class="flex flex-col gap-2" @submit.prevent="submit">
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label for="name" <label for="name"
@@ -166,7 +228,7 @@ const submit = async () => {
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label for="email" <label for="email"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label> class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label>
<input required pattern=".{1,}@.{1,}\..{2,3}" ref="emailInputEl" type="email" <input required pattern=".+@.+\..+" ref="emailInputEl" type="email"
autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com" autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" /> class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div> </div>
@@ -189,6 +251,14 @@ const submit = async () => {
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" /> class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div> </div>
<p class="flex gap-2 mt-1 text-xs leading-normal text-[var(--text-secondary)]">
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
<span>
Your password also encrypts your API keys in the browser. If you forget it, saved
keys can't be recovered and have to be re-entered.
</span>
</p>
<button :disabled="!hydrated" <button :disabled="!hydrated"
class="accent w-full mt-2 h-11 flex items-center justify-center font-semibold" type="submit"> class="accent w-full mt-2 h-11 flex items-center justify-center font-semibold" type="submit">
<span v-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span> <span v-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { deriveKey } from '~/utils/crypto';
const { signIn, fetchSession } = useAuth();
definePageMeta({
layout: 'auth',
});
// the auth middleware only lets this page render while no account exists
const setupNeeded = useState<boolean | null>('instance:setupNeeded', () => null);
const form = reactive({
name: '',
email: '',
password: '',
confirmPassword: '',
});
const loading = ref(false);
const error = ref('');
const alreadySetUp = ref(false);
const nameInputEl = ref<HTMLInputElement | null>(null);
const emailInputEl = ref<HTMLInputElement | null>(null);
const passwordInputEl = ref<HTMLInputElement | null>(null);
const confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
const tempForm = {
name: '',
email: '',
password: '',
confirmPassword: '',
};
// prevent text fields from clearing on hydration
onBeforeMount(() => {
tempForm.name = (document.getElementById('name') as HTMLInputElement)?.value ?? '';
tempForm.email = (document.getElementById('email') as HTMLInputElement)?.value ?? '';
tempForm.password = (document.getElementById('password') as HTMLInputElement)?.value ?? '';
tempForm.confirmPassword = (document.getElementById('confirmPassword') as HTMLInputElement)?.value ?? '';
});
const hydrated = ref(false);
onMounted(() => {
form.name = tempForm.name;
form.email = tempForm.email;
form.password = tempForm.password;
form.confirmPassword = tempForm.confirmPassword;
hydrated.value = true;
emailInputEl.value!.addEventListener('input', () => {
emailInputEl.value!.setCustomValidity('');
});
const checkPasswordsMatch = () => {
const matches = passwordInputEl.value!.value === confirmPasswordInputEl.value!.value;
confirmPasswordInputEl.value!.setCustomValidity(matches ? '' : 'Passwords do not match');
};
passwordInputEl.value!.addEventListener('input', checkPasswordsMatch);
confirmPasswordInputEl.value!.addEventListener('input', () => {
checkPasswordsMatch();
confirmPasswordInputEl.value!.reportValidity();
});
});
const submit = async () => {
if (loading.value) return;
const inputs = [nameInputEl, emailInputEl, passwordInputEl, confirmPasswordInputEl];
for (const input of inputs) {
if (input.value?.validity.valid === false) {
input.value!.reportValidity();
return;
}
}
loading.value = true;
error.value = '';
try {
await $fetch('/api/setup', {
method: 'POST',
body: {
name: form.name,
email: form.email,
password: form.password,
},
});
} catch (err: any) {
loading.value = false;
// TODO: i18n
switch (err?.data?.data?.code as string | undefined) {
case 'SETUP_ALREADY_DONE':
// someone else created the first account in the meantime
setupNeeded.value = false;
alreadySetUp.value = true;
break;
case 'INVALID_EMAIL':
emailInputEl.value!.setCustomValidity('That email address is not valid');
emailInputEl.value!.reportValidity();
break;
case 'INVALID_BODY':
case 'PASSWORD_TOO_LONG':
error.value = err.data.message ?? 'Check the form and try again.';
break;
default:
console.error('Failed to set up the instance:', err);
error.value = 'The admin account could not be created. Try again.';
break;
}
return;
}
setupNeeded.value = false;
const res = await signIn(form.email, form.password);
if (!res.ok || res.data.twoFactorRequired) {
// the account exists, so logging in by hand still works
return navigateTo('/auth/login');
}
const key = await deriveKey(form.password, res.data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key));
// the next steps are for admins only, so make sure the role is loaded
await fetchSession();
// on to the rest of the setup: who can register, then a first provider
return navigateTo('/setup/instance');
};
</script>
<template>
<div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<!-- Background decorative blobs -->
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
</div>
<div
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
<p class="text-[var(--text-secondary)]">Set up this instance</p>
</div>
<div v-if="alreadySetUp" class="flex flex-col gap-4">
<p role="alert"
class="px-3 py-2.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] text-sm leading-snug text-[var(--text-secondary)]">
Someone else just created the first account, so this instance already has its
administrator. Nothing was created for you.
</p>
<NuxtLink to="/auth/login" class="w-full h-11 flex items-center justify-center font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)]">
Go to log in
</NuxtLink>
</div>
<template v-else>
<OnboardingSetupSteps :current="1"></OnboardingSetupSteps>
<p
class="px-3 py-2.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] text-sm leading-snug text-[var(--text-secondary)]">
No accounts exist yet. The account you create here becomes the
<strong class="font-medium text-[var(--text-primary)]">administrator</strong> of this instance.
</p>
<form class="flex flex-col gap-2" @submit.prevent="submit">
<div class="flex flex-col gap-1.5">
<label for="name"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Name</label>
<input required maxlength="128" ref="nameInputEl" type="text" autocomplete="name" id="name"
v-model="form.name" placeholder="John Doe"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<div class="flex flex-col gap-1.5">
<label for="email"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label>
<input required pattern=".+@.+\..+" ref="emailInputEl" type="email"
autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<div class="flex flex-col gap-1.5">
<label for="password"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Password</label>
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
autocomplete="new-password" id="password" v-model="form.password" placeholder="••••••••"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<div class="flex flex-col gap-1.5">
<label for="confirmPassword"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Confirm
password</label>
<input required minlength="8" maxlength="128" ref="confirmPasswordInputEl" type="password"
autocomplete="new-password" id="confirmPassword" v-model="form.confirmPassword"
placeholder="••••••••"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<p class="flex gap-2 mt-1 text-xs leading-normal text-[var(--text-secondary)]">
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
<span>
Your password also encrypts your API keys in the browser. If you forget it, saved
keys can't be recovered and have to be re-entered.
</span>
</p>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<button :disabled="!hydrated || loading"
class="accent w-full mt-2 h-11 flex items-center justify-center font-semibold" type="submit">
<span v-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
<span v-else>Create admin account</span>
</button>
</form>
</template>
</div>
<div class="absolute bottom-4 right-4">
<ThemeSwitcher size="medium" />
</div>
</div>
</template>
+1 -1
View File
@@ -95,7 +95,7 @@ const handleChatSubmit = async (message: BaseMessage, model: ModelWithProvider |
} }
} }
const topic = await createTopic(targetAgent.id); const topic = await createTopic(targetAgent.id, message);
if (!topic) { if (!topic) {
console.error('Failed to create topic'); console.error('Failed to create topic');
return; return;
+2 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue'; import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
import { resolveFileUrl } from '~~/utils/url';
const { open: sidebarOpen, openSidebar } = useSidebar(); const { open: sidebarOpen, openSidebar } = useSidebar();
const { const {
@@ -88,7 +89,7 @@ const handleRowClick = (e: MouseEvent) => {
if (file && isImage(file.mimeType)) { if (file && isImage(file.mimeType)) {
const row = trigger.closest('[data-file-id]') as HTMLElement; const row = trigger.closest('[data-file-id]') as HTMLElement;
previewOrigin.value = row?.getBoundingClientRect() ?? null; previewOrigin.value = row?.getBoundingClientRect() ?? null;
previewSrc.value = file.url; previewSrc.value = resolveFileUrl(file.url);
} }
break; break;
} }
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
const { open: sidebarOpen, openSidebar } = useSidebar();
const { providers } = await useModels();
const route = useRoute();
const TITLES: Record<string, string> = {
profile: 'Profile',
security: 'Security',
general: 'General',
appearance: 'Appearance',
providers: 'AI Providers',
assistants: 'System Assistants',
instance: 'Instance',
};
const title = computed(() => {
const [section, id] = route.path.split('/').slice(2);
if (section === 'providers' && id) {
const provider = providers.value.find(p => p.id === id);
return provider ? provider.name : 'Unknown Provider';
}
if (section === 'instance' && id === 'users') return 'Users';
return TITLES[section ?? ''] ?? 'Settings';
});
</script>
<template>
<div class="h-full w-full flex flex-col">
<div class="h-14 flex items-center justify-between px-4 shrink-0">
<div class="flex items-center gap-2">
<button v-if="!sidebarOpen" @click="openSidebar"
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-panel-left-open text-5"></span>
</button>
<h1 class="text-lg font-semibold text-[var(--text-primary)] case-capital">{{ title }}</h1>
</div>
</div>
<div class="flex-1 min-h-0 flex flex-col px-4">
<NuxtPage />
</div>
</div>
</template>
@@ -16,13 +16,11 @@ const updateHinting = (e: Event) => {
const hinting = parseInt((e.target as HTMLInputElement).value); const hinting = parseInt((e.target as HTMLInputElement).value);
updateSettings({ appearance: { hinting } }); updateSettings({ appearance: { hinting } });
}; };
defineEmits(['navigate']);
</script> </script>
<template> <template>
<div <div
class="flex flex-col gap-6 py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]"> class="flex flex-col gap-6 -mx-4 p-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<div class="flex flex-row items-center justify-between gap-2"> <div class="flex flex-row items-center justify-between gap-2">
<h4 class="font-medium">Theme</h4> <h4 class="font-medium">Theme</h4>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -1,18 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ModelWithProvider } from '~/composables/useModels'; import type { ModelWithProvider } from '~/composables/useModels';
const props = defineProps<{
params?: string;
}>();
const { providers, allModels } = await useModels(); const { providers, allModels } = await useModels();
const { settings, updateSettings } = await useUserSettings(); const { settings, updateSettings } = await useUserSettings();
const toggle = async (key: string) => { const toggle = async () => {
const current = (settings.value.systemAssistants as any)[key]; const current = settings.value.systemAssistants.rename;
await updateSettings({ await updateSettings({
systemAssistants: { systemAssistants: {
[key]: { rename: {
...current, ...current,
enabled: !current.enabled enabled: !current.enabled
} }
@@ -20,11 +16,11 @@ const toggle = async (key: string) => {
}); });
}; };
const updateModel = async (key: string, model: ModelWithProvider | undefined | null) => { const updateModel = async (model: ModelWithProvider | undefined | null) => {
const current = (settings.value.systemAssistants as any)[key]; const current = settings.value.systemAssistants.rename;
await updateSettings({ await updateSettings({
systemAssistants: { systemAssistants: {
[key]: { rename: {
...current, ...current,
modelId: model?.id ?? null modelId: model?.id ?? null
} }
@@ -37,22 +33,20 @@ const getModel = (id: string | null | undefined) => {
return allModels.value.find(m => m.id === id); return allModels.value.find(m => m.id === id);
} }
defineEmits(['navigate']);
</script> </script>
<template> <template>
<div <div
class="flex flex-col gap-4 py-4 flex-grow overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]"> class="flex flex-col gap-4 py-4 flex-grow overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<div class="flex flex-col" v-for="(systemAssistant, key) in settings.systemAssistants" :key="key"> <div class="flex flex-col">
<label :for="`slider-${key}`" class="flex justify-between gap-4 items-center"> <label for="slider-rename" class="flex justify-between gap-4 items-center">
<h4 class="case-capital">{{ key }}</h4> <h4 class="case-capital">Rename</h4>
<Slider :id="`slider-${key}`" :checked="systemAssistant?.enabled ?? false" <Slider id="slider-rename" :checked="settings.systemAssistants.rename?.enabled ?? false"
@click="toggle(String(key))" /> @click="toggle" />
</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(settings.systemAssistants.rename?.modelId)"
@update:model-value="(model) => updateModel(String(key), model)" /> @update:model-value="updateModel" />
</div> </div>
</div> </div>
</div> </div>
+5
View File
@@ -0,0 +1,5 @@
<template>
<div class="py-4 text-sm text-[var(--text-secondary)]">
Nothing to configure here yet.
</div>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({
redirect: '/settings/profile',
});
</script>
<template>
<div></div>
</template>
+204
View File
@@ -0,0 +1,204 @@
<script setup lang="ts">
import type { RegistrationMode } from '~~/drizzle/schema';
import { DialogType } from '~/composables/useDialog';
import { timeLeft } from '~/utils/time';
type InstanceSettings = {
registration: RegistrationMode;
registrationLocked: boolean;
};
const REGISTRATION_OPTIONS: { value: RegistrationMode; label: string; description: string }[] = [
{
value: 'open',
label: 'Open',
description: 'Anyone who can reach this instance can create an account.',
},
{
value: 'invite',
label: 'Invite only',
description: 'New accounts need an invite link from an admin.',
},
{
value: 'closed',
label: 'Closed',
description: 'Nobody can register. Admins create accounts under Users.',
},
];
const { isAdmin } = useAuth();
const { openDialog } = useDialog();
if (!isAdmin.value) {
await navigateTo('/settings/profile');
}
const { data: instance } = await useFetch<InstanceSettings>('/api/admin/instance', {
key: 'admin_instance',
});
type Invite = {
id: string;
token: string;
maxUses: number;
uses: number;
expiresAt: string;
createdAt: string;
};
const { data: inviteData, refresh: loadInvites } = await useFetch<Invite[]>('/api/admin/invites', {
key: 'admin_invites',
});
const invites = computed(() => inviteData.value ?? []);
const saving = ref(false);
const error = ref('');
const locked = computed(() => !!instance.value?.registrationLocked);
const current = computed(() => REGISTRATION_OPTIONS.find(o => o.value === instance.value?.registration));
const setRegistration = async (registration: RegistrationMode) => {
if (saving.value || locked.value || !instance.value || instance.value.registration === registration) return;
saving.value = true;
error.value = '';
try {
instance.value = await $fetch<InstanceSettings>('/api/admin/instance', {
method: 'PUT',
body: { registration },
});
// the sign in pages read this to decide whether to offer registering
clearNuxtData('instance');
} catch (err) {
console.error('Failed to change registration:', err);
error.value = 'Registration could not be changed. Try again.';
}
saving.value = false;
};
// ---------------------------------------------------------------------------
// Invite links
// ---------------------------------------------------------------------------
const inviteBusy = ref(false);
const inviteError = ref('');
const copiedId = ref<string | null>(null);
const invitePath = (invite: Invite) => `/auth/register?invite=${invite.token}`;
const describeInvite = (invite: Invite) => {
return `${invite.uses} of ${invite.maxUses} ${invite.maxUses === 1 ? 'use' : 'uses'} · expires in ${timeLeft(invite.expiresAt)}`;
};
const copyInvite = async (invite: Invite) => {
try {
await navigator.clipboard.writeText(`${window.location.origin}${invitePath(invite)}`);
copiedId.value = invite.id;
setTimeout(() => {
if (copiedId.value === invite.id) copiedId.value = null;
}, 2000);
} catch (err) {
console.error('Failed to copy invite link:', err);
inviteError.value = 'The link could not be copied. Select it and copy it by hand.';
}
};
// The dialog asks how long the link lasts and for how many people, and shows
// the link. It may be closed after the link was made, so always reload.
const createInvite = () => {
openDialog(DialogType.CreateInvite, async () => {
await loadInvites();
});
};
const revokeInvite = async (invite: Invite) => {
if (inviteBusy.value) return;
inviteBusy.value = true;
inviteError.value = '';
try {
await $fetch(`/api/admin/invites/${invite.id}`, { method: 'DELETE' });
await loadInvites();
} catch (err) {
console.error('Failed to revoke invite:', err);
inviteError.value = 'The invite link could not be revoked. Try again.';
}
inviteBusy.value = false;
};
</script>
<template>
<div
class="flex flex-col py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<div class="flex flex-col gap-7 w-full max-w-3xl">
<section class="flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col gap-0.5 flex-1 min-w-70">
<h2 class="font-medium">Registration</h2>
<p class="text-[13px] text-[var(--text-secondary)]">
{{ current?.description ?? 'The instance settings could not be loaded.' }}
</p>
</div>
<div role="group" aria-label="Registration mode"
class="flex gap-0.5 p-0.5 rounded-lg bg-[var(--bg-container)]">
<button v-for="option in REGISTRATION_OPTIONS" :key="option.value" type="button"
:aria-pressed="instance?.registration === option.value"
:disabled="saving || locked || !instance" @click="setRegistration(option.value)"
class="flex items-center h-7.5 px-3 text-sm rounded-md disabled:cursor-not-allowed transition-colors duration-200"
:class="instance?.registration === option.value
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] font-medium'
: 'text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)]'">
{{ option.label }}
</button>
</div>
</div>
<p v-if="locked" class="flex gap-2 text-[13px] leading-normal text-[var(--text-secondary)]">
<span class="i-mynaui-lock text-4 mt-0.5 flex-shrink-0" aria-hidden="true"></span>
<span>
Registration is turned off by the <code class="font-mono text-xs">DISABLE_SIGNUP</code>
environment variable. Remove it and restart the server to change this here.
</span>
</p>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<!-- links only let anyone in while the instance is invite only -->
<template v-if="instance?.registration === 'invite'">
<ul v-if="invites.length > 0" class="flex flex-col gap-1">
<li v-for="invite in invites" :key="invite.id"
class="flex flex-wrap items-center gap-3 min-h-11 px-3 py-1.5 rounded-lg bg-[var(--bg-container)] text-sm">
<span
class="flex-1 min-w-55 font-mono text-[13px] overflow-hidden text-ellipsis whitespace-nowrap select-all">
{{ invitePath(invite) }}
</span>
<span class="text-xs text-[var(--text-secondary)]">{{ describeInvite(invite) }}</span>
<button type="button" @click="copyInvite(invite)"
class="flex items-center h-7 px-2 text-[13px] rounded-md @hover:bg-[var(--color-hover)] transition-colors duration-200">
{{ copiedId === invite.id ? 'Copied' : 'Copy' }}
</button>
<button type="button" :disabled="inviteBusy" @click="revokeInvite(invite)"
class="flex items-center h-7 px-2 text-[13px] rounded-md text-red-400 @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
Revoke
</button>
</li>
</ul>
<p v-if="inviteError" role="alert" class="text-sm text-red-400">{{ inviteError }}</p>
<button type="button" @click="createInvite"
class="self-start flex items-center gap-1.5 h-8 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] transition-colors duration-200">
<span class="i-mynaui-plus text-4" aria-hidden="true"></span>
New invite link
</button>
</template>
</section>
</div>
</div>
</template>
+383
View File
@@ -0,0 +1,383 @@
<script setup lang="ts">
import { DialogType } from '~/composables/useDialog';
type UserRow = {
id: string;
name: string;
email: string;
image: string | null;
role: 'admin' | 'user';
banned: boolean;
twoFactorEnabled: boolean;
// created by an admin and has not set a password yet
pending: boolean;
createdAt: string;
lastActiveAt: string | null;
};
type Filter = 'all' | 'admins' | 'disabled';
const GRID_CLASS = 'grid grid-cols-[minmax(230px,2fr)_90px_100px_60px_150px_110px_36px] items-center gap-3 px-3';
const MENU_ITEM_CLASS = 'flex items-center gap-2 h-8.5 px-2.5 text-sm text-left rounded-lg @hover:bg-[var(--color-hover)] transition-colors duration-150';
// same tinted pill as the role badge on the profile page
const ACCENT_PILL_STYLE = {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 20%, transparent)',
borderColor: 'color-mix(in srgb, var(--color-accent) 50%, transparent)',
};
const { client: authClient, user: me, isAdmin } = useAuth();
const { openDialog } = useDialog();
if (!isAdmin.value) {
await navigateTo('/settings/profile');
}
const { data: userData, error: loadError, refresh: loadUsers } = await useFetch<UserRow[]>('/api/admin/users', {
key: 'admin_users',
});
const users = computed(() => userData.value ?? []);
const search = ref('');
const filter = ref<Filter>('all');
const filters = computed<{ value: Filter; label: string; count: number }[]>(() => [
{ value: 'all', label: 'All', count: users.value.length },
{ value: 'admins', label: 'Admins', count: users.value.filter(u => u.role === 'admin').length },
{ value: 'disabled', label: 'Disabled', count: users.value.filter(u => u.banned).length },
]);
const visibleUsers = computed(() => {
const query = search.value.trim().toLowerCase();
return users.value.filter((u) => {
if (filter.value === 'admins' && u.role !== 'admin') return false;
if (filter.value === 'disabled' && !u.banned) return false;
return query === '' || u.name.toLowerCase().includes(query) || u.email.toLowerCase().includes(query);
});
});
// fixed locale and time zone so the server and client render the same text
const formatDate = (date: string | Date) => new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
// The server does not know the viewer's time zone, so it renders the date only
// and the time of day is filled in once the page is running in the browser.
const mounted = ref(false);
onMounted(() => {
mounted.value = true;
});
const formatDateTime = (date: string | Date) => {
if (!mounted.value) return formatDate(date);
return new Date(date).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
};
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
const busyId = ref<string | null>(null);
const actionError = ref('');
// Runs one action on a user and reloads the list. The action returns false
// when it did not go through.
const run = async (user: UserRow, failure: string, action: () => Promise<boolean>) => {
if (busyId.value !== null) return;
busyId.value = user.id;
actionError.value = '';
let ok = false;
try {
ok = await action();
} catch (error) {
console.error(failure, error);
}
if (!ok) actionError.value = failure;
await loadUsers();
busyId.value = null;
};
const confirmThen = (options: { title: string; message: string; confirmLabel: string; danger?: boolean }, action: () => void) => {
openDialog(DialogType.Confirm, (result) => {
if (result.ok === false) return;
action();
}, options);
};
const setRole = (user: UserRow, role: 'admin' | 'user') => {
const change = () => run(user, `${user.name}'s role could not be changed.`, async () => {
const { error } = await authClient.admin.setRole({ userId: user.id, role });
return !error;
});
if (role === 'user') return change();
confirmThen({
title: `Make ${user.name} an admin?`,
message: 'Admins can create, disable and delete accounts, change roles and change who can register.',
confirmLabel: 'Make admin',
}, change);
};
const signOutEverywhere = (user: UserRow) => run(user, `${user.name} could not be signed out.`, async () => {
const { error } = await authClient.admin.revokeUserSessions({ userId: user.id });
return !error;
});
const recoverAccount = (user: UserRow) => {
openDialog(DialogType.RecoverAccount, undefined, { user });
};
const resetTwoFactor = (user: UserRow) => confirmThen({
title: `Reset two-factor for ${user.name}?`,
message: 'Turns two-factor sign-in off for this account and throws away its backup codes. They sign in with only their password until they set it up again.',
confirmLabel: 'Reset two-factor',
}, () => run(user, `Two-factor could not be reset for ${user.name}.`, async () => {
await $fetch(`/api/admin/users/${user.id}/two-factor`, { method: 'DELETE' });
return true;
}));
const setDisabled = (user: UserRow, disabled: boolean) => {
const failure = `${user.name}'s account could not be ${disabled ? 'disabled' : 'enabled'}.`;
if (!disabled) {
return run(user, failure, async () => {
const { error } = await authClient.admin.unbanUser({ userId: user.id });
return !error;
});
}
confirmThen({
title: `Disable ${user.name}'s account?`,
message: 'They are signed out and can\'t sign in until the account is enabled again. Nothing is deleted.',
confirmLabel: 'Disable account',
}, () => run(user, failure, async () => {
const { error } = await authClient.admin.banUser({ userId: user.id });
return !error;
}));
};
const deleteUser = (user: UserRow) => confirmThen({
title: `Delete ${user.name}'s account?`,
message: `This permanently deletes ${user.email} with their agents, topics, messages, providers, saved API keys and file list. It can't be undone.`,
confirmLabel: 'Delete account',
danger: true,
}, () => run(user, `${user.name}'s account could not be deleted.`, async () => {
await $fetch(`/api/admin/users/${user.id}`, { method: 'DELETE' });
return true;
}));
// ---------------------------------------------------------------------------
// Invite links
// ---------------------------------------------------------------------------
// only offered while the instance is invite only, links do nothing otherwise
const { data: instance } = await useFetch<{ registration: string }>('/api/admin/instance', {
key: 'admin_instance',
});
// the dialog asks for the details and shows the link to copy
const createInvite = () => {
openDialog(DialogType.CreateInvite, () => {
// the Instance page lists the links, so it has to load the new one
clearNuxtData('admin_invites');
});
};
const openCreateUser = () => {
// the dialog can be closed after the account was made, so always reload
openDialog(DialogType.CreateUser, async () => {
await loadUsers();
});
};
</script>
<template>
<div
class="flex flex-col pb-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<div class="flex flex-wrap items-center justify-between gap-3 py-3">
<div class="flex flex-wrap items-center gap-2">
<label class="flex items-center gap-1.5 h-8 px-2 rounded-md bg-[var(--bg-container)]">
<span class="i-mynaui-search text-4 text-[var(--text-secondary)]" aria-hidden="true"></span>
<input v-model="search" type="search" aria-label="Search users" placeholder="Search users..."
class="w-45 bg-transparent text-[13px] outline-none placeholder:text-[var(--text-tertiary)]" />
</label>
<button v-for="f in filters" :key="f.value" type="button" :aria-pressed="filter === f.value"
@click="filter = f.value"
class="flex items-center gap-1.5 h-8 px-2.5 text-[13px] rounded-md transition-colors duration-200"
:class="filter === f.value
? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] @hover:bg-[var(--color-hover)]'">
{{ f.label }} <span class="text-[var(--text-secondary)]">{{ f.count }}</span>
</button>
</div>
<div class="flex gap-2">
<button type="button" @click="openCreateUser"
class="flex items-center h-8 px-3 text-[13px] rounded-md border border-[var(--color-border)] @hover:bg-[var(--color-hover)] transition-colors duration-200">
Create user
</button>
<button v-if="instance?.registration === 'invite'" type="button" @click="createInvite"
class="flex items-center gap-1.5 h-8 px-3 text-[13px] font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-50 disabled:cursor-not-allowed">
<span class="i-mynaui-link text-4" aria-hidden="true"></span>
Invite link
</button>
</div>
</div>
<p v-if="loadError" role="alert" class="pb-3 text-sm text-red-400">The users could not be loaded.</p>
<p v-if="actionError" role="alert" class="pb-3 text-sm text-red-400">{{ actionError }}</p>
<div class="overflow-x-auto rounded-lg border border-[var(--color-border)]">
<div role="table" aria-label="Registered users" class="min-w-215">
<div role="row" :class="GRID_CLASS"
class="h-9 bg-[var(--bg-container)] text-xs font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
<span role="columnheader">User</span>
<span role="columnheader">Role</span>
<span role="columnheader">Status</span>
<span role="columnheader">2FA</span>
<span role="columnheader">Last active</span>
<span role="columnheader">Joined</span>
<span role="columnheader" aria-label="Actions"></span>
</div>
<div v-for="u in visibleUsers" :key="u.id" role="row" :class="GRID_CLASS"
class="h-14 border-t border-[var(--color-border)] text-sm">
<div role="cell" class="flex items-center gap-2.5 min-w-0" :class="u.banned ? 'opacity-60' : ''">
<UserAvatar :user="u" class="w-8 h-8 rounded-lg text-sm"></UserAvatar>
<span class="flex flex-col min-w-0">
<span class="font-medium whitespace-nowrap overflow-hidden text-ellipsis">
{{ u.name }}
<span v-if="u.id === me?.id"
class="font-normal text-[var(--text-secondary)]">(you)</span>
</span>
<span
class="text-xs text-[var(--text-secondary)] whitespace-nowrap overflow-hidden text-ellipsis">
{{ u.email }}
</span>
</span>
</div>
<span role="cell">
<span v-if="u.role === 'admin'"
class="text-xs font-medium px-2 py-0.5 rounded-md border text-[var(--text-primary)]"
:style="ACCENT_PILL_STYLE">Admin</span>
<span v-else
class="text-xs font-medium px-2 py-0.5 rounded-md border border-[var(--color-border-active)] text-[var(--text-secondary)]">User</span>
</span>
<span role="cell" class="flex items-center gap-1.5 text-[13px]"
:class="u.banned ? 'text-red-400' : u.pending ? 'text-amber-400' : 'text-green-400'">
<span aria-hidden="true" class="w-1.75 h-1.75 rounded-full bg-current"></span>
{{ u.banned ? 'Disabled' : u.pending ? 'Invited' : 'Active' }}
</span>
<span role="cell" class="text-[13px]"
:class="u.twoFactorEnabled ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
{{ u.twoFactorEnabled ? 'On' : 'Off' }}
</span>
<span role="cell" class="text-[13px] text-[var(--text-secondary)]">
{{ u.lastActiveAt ? formatDateTime(u.lastActiveAt) : 'Never' }}
</span>
<span role="cell" class="text-[13px] text-[var(--text-secondary)]">
{{ formatDate(u.createdAt) }}
</span>
<span role="cell">
<span v-if="busyId === u.id" class="flex items-center justify-center w-7.5 h-7.5">
<span class="i-svg-spinners-180-ring-with-bg text-4 text-[var(--text-secondary)]"></span>
</span>
<Dropdown v-else-if="u.id !== me?.id" placement="bottom-end" dropdownClass="w-60">
<template #default="{ setRef, isOpen, toggle }">
<button :ref="setRef" type="button" :aria-label="`Actions for ${u.name}`"
:disabled="busyId !== null" @click="toggle"
class="flex items-center justify-center w-7.5 h-7.5 rounded-md text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 transition-colors duration-200"
:class="isOpen ? 'bg-[var(--color-hover)]' : ''">
<span class="i-tabler-dots text-4.5 pointer-events-none"></span>
</button>
</template>
<template #dropdown="{ close }">
<button v-if="u.role === 'admin'" type="button" role="menuitem" :class="MENU_ITEM_CLASS"
@click="close(); setRole(u, 'user')">
<span class="i-tabler-shield-minus text-4.5" aria-hidden="true"></span>
Remove admin
</button>
<button v-else type="button" role="menuitem" :class="MENU_ITEM_CLASS"
@click="close(); setRole(u, 'admin')">
<span class="i-tabler-shield-check text-4.5" aria-hidden="true"></span>
Make admin
</button>
<button type="button" role="menuitem" :class="MENU_ITEM_CLASS"
@click="close(); recoverAccount(u)">
<span class="i-tabler-key text-4.5" aria-hidden="true"></span>
{{ u.pending ? 'New setup link…' : 'Recover account…' }}
</button>
<button v-if="u.twoFactorEnabled" type="button" role="menuitem" :class="MENU_ITEM_CLASS"
@click="close(); resetTwoFactor(u)">
<span class="i-tabler-shield-off text-4.5" aria-hidden="true"></span>
Reset two-factor
</button>
<button type="button" role="menuitem" :class="MENU_ITEM_CLASS"
@click="close(); signOutEverywhere(u)">
<span class="i-tabler-logout text-4.5" aria-hidden="true"></span>
Sign out all sessions
</button>
<hr class="w-full my-1 border-0 border-t border-[var(--color-border)]" />
<button v-if="u.banned" type="button" role="menuitem" :class="MENU_ITEM_CLASS"
@click="close(); setDisabled(u, false)">
<span class="i-tabler-circle-check text-4.5" aria-hidden="true"></span>
Enable account
</button>
<button v-else type="button" role="menuitem" :class="MENU_ITEM_CLASS"
@click="close(); setDisabled(u, true)">
<span class="i-tabler-ban text-4.5" aria-hidden="true"></span>
Disable account
</button>
<button type="button" role="menuitem" :class="[MENU_ITEM_CLASS, 'text-red-400']"
@click="close(); deleteUser(u)">
<span class="i-tabler-trash text-4.5" aria-hidden="true"></span>
Delete account…
</button>
</template>
</Dropdown>
</span>
</div>
<div v-if="visibleUsers.length === 0"
class="px-3 py-6 border-t border-[var(--color-border)] text-sm text-center text-[var(--text-secondary)]">
{{ users.length === 0 ? 'No users to show.' : 'No users match.' }}
</div>
</div>
</div>
<p class="mt-4 max-w-190 text-[13px] leading-relaxed text-[var(--text-secondary)]">
Invited accounts have no password until their owner opens the setup link.
you manage your account from Profile and Security, you can't remove
your own admin role.
</p>
</div>
</template>
+275
View File
@@ -0,0 +1,275 @@
<script setup lang="ts">
import { Err, Ok, type Result } from '~~/types/result';
import { DialogType } from '~/composables/useDialog';
const AVATAR_SIZE = 256;
const MAX_SOURCE_SIZE = 10 * 1024 * 1024;
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
const { user, isAdmin, updateProfile } = useAuth();
const { openDialog } = useDialog();
// same tinted pill as the states that are on in Security
const ACCENT_PILL_STYLE = {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 20%, transparent)',
borderColor: 'color-mix(in srgb, var(--color-accent) 50%, transparent)',
};
const fileInput = ref<HTMLInputElement | null>(null);
const avatarBusy = ref(false);
const avatarError = ref('');
const name = ref(user.value?.name ?? '');
const nameSaving = ref(false);
const nameError = ref('');
watch(() => user.value?.name, (value) => {
if (!nameSaving.value) name.value = value ?? '';
});
const trimmedName = computed(() => name.value.trim());
const nameChanged = computed(() => trimmedName.value !== '' && trimmedName.value !== user.value?.name);
// Center-crops the image to a square and scales it down, so we only ever
// store a small file no matter what the user picked.
const cropToSquare = async (file: File): Promise<Result<Blob, string>> => {
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
return Err('That file could not be read as an image.');
}
const side = Math.min(bitmap.width, bitmap.height);
const canvas = document.createElement('canvas');
canvas.width = AVATAR_SIZE;
canvas.height = AVATAR_SIZE;
const ctx = canvas.getContext('2d');
if (ctx === null) {
bitmap.close();
return Err('Your browser could not process the image.');
}
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(
bitmap,
(bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side,
0, 0, AVATAR_SIZE, AVATAR_SIZE,
);
bitmap.close();
// browsers that can't encode webp hand back a png instead
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/webp', 0.9));
if (blob === null) {
return Err('Your browser could not process the image.');
}
return Ok(blob);
};
const uploadAvatar = async (blob: Blob): Promise<Result<string, string>> => {
const extension = blob.type === 'image/webp' ? 'webp' : 'png';
try {
const { url, assetUrl } = await $fetch<{ url: string; assetUrl: string }>('/api/upload/presigned', {
method: 'POST',
body: {
file: {
name: `avatar.${extension}`,
mimeType: blob.type,
},
},
});
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': blob.type },
body: blob,
});
if (!response.ok) {
return Err('Upload failed. Check that file storage is configured for this instance.');
}
return Ok(assetUrl);
} catch (error) {
console.error('Avatar upload failed:', error);
return Err('Upload failed. Check that file storage is configured for this instance.');
}
};
const handleFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
// reset so picking the same file again still fires a change event
input.value = '';
if (!file) return;
avatarError.value = '';
if (!ACCEPTED_TYPES.includes(file.type)) {
avatarError.value = 'Pick a PNG, JPG or WebP image.';
return;
}
if (file.size > MAX_SOURCE_SIZE) {
avatarError.value = 'That image is over 10 MB. Pick a smaller one.';
return;
}
avatarBusy.value = true;
const cropped = await cropToSquare(file);
if (cropped.ok === false) {
avatarError.value = cropped.error;
avatarBusy.value = false;
return;
}
const uploaded = await uploadAvatar(cropped.data);
if (uploaded.ok === false) {
avatarError.value = uploaded.error;
avatarBusy.value = false;
return;
}
const updated = await updateProfile({ image: uploaded.data });
if (updated.ok === false) {
avatarError.value = 'The image uploaded, but your profile could not be updated. Try again.';
}
avatarBusy.value = false;
};
const removeAvatar = async () => {
avatarError.value = '';
avatarBusy.value = true;
const updated = await updateProfile({ image: null });
if (updated.ok === false) {
avatarError.value = 'Your profile picture could not be removed. Try again.';
}
avatarBusy.value = false;
};
const saveName = async () => {
if (!nameChanged.value || nameSaving.value) return;
nameError.value = '';
nameSaving.value = true;
const updated = await updateProfile({ name: trimmedName.value });
if (updated.ok === false) {
nameError.value = 'Your name could not be saved. Try again.';
} else {
name.value = trimmedName.value;
}
nameSaving.value = false;
};
const memberSince = computed(() => {
if (!user.value?.createdAt) return '';
// fixed locale and time zone so the server and client render the same text
return new Date(user.value.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
});
});
</script>
<template>
<div
class="flex flex-col gap-5 py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<div class="flex flex-col gap-5 w-full max-w-3xl">
<section class="flex flex-wrap items-center gap-5 pb-5 border-b border-[var(--color-border)]">
<UserAvatar :user="user" class="w-20 h-20 rounded-xl text-3xl" />
<div class="flex flex-col gap-2 flex-1 min-w-60">
<h2 class="font-medium">Profile picture</h2>
<div class="flex flex-wrap gap-2">
<button type="button" :disabled="avatarBusy" @click="fileInput?.click()"
class="flex items-center gap-2 h-8 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span :class="avatarBusy ? 'i-svg-spinners-180-ring-with-bg' : 'i-mynaui-upload'"
class="text-4"></span>
{{ avatarBusy ? 'Working…' : 'Upload image' }}
</button>
<button v-if="user?.image" type="button" :disabled="avatarBusy" @click="removeAvatar"
class="flex items-center h-8 px-3 text-sm rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
Remove
</button>
</div>
<input ref="fileInput" type="file" class="hidden" :accept="ACCEPTED_TYPES.join(',')"
aria-label="Upload a profile picture" @change="handleFileChange" />
<p v-if="avatarError" role="alert" class="text-xs text-red-400">{{ avatarError }}</p>
<p v-else class="text-xs text-[var(--text-secondary)]">
PNG, JPG or WebP up to 10MB.
</p>
</div>
</section>
<form class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2"
@submit.prevent="saveName">
<label class="whitespace-nowrap" for="profile-name">Display name</label>
<div class="flex flex-col gap-1 flex-1 min-w-60 max-w-120">
<div class="flex gap-2">
<input id="profile-name" v-model="name" type="text" autocomplete="name" maxlength="64"
class="flex-1 min-w-0 h-8.5 px-2.5 text-sm rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]" />
<button type="submit" :disabled="!nameChanged || nameSaving"
class="h-8.5 px-3 items-center text-sm font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed transition-opacity duration-200">
{{ nameSaving ? 'Saving…' : 'Save' }}
</button>
</div>
<p v-if="nameError" role="alert" class="text-xs text-red-400">{{ nameError }}</p>
</div>
</form>
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
<span class="whitespace-nowrap">Email</span>
<span
class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis">
{{ user?.email }}
</span>
</div>
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
<span class="whitespace-nowrap">Member since</span>
<span class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)]">
{{ memberSince }}
</span>
</div>
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
<span class="whitespace-nowrap">Role</span>
<div class="flex-1 min-w-60 max-w-120 flex items-center gap-2.5">
<span v-if="isAdmin"
class="text-[13px] font-medium px-2 py-0.5 rounded-md border text-[var(--text-primary)]"
:style="ACCENT_PILL_STYLE">Admin</span>
<span v-else
class="text-[13px] font-medium px-2 py-0.5 rounded-md border border-[var(--color-border-active)] text-[var(--text-secondary)]">User</span>
</div>
</div>
<section
class="flex flex-wrap items-center justify-between gap-3 mt-3 p-4 rounded-lg border border-red-400/35">
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
<h2 class="text-[15px] font-semibold">Delete account</h2>
<p class="text-[13px] leading-snug text-[var(--text-secondary)]">
Permanently deletes your agents, topics, and api keys from this instance.
<template v-if="isAdmin">The last remaining admin can't delete their account.</template>
</p>
</div>
<button type="button" @click="openDialog(DialogType.DeleteAccount)"
class="flex items-center h-8.5 px-3 text-sm font-medium rounded-md bg-red-600 text-white">
Delete account
</button>
</section>
</div>
</div>
</template>
@@ -2,20 +2,19 @@
import { sortByReleaseDate } from '~/utils/sort'; import { sortByReleaseDate } from '~/utils/sort';
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto'; import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
import { type Model } from '~/types/model'; import { type Model } from '~/types/model';
import ModelItem from './ModelItem.vue'; import ModelItem from '~/components/Settings/ModelItem.vue';
import RowVirtualizerDynamic from '../RowVirtualizerDynamic.vue'; import RowVirtualizerDynamic from '~/components/RowVirtualizerDynamic.vue';
const props = defineProps<{ const route = useRoute();
params?: string; const providerId = computed(() => route.params.id as string | undefined);
}>();
const { providers, updateProvider, createModel, updateModel } = await useModels(); const { providers, updateProvider, createModel, updateModel } = await useModels();
const scrollContainerRef = ref<HTMLDivElement | null>(null); const scrollContainerRef = ref<HTMLDivElement | null>(null);
const provider = computed(() => { const provider = computed(() => {
if (props.params === undefined) return null; if (providerId.value === undefined) return null;
return providers.value!.find(p => p.id === props.params); return providers.value!.find(p => p.id === providerId.value);
}); });
watch(provider, async () => { watch(provider, async () => {
@@ -29,8 +28,8 @@ const apiKey = ref('');
const apiProxyUrl = ref(provider.value?.config.apiProxyUrl ?? ''); const apiProxyUrl = ref(provider.value?.config.apiProxyUrl ?? '');
const modelSearch = ref(''); const modelSearch = ref('');
watch(() => props.params, () => { watch(() => providerId.value, () => {
if (props.params === undefined) return; if (providerId.value === undefined) return;
apiKey.value = provider.value?.config.apiKey ?? ''; apiKey.value = provider.value?.config.apiKey ?? '';
apiProxyUrl.value = provider.value?.config.apiProxyUrl ?? ''; apiProxyUrl.value = provider.value?.config.apiProxyUrl ?? '';
modelSearch.value = ''; modelSearch.value = '';
@@ -69,55 +68,73 @@ const toggleProvider = async () => {
}); });
}; };
let apiKeyTimeout: NodeJS.Timeout | undefined; let apiKeyTimeout: ReturnType<typeof setTimeout> | undefined;
let proxyUrlTimeout: ReturnType<typeof setTimeout> | undefined;
const configError = ref("");
const updateApiKey = async (value: string) => { // Config saves run one after the other, and each reads the revision only when
if (!provider.value) return; // it is sent. A save that was typed while another was still on its way would
// otherwise carry the revision from before that one and be turned down.
let configSaves: Promise<void> = Promise.resolve();
const key = await crypto.subtle.importKey( const saveConfig = (
"jwk", id: string,
JSON.parse(window.localStorage.getItem("encryptionKey")!), failure: string,
"AES-GCM", config: () => Promise<{ apiKey?: string; apiProxyUrl?: string }>,
false, expectedEncryptionKey?: string,
["encrypt", "decrypt"] ) => {
); configSaves = configSaves.then(async () => {
const encypted = await encryptData(key, value); // A queued edit must not pair an old encryption key with a new revision.
if (expectedEncryptionKey !== undefined && localStorage.getItem("encryptionKey") !== expectedEncryptionKey) {
configError.value = failure;
return;
}
const current = providers.value.find(p => p.id === id);
if (!current) {
return;
}
configError.value = "";
try {
await updateProvider(id, { config: await config() }, new Date(current.updatedAt).toISOString());
} catch {
configError.value = failure;
}
});
};
if (apiKeyTimeout) { const updateApiKey = (value: string) => {
clearTimeout(apiKeyTimeout); const snapshot = provider.value;
const storedKey = localStorage.getItem("encryptionKey");
if (!snapshot || !storedKey) {
return;
} }
clearTimeout(apiKeyTimeout);
apiKeyTimeout = setTimeout(async () => { apiKeyTimeout = setTimeout(() => {
await updateProvider(provider.value!.id, { saveConfig(snapshot.id, "The key could not be saved. Reload this page before trying again.", async () => {
config: { const key = await crypto.subtle.importKey("jwk", JSON.parse(storedKey), "AES-GCM", false, ["encrypt", "decrypt"]);
...provider.value!.config, const encrypted = await encryptData(key, value);
apiKey: uint8ArrayToBase64(encypted), return { apiKey: uint8ArrayToBase64(encrypted) };
}, }, storedKey);
});
}, 700); }, 700);
}; };
let proxyUrlTimeout: NodeJS.Timeout | undefined; const updateProxyUrl = (value: string) => {
const snapshot = provider.value;
const updateProxyUrl = async (value: string) => { if (!snapshot) {
if (!provider.value) return; return;
}
apiProxyUrl.value = value; apiProxyUrl.value = value;
clearTimeout(proxyUrlTimeout);
if (proxyUrlTimeout) { proxyUrlTimeout = setTimeout(() => {
clearTimeout(proxyUrlTimeout); saveConfig(snapshot.id, "The address could not be saved. Reload this page before trying again.", async () => ({ apiProxyUrl: value }));
}
proxyUrlTimeout = setTimeout(async () => {
await updateProvider(provider.value!.id, {
config: {
...provider.value!.config,
apiProxyUrl: value,
},
});
}, 700); }, 700);
}; };
onUnmounted(() => {
clearTimeout(apiKeyTimeout);
clearTimeout(proxyUrlTimeout);
});
const fetchingModels = ref(false); const fetchingModels = ref(false);
const fetchModels = async () => { const fetchModels = async () => {
@@ -373,8 +390,6 @@ const formatMoney = (value: string) => {
const isFormValid = computed(() => { const isFormValid = computed(() => {
return formData.value.name.trim() && formData.value.externalId.trim(); return formData.value.name.trim() && formData.value.externalId.trim();
}); });
defineEmits(['navigate']);
</script> </script>
<template> <template>
@@ -410,6 +425,8 @@ defineEmits(['navigate']);
</div> </div>
</div> </div>
<p v-if="configError" role="alert" class="text-sm text-red-400">{{ configError }}</p>
<div class="flex flex-row justify-center text-xs"> <div class="flex flex-row justify-center text-xs">
<p class="text-[var(--text-secondary)]"> <p class="text-[var(--text-secondary)]">
<span class="i-mynaui-lock inline-block"></span> Your API key is encrypted using <span class="i-mynaui-lock inline-block"></span> Your API key is encrypted using
@@ -3,10 +3,6 @@ import { providerIcons } from '~/utils/model-mapping';
const { providers, updateProvider } = await useModels(); const { providers, updateProvider } = await useModels();
const props = defineProps<{
params?: string;
}>();
if (providers.value === undefined) throw new Error('Providers not loaded'); if (providers.value === undefined) throw new Error('Providers not loaded');
const toggleProvider = async (id: string) => { const toggleProvider = async (id: string) => {
@@ -18,7 +14,7 @@ const toggleProvider = async (id: string) => {
}); });
}; };
defineEmits(['navigate']); const openProvider = (id: string) => navigateTo(`/settings/providers/${id}`);
</script> </script>
<template> <template>
@@ -31,8 +27,7 @@ defineEmits(['navigate']);
</h2> </h2>
<div <div
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="openProvider(p.id)" v-for="p in providers?.filter(p => p.enabled)" :key="p.id"
:key="p.id"
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]"> class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]">
<div class="flex flex-col flex-grow"> <div class="flex flex-col flex-grow">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
@@ -55,8 +50,7 @@ defineEmits(['navigate']);
</h2> </h2>
<div <div
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="openProvider(p.id)" v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
:key="p.id"
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]"> class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]">
<div class="flex flex-col flex-grow"> <div class="flex flex-col flex-grow">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
+283
View File
@@ -0,0 +1,283 @@
<script setup lang="ts">
import { DialogType } from '~/composables/useDialog';
const BUTTON_CLASS = 'flex items-center justify-center gap-2 h-8.5 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]';
const PRIMARY_BUTTON_CLASS = 'flex items-center justify-center gap-2 h-8.5 px-3 text-sm font-medium rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed transition-opacity duration-200';
const OUTLINE_BUTTON_CLASS = 'flex items-center justify-center gap-2 h-8.5 px-3 text-sm rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:text-[var(--text-tertiary)] disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]';
const { client: authClient, user } = useAuth();
const { providers } = await useModels();
// tinted pill used for the states that are on, same look as the role badge
const ACCENT_PILL_STYLE = {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 20%, transparent)',
borderColor: 'color-mix(in srgb, var(--color-accent) 50%, transparent)',
};
// fixed locale and time zone so the server and client render the same text
const formatDate = (date: string | Date) => new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
// The server does not know the viewer's time zone, so it renders the date only
// and the time of day is filled in once the page is running in the browser.
const mounted = ref(false);
onMounted(() => {
mounted.value = true;
});
const formatDateTime = (date: string | Date) => {
if (!mounted.value) return formatDate(date);
return new Date(date).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
};
// ---------------------------------------------------------------------------
// Password
// ---------------------------------------------------------------------------
const { openDialog } = useDialog();
const savedKeyCount = computed(() => providers.value.filter(p => !!p.config.apiKey).length);
const { data: passwordInfo, refresh: refreshPasswordInfo } = await useFetch<{ changedAt: string | null }>('/api/account/password', {
key: 'account_password',
});
const openChangePassword = () => {
openDialog(DialogType.ChangePassword, async (result) => {
if (result.ok === false) return;
await Promise.all([refreshPasswordInfo(), loadSessions()]);
});
};
// ---------------------------------------------------------------------------
// Two-factor authentication
// ---------------------------------------------------------------------------
const twoFactorEnabled = computed(() => !!user.value?.twoFactorEnabled);
const openTwoFactor = (action: 'enable' | 'disable' | 'regenerate') => {
// Turning two-factor on or off swaps the session for a new one, and the
// dialog can be dismissed after that happened, so always reload.
openDialog(DialogType.TwoFactor, async () => {
await loadSessions();
}, { action });
};
// ---------------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------------
type SessionRow = {
id: string;
createdAt: string;
updatedAt: string;
ipAddress: string | null;
userAgent: string | null;
current: boolean;
};
const sessionsError = ref('');
const sessionsBusy = ref(false);
// Better Auth only lists sessions for a device that signed in within the last
// day, so this goes through our own endpoint instead.
const { data: sessionData, error: sessionsLoadError, refresh: loadSessions } = await useFetch<SessionRow[]>('/api/account/sessions', {
key: 'account_sessions',
});
const sessions = computed(() => sessionData.value ?? []);
// this device first, then most recently active
const sortedSessions = computed(() => [...sessions.value].sort((a, b) => {
if (a.current) return -1;
if (b.current) return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}));
watch(sessionsLoadError, (error) => {
sessionsError.value = error ? 'Your sessions could not be loaded.' : '';
}, { immediate: true });
const revokeSession = async (id: string) => {
if (sessionsBusy.value) return;
sessionsBusy.value = true;
sessionsError.value = '';
try {
await $fetch(`/api/account/sessions/${id}`, { method: 'DELETE' });
await loadSessions();
} catch (error) {
console.error('Failed to revoke session:', error);
sessionsError.value = 'That session could not be signed out. Try again.';
}
sessionsBusy.value = false;
};
const revokeOtherSessions = async () => {
if (sessionsBusy.value) return;
sessionsBusy.value = true;
sessionsError.value = '';
const { error } = await authClient.revokeOtherSessions();
if (error) {
sessionsError.value = 'Your other sessions could not be signed out. Try again.';
} else {
await loadSessions();
}
sessionsBusy.value = false;
};
const describeDevice = (userAgent: string | null | undefined) => {
const ua = userAgent ?? '';
const browser =
/Edg\//.test(ua) ? 'Edge'
: /OPR\//.test(ua) ? 'Opera'
: /Firefox\//.test(ua) ? 'Firefox'
: /Chrome\//.test(ua) ? 'Chrome'
: /Safari\//.test(ua) ? 'Safari'
: 'Unknown browser';
const os =
/Android/.test(ua) ? 'Android'
: /iPhone|iPad|iPod/.test(ua) ? 'iOS'
: /Windows/.test(ua) ? 'Windows'
: /Mac OS X/.test(ua) ? 'macOS'
: /Linux/.test(ua) ? 'Linux'
: '';
return {
label: os ? `${browser} on ${os}` : browser,
mobile: /Android|iPhone|iPad|iPod|Mobile/.test(ua),
};
};
</script>
<template>
<div
class="flex flex-col py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<div class="flex flex-col gap-7 w-full max-w-3xl">
<!-- PASSWORD -->
<section class="flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
<h2 class="font-medium">Password</h2>
<p class="text-[13px] text-[var(--text-secondary)]">
{{ passwordInfo?.changedAt
? `Last changed ${formatDate(passwordInfo.changedAt)}`
: 'Never changed' }}
</p>
</div>
<button type="button" :class="BUTTON_CLASS" @click="openChangePassword">
Change password…
</button>
</div>
</section>
<!-- TWO-FACTOR -->
<section class="flex flex-col gap-3 pt-6 border-t border-[var(--color-border)]">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
<h2 class="font-medium flex items-center gap-2">
Two-factor sign-in
<span v-if="twoFactorEnabled"
class="text-xs font-normal px-2 py-0.25 rounded-md border text-[var(--text-primary)]"
:style="ACCENT_PILL_STYLE">On</span>
<span v-else
class="text-xs font-normal px-2 py-0.25 rounded-md border border-[var(--color-border-active)] text-[var(--text-secondary)]">Off</span>
</h2>
</div>
<button v-if="twoFactorEnabled" type="button" :class="OUTLINE_BUTTON_CLASS"
@click="openTwoFactor('disable')">
Turn off
</button>
<button v-else type="button" :class="[PRIMARY_BUTTON_CLASS, 'font-semibold']"
@click="openTwoFactor('enable')">
Set up
</button>
</div>
<div v-if="twoFactorEnabled" class="flex flex-wrap items-center justify-between gap-3">
<span class="flex-1 min-w-60 text-sm">
Backup codes
<span class="text-[13px]" :class="twoFactorEnabled ? 'text-[var(--text-secondary)]' : ''">
· {{ twoFactorEnabled
? 'each one signs you in once if you lose your authenticator app'
: 'available once two-factor is on' }}
</span>
</span>
<button type="button" :disabled="!twoFactorEnabled" :class="OUTLINE_BUTTON_CLASS"
@click="openTwoFactor('regenerate')">
Regenerate
</button>
</div>
</section>
<!-- SESSIONS -->
<section class="flex flex-col gap-3 pt-6 border-t border-[var(--color-border)]">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
<h2 class="font-medium">Sessions</h2>
<p class="text-[13px] text-[var(--text-secondary)]">
Devices signed in to your account.
</p>
</div>
<button type="button" :disabled="sessionsBusy || sessions.length < 2" :class="OUTLINE_BUTTON_CLASS"
@click="revokeOtherSessions">
Sign out everywhere else
</button>
</div>
<p v-if="sessionsError" role="alert" class="text-sm text-red-400">{{ sessionsError }}</p>
<ul class="flex flex-col gap-1">
<li v-for="s in sortedSessions" :key="s.id"
class="flex flex-wrap items-center gap-3 min-h-13 px-3 py-2 rounded-lg bg-[var(--bg-container)]">
<span class="text-5 flex-shrink-0 text-[var(--text-secondary)]"
:class="describeDevice(s.userAgent).mobile ? 'i-tabler-device-mobile' : 'i-tabler-device-desktop'"></span>
<div class="flex flex-col flex-1 min-w-40">
<span class="text-sm font-medium">
{{ describeDevice(s.userAgent).label }}
<span v-if="s.ipAddress" class="font-normal text-[var(--text-tertiary)]">
· {{ s.ipAddress }}
</span>
</span>
<span class="text-xs text-[var(--text-secondary)]">
{{ s.current ? 'Active now' : `Last active ${formatDateTime(s.updatedAt)}` }}
</span>
</div>
<span v-if="s.current" class="text-xs px-2 py-0.5 rounded-md border text-[var(--text-primary)]"
:style="ACCENT_PILL_STYLE">
This device
</span>
<button v-else type="button" :disabled="sessionsBusy"
class="flex items-center h-7.5 px-2.5 text-[13px] rounded-md border border-[var(--color-border)] text-[var(--text-primary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
@click="revokeSession(s.id)">
Revoke
</button>
</li>
</ul>
</section>
</div>
</div>
</template>
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
import type { RegistrationMode } from '~~/drizzle/schema';
type InstanceSettings = {
registration: RegistrationMode;
registrationLocked: boolean;
};
const REGISTRATION_OPTIONS: { value: RegistrationMode; label: string; description: string }[] = [
{
value: 'open',
label: 'Open',
description: 'Anyone who can reach this URL can create an account.',
},
{
value: 'invite',
label: 'Invite only',
description: 'Admins create invite links. Good default for a shared instance.',
},
{
value: 'closed',
label: 'Closed',
description: 'No sign-ups. Only you use this instance.',
},
];
// Tint of the chosen option. UnoCSS can't put an opacity on a CSS variable, so
// "bg-[var(--color-accent)]/10" comes out as the solid accent colour.
const SELECTED_STYLE = {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 10%, transparent)',
};
definePageMeta({
layout: 'auth',
});
const { isAdmin } = useAuth();
// the rest of first-run setup belongs to the admin who just made the account
if (!isAdmin.value) {
await navigateTo('/');
}
const { data: instance } = await useFetch<InstanceSettings>('/api/admin/instance', {
key: 'admin_instance',
});
const registration = ref<RegistrationMode>(instance.value?.registration ?? 'invite');
const locked = computed(() => !!instance.value?.registrationLocked);
const saving = ref(false);
const error = ref('');
const submit = async () => {
if (saving.value) return;
// nothing to save when the environment decides, or nothing changed
if (locked.value || instance.value?.registration === registration.value) {
return navigateTo('/setup/provider');
}
saving.value = true;
error.value = '';
try {
instance.value = await $fetch<InstanceSettings>('/api/admin/instance', {
method: 'PUT',
body: { registration: registration.value },
});
// the sign in pages read this to decide whether to offer registering
clearNuxtData('instance');
} catch (err) {
console.error('Failed to save registration:', err);
error.value = 'That could not be saved. Try again.';
saving.value = false;
return;
}
saving.value = false;
return navigateTo('/setup/provider');
};
</script>
<template>
<div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<!-- Background decorative blobs -->
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
</div>
<div
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
<p class="text-[var(--text-secondary)]">Set up this instance</p>
</div>
<OnboardingSetupSteps :current="2"></OnboardingSetupSteps>
<form class="flex flex-col gap-4" @submit.prevent="submit">
<fieldset class="flex flex-col gap-1.5" :disabled="saving || locked">
<legend
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Who can register
</legend>
<label v-for="option in REGISTRATION_OPTIONS" :key="option.value"
class="flex items-start gap-3 px-3 py-2.5 rounded-lg border transition-colors duration-200"
:style="registration === option.value ? SELECTED_STYLE : undefined" :class="[
registration === option.value
? 'border-[var(--color-accent)]'
: 'border-[var(--color-border)]',
locked ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
]">
<!-- a plain radio: the auth layout sizes every input as a text field -->
<input v-model="registration" type="radio" name="registration" :value="option.value"
class="!w-auto !p-0 mt-0.75 flex-shrink-0" style="accent-color: var(--color-accent)" />
<span class="flex flex-col gap-0.5">
<span class="font-medium">{{ option.label }}</span>
<span class="text-[13px] text-[var(--text-secondary)]">
{{ option.description }}
</span>
</span>
</label>
</fieldset>
<p v-if="locked" class="flex gap-2 text-xs leading-normal text-[var(--text-secondary)]">
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
<span>
Registration is turned off by the <code class="font-mono">DISABLE_SIGNUP</code>
environment variable. Remove it and restart the server to choose here.
</span>
</p>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<button type="submit" :disabled="saving"
class="accent w-full h-11 flex items-center justify-center font-semibold">
<span v-if="saving" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
<span v-else>Continue</span>
</button>
<p class="text-xs text-center text-[var(--text-secondary)]">
This can be changed later in Settings → Instance.
</p>
</form>
</div>
</div>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
definePageMeta({
layout: 'auth',
});
const { isAdmin } = useAuth();
// the rest of first-run setup belongs to the admin who just made the account
if (!isAdmin.value) {
await navigateTo('/');
}
const finish = () => navigateTo('/');
const back = () => navigateTo('/setup/instance');
</script>
<template>
<div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<!-- Background decorative blobs -->
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
</div>
<div
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
<p class="text-[var(--text-secondary)]">Connect your first AI provider</p>
</div>
<OnboardingSetupSteps :current="3"></OnboardingSetupSteps>
<OnboardingProviderForm submit-label="Fetch models and finish" :show-back="true" @done="finish"
@back="back">
<template #note>
<p class="text-xs leading-normal text-[var(--text-secondary)]">
Providers are per account. Other people on this instance add their own keys, and nobody,
including admins, can read yours.
</p>
</template>
</OnboardingProviderForm>
</div>
</div>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
definePageMeta({
layout: 'auth',
});
const to = useRoute().query.to as string | undefined;
const finish = () => navigateTo(to ?? '/');
</script>
<template>
<div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
</div>
<div
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
<p class="text-[var(--text-secondary)]">One more thing: connect an AI provider</p>
</div>
<p class="text-sm leading-normal text-center text-[var(--text-secondary)]">
Veridian runs on your own API keys. Add one now and it'll be ready for you're ready to get started.
</p>
<OnboardingProviderForm submit-label="Fetch models and continue" @done="finish"></OnboardingProviderForm>
</div>
</div>
</template>
+1 -1
View File
@@ -3,7 +3,7 @@ export default defineNuxtPlugin((nuxtApp) => {
mounted(el: any, binding: any) { mounted(el: any, binding: any) {
el.clickOutsideEvent = (event: Event) => { el.clickOutsideEvent = (event: Event) => {
if (!el.contains(event.target as Node)) { if (!el.contains(event.target as Node)) {
binding.value(); binding.value(event);
} }
}; };
-65
View File
@@ -1,65 +0,0 @@
import { unified } from 'unified';
import { visit } from 'unist-util-visit';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import remarkBreaks from 'remark-breaks';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
function remarkSplitBlocks() {
return (tree: any) => {
visit(tree, 'paragraph', (node, index, parent) => {
const breakIndex = node.children.findIndex((child: any) => child.type === 'break');
if (breakIndex !== -1) {
const beforeBreak = node.children.slice(0, breakIndex);
const afterBreak = node.children.slice(breakIndex + 1);
node.children = beforeBreak;
const newParagraph = {
type: 'paragraph',
children: afterBreak,
};
let rootChildIndex = -1;
if (parent.type === 'root') {
rootChildIndex = index!;
} else {
rootChildIndex = tree.children.findIndex((child: any) =>
child === parent || (child.children && child.children.includes(node))
);
if (rootChildIndex === -1) {
rootChildIndex = tree.children.indexOf(parent);
}
}
if (rootChildIndex !== -1) {
tree.children.splice(rootChildIndex + 1, 0, newParagraph);
}
return index! + 1;
}
});
};
}
export default defineNuxtPlugin((nuxtApp) => {
const remark =
unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath)
.use(remarkBreaks)
.use(remarkSplitBlocks)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeKatex, { output: 'mathml' });
return {
provide: {
remark,
}
}
})
+29
View File
@@ -0,0 +1,29 @@
const AVATAR_COLORS = [
'#d12655',
'#c42685',
'#a334c2',
'#853de0',
'#6548db',
'#485ddd',
'#2670cf',
'#087caa',
'#008579',
'#148348',
'#7846c7',
'#d13243',
];
export const avatarColor = (seed: string): string => {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
return AVATAR_COLORS[hash % AVATAR_COLORS.length]!;
};
export const avatarInitial = (name: string | null | undefined): string => {
const first = Array.from((name ?? '').trim())[0];
return first ? first.toUpperCase() : '?';
};
+45
View File
@@ -0,0 +1,45 @@
import type { ComarkPlugin } from "comark";
import type { SearchSource, StoredToolValue } from "./tool-rendering";
import { parseToolValue, readSearchSources } from "./tool-rendering";
export const citationPlugin: ComarkPlugin = {
name: "source-citations",
markdownItPlugins: [
(markdown) => {
markdown.inline.ruler.before("link", "source_citation", (state, silent) => {
if (state.linkLevel > 0 || !state.src.startsWith("[[cite:", state.pos)) {
return false;
}
const match = /^\[\[cite:([a-zA-Z0-9_-]+(?:,[a-zA-Z0-9_-]+)*)\]\]/.exec(state.src.slice(state.pos));
if (!match) {
return false;
}
if (silent) {
return false;
}
const token = state.push("mdc_inline_component", "source-cite", 0);
token.attrSet("ids", match[1] ?? "");
state.pos += match[0].length;
return true;
});
},
],
};
export const collectCitationSources = (
parts: readonly { toolCall?: { toolName: string; output?: StoredToolValue } | null }[],
): ReadonlyMap<string, SearchSource> => {
const sources = new Map<string, SearchSource>();
for (const part of parts) {
if (part.toolCall?.toolName !== "search") {
continue;
}
const results = readSearchSources(parseToolValue(part.toolCall.output), { deduplicate: false });
for (const source of results ?? []) {
if (source.sourceId) {
sources.set(source.sourceId, source);
}
}
}
return sources;
};
+11
View File
@@ -0,0 +1,11 @@
export const getComposerHeight = (
scrollHeight: number,
lineHeight: number,
padding: number,
borders: number,
borderBox: boolean,
): number => {
const contentHeight = Math.min(Math.max(scrollHeight - padding, lineHeight * 2), lineHeight * 10);
// A stable CSS-pixel boundary avoids 47.999… / 48.01 oscillations.
return Math.round(contentHeight + (borderBox ? padding + borders : 0));
};
+277
View File
@@ -0,0 +1,277 @@
import type { MarkdownDocument, Node } from 'comark';
import { createSerializedMarkdownParser as createComarkParser } from 'comark';
import breaks from 'comark/plugins/breaks';
import footnotes from 'comark/plugins/footnotes';
import math from 'comark/plugins/math';
import { citationPlugin } from './citations';
export type MarkdownTree = MarkdownDocument;
export type MarkdownAstNode = Node;
export type MarkdownElementNode = Exclude<MarkdownAstNode, string | [null, ...unknown[]]>;
const footnoteSyntax = {
name: 'footnote-syntax',
markdownItPlugins: [
(markdown: any) => markdown.inline.ruler.before('link', 'footnote_inline', (state: any, silent: boolean) => {
const start = state.pos;
if (!state.src.startsWith('[^', start)) {
return false;
}
const end = state.src.indexOf(']', start + 2);
const label = end === -1 ? '' : state.src.slice(start + 2, end);
if (end === -1 || label.length === 0 || /\s/.test(label) || silent) {
return false;
}
state.push('mdc_inline_span', 'span', 1);
state.push('text', '', 0).content = `^${label}`;
state.push('mdc_inline_span', 'span', -1);
state.pos = end + 1;
return true;
}),
],
};
const parserPlugins = [
breaks(),
footnoteSyntax,
footnotes(),
math(),
] as const;
const createConfiguredParser = (autoClose: boolean, citations: boolean) => createComarkParser({
registerDefaultPlugins: false,
autoClose,
plugins: citations ? [...parserPlugins, citationPlugin] : parserPlugins,
});
const protectDollarRunsInLine = (line: string): string => {
let result = '';
let segment = '';
let codeDelimiterLength = 0;
const flushSegment = () => {
result += segment.replace(/\${3,}/g, (run) => run.replaceAll('$', '&#36;'));
segment = '';
};
for (let index = 0; index < line.length;) {
let delimiterLength = 0;
if (line[index] === '`') {
while (line[index + delimiterLength] === '`') {
delimiterLength += 1;
}
}
if (codeDelimiterLength !== 0) {
result += line.slice(index, index + (delimiterLength || 1));
if (delimiterLength === codeDelimiterLength) {
codeDelimiterLength = 0;
}
index += delimiterLength || 1;
continue;
}
if (delimiterLength === 0) {
segment += line[index];
index += 1;
continue;
}
flushSegment();
result += line.slice(index, index + delimiterLength);
codeDelimiterLength = delimiterLength;
index += delimiterLength;
}
flushSegment();
return result;
};
const protectDollarRuns = (content: string): string => {
// Fast path: most messages contain no dollar runs; avoid line-splitting entirely.
if (!content.includes('$')) {
return content;
}
const lines = content.split('\n');
let inFence = false;
return lines.map((line) => {
const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
if (fence !== null) {
inFence = !inFence;
return line;
}
if (inFence) {
return line;
}
return protectDollarRunsInLine(line);
}).join('\n');
};
export const createMarkdownParser = (options: { citations?: boolean } = {}) => {
const citations = options.citations === true;
const streamingParser = createConfiguredParser(true, citations);
const finalParser = createConfiguredParser(false, citations);
return (content: string, options: { streaming?: boolean } = {}) => {
const protectedContent = protectDollarRuns(content);
// Do not let auto-close complete an unfinished citation on the model's behalf.
const incompleteCitation = citations && /\[\[cite:[a-zA-Z0-9_,\-]*\]?$/.test(protectedContent);
if (options.streaming === true && !incompleteCitation) {
return streamingParser(protectedContent, { streaming: true });
}
return finalParser(protectedContent, { streaming: false });
};
};
export const agentParser = createMarkdownParser({ citations: true });
export const userParser = createMarkdownParser({ citations: false });
export const getNodeTag = (node: MarkdownAstNode): string | null => {
if (!Array.isArray(node) || node[0] === null) {
return null;
}
return node[0];
};
export const getNodeAttributes = (node: MarkdownAstNode): Record<string, unknown> => {
if (!Array.isArray(node) || node[0] === null || typeof node[1] !== 'object' || node[1] === null) {
return {};
}
return node[1] as Record<string, unknown>;
};
export const getNodeChildren = (node: MarkdownAstNode): MarkdownAstNode[] => {
if (!Array.isArray(node) || node[0] === null) {
return [];
}
return node.slice(2) as MarkdownAstNode[];
};
export const textContent = (node: MarkdownAstNode): string => {
if (typeof node === 'string') {
return node;
}
if (!Array.isArray(node) || node[0] === null) {
return '';
}
return getNodeChildren(node).map(textContent).join('');
};
const splitParagraph = (attributes: Record<string, unknown>, children: MarkdownAstNode[]): MarkdownElementNode[] => {
const paragraphs: MarkdownElementNode[] = [];
let current: MarkdownAstNode[] = [];
for (const child of children) {
if (getNodeTag(child) === 'br') {
paragraphs.push(['p', { ...attributes }, ...current] as MarkdownElementNode);
current = [];
continue;
}
current.push(child);
}
if (current.length > 0 || paragraphs.length === 0) {
paragraphs.push(['p', { ...attributes }, ...current] as MarkdownElementNode);
}
return paragraphs;
};
const hasBrChild = (children: MarkdownAstNode[]): boolean =>
children.some((child) => getNodeTag(child) === 'br');
/**
* Transform a single element node into its replacement list. Returns a
* single-element array holding the ORIGINAL node reference when nothing
* changed, so callers can skip all allocation for unaffected subtrees.
*/
const transformElement = (node: MarkdownElementNode): MarkdownAstNode[] => {
const tag = node[0];
const children = getNodeChildren(node);
const transformedChildren = transformChildren(children);
if (tag === 'p') {
if (!hasBrChild(transformedChildren)) {
if (transformedChildren === children) return [node];
return [[tag, { ...node[1] }, ...transformedChildren] as MarkdownElementNode];
}
return splitParagraph({ ...node[1] }, transformedChildren);
}
if ((tag === 'li' || tag === 'blockquote') && hasBrChild(transformedChildren)) {
const segments: MarkdownAstNode[][] = [[]];
for (const child of transformedChildren) {
if (getNodeTag(child) === 'br') {
segments.push([]);
} else {
segments[segments.length - 1]?.push(child);
}
}
return [[
tag,
{ ...node[1] },
...segments
.filter((segment) => segment.length > 0)
.map((segment) => ['p', {}, ...segment] as MarkdownElementNode),
] as MarkdownElementNode];
}
if (transformedChildren === children) return [node];
return [[tag, { ...node[1] }, ...transformedChildren] as MarkdownElementNode];
};
const transformChildren = (nodes: MarkdownAstNode[]): MarkdownAstNode[] => {
let result: MarkdownAstNode[] | null = null;
for (let index = 0; index < nodes.length; index++) {
const node = nodes[index]!;
const replacement = typeof node === 'string' || !Array.isArray(node) || node[0] === null
? [node]
: transformElement(node);
if (result === null) {
if (replacement.length === 1 && replacement[0] === node) continue;
result = nodes.slice(0, index);
}
result.push(...replacement);
}
return result ?? nodes;
};
/**
* Comark's breaks plugin emits `br` nodes. Promote each hard break to a
* sibling paragraph to keep the chat renderer's established layout.
*
* Copy-on-write: trees without `br` nodes are returned by reference with no
* allocation, so the common case costs a single tag scan per node.
*/
export const splitParagraphBreaks = (tree: MarkdownTree): MarkdownTree => {
const nodes = transformChildren(tree.nodes);
return nodes === tree.nodes ? tree : { ...tree, nodes };
};
export const toVueAttributes = (attributes: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(attributes)) {
if (key !== '$') {
result[key] = value;
}
}
return result;
};
+18 -3
View File
@@ -54,6 +54,9 @@ import {
LogoClosedRouter, LogoClosedRouter,
LogoPoolside, LogoPoolside,
LogoTencent, LogoTencent,
LogoThinkingMachines,
LogoDotsStudio,
LogoNexAgi,
LogoHuggingFace, LogoHuggingFace,
} from '#components'; } from '#components';
import { markRaw } from 'vue'; import { markRaw } from 'vue';
@@ -65,6 +68,18 @@ interface ModelConfig {
} }
const MODEL_MAPPINGS: ModelConfig[] = [ const MODEL_MAPPINGS: ModelConfig[] = [
{
Icon: markRaw(LogoNexAgi),
keywords: [/nex-agi\//, /\/nex-/],
},
{
Icon: markRaw(LogoDotsStudio),
keywords: [/dots-studio/, /^dots/, /\/dots/],
},
{
Icon: markRaw(LogoThinkingMachines),
keywords: [/^thinkingmachines\//, /\/linkling/],
},
{ Icon: markRaw(LogoOpenAI), keywords: [/gpt-3/], props: { type: 'gpt3' } }, { Icon: markRaw(LogoOpenAI), keywords: [/gpt-3/], props: { type: 'gpt3' } },
{ Icon: markRaw(LogoOpenAI), keywords: [/gpt-4/], props: { type: 'gpt4' } }, { Icon: markRaw(LogoOpenAI), keywords: [/gpt-4/], props: { type: 'gpt4' } },
{ Icon: markRaw(LogoOpenAI), keywords: [/gpt-5/], props: { type: 'gpt5' } }, { Icon: markRaw(LogoOpenAI), keywords: [/gpt-5/], props: { type: 'gpt5' } },
@@ -99,7 +114,7 @@ const MODEL_MAPPINGS: ModelConfig[] = [
}, },
{ {
Icon: markRaw(LogoGrok), Icon: markRaw(LogoGrok),
keywords: [/^grok-/, /^x-ai\//], keywords: [/^grok-/, /^x-ai\//, /^~x-ai\/grok-latest$/],
}, },
{ {
Icon: markRaw(LogoGemini), Icon: markRaw(LogoGemini),
@@ -164,7 +179,7 @@ const MODEL_MAPPINGS: ModelConfig[] = [
}, },
{ {
Icon: markRaw(LogoZAI), Icon: markRaw(LogoZAI),
keywords: [/zai-/, /(^|\/|-)?glm-?\d/] keywords: [/zai-/, /(^|\/|-)?glm-?\d/, /^~z-ai\/glm-(flash-)?latest$/]
}, },
{ {
Icon: markRaw(LogoGLMV), Icon: markRaw(LogoGLMV),
@@ -196,7 +211,7 @@ const MODEL_MAPPINGS: ModelConfig[] = [
}, },
{ {
Icon: markRaw(LogoMeta), Icon: markRaw(LogoMeta),
keywords: [/llama/, /\/l3/, /meta-llama\//] keywords: [/llama/, /\/l3/, /meta-llama\//, /(^|\/)muse-spark($|-)/, /^meta\//]
}, },
{ {
Icon: markRaw(LogoPerplexity), Icon: markRaw(LogoPerplexity),
+15
View File
@@ -0,0 +1,15 @@
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
/** How long until a date, in whole days or hours: "6 days", "3 hours". */
export const timeLeft = (until: string | Date): string => {
const ms = new Date(until).getTime() - Date.now();
if (ms >= DAY_MS) {
const days = Math.ceil(ms / DAY_MS);
return `${days} ${days === 1 ? 'day' : 'days'}`;
}
const hours = Math.max(1, Math.ceil(ms / HOUR_MS));
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
};

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