Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions src/core/chorus/api/ChatAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ export type Chat = {
projectContextSummaryIsStale: boolean;
replyToId: string | null;
gcPrototype: boolean;

pinned: boolean; // deprecated
pinned: boolean;

// Cost tracking
totalCostUsd?: number;
Expand Down Expand Up @@ -103,7 +102,7 @@ export async function fetchChats(): Promise<Chat[]> {
project_context_summary, project_context_summary_is_stale, reply_to_id, gc_prototype_chat, total_cost_usd
FROM chats
WHERE reply_to_id IS NULL
ORDER BY updated_at DESC`,
ORDER BY pinned DESC, updated_at DESC`,
)
.then((rows) => rows.map(readChat));
}
Expand Down Expand Up @@ -141,9 +140,13 @@ export function useCacheUpdateChat() {
updateFn(chat);
// NOTE: We don't always need to sort, if this becomes expensive we could gate
// this behind a flag
draft.sort((a, b) =>
b.updatedAt.localeCompare(a.updatedAt),
);
draft.sort((a, b) => {
// Sort pinned chats first, then by updatedAt
if (a.pinned !== b.pinned) {
return b.pinned ? 1 : -1;
}
return b.updatedAt.localeCompare(a.updatedAt);
});
}
}),
);
Expand Down Expand Up @@ -396,3 +399,25 @@ export function useRenameChat() {
},
});
}

export function useTogglePinChat() {
const queryClient = useQueryClient();
const cacheUpdateChat = useCacheUpdateChat();

return useMutation({
mutationKey: ["togglePinChat"] as const,
mutationFn: async ({ chatId, pinned }: { chatId: string; pinned: boolean }) => {
await db.execute("UPDATE chats SET pinned = $1 WHERE id = $2", [
pinned ? 1 : 0,
chatId,
]);
return { chatId, pinned };
},
onSuccess: async (_data, variables) => {
cacheUpdateChat(variables.chatId, (chat) => {
chat.pinned = variables.pinned;
});
await queryClient.invalidateQueries(chatQueries.list());
},
});
}
163 changes: 163 additions & 0 deletions src/core/chorus/api/ExportAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { db } from "../DB";
import { fetchChat } from "./ChatAPI";
import { save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";

interface MessageRow {
message_id: string;
message_set_id: string;
model: string;
text: string;
created_at: string;
}

interface Turn {
messageSetId: string;
user: {
content: string;
timestamp: string;
};
responses: Array<{
model: string;
content: string;
timestamp: string;
}>;
}

interface ExportData {
chatId: string;
title: string;
createdAt: string;
turns: Turn[];
}

async function fetchChatMessages(chatId: string): Promise<MessageRow[]> {
const messages = await db.select<MessageRow[]>(
`SELECT
m.id as message_id,
m.message_set_id,
m.model,
CASE
WHEN m.model = 'user' THEN COALESCE(m.text, '')
ELSE COALESCE(NULLIF(m.text, ''), mp.content, '')
END as text,
m.created_at
FROM messages m
LEFT JOIN message_parts mp ON m.id = mp.message_id AND m.chat_id = mp.chat_id
WHERE m.chat_id = ?
ORDER BY m.created_at ASC`,
[chatId],
);
return messages;
}

function groupMessagesByTurns(messages: MessageRow[]): Turn[] {
const turnMap = new Map<string, Turn>();

for (const message of messages) {
if (!turnMap.has(message.message_set_id)) {
turnMap.set(message.message_set_id, {
messageSetId: message.message_set_id,
user: {
content: "",
timestamp: "",
},
responses: [],
});
}

const turn = turnMap.get(message.message_set_id)!;

if (message.model === "user") {
turn.user = {
content: message.text,
timestamp: message.created_at,
};
} else {
turn.responses.push({
model: message.model,
content: message.text,
timestamp: message.created_at,
});
}
}

return Array.from(turnMap.values());
}

async function fetchExportData(chatId: string): Promise<ExportData> {
const chat = await fetchChat(chatId);
const messages = await fetchChatMessages(chatId);
const turns = groupMessagesByTurns(messages);

return {
chatId: chat.id,
title: chat.title,
createdAt: chat.createdAt,
turns,
};
}

function formatAsJSON(data: ExportData): string {
return JSON.stringify(data, null, 2);
}

function formatAsMarkdown(data: ExportData): string {
let md = `# ${data.title}\n`;
md += `Created: ${new Date(data.createdAt).toLocaleDateString()}\n\n`;
md += `---\n\n`;

for (const turn of data.turns) {
// User message
if (turn.user.content) {
md += `### You\n${turn.user.content}\n\n`;
}

// AI responses
for (const response of turn.responses) {
md += `### ${response.model}\n${response.content}\n\n`;
}

md += `---\n\n`;
}

return md;
}

export async function exportChatAsJSON(chatId: string): Promise<void> {
const data = await fetchExportData(chatId);
const jsonContent = formatAsJSON(data);

const filePath = await save({
defaultPath: `${data.title || "chat"}.json`,
filters: [
{
name: "JSON",
extensions: ["json"],
},
],
});

if (filePath) {
await writeTextFile(filePath, jsonContent);
}
}

export async function exportChatAsMarkdown(chatId: string): Promise<void> {
const data = await fetchExportData(chatId);
const mdContent = formatAsMarkdown(data);

const filePath = await save({
defaultPath: `${data.title || "chat"}.md`,
filters: [
{
name: "Markdown",
extensions: ["md"],
},
],
});

if (filePath) {
await writeTextFile(filePath, mdContent);
}
}
Loading
Loading