From 87deee7fbdc6a0d3ef672866c90b75090689c9fd Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 10 Jun 2026 02:35:27 -0700 Subject: [PATCH] Studio: faithful conversation export and import round trips (ShareGPT system role, CSV quoted newlines) (#6131) * fix(studio): preserve system role in ShareGPT exports System messages in ShareGPT conversation exports were serialized as gpt turns, which changes the semantics of exported training data. Map role system to from system in both the single-thread and bulk export paths, matching the importer (sharegptToRecords), which already maps from system back to a system role. Extracted from #5606 by @LeoBorcherding (commit 7b277913). * fix(studio): parse quoted newlines when importing conversation CSV csvToRecords split the file on raw newlines before parsing quotes, so any exported message containing a newline broke on re-import (the record was cut mid-field and remainder lines were dropped). The module already ships an RFC 4180 parser (parseCsv) used by the prompt and list importers; use it for conversation CSV too. Multi-line content, embedded quotes and commas, and CRLF files now round-trip. Unquoted commas in hand-made CSV keep the previous behavior (rest of line is the content). Flagged in #5606 review as 'Preserve quoted newlines when importing CSV'. --- .../prompt-storage/prompt-storage-dialog.tsx | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx b/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx index 80c2de54a2..3908c313b6 100644 --- a/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx +++ b/studio/frontend/src/features/chat/prompt-storage/prompt-storage-dialog.tsx @@ -331,7 +331,7 @@ function messageToOpenAI(msg: { role: unknown; content: unknown; attachments?: u return contentParts.length > 0 ? [{ role: role as "user" | "system", content: contentParts }] : []; } -// ShareGPT training JSONL (human/gpt turns). +// ShareGPT training JSONL (human/system/gpt turns). export async function exportConversationShareGPT(threadId: string): Promise { const messages = await loadConversationMessages(threadId); if (!messages) return; @@ -339,7 +339,7 @@ export async function exportConversationShareGPT(threadId: string): Promise = []; for (const msg of messages) { const role = msg.role as string; - const from = role === "user" ? "human" : "gpt"; + const from = role === "user" ? "human" : role === "system" ? "system" : "gpt"; const value = messageToText(msg); if (value.trim()) conversations.push({ from, value }); } @@ -413,7 +413,7 @@ async function buildThreadContent( for (const msg of messages) { const role = msg.role as string; const value = messageToText(msg); - if (value.trim()) conversations.push({ from: role === "user" ? "human" : "gpt", value }); + if (value.trim()) conversations.push({ from: role === "user" ? "human" : role === "system" ? "system" : "gpt", value }); } if (conversations.length === 0) return null; return JSON.stringify({ conversations }); @@ -626,30 +626,16 @@ function sharegptToRecords( } function csvToRecords(csvText: string, threadId: string, baseTs: number): MessageRecord[] { - const lines = csvText.split(/\r?\n/); + // parseCsv handles quoted newlines, so multi-line message content + // round-trips; a naive per-line split would break those records. + const rows = parseCsv(csvText).slice(1); const records: MessageRecord[] = []; let prevId: string | null = null; let idx = 0; - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) continue; - let role = ""; - let content = ""; - if (line.startsWith('"')) { - const m = line.match(/^"((?:[^"]|"")*)"\s*,\s*([\s\S]*)$/); - if (!m) continue; - role = m[1].replace(/""/g, '"'); - content = m[2].startsWith('"') - ? m[2].slice(1, m[2].endsWith('"') ? -1 : undefined).replace(/""/g, '"') - : m[2]; - } else { - const comma = line.indexOf(","); - if (comma === -1) continue; - role = line.slice(0, comma); - content = line.slice(comma + 1).startsWith('"') - ? line.slice(comma + 2, line.endsWith('"') ? -1 : undefined).replace(/""/g, '"') - : line.slice(comma + 1); - } + for (const row of rows) { + if (row.length < 2) continue; + const role = row[0].trim(); + const content = row.slice(1).join(","); if (!content.trim()) continue; const validRole = role === "user" || role === "assistant" || role === "system" ? role : "user"; const id = crypto.randomUUID();