feat: ditch triplit, move to postgresql + drizzle orm

This commit is contained in:
Zoe
2026-04-08 17:03:07 -05:00
parent c341c96798
commit e2e3ac6e86
121 changed files with 6680 additions and 4373 deletions
+196 -58
View File
@@ -1,75 +1,213 @@
import type { Entity } from "@triplit/client";
import type schema from "#triplit/schema";
import { nanoid } from "nanoid";
import { assert } from "~~/utils/assert";
import { attempt } from "~~/types/result";
import * as schema from '~~/drizzle/schema';
export type Agent = Readonly<Entity<typeof schema, 'agents'> & { topics: Readonly<Entity<typeof schema, 'topics'>>[] }>;
export type Topic = typeof schema.topics.$inferSelect;
export type Agent = typeof schema.agents.$inferSelect;
export type AgentWithTopics = Agent & { topics: Topic[] };
export const useAgents = () => {
const nuxtApp = useNuxtApp();
const triplit = useTriplitClient();
export const useAgents = async () => {
const agents = useState<AgentWithTopics[]>('agents_state', () => []);
const loaded = useState('agents_loaded', () => false);
// dont leaking between different users/requests
if (!nuxtApp._agentsState) {
nuxtApp._agentsState = {
list: ref<Agent[]>([]),
initPromise: null as Promise<void> | null,
};
}
const { refresh } = await useFetch<AgentWithTopics[]>('/api/agents', {
key: 'agents_request',
immediate: !loaded.value,
onRequest() {
loaded.value = true;
},
onResponse({ response }) {
if (response.ok) {
agents.value = response._data ?? [];
}
}
});
const state = nuxtApp._agentsState as {
list: Ref<Agent[]>;
initPromise: Promise<void> | null;
};
const init = (): Promise<void> => {
if (state.initPromise) return state.initPromise;
state.initPromise = (async () => {
const query = triplit.query('agents')
.Include('topics', (rel) => rel('topics').Order('createdAt', 'DESC'))
.Order('createdAt', 'ASC');
const { results } = await useQuery('agents', triplit, query)
watch(results, (newAgents) => {
if (newAgents && newAgents.length > 0) {
state.list.value = newAgents as unknown as Agent[];
}
}, { immediate: true, flush: 'sync' });
})();
return state.initPromise;
};
const getAgent = (id: MaybeRef<string>) => {
return computed(() => state.list.value.find((agent) => agent.id === toRef(id).value) || null);
}
const createAgent = async () => {
const triplit = useTriplitClient();
const createAgent = async (navigate: boolean = true) => {
const { user } = useAuth();
if (!user.value) throw new Error('No user');
if (!user.value) {
console.error('No user');
return null;
}
const id = nanoid();
await triplit.insert('agents', {
id,
name: 'New Agent',
const agentId = nanoid();
const agent = {
id: agentId,
userId: user.value.id,
name: 'New Agent',
systemPrompt: 'You are a helpful assistant.',
defaultModelId: null,
imageUrl: null,
createdAt: new Date().toISOString(),
} as AgentWithTopics;
const router = useRouter()
// @ts-ignore - stack depth
const res = await attempt($fetch('/api/agent', {
method: 'POST',
body: agent,
onRequest() {
agents.value = [...(agents.value), { ...agent, topics: [] as Topic[], createdAt: new Date() }];
if (navigate) {
router.push(`/agent/${agentId}`);
}
},
onRequestError() {
if (navigate) {
const route = useRoute();
if (route.params.id === agentId) {
router.push('/');
}
}
agents.value = agents.value.filter(a => a.id !== agentId);
},
onResponseError() {
if (navigate) {
const route = useRoute();
if (route.params.id === agentId) {
router.push('/');
}
}
agents.value = agents.value.filter(a => a.id !== agentId);
},
async onResponse() {
await refresh();
}
}));
if (!res.ok) return null;
return agent;
};
const patchAgentLocally = (id: string, updates: Partial<Agent>) => {
if (!agents.value) return null;
agents.value = agents.value.map(a =>
a.id === id ? { ...a, ...updates } : a
);
};
const patchTopicLocally = (id: string, updates: Partial<Topic>) => {
if (!agents.value) return null;
agents.value = agents.value.map(a =>
a.topics.find(t => t.id === id) ? {
...a, topics: a.topics.map(t =>
t.id === id ? { ...t, ...updates } : t
)
} : a
);
};
const updateAgent = async (id: string, updates: Partial<Agent>) => {
const agent = agents.value.find(a => a.id === id);
if (!agent) return;
await $fetch(`/api/agent/${id}`, {
method: 'PATCH',
body: updates,
onRequest() {
agents.value = agents.value.map(a =>
a.id === id ? { ...a, ...updates } : a
);
},
onRequestError() {
agents.value = agents.value.map(a =>
a.id === id ? agent : a
);
},
onResponseError() {
agents.value = agents.value.map(a =>
a.id === id ? agent : a
);
},
async onResponse() {
await refresh();
}
});
}
const getAgent = (id: MaybeRef<string>) => {
return computed(() => agents.value?.find((agent) => agent.id === unref(id)) || null);
}
const deleteAgent = async (id: string) => {
let agent = agents.value.find(a => a.id === id);
if (!agent) return;
await $fetch(`/api/agent/${id}`, {
method: 'DELETE',
onRequest() {
agents.value = agents.value.filter(a => a.id !== id);
},
onResponseError() {
agents.value = [...agents.value.filter(a => a.id !== id), agent];
},
async onResponse() {
await refresh();
}
});
}
// TODO: since topics are contained within each agent struct, all topic
// actions must be done through the agents composable
const createTopic = async (agentId: string) => {
const topicId = nanoid();
const topic = {
id: topicId,
name: 'New Topic',
agentId,
};
await $fetch(`/api/topic`, {
method: 'POST',
body: topic,
onRequest() {
agents.value = agents.value.map(a =>
a.id === agentId ? { ...a, topics: [{ ...topic, createdAt: new Date() }, ...a.topics] } : a
);
},
onResponseError() {
agents.value = agents.value.map(a =>
a.id === agentId ? { ...a, topics: a.topics.filter(t => t.id !== topicId) } : a
);
},
});
assert('flush' in triplit);
await triplit.flush();
return topic;
}
const deleteTopic = async (agentId: string, topicId: string) => {
const targetTopic = agents.value.flatMap(agent => agent.topics).find(topic => topic.id === topicId);
if (!targetTopic) return;
await $fetch(`/api/topic/${topicId}`, {
method: 'DELETE',
onRequest() {
agents.value = agents.value.map(a =>
a.id === agentId ? { ...a, topics: a.topics.filter(t => t.id !== topicId) } : a
);
},
onResponseError() {
agents.value = agents.value.map(a =>
a.id === agentId ? { ...a, topics: [...a.topics.filter(t => t.id !== topicId), targetTopic] } : a
);
},
});
}
return state.list.value.find((agent) => agent.id === id)!;
};
return {
init,
agents: state.list,
agents,
refresh,
createAgent,
createTopic,
getAgent,
createAgent
patchAgentLocally,
patchTopicLocally,
updateAgent,
deleteAgent,
deleteTopic
};
};
}
+8 -33
View File
@@ -30,11 +30,9 @@ export const useAuth = () => {
return sessionPromise;
}
let finish: (value: Result<sessionData, AuthError>) => void;
sessionFetching.value = true;
sessionPromise = new Promise(async (resolve, reject) => {
finish = resolve;
});
const { promise, resolve } = Promise.withResolvers<Result<sessionData, AuthError>>();
sessionPromise = promise;
let data: {
session: InferSessionFromClient<BetterAuthClientOptions>;
user: InferUserFromClient<BetterAuthClientOptions>;
@@ -52,18 +50,17 @@ export const useAuth = () => {
} else {
data = (await authClient.getSession()).data;
}
session.value = data?.session || null;
user.value = data?.user || null;
resolve(Ok({ session: data?.session || null, user: data?.user || null }));
} catch (error) {
console.error('Failed to fetch session:', error);
resolve(Err(AuthError.NetworkError));
} finally {
sessionFetching.value = false;
finish!(Err(AuthError.NetworkError));
return sessionPromise;
}
session.value = data?.session || null;
user.value = data?.user || null;
sessionFetching.value = false;
sessionFetching.value = false;
finish!(Ok({ session: data?.session || null, user: data?.user || null }));
return sessionPromise;
};
@@ -71,15 +68,6 @@ export const useAuth = () => {
authClient.$store.listen('$sessionSignal', async (signal) => {
if (!signal) return;
await fetchSession();
if (!session.value) return;
const triplit = useTriplitClient();
if ('updateOptions' in triplit) {
triplit.updateOptions({
token: session.value.token,
});
}
});
}
@@ -106,11 +94,6 @@ export const useAuth = () => {
user.value = data.user;
const triplit = useTriplitClient();
if ('startSession' in triplit && data.token) {
await triplit.startSession(data.token);
}
clearNuxtData();
return Ok({ user: data.user, token: data.token });
@@ -147,10 +130,6 @@ export const useAuth = () => {
user.value = data.user;
const triplit = useTriplitClient();
assert('startSession' in triplit);
await triplit.startSession(data.token);
clearNuxtData();
return Ok({ user: data.user, token: data.token });
@@ -176,10 +155,6 @@ export const useAuth = () => {
user.value = null;
session.value = null;
const triplit = useTriplitClient();
assert('disconnect' in triplit);
triplit.disconnect();
clearNuxtData();
return Ok(undefined);
+341 -419
View File
@@ -1,33 +1,27 @@
import type schema from "#triplit/schema";
import type { Entity } from "@triplit/client";
import type { FilePart, ImagePart, ModelMessage } from "ai";
import { nanoid } from "nanoid";
import { decrypt, base64ToUint8Array } from "~/utils/crypto";
import { type Result, Ok, Err, attempt } from "~~/types/result";
import { assert } from "~~/utils/assert";
import { nanoid } from 'nanoid';
import * as schema from '~~/drizzle/schema';
import { Err, Ok, type Result } from '~~/types/result';
import { buildMessageTree } from '~~/utils/message';
export type BaseMessage = {
content: string;
fileIds: string[];
}
export type MessageEntity = Entity<typeof schema, 'messages'> & {
parts: (Entity<typeof schema, 'message_parts'> & {
toolCall: Entity<typeof schema, 'tool_calls'> | null
})[] | undefined
} & { generation: Entity<typeof schema, 'generations'> | null }
& { attachments: Entity<typeof schema, 'attachments'>[] }
export type ToolCall = typeof schema.toolCalls.$inferSelect
export type Message =
MessageEntity & {
children: (MessageEntity | undefined)[];
}
export type MessagePart = typeof schema.messageParts.$inferSelect & { toolCall: ToolCall | null }
export type MessageEntity = typeof schema.messages.$inferSelect & { parts: MessagePart[] | undefined } & { generation: typeof schema.generations.$inferSelect | null } & { attachments: (typeof schema.attachments.$inferSelect & { file: typeof schema.files.$inferSelect })[] }
export type Message = MessageEntity & { children: (MessageEntity | undefined)[] }
export enum ChatErrorType {
NoModel = 0,
NoProvider,
NoAgent,
NoUser,
NoTopic,
DatabaseOperationFailed,
FailedToDecryptProviderApiKey,
GenerationFailed,
@@ -37,315 +31,347 @@ export enum ChatErrorType {
Unimplemented,
}
export const useChat = (agentId: string) => {
const triplit = useTriplitClient();
type Event = { type: string; payload: any; timestamp: number };
const createTopic = async () => {
const { user } = useAuth();
if (!user.value) {
console.error('No user');
const TARGET_UPDATES_PER_SECOND = 24;
export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
let data = useState<Topic & { messages: Message[] } | undefined>('useChat:data', () => undefined);
let sse: EventSource | undefined;
const textDeltaBuffer: Map<string, Map<string, string>> = new Map();
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
let flushCbs: Set<() => void> = new Set();
const flushTextDeltas = () => {
if (!data.value) return;
for (const [messageId, parts] of textDeltaBuffer) {
const msg = data.value.messages.find(m => m.id === messageId);
if (!msg) continue;
for (const [partId, content] of parts) {
console.log("content", content);
const part = msg.parts?.find(p => p.id === partId);
if (part) {
part.content += content;
}
}
}
textDeltaBuffer.clear();
flushTimeout = undefined;
for (const cb of flushCbs) {
cb();
}
flushCbs.clear();
};
const nextFlush = (cb: () => void) => {
if (flushTimeout) {
flushCbs.add(cb);
return;
}
const newTopic = await triplit.insert('topics', {
name: 'New Topic',
userId: user.value.id,
agentId,
createdAt: new Date().toISOString(),
});
cb();
}
assert('flush' in triplit);
await triplit.flush();
return newTopic;
const scheduleFlush = () => {
if (flushTimeout) return;
flushTimeout = setTimeout(flushTextDeltas, 1000 / TARGET_UPDATES_PER_SECOND);
};
const marshallMessages = (agent: Entity<typeof schema, 'agents'>, messages: Readonly<MessageEntity[]>): Result<ModelMessage[], string> => {
const marshalledMessages: ModelMessage[] = [];
const processEvent = (event: Event) => {
if (!data.value) return;
if (agent && agent.systemPrompt) {
marshalledMessages.push({
role: 'system',
content: agent.systemPrompt,
});
const { type, payload } = event;
switch (type) {
case 'MESSAGE_CREATED': {
// Push the new message if it doesn't exist (prevents duplicates from HTTP vs SSE)
if (!data.value.messages.find(m => m.id === payload.id)) {
data.value.messages.push({ ...payload, parts: [] });
} else {
// replace the existing data with the new data
const index = data.value.messages.findIndex(m => m.id === payload.id);
if (index !== -1) {
data.value.messages[index] = { ...payload, parts: [] };
}
}
break;
}
case 'MESSAGE_UPDATED': {
const msgIndex = data.value.messages.findIndex(m => m.id === payload.id);
if (msgIndex !== -1) {
data.value.messages[msgIndex] = { ...data.value.messages[msgIndex], ...payload };
}
break;
}
case 'MESSAGE_DELETED': {
data.value.messages = data.value.messages.filter(m => m.id !== payload.id);
break;
}
case 'text-start': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg) {
if (!msg.parts) msg.parts = [];
msg.parts.push(payload.part);
}
break;
}
case 'text-delta': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (!msg) break;
if (!textDeltaBuffer.has(payload.messageId)) {
textDeltaBuffer.set(payload.messageId, new Map());
}
const messageBuffer = textDeltaBuffer.get(payload.messageId)!;
const existing = messageBuffer.get(payload.partId) || '';
messageBuffer.set(payload.partId, existing + payload.content);
console.log("messageBuffer", messageBuffer, existing + payload.content);
scheduleFlush();
break;
}
case 'text-end': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
const part = msg?.parts?.find(p => p.id === payload.partId);
if (part) {
nextFlush(() => {
part.content = payload.content;
part.finished = true;
});
}
break;
}
case 'tool-call-start': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg) {
if (!msg.parts) msg.parts = [];
msg.parts.push(payload.part);
}
break;
}
case 'tool-call-delta': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
const part = msg?.parts?.find(p => p.toolCallId === payload.toolCallId);
if (part?.toolCall) {
if (payload.input) part.toolCall.input = payload.input;
if (payload.output) part.toolCall.output = payload.output;
if (payload.error) part.toolCall.error = payload.error;
if (payload.status) part.toolCall.status = payload.status;
}
break;
}
case 'generation-complete': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg && msg.generation) {
msg.generation.status = 'completed';
msg.generation.tokens = payload.tokens;
}
break;
}
case 'generation-failed': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg && msg.generation) {
msg.generation.status = 'failed';
msg.generation.error = payload.error;
}
break;
}
case 'topic_updated': {
data.value = { ...data.value, ...payload };
break;
}
}
}
const connectSSE = async () => {
const { promise, resolve } = Promise.withResolvers<void>();
const id = unref(topicId);
const lastUpdate = data.value?.messages.at(-1)?.updatedAt || '0';
const msgCount = data.value?.messages.length || 0;
if (sse && sse.readyState !== EventSource.CLOSED) {
if (!(new URL(sse.url).pathname.startsWith(`/api/topic/${id}`))) {
sse.close();
} else {
return;
}
}
messages.forEach((message) => {
switch (message.role) {
case 'user':
const attachments = message.attachments.map(attachment => {
if (attachment.mimeType.startsWith('image/')) {
return {
type: 'image',
image: attachment.url,
};
}
sse = new EventSource(`/api/topic/${id}?lastUpdate=${lastUpdate}&count=${msgCount}`);
return {
type: 'file',
data: attachment.url,
filename: attachment.name,
mediaType: attachment.mimeType,
};
}) as (FilePart | ImagePart)[];
sse.addEventListener("error", (e) => {
console.error('sse error', e);
setTimeout(connectSSE, 1500);
})
marshalledMessages.push({
role: 'user',
// TODO: when we have images or files, this is where we need to handle them
content: [
{
type: 'text',
text: message.content
},
...attachments,
],
});
break;
case 'assistant':
(message.parts || []).forEach((part) => {
if (!part) return Err('Part is undefined')
sse.addEventListener("message", (e) => {
const raw = JSON.parse(e.data);
const events: Event[] = Array.isArray(raw) ? raw : [raw];
switch (part.type) {
case 'text':
case 'reasoning': {
marshalledMessages.push({
role: 'assistant',
content: part.content,
});
break;
}
case 'tool-call': {
if (part.toolCall === null) return Err('Tool call is null')
for (const { type, payload, timestamp } of events) {
if (!type || !payload) continue;
if (part.toolCall.status === 'pending') {
return Err('Marshalling tool call that is still pending. This is likely a UI bug if this happens.')
}
if (type === 'initial_state') {
data.value = payload;
resolve()
continue;
}
let inputValue: string = '';
switch (part.toolCall.input!.type) {
case 'text':
inputValue = part.toolCall.input!.value;
break;
case 'json':
inputValue = JSON.parse(part.toolCall.input!.value);
break;
}
marshalledMessages.push({
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: part.toolCall.id,
toolName: part.toolCall.toolName,
input: inputValue,
},
],
providerOptions: part.providerOptions,
});
if (part.toolCall.status === 'failed') {
let failureType: 'error-text' | 'error-json';
let failureValue: string;
if (part.toolCall.error === null || part.toolCall.error === undefined) {
failureType = 'error-text';
failureValue = 'An unknown error occurred';
} else {
switch (part.toolCall.error!.type) {
case 'text':
failureType = 'error-text';
failureValue = part.toolCall.error!.value;
break;
case 'json':
failureType = 'error-json';
failureValue = JSON.stringify(part.toolCall.error!.value, null, 2);
break;
}
}
marshalledMessages.push({
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: part.toolCall.id,
toolName: part.toolCall.toolName,
output: {
type: failureType,
value: failureValue,
},
},
],
providerOptions: part.providerOptions,
});
break;
}
if (part.toolCall.status === 'completed') {
marshalledMessages.push({
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: part.toolCall.id,
toolName: part.toolCall.toolName,
output: {
type: 'json',
value: JSON.stringify(part.toolCall.output!.value, null, 2),
},
},
],
providerOptions: part.providerOptions,
});
break;
}
} break;
default:
return Err(`Unknown part type: ${part.type}`)
}
});
break;
default:
return Err(`Unknown message role: ${message.role}`)
processEvent({ type, payload, timestamp });
}
});
return Ok(marshalledMessages);
};
await promise;
}
const startGeneration = async (
messages: ModelMessage[],
args: Record<string, any>,
topic: Entity<typeof schema, 'topics'>,
provider: Entity<typeof schema, 'providers'>,
model: Entity<typeof schema, 'models'>,
parentMessageId: string | null = null
): Promise<Result<void, ChatErrorType>> => {
let providerApiKey: string | undefined = undefined;
if (provider.config.apiKey !== undefined) {
try {
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
providerApiKey = await decrypt(
key,
base64ToUint8Array(provider.config.apiKey)
);
} catch (error) {
console.error('Failed to decrypt provider API key:', error);
return Err(ChatErrorType.FailedToDecryptProviderApiKey);
if (connect) {
onBeforeUnmount(() => {
if (flushTimeout) {
clearTimeout(flushTimeout);
flushTextDeltas();
}
if (sse) {
console.log('[SSE] Closing connection');
sse.close();
sse = undefined;
}
});
if (import.meta.server) {
const id = unref(topicId);
const { data: ssrData } = await useFetch<Topic & { messages: Message[] }>(`/api/topic/${id}`);
data.value = ssrData.value;
} else {
// if (data.value === undefined) {
// const id = unref(topicId);
// const { data: ssrData } = await useFetch<Topic & { messages: Message[] }>(`/api/topic/${id}`);
// data.value = ssrData.value;
// }
await connectSSE();
}
}
const topic = computed(() => ({
...data.value,
messages: buildMessageTree(data.value?.messages || [])
}));
const getProviderAPIKey = async (provider: Provider): Promise<Result<string | undefined, ChatErrorType>> => {
if (provider.config.apiKey === undefined) {
return Ok(undefined);
}
try {
await $fetch('/api/chat/generate', {
method: 'POST',
body: {
messages,
topicId: topic.id,
parentMessageId,
model: {
providerId: provider.id,
modelId: model.id,
args,
},
providerApiKey: providerApiKey,
},
headers: {
'Content-Type': 'application/json',
},
});
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
return Ok(undefined);
return Ok(await decrypt(
key,
base64ToUint8Array(provider.config.apiKey)
));
} catch (error) {
console.error('Failed to generate:', error);
return Err(ChatErrorType.GenerationFailed);
console.error('Failed to decrypt provider API key:', error);
return Err(ChatErrorType.FailedToDecryptProviderApiKey);
}
}
const sendMessage = async (
message: BaseMessage,
topic: Entity<typeof schema, 'topics'>,
topicMessages: MessageEntity[],
agent: Entity<typeof schema, 'agents'>,
provider: Entity<typeof schema, 'providers'>,
model: Entity<typeof schema, 'models'>,
baseMessage: BaseMessage,
onRequest?: () => void,
): Promise<Result<void, ChatErrorType>> => {
console.log("sendMessage", baseMessage);
const { user } = useAuth();
if (!user.value) {
console.error('No user');
return Err(ChatErrorType.NoUser);
}
if (!user.value) return Err(ChatErrorType.NoUser);
const messageId = nanoid();
const attachmentsPromise = message.fileIds.map(async fileId => {
const file = await triplit.fetchOne(triplit.query('files').Where('id', '=', fileId));
assert(file !== null);
return await triplit.insert('attachments', {
userId: user.value!.id,
topicId: topic.id,
messageId: messageId,
fileId: fileId,
name: file.name,
mimeType: file.mimeType,
url: file.url,
createdAt: file.createdAt,
})!;
});
const newMessage = await triplit.insert('messages', {
id: messageId,
userId: user.value.id,
topicId: topic.id,
createdAt: new Date().toISOString(),
content: message.content,
role: 'user',
}).catch(async error => {
console.error('Failed to insert message:', error);
await triplit.delete('messages', messageId);
return Err(ChatErrorType.DatabaseOperationFailed);
}) as Message;
const attachments = await Promise.all(attachmentsPromise);
newMessage.attachments = attachments;
const messages = marshallMessages(
agent,
topicMessages.concat(newMessage)
);
if (messages.ok === false) {
console.error('Failed to marshall messages:', messages.error);
return Err(ChatErrorType.MarshallFailed)
}
const args = {
temperature: 1,
max_tokens: 100,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
};
return startGeneration(messages.data, args, topic, provider, model).then(async res => {
if (res.ok === false) {
console.error('Failed to start generation:', res.error);
await triplit.delete('messages', messageId);
try {
const message = {
id: nanoid(),
role: 'user',
content: baseMessage.content,
fileIds: baseMessage.fileIds,
}
return res;
});
console.log("sendMessage", message, baseMessage.content, baseMessage.fileIds);
await $fetch(`/api/topic/${unref(topicId)}/message`, {
method: 'POST',
body: {
message
},
onRequest() {
if (data.value) {
data.value!.messages.push({
topicId: unref(topicId),
userId: user.value!.id,
// TODO
attachments: [],
parts: undefined,
generation: null,
parentMessageId: null,
generationId: null,
focusedIndex: null,
deleted: null,
createdAt: new Date(),
updatedAt: new Date(),
children: [],
...message
} as Message);
}
onRequest?.();
},
onResponseError() {
data.value!.messages = data.value!.messages.filter(m => m.id !== message.id);
},
});
return Ok(undefined);
} catch (error) {
console.error('Failed to send message:', error);
return Err(ChatErrorType.GenerationFailed);
}
};
const startGeneration = async (model: ModelWithProvider) => {
console.log(model.provider);
const providerApiKeyRes = await getProviderAPIKey(model.provider);
if (providerApiKeyRes.ok === false) {
return providerApiKeyRes;
}
const providerApiKey = providerApiKeyRes.data;
await $fetch(`/api/topic/${unref(topicId)}/chat`, {
method: 'POST',
body: {
modelId: model.id,
providerApiKey,
},
});
}
/**
*
* @param messageId The ID of the message we wish to regenerate *for*, this
@@ -364,11 +390,8 @@ export const useChat = (agentId: string) => {
*/
const regenerateMessage = async (
messageId: string,
topic: Entity<typeof schema, 'topics'>,
topicMessages: MessageEntity[],
agent: Entity<typeof schema, 'agents'>,
provider: Entity<typeof schema, 'providers'>,
model: Entity<typeof schema, 'models'>
model: ModelWithProvider
): Promise<Result<void, ChatErrorType>> => {
const targetMessage = topicMessages.find(message => message.id === messageId);
if (!targetMessage) {
@@ -376,16 +399,14 @@ export const useChat = (agentId: string) => {
}
let targetMessageIndex = topicMessages.indexOf(targetMessage);
const args = {
temperature: 1,
max_tokens: 100,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
};
const providerApiKeyRes = await getProviderAPIKey(model.provider);
if (providerApiKeyRes.ok === false) {
return providerApiKeyRes;
}
const providerApiKey = providerApiKeyRes.data;
let parentMessageId = null;
let focusedMessages;
let parentMessageId = undefined;
let focusedMessages: MessageEntity[] | undefined;
if (targetMessage.role === 'user') {
// we need to find the next agent message
while (targetMessageIndex < topicMessages.length) {
@@ -408,130 +429,31 @@ export const useChat = (agentId: string) => {
focusedMessages = topicMessages;
}
if (parentMessageId === null) {
const messages = marshallMessages(
agent,
topicMessages
);
if (messages.ok === false) {
console.error('Failed to marshall messages:', messages.error);
return Err(ChatErrorType.MarshallFailed)
}
return startGeneration(messages.data, args, topic, provider, model);
}
const focusedMessageIndex = topicMessages.findIndex(m => m.id === parentMessageId);
if (focusedMessageIndex !== topicMessages.length - 1) {
}
const messages = marshallMessages(
agent,
focusedMessages
);
if (messages.ok === false) {
console.error('Failed to marshall messages:', messages.error);
return Err(ChatErrorType.MarshallFailed)
}
return startGeneration(messages.data, args, topic, provider, model, parentMessageId);
}
enum AutoRenameError {
AutoRenameDisabled = 0,
NoModelSelected,
NoModelFound,
ModelDisabled,
DatabaseOperationFailed,
FailedToDecryptProviderApiKey,
FailedToGenerate,
}
const autoRename = async (topicId: string, prompt: string): Promise<Result<string, AutoRenameError>> => {
const { settings } = useUserSettings();
console.log(settings.value);
if (!settings.value.systemAssistants.rename.enabled) {
return Err(AutoRenameError.AutoRenameDisabled);
}
if (!settings.value.systemAssistants.rename.modelId) {
return Err(AutoRenameError.NoModelSelected);
}
const modelResult = await attempt(triplit.fetchOne(triplit.query('models').Where('id', '=', settings.value.systemAssistants.rename.modelId).Include('provider')));
if (modelResult.ok === false) {
return Err(AutoRenameError.DatabaseOperationFailed);
}
const model = modelResult.data;
if (!model) {
return Err(AutoRenameError.NoModelFound);
}
if (model.enabled === false || model.provider?.enabled === false) {
return Err(AutoRenameError.ModelDisabled)
}
let providerApiKey: string | undefined = undefined;
if (model.provider!.config.apiKey !== undefined) {
try {
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
providerApiKey = await decrypt(
key,
base64ToUint8Array(model.provider!.config.apiKey)
);
} catch (error) {
console.error('Failed to decrypt provider API key:', error);
return Err(AutoRenameError.FailedToDecryptProviderApiKey);
}
}
await triplit.update('topics', topicId, {
renaming: true
await $fetch(`/api/topic/${unref(topicId)}/chat`, {
method: 'POST',
body: {
modelId: model.id,
providerApiKey,
parentMessageId,
},
});
try {
// @ts-ignore - excessive stack depth
const res = await $fetch(`/api/auto-rename/${topicId}`, {
method: 'POST',
body: JSON.stringify({
modelId: model.id,
prompt,
providerApiKey,
}),
}) as { ok: true, renameId: string } | { ok: false, code: string };
if (!res.ok) {
console.error('Failed to auto-rename:', res.code);
return Err(AutoRenameError.FailedToGenerate);
}
return Ok(undefined);
}
return Ok(res.renameId);
} catch (error) {
triplit.update('topics', topicId, {
renaming: false,
});
const patchMessageLocally = (id: string, updates: Partial<Message>) => {
if (!data.value) return;
console.error('Failed to auto-rename:', error);
return Err(AutoRenameError.FailedToGenerate);
}
data.value.messages = data.value.messages.map(m =>
m.id === id ? { ...m, ...updates } : m
);
}
return {
topic,
sendMessage,
AutoRenameError,
autoRename,
startGeneration,
regenerateMessage,
createTopic,
patchMessageLocally
};
}
}
+143 -47
View File
@@ -1,54 +1,37 @@
import type { Entity } from '@triplit/client';
import type schema from '#triplit/schema';
import { nanoid } from 'nanoid';
import * as schema from '~~/drizzle/schema';
type Provider = Entity<typeof schema, 'providers'>;
type Model = Entity<typeof schema, 'models'>;
export type Model = typeof schema.models.$inferSelect;
export type Provider = typeof schema.providers.$inferSelect;
export interface ModelWithProvider extends Model {
provider: Provider;
}
export interface ProviderWithModels extends Provider {
export type ProviderWithModels = Provider & {
models: Model[];
}
};
export const useModels = () => {
const nuxtApp = useNuxtApp();
const triplit = useTriplitClient();
export type ModelWithProvider = Model & {
provider: Provider;
};
if (!nuxtApp._modelsState) {
nuxtApp._modelsState = {
providers: shallowRef([]),
isReady: ref(false)
};
}
export const useModels = async () => {
const providers = useState<ProviderWithModels[]>('models_state', () => []);
const loaded = useState('models_loaded', () => false);
const state = nuxtApp._modelsState as {
providers: Ref<ProviderWithModels[]>;
initPromise: Promise<void> | null;
};
const init = (): Promise<void> => {
if (state.initPromise) return state.initPromise;
state.initPromise = (async () => {
const query = triplit.query('providers').Include('models');
const { results } = await useQuery('providers', triplit, query)
watch(results, (newProviders) => {
if (newProviders && newProviders.length > 0) {
state.providers.value = newProviders as unknown as ProviderWithModels[];
}
}, { immediate: true, flush: 'sync' });
})();
return state.initPromise;
};
const { refresh } = await useFetch<ProviderWithModels[]>('/api/providers', {
key: 'models_request',
immediate: !loaded.value,
onRequest() {
loaded.value = true;
},
onResponse({ response }) {
if (response.ok) {
providers.value = response._data ?? [];
}
}
});
const allModels = computed<ModelWithProvider[]>(() => {
const result: ModelWithProvider[] = [];
for (const provider of state.providers.value) {
for (const provider of providers.value) {
if (!provider.enabled) continue;
for (const model of provider.models || []) {
if (!model.enabled) continue;
@@ -63,19 +46,132 @@ export const useModels = () => {
};
const getProvider = (id: string): ProviderWithModels | undefined => {
return state.providers.value.find((provider) => provider.id === id);
return providers.value.find((provider) => provider.id === id);
};
const getFirstAvailableModel = () => {
return allModels.value[0] ?? null;
};
const createModel = async (model: Model) => {
model.id = nanoid();
await $fetch(`/api/model`, {
method: 'POST',
body: model,
onRequest() {
providers.value = providers.value.map(p => ({
...p,
models: p.id === model.providerId ? [...p.models, model] : p.models
}));
},
onResponseError() {
providers.value = providers.value.map(p => ({
...p,
models: p.id === model.providerId ? p.models.filter(m => m.id !== model.id) : p.models
}));
},
});
}
const updateModel = async (id: string, updates: Partial<Model>) => {
const model = providers.value.find(p => p.models.find(m => m.id === id));
if (!model) return;
const original = model;
await $fetch(`/api/model/${id}`, {
method: 'PATCH',
body: updates,
onRequest() {
providers.value = providers.value.map(p => ({
...p,
models: p.models.map(m =>
m.id === id ? { ...m, ...updates } : m
)
}));
},
onRequestError() {
providers.value = providers.value.map(p => ({
...p,
models: p.models.map(m =>
m.id === id ? original : m
)
}) as ProviderWithModels);
},
onResponseError() {
providers.value = providers.value.map(p => ({
...p,
models: p.models.map(m =>
m.id === id ? original : m
)
}) as ProviderWithModels);
},
// async onResponse() {
// await refresh();
// }
});
};
const deleteModel = async (id: string) => {
const model = providers.value.find(p => p.models.find(m => m.id === id));
if (!model) return;
await $fetch(`/api/model/${id}`, {
method: 'DELETE',
onRequest() {
providers.value = providers.value.map(p => ({
...p,
models: p.models.filter(m => m.id !== id)
}));
},
onResponseError() {
providers.value.push(model);
},
async onResponse() {
await refresh();
}
});
}
const updateProvider = async (id: string, updates: Partial<Provider>) => {
const provider = providers.value.find(p => p.id === id);
if (!provider) return;
const original = provider;
await $fetch(`/api/provider/${id}`, {
method: 'PATCH',
body: updates,
onRequest() {
providers.value = providers.value.map(p =>
p.id === id ? { ...p, ...updates } : p
);
},
onRequestError() {
providers.value = providers.value.map(p =>
p.id === id ? original : p
);
},
onResponseError() {
providers.value = providers.value.map(p =>
p.id === id ? original : p
);
},
async onResponse() {
await refresh();
}
});
};
return {
init,
providers: state.providers,
providers,
allModels,
getModel,
getProvider,
getFirstAvailableModel
getFirstAvailableModel,
createModel,
updateModel,
updateProvider,
deleteModel
};
};
}
+2 -3
View File
@@ -1,7 +1,6 @@
import type { Entity } from '@triplit/client';
import type { schema } from '#triplit/schema';
import * as schema from '~~/drizzle/schema';
type Generation = Entity<typeof schema, 'generations'>;
type Generation = typeof schema.generations.$inferSelect;
export const useTokenDropdown = () => {
const isOpen = useState('token-dropdown:open', () => false);
+101
View File
@@ -0,0 +1,101 @@
import { Err, Ok, type Result } from "~~/types/result";
export const useTopic = () => {
enum AutoRenameError {
AutoRenameDisabled = 0,
NoModelSelected,
NoModelFound,
ModelDisabled,
DatabaseOperationFailed,
FailedToDecryptProviderApiKey,
FailedToGenerate,
}
const autoRename = async (topicId: string): Promise<Result<string, AutoRenameError>> => {
const { settings } = await useUserSettings();
if (!settings.value.systemAssistants.rename.enabled) {
return Err(AutoRenameError.AutoRenameDisabled);
}
if (!settings.value.systemAssistants.rename.modelId) {
return Err(AutoRenameError.NoModelSelected);
}
const { allModels } = await useModels();
const model = allModels.value.find(model => model.id === settings.value.systemAssistants.rename.modelId);
if (!model) {
return Err(AutoRenameError.NoModelFound);
}
if (model.enabled === false || model.provider?.enabled === false) {
return Err(AutoRenameError.ModelDisabled)
}
let providerApiKey: string | undefined = undefined;
if (model.provider!.config.apiKey !== undefined) {
try {
const key = await crypto.subtle.importKey(
"jwk",
JSON.parse(window.localStorage.getItem("encryptionKey")!),
"AES-GCM",
false,
["encrypt", "decrypt"]
)
providerApiKey = await decrypt(
key,
base64ToUint8Array(model.provider!.config.apiKey)
);
} catch (error) {
console.error('Failed to decrypt provider API key:', error);
return Err(AutoRenameError.FailedToDecryptProviderApiKey);
}
}
const { agents } = await useAgents();
try {
// @ts-ignore - excessive stack depth
const res = await $fetch(`/api/topic/${topicId}/auto-rename`, {
method: 'POST',
body: JSON.stringify({
modelId: model.id,
providerApiKey,
}),
onRequest() {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(topic => topic.id === topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: true } : t) } : agent;
});
}
},
onRequestError() {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(topic => topic.id === topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: false } : t) } : agent;
});
}
},
}) as { ok: true, renameId: string } | { ok: false, code: string };
if (!res.ok) {
console.error('Failed to auto-rename:', res.code);
return Err(AutoRenameError.FailedToGenerate);
}
return Ok(res.renameId);
} catch (error) {
console.error('Failed to auto-rename:', error);
return Err(AutoRenameError.FailedToGenerate);
}
}
return {
autoRename
};
}
+190
View File
@@ -0,0 +1,190 @@
export const useUserEvents = () => {
const eventSource = ref<EventSource | null>(null);
const isConnected = useState('userEvents:connected', () => false);
const lastEventTimestamp = useState<number>('userEvents:lastTimestamp', () => 0);
const reconnectAttempts = ref(0);
const connectionStartTime = ref<number>(0);
const pendingReconnect = ref<NodeJS.Timeout | null>(null);
const BASE_DELAY = 1000;
const MAX_DELAY = 30000;
const MAX_RECONNECT_ATTEMPTS = 10;
const cleanup = () => {
if (pendingReconnect.value) {
clearTimeout(pendingReconnect.value);
}
if (eventSource.value) {
eventSource.value.close();
eventSource.value = null;
}
isConnected.value = false;
};
const getReconnectDelay = (attempt: number): number => {
const exponentialDelay = Math.min(
BASE_DELAY * Math.pow(2, attempt),
MAX_DELAY
);
const jitter = exponentialDelay * Math.random() * 0.25;
return exponentialDelay + jitter;
};
const scheduleReconnect = () => {
if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {
console.error('Max reconnection attempts reached');
return;
}
const delay = getReconnectDelay(reconnectAttempts.value);
console.log(`Scheduling reconnect in ${delay}ms (attempt ${reconnectAttempts.value + 1})`);
pendingReconnect.value = setTimeout(() => {
reconnectAttempts.value++;
connect();
}, delay);
};
const connect = async () => {
cleanup();
connectionStartTime.value = Date.now();
reconnectAttempts.value = 0;
const source = new EventSource('/api/events');
eventSource.value = source;
source.onopen = () => {
console.log('User events connected');
isConnected.value = true;
reconnectAttempts.value = 0;
};
source.onerror = () => {
isConnected.value = false;
if (source.readyState === EventSource.CLOSED) {
console.log('EventSource closed, scheduling reconnect');
scheduleReconnect();
}
};
source.addEventListener('message', async (event) => {
if (event.data === '') return;
try {
const data = JSON.parse(event.data);
if (data.timestamp && data.timestamp > lastEventTimestamp.value) {
lastEventTimestamp.value = data.timestamp;
}
handleUserEvent(data);
} catch (e) {
console.error('Failed to parse user event:', e);
}
});
};
const handleUserEvent = async (data: { entity: string; op: string; payload: any; timestamp?: number }) => {
const { agents } = await useAgents();
switch (data.entity) {
case 'topics': {
switch (data.op) {
case 'create': {
const existing = agents.value.find(a => a.id === data.payload.agentId);
if (existing) {
const topicExists = existing.topics.some(t => t.id === data.payload.id);
if (!topicExists) {
agents.value = agents.value.map(agent => {
return agent.id === data.payload.agentId
? { ...agent, topics: [data.payload, ...agent.topics] }
: agent;
});
}
}
break;
}
case 'update': {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(t => t.id === data.payload.topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId
? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...t, ...data.payload } : t) }
: agent;
});
}
break;
}
case 'delete': {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(t => t.id === data.payload.topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId
? { ...agent, topics: agent.topics.filter(t => t.id !== topic.id) }
: agent;
});
}
break;
}
}
break;
}
case 'agents': {
switch (data.op) {
case 'create': {
const existing = agents.value.find(a => a.id === data.payload.id);
if (existing) {
agents.value = agents.value.map(agent =>
agent.id === data.payload.id ? { ...agent, ...data.payload } : agent
);
} else {
agents.value = [...agents.value, data.payload];
}
break;
}
case 'update': {
agents.value = agents.value.map(agent =>
agent.id === data.payload.id ? { ...agent, ...data.payload } : agent
);
break;
}
case 'delete': {
agents.value = agents.value.filter(agent => agent.id !== data.payload.id);
break;
}
}
break;
}
}
};
const reconcile = async () => {
console.log('Reconciling user state...');
const { data } = await useFetch<AgentWithTopics[]>('/api/agents');
if (data.value) {
const { agents } = await useAgents();
agents.value = data.value;
}
};
onMounted(() => {
connect();
});
onBeforeUnmount(() => {
cleanup();
});
return {
eventSource,
isConnected,
connect,
cleanup,
reconcile,
};
};
+105 -172
View File
@@ -1,197 +1,130 @@
import { schema } from '#triplit/schema';
import { type Entity } from '@triplit/client';
import { computed, watch, ref, type Ref } from 'vue';
import * as schema from '~~/drizzle/schema';
type UserSettings = Entity<typeof schema, 'settings'>;
type UserSettings = typeof schema.settings.$inferSelect;
type Appearance = UserSettings['appearance'];
type SystemAssistants = UserSettings['systemAssistants'];
interface UserSettingsState {
accent: Ref<string>;
neutral: Ref<string>;
hinting: Ref<string>;
colorSchemePreference: Ref<'light' | 'dark' | 'system'>;
colorSchemeClass: Ref<'light' | 'dark' | undefined>;
remoteSettings: Ref<UserSettings | null>;
initPromise: Promise<void> | null;
}
const defaultAppearance: Appearance = {
colorScheme: 'system',
accent: 'violet',
neutral: 'zinc',
hinting: 0,
fontSize: 'md',
};
export const useUserSettings = () => {
const nuxtApp = useNuxtApp();
const triplit = useTriplitClient();
const { user, loggedIn } = useAuth();
export const useUserSettings = async () => {
const settings = useState<UserSettings | null>('settings_state', () => null);
const loaded = useState('settings_loaded', () => false);
const syncing = useState('settings_syncing', () => false);
if (!nuxtApp._userSettingsState) {
nuxtApp._userSettingsState = {
accent: ref('violet'),
neutral: ref('zinc'),
hinting: ref('0'),
colorSchemePreference: ref<'light' | 'dark' | 'system'>('system'),
colorSchemeClass: ref<undefined | 'light' | 'dark'>(undefined),
remoteSettings: ref<UserSettings | null>(null),
initPromise: null,
} as UserSettingsState;
}
const localAppearance = useState<Appearance | null>('settings_local_appearance', () => null);
const localSystemAssistants = useState<SystemAssistants | null>('settings_local_sa', () => null);
const state = nuxtApp._userSettingsState as UserSettingsState;
const init = (): Promise<void> => {
if (state.initPromise) return state.initPromise;
state.initPromise = (async () => {
const { results } = await useQuery('settings', triplit, triplit.query('settings'));
watch(results, (val) => {
if (val && val.length > 0) {
state.remoteSettings.value = val[0] as UserSettings;
} else {
state.remoteSettings.value = null;
}
}, { immediate: true, deep: true });
watch(state.remoteSettings, (newSettings) => {
if (!newSettings?.appearance) return;
const { appearance } = newSettings;
if (appearance.colorScheme) {
state.colorSchemePreference.value = appearance.colorScheme as 'light' | 'dark' | 'system';
}
if (appearance.accent) {
state.accent.value = appearance.accent;
}
if (appearance.neutral) {
state.neutral.value = appearance.neutral;
}
if (appearance.hinting !== undefined) {
state.hinting.value = String(appearance.hinting);
}
}, { immediate: true, deep: true });
if (import.meta.client) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const update = () => {
if (state.colorSchemePreference.value === 'system') {
state.colorSchemeClass.value = mediaQuery.matches ? 'dark' : 'light';
} else {
state.colorSchemeClass.value = state.colorSchemePreference.value as 'dark' | 'light';
}
};
mediaQuery.addEventListener('change', update);
watch(state.colorSchemePreference, update, { immediate: true });
} else {
watch(state.colorSchemePreference, (pref) => {
state.colorSchemeClass.value = pref === 'system' ? 'dark' : (pref as 'dark' | 'light');
}, { immediate: true });
const { refresh } = await useFetch<UserSettings>('/api/settings', {
key: 'settings_request',
immediate: !loaded.value,
onRequest() {
loaded.value = true;
},
onResponse({ response }) {
if (response.ok && response._data) {
settings.value = response._data;
localAppearance.value = { ...defaultAppearance, ...response._data.appearance };
localSystemAssistants.value = response._data.systemAssistants;
}
})();
}
});
return state.initPromise;
const applyOptimisticUpdates = (updates: { appearance?: Partial<Appearance>; systemAssistants?: Partial<SystemAssistants> }) => {
if (updates.appearance && localAppearance.value) {
localAppearance.value = { ...localAppearance.value, ...updates.appearance };
}
if (updates.systemAssistants && localSystemAssistants.value) {
localSystemAssistants.value = { ...localSystemAssistants.value, ...updates.systemAssistants };
}
if (updates.appearance && settings.value) {
settings.value = {
...settings.value,
appearance: { ...settings.value.appearance, ...updates.appearance }
};
}
if (updates.systemAssistants && settings.value) {
settings.value = {
...settings.value,
systemAssistants: { ...settings.value.systemAssistants, ...updates.systemAssistants }
};
}
};
const updateSettings = async (updates: { appearance?: Partial<Appearance>; systemAssistants?: Partial<SystemAssistants> }) => {
const original = settings.value;
const originalAppearance = localAppearance.value;
const originalSA = localSystemAssistants.value;
applyOptimisticUpdates(updates);
try {
syncing.value = true;
await $fetch(`/api/settings`, {
method: 'PATCH',
body: updates,
});
await refresh();
} catch (error) {
settings.value = original;
localAppearance.value = originalAppearance;
localSystemAssistants.value = originalSA;
throw error;
} finally {
syncing.value = false;
}
};
const effectiveAppearance = computed(() => {
return localAppearance.value || defaultAppearance;
});
const effectiveSystemAssistants = computed(() => {
return localSystemAssistants.value || {};
});
const colorSchemeValue = computed(() => {
if (state.colorSchemePreference.value === 'system') {
if (effectiveAppearance.value.colorScheme === 'system') {
if (import.meta.client) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'dark';
}
return state.colorSchemePreference.value as 'dark' | 'light';
return effectiveAppearance.value.colorScheme as 'dark' | 'light';
});
const settings = computed(() => {
const remote = state.remoteSettings.value;
return {
appearance: {
colorScheme: remote?.appearance?.colorScheme ?? state.colorSchemePreference.value,
accent: remote?.appearance?.accent ?? state.accent.value,
neutral: remote?.appearance?.neutral ?? state.neutral.value,
hinting: remote?.appearance?.hinting ?? Number(state.hinting.value),
fontSize: remote?.appearance?.fontSize ?? 'medium',
},
systemAssistants: {
rename: {
enabled: remote?.systemAssistants?.rename?.enabled ?? false,
prompt: remote?.systemAssistants?.rename?.prompt ?? null,
modelId: remote?.systemAssistants?.rename?.modelId ?? null,
}
}
};
});
const updateSettings = async (updates: {
appearance?: {
colorScheme?: 'light' | 'dark' | 'system';
accent?: string;
neutral?: string;
hinting?: number;
fontSize?: string;
};
systemAssistants?: {
rename?: {
enabled?: boolean;
prompt?: string | null;
modelId?: string | null;
};
};
}) => {
if (updates.appearance) {
if (updates.appearance.colorScheme) state.colorSchemePreference.value = updates.appearance.colorScheme;
if (updates.appearance.accent) state.accent.value = updates.appearance.accent;
if (updates.appearance.neutral) state.neutral.value = updates.appearance.neutral;
if (updates.appearance.hinting !== undefined) state.hinting.value = String(updates.appearance.hinting);
}
if (!loggedIn.value || !user.value?.id) return;
const current = state.remoteSettings.value;
if (!current) {
await triplit.insert('settings', {
userId: user.value.id,
appearance: {
colorScheme: state.colorSchemePreference.value,
accent: state.accent.value,
neutral: state.neutral.value,
hinting: Number(state.hinting.value),
...(updates.appearance || {})
},
systemAssistants: {
rename: {
enabled: updates.systemAssistants?.rename?.enabled ?? true,
prompt: updates.systemAssistants?.rename?.prompt ?? null,
modelId: updates.systemAssistants?.rename?.modelId ?? null,
}
}
});
return;
}
await triplit.update('settings', current.id, (s) => {
if (updates.appearance) {
s.appearance = {
...(s.appearance || {}),
...updates.appearance
};
}
if (updates.systemAssistants) {
// @ts-expect-error
s.systemAssistants = {
...(s.systemAssistants || {}),
...updates.systemAssistants
};
}
});
};
const effectiveSettings = computed(() => ({
appearance: effectiveAppearance.value,
systemAssistants: effectiveSystemAssistants.value,
}));
return {
init,
settings,
remoteSettings: state.remoteSettings,
settings: effectiveSettings,
rawSettings: readonly(settings),
updateSettings,
accent: state.accent,
neutral: state.neutral,
hinting: state.hinting,
syncing: readonly(syncing),
loaded: readonly(loaded),
refresh,
accent: computed(() => effectiveAppearance.value.accent ?? 'violet'),
neutral: computed(() => effectiveAppearance.value.neutral ?? 'zinc'),
hinting: computed(() => effectiveAppearance.value.hinting ?? 0),
colorScheme: {
preference: state.colorSchemePreference,
preference: computed(() => effectiveAppearance.value.colorScheme ?? 'system'),
value: colorSchemeValue,
class: state.colorSchemeClass
class: computed(() => {
if (effectiveAppearance.value.colorScheme === 'system') {
if (import.meta.client) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'dark';
}
return effectiveAppearance.value.colorScheme as 'dark' | 'light';
}),
}
};
};