Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff7797d3f8 | |||
| b6f4d85ed7 | |||
| 61f9bb74b4 | |||
| fae05ebc4c | |||
| f6e1bc1f47 | |||
| 7b0800882a | |||
| 4bba529ba8 | |||
| ff33e9c573 | |||
| 2b9f8d64d7 | |||
| d48d8163a7 | |||
| bb5795e00d | |||
|
3f81f96bda
|
|||
|
4b99921023
|
|||
|
c9e48687ef
|
|||
|
a836082de8
|
|||
|
5aebd30808
|
|||
|
166052012d
|
|||
|
28ef0c8a07
|
|||
|
886f385835
|
|||
|
8ccaa824dd
|
|||
|
47009b1f0a
|
|||
|
9550d44220
|
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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* ./
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 */
|
||||||
|
|||||||
@@ -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,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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 <email></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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="py-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
Nothing to configure here yet.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
redirect: '/settings/profile',
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
+66
-49
@@ -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
-9
@@ -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">
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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() : '?';
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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));
|
||||||
|
};
|
||||||
@@ -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('$', '$'));
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user