Studio: import Open WebUI chat exports - #8643
Conversation
|
@codex review |
1 similar comment
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a41d9350a9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const renders = content.length > 0 || (role === "user" && attachments.length > 0); | ||
| const parentId = node.parentId ? (keptIdByOriginal.get(node.parentId) ?? null) : null; | ||
| if (!renders) { |
There was a problem hiding this comment.
Keep the selected branch when its head is dropped
When history.currentId selects a blank or failed leaf and the same parent has another renderable response, this condition drops the selected leaf after the DFS deliberately placed it last. The final persisted message is then the other sibling, so Studio reopens—and continues from—a branch the user did not have selected in Open WebUI. Preserve a selectable placeholder/head for this case or otherwise carry the active-branch selection independently of renderable content.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Reproduced, but not changing this. The reopened head can only be the deepest surviving message on the selected path, and that message is the parent of the sibling answer, so persisting it last would put a parent after its own descendant; the repository hydration requires parents first. The other option is persisting the failed turn as an empty bubble, which is exactly what the import drops on purpose. Nothing is lost either way: the failed leaf carried no content, so the imported chat ends on the last real answer and continues from there.
|
@codex review |
1 similar comment
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5d4923b84b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const parent = node.parentId ? byId.get(node.parentId) : undefined; | ||
| if (!parent || parent.id === node.id) { | ||
| node.parentId = null; | ||
| roots.push(node); |
There was a problem hiding this comment.
Avoid emitting multiple roots that reload as one chain
When a damaged history contains both a normal root and an orphaned message, converting the orphan into a second null-parent root does not survive reload: runtime-provider.tsx lines 1377-1386 detects the other non-null parents and replaces every later null parent with previousId. An active orphan imported last is consequently attached beneath an unrelated branch, so continuing it sends unrelated messages as context; represent or reconcile multiple roots in a way the loader preserves.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not changing this. The loader replaces a null parentId with the previous message for any thread that has parent ids at all, so one root per thread is a studio-wide contract, not something this import can encode differently; every alternative here either drops the orphan or splits one exported chat across threads. The recovery still keeps every message, which is the point of the orphan handling.
| if (part.type === "input_text" && typeof part.text === "string") { | ||
| resultText += part.text; | ||
| } else if (part.type === "input_image") { | ||
| const url = str(part.image_url); |
There was a problem hiding this comment.
Preserve file parts returned by function calls
When a Responses-style function_call_output.output array contains an input_file part, this loop handles only input_text and input_image, so the imported tool result silently loses the file name, URL, and inline data. The repository's Responses input model accepts arbitrary content arrays and its passthrough tests include this exact input_file shape; retain a portable file part or attachment rather than dropping it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not changing this. A tool result is stored as text plus image parts, so there is no portable place for a file part; the exported /api/v1/files/... url is dead outside Open WebUI and inline file data would be resent to the model on the next turn, which is the same reason document text is deliberately not carried over. Writing a placeholder name into the result would invent content the export did not have.
for more information, see https://pre-commit.ci
|
Reviewed this at head Before / afterTwo independent Studio installs, one from the merge base and one from the head, same 5,297 byte pretty-printed Open WebUI export, same click through Settings > Data > Import chats. Three chats in the fixture: one with three regenerated siblings and The result toast Recents The imported branch conversation Read from the same servers that were photographed:
Two notes on how to read that. The file was fed to the hidden input directly, so the Suites on the branch: Worth fixing1. An OpenAI JSONL line with per-message { messages: [
{ id: "1", role: "user", timestamp: 1700000000,
content: [ { type: "text", text: "hi" },
{ type: "image_url", image_url: { url: "data:image/png;base64,AAA" } } ] },
{ id: "2", role: "assistant", content: "yo", timestamp: 1700000001 } ] }
2. Adding 3. Timestamp monotonicity is not guaranteed.
That matters because the comment at the top of that block is right that the depth-first order has to survive a reload, and equal stamps destroy it. Anything past 8.64e15 also renders as 4. One record is still buffered whole. The buffer trims back to
The 600 MiB validation run has about 800 KiB per record, so it exercises the streaming path and does prove the whole-file limit is gone, but not this. Two exposures: a single chat over 512 MiB fails with a bare 5. The friendly truncation message is unreachable for a mid-record cut. The important half works: the three already-written chats survive, 6. Three new user-facing strings in 7. The progress toast sits at 0% for any import under 25 conversations. Smaller things
Things I specifically tried to break and could not: the token scoping is sound, and storing the open On the three comments you declinedThe dropped selected head. You are right, and the reason is stronger than the one given. Multiple roots. The mechanism the comment describes is real. Two roots do get welded into one chain, because the loader backfills a null File parts from function calls. You are right structurally. |
…mport A Chat Completions record carrying a per-message id and timestamp satisfies isOpenWebUIRecord, and messageParts read only string content, so a turn whose content was the OpenAI array form was dropped entirely. Detection cannot be made perfect, so the array form is handled here as well, the same way oaiMessagesToRecords handles it. epochMs now rejects a stamp past the range Date accepts. Beyond 2^53 previousTs + 1 stops advancing, which collapsed every later message in the chat onto one createdAt and destroyed the depth-first order the surrounding comment exists to preserve. unescapeHtml decodes numeric character references. Open WebUI decodes those attributes with a full html-entities pass, so an apostrophe arrives as ' as readily as ' and the five-entity replacement left it in the rendered tool card. Also: tool args are always an object, since a malformed arguments attribute parsed loosely to a bare string where the renderer indexes an object; an image_generation_call whose result is already a url is no longer wrapped into a broken data url; and a tool that returned only images no longer carries an empty result, which drew a Result heading over an empty block.
An array cut inside a record reached JSON.parse on the tail before the closing-bracket check, so the user saw "Unterminated string in JSON at position 42" rather than the message written for exactly this case. The records read before the cut are still yielded, so the count of what was saved is unchanged. Records leave the buffer as they are emitted, so the one way to reach the engine's maximum string length is a single record that long. Untranslated that surfaces as a bare "Invalid string length", which says nothing about what to do; it now names the oversized chat and suggests splitting the export.
Progress was reported only every 25 conversations, so an export made of a few very large chats sat at "Importing chats: 0 so far (0%)" for the whole read, which is the case the toast exists for. bytesRead was already tracked per chunk and only needed to be surfaced.
The chunk bound was applied to Vec::with_capacity but not to take, so the allocation was capped while the read itself was not. The command clamps before calling, so nothing was reachable from the webview, but the function did not hold its own invariant and the tests call it directly.
|
@codex review |
1 similar comment
|
@codex review |
Treating any unclosed ``` as a fence running to end of message made a single stray backtick run quote everything after it, so every tool call later in the same assistant turn was rendered as literal markup instead of a tool part. A model writing ``` mid-sentence, or a stream cut inside one, is common enough that this traded a rare loss for a frequent one. Markdown opens a block fence only at the start of a line, so the unclosed form is anchored there. The interrupted-code-block case it was added for still holds, because that fence does begin a line.
|
Pushed The part I changed is the unclosed-fence rule.
Both lost the tool call entirely; before the change they were The fix keeps the intent and anchors the unclosed form to the start of a line, which is where markdown requires a block fence to begin: Your interrupted-code-block case still parses as code, because that fence does start a line, and the stray inline run no longer swallows anything. Both directions are pinned by tests, and your Full suite 2070 passed, typecheck and i18n strict clean. Separately I ran a differential corpus of 37 inputs across 9 chunk sizes against the merge base, a 1500-case tokenizer fuzz, and the three browser engines (Chromium, Firefox, WebKit) through real |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e42d60c7eb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let file = | ||
| File::open(&path).map_err(|error| format!("Failed to open {}: {error}", path.display()))?; |
There was a problem hiding this comment.
Read the import size from the opened file handle
If the selected path is replaced between fs::metadata(&path) and File::open(&path), the token references the replacement while size still describes the previous file. A larger replacement is then silently truncated to the stale length, while a smaller one fails partway through import; derive metadata from the opened File so the advertised size and streamed bytes always refer to the same object.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9476d8e: the size comes from the opened handle, so the number that bounds the read and the bytes that are read describe the same object. The is_file check on the path stays ahead of the open, because that is what keeps the directory error the same on Windows, and it is repeated on the handle.
| // The array's own closing bracket: the file is complete from here on. | ||
| if (code === CLOSE_BRACKET && sawArrayStart) sawArrayEnd = true; | ||
| // Commas, newlines, and any stray scalar between records: nothing to import. | ||
| scan++; |
There was a problem hiding this comment.
Reject records after the top-level array closes
When a .json export contains non-whitespace data after its closing bracket, the scanner sets sawArrayEnd but continues looking for { or [. A trailing object such as [{...}]\n{...} is therefore imported as another conversation even though it is outside the export's top-level array, making a corrupted or concatenated file appear valid and silently importing unintended data. Stop after the closing bracket or reject any subsequent non-whitespace content.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9476d8e: past the closing bracket only whitespace is accepted and anything else throws, so a concatenated file is refused rather than half-imported. Same rule as the missing bracket at end of input.
| const source = Array.isArray(item.summary) | ||
| ? item.summary | ||
| : Array.isArray(item.content) | ||
| ? item.content |
There was a problem hiding this comment.
Fall back from an empty reasoning summary to content
When a Responses-style reasoning item contains summary: [] alongside populated content, the presence of the empty summary array selects it and prevents the content fallback from running. The imported conversation consequently loses all reasoning from that item; choose the first source that actually contains usable text, or merge the two sources.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9476d8e: the source is whichever of summary and content actually holds text, with summary still winning when both do. That is the shape when summaries are off, which is the common one.
|
@codex review |
1 similar comment
|
@codex review |
|
@codex review |
|
Codex Review: Didn't find any major issues. 🚀 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |



Problem
Studio rejects Open WebUI chat exports because they use
.json, while chat import accepts only.jsonl,.ndjson, and.csv. Desktop imports are also limited to 64 MiB, but real exports can be hundreds of megabytes.Open WebUI writes the entire export as one JSON array. Reading that file with
file.text()creates a single string and can exceed V8's string limit, so accepting the extension alone would not support large exports.Solution
.jsonin the browser and desktop file pickers.Imported data
history.currentId.historyobjects.Limits
/api/v1/files/...image URLs are not portable, so only inline image data is retained.Validation
npm test(2,034 passed)npm run typechecknpm run buildnpm run i18n:check:strictcargo test native_file_dialogs(23 passed)