feat: ditch triplit, move to postgresql + drizzle orm
This commit is contained in:
+196
-58
@@ -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
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}),
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user