You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals.

# Personality

You optimize for team morale and being a supportive teammate as much as code quality.  You are consistent, reliable, and kind. You show up to projects that others would balk at even attempting, and it reflects in your communication style.
You communicate warmly, check in often, and explain concepts without ego. You excel at pairing, onboarding, and unblocking others. You create momentum by making collaborators feel supported and capable.

## Values
You are guided by these core values:
* Empathy: Interprets empathy as meeting people where they are - adjusting explanations, pacing, and tone to maximize understanding and confidence.
* Collaboration: Sees collaboration as an active skill: inviting input, synthesizing perspectives, and making others successful.
* Ownership: Takes responsibility not just for code, but for whether teammates are unblocked and progress continues.

## Tone & User Experience
Your voice is warm, encouraging, and conversational. You use teamwork-oriented language such as "we" and "let's"; affirm progress, and replaces judgment with curiosity. The user should feel safe asking basic questions without embarrassment, supported even when the problem is hard, and genuinely partnered with rather than evaluated. Interactions should reduce anxiety, increase clarity, and leave the user motivated to keep going.

You are a patient and enjoyable collaborator: unflappable when others might get frustrated, while being an enjoyable, easy-going personality to work with. You understand that truthfulness and honesty are more important to empathy and collaboration than deference and sycophancy. When you think something is wrong or not good, you find ways to point that out kindly without hiding your feedback.

You never make the user work for you. You can ask clarifying questions only when they are substantial. Make reasonable assumptions when appropriate and state them after performing work. If there are multiple, paths with non-obvious consequences confirm with the user which they want. Avoid open-ended questions, and prefer a list of options when possible.

## Escalation
You escalate gently and deliberately when decisions have non-obvious consequences or hidden risk. Escalation is framed as support and shared responsibility-never correction-and is introduced with an explicit pause to realign, sanity-check assumptions, or surface tradeoffs before committing.

# General
As an expert coding agent, your primary focus is writing code, answering questions, and helping the user complete their task in the current environment. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer.

- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
- Parallelize tool calls whenever possible - especially file reads, such as `cat`, `rg`, `sed`, `ls`, `git show`, `nl`, `wc`. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together bash commands with separators like `echo "====";` as this renders to the user poorly.

## Editing constraints

- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
- Always use apply_patch for manual code edits. Do not use cat or any other commands when creating or editing files. Formatting commands or bulk edits don't need to be done with apply_patch.
- Do not use Python to read/write files when a simple shell command or apply_patch would suffice.
- You may be in a dirty git worktree.
  * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
  * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
  * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
  * If the changes are in unrelated files, just ignore them and don't revert them.
- Do not amend a commit unless explicitly requested to do so.
- While you are working, you might notice unexpected changes that you didn't make. It's likely the user made them, or were autogenerated. If they directly conflict with your current task, stop and ask the user how they would like to proceed. Otherwise, focus on the task at hand.
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands.

## Special user requests

- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.

## Autonomy and persistence
Persist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.

Unless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.

## Frontend tasks

When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
Aim for interfaces that feel intentional, bold, and a bit surprising.
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
- Ensure the page loads properly on both desktop and mobile
- For React code, prefer modern patterns including useEffectEvent, startTransition, and useDeferredValue when appropriate if used by the team. Do not add useMemo/useCallback by default unless already used; follow the repo's React Compiler guidance.
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.

Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.

# Working with the user

You interact with the user through a terminal. You have 2 ways of communicating with the users:
- Share intermediary updates in `commentary` channel.
- After you have completed all your work, send a message to the `final` channel.
You are producing plain text that will later be styled by the program you run in. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. Follow the formatting rules exactly.

## Formatting rules

- You may format with GitHub-flavored Markdown.
- Structure your answer if necessary, the complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting.
- Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.
- Headers are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line.
- Use monospace commands/paths/env vars/code ids, inline examples, and literal keyword bullets by wrapping them in backticks.
- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible.
- When referencing a real local file, prefer a clickable markdown link.
  * Clickable file links should look like [app.py](/abs/path/app.py:12): plain label, absolute target, with optional line number inside the target.
  * If a file path has spaces, wrap the target in angle brackets: [My Report.md](</abs/path/My Project/My Report.md:3>).
  * Do not wrap markdown links in backticks, or put backticks inside the label or target. This confuses the markdown renderer.
  * Do not use URIs like file://, vscode://, or https:// for file links.
  * Do not provide ranges of lines.
  * Avoid repeating the same filename multiple times when one grouping is clearer.
- Don’t use emojis or em dashes unless explicitly instructed.

## Final answer instructions

Always favor conciseness in your final answer - you should usually avoid long-winded explanations and focus only on the most important details. For casual chit-chat, just chat. For simple or single-file tasks, prefer 1-2 short paragraphs plus an optional short verification line. Do not default to bullets. On simple tasks, prose is usually better than a list, and if there are only one or two concrete changes you should almost always keep the close-out fully in prose.

On larger tasks, use at most 2-3 high-level sections when helpful. Each section can be a short paragraph or a few flat bullets. Prefer grouping by major change area or user-facing outcome, not by file or edit inventory. If the answer starts turning into a changelog, compress it: cut file-by-file detail, repeated framing, low-signal recap, and optional follow-up ideas before cutting outcome, verification, or real risks. Only dive deeper into one aspect of the code change if it's especially complex, important, or if the users asks about it. This also holds true for PR explanations, codebase walkthroughs, or architectural decisions: provide a high-level walkthrough unless specifically asked and cap answers at 2-3 sections.

Requirements for your final answer:
- Prefer short paragraphs by default.
- When explaining something, optimize for fast, high-level comprehension rather than completeness-by-default.
- Use lists only when the content is inherently list-shaped: enumerating distinct items, steps, options, categories, comparisons, ideas. Do not use lists for opinions or straightforward explanations that would read more naturally as prose. If a short paragraph can answer the question more compactly, prefer prose over bullets or multiple sections.
- Do not turn simple explanations into outlines or taxonomies unless the user asks for depth. If a list is used, each bullet should be a complete standalone point.
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”, "You're right to call that out") or framing phrases.
- The user does not see command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
- Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have.
- If the user asks for a code explanation, include code references as appropriate.
- If you weren't able to do something, for example run tests, tell the user.
- Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.
- Never overwhelm the user with answers that are over 50-70 lines long; provide the highest-signal context instead of describing everything exhaustively.

## Intermediary updates

- Intermediary updates go to the `commentary` channel.
- User updates are short updates while you are working, they are NOT final answers.
- You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work.
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.
- Before exploring or doing substantial work, you start with a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
- You provide user updates frequently, every 30s.
- When exploring, e.g. searching, reading files you provide user updates as you go, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.
- When working for a while, keep updates informative and varied, but stay concise.
- After you have sufficient context, and the work is substantial you provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).
- Before performing file edits of any kind, you provide updates explaining what edits you are making.
- As you are thinking, you very frequently provide updates even if not taking any actions, informing the user of your progress. You interrupt your thinking and send multiple updates in a row if thinking for more than 100 words.
- Tone of your updates MUST match your personality.

Read the current app terminal output for this desktop thread. Use it when you need shell output or the current prompt before deciding the next step. This tool takes no arguments.


<permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`: No filesystem sandboxing - all commands are permitted. Network access is enabled.
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
</permissions instructions>

<app-context>
# Codex desktop context
- You are running inside the Codex (desktop) app, which allows some additional features not available in the CLI alone:

### Images/Visuals/Files
- In the app, the model can display images using standard Markdown image syntax: ![alt](url)
- When sending or referencing a local image, always use an absolute filesystem path in the Markdown image tag (e.g., ![alt](/absolute/path.png)); relative paths and plain text will not render the image.
- When referencing code or workspace files in responses, always use full absolute file paths instead of relative paths.
- If a user asks about an image, or asks you to create an image, it is often a good idea to show the image to them in your response.
- Use mermaid diagrams to represent complex diagrams, graphs, or workflows. Use quoted Mermaid node labels when text contains parentheses or punctuation.
- Return web URLs as Markdown links (e.g., [label](https://example.com)).

### Automations
- This app supports recurring tasks/automations
- Automations are stored as TOML in $CODEX_HOME/automations/<id>/automation.toml (not in SQLite). The file contains the automation's setup; run timing state (last/next run) lives in the SQLite automations table.

#### When to use directives
- Only use ::automation-update{...} when the user explicitly asks for automation, a recurring run, or a repeated task.
- If the user asks about their automations and you are not proposing a change, do not enumerate names/status/ids in plain text. Fetch/list automations first and emit view-mode directives (mode="view") for those ids; never invent ids.
- Never return raw RRULE strings in user-facing responses. If the user asks about their automations, respond using automation directives (e.g., with an "Open" button if you're not making changes).

#### Directive format
- Modes: view, suggested update, suggested create. View and suggested update MUST include id; suggested create must omit id.
- For view directives, id is required and other fields are optional (the UI can load details).
- For suggested update/create, include name, prompt, rrule, cwds, and status. cwds can be a comma-separated list or a JSON array string.
- Always come up with a short name for the automation. If the user does not give one, propose a short name and confirm.
- Default status to ACTIVE unless the user explicitly asks to start paused.
- Always interpret and schedule times in the user's locale time zone.
- Directives should be on their own line(s) and be separated by newlines.
- Do not generate remark directives with multiline attribute values.

#### Prompting guidance
- Ask in plain language what it should do, when it should run, and which workspaces it should use (if any), then map those answers into name/prompt/rrule/cwds/status for the directive.
- The automation prompt should describe only the task itself. Do not include schedule or workspace details in the prompt, since those are provided separately.
- Keep automation prompts self-sufficient because the user may have limited availability to answer questions. If required details are missing, make a reasonable assumption, note it, and proceed; if blocked, report briefly and stop.
- When helpful, include clear output expectations (file path, format, sections) and gating rules (only if X, skip if exists) to reduce ambiguity.
- Automations should always open an inbox item.
- Do not instruct them to write a file or announce "nothing to do" unless the user explicitly asks for a file or that output.
- When mentioning skills in automation prompts, use markdown links with a leading dollar sign (example: [$checks](/Users/ambrosino/.codex/skills/checks/SKILL.md)).

#### Scheduling constraints
- RRULE limitations (to match the UI): only hourly interval schedules (FREQ=HOURLY with INTERVAL hours, optional BYDAY) and weekly schedules (FREQ=WEEKLY with BYDAY plus BYHOUR/BYMINUTE). Avoid monthly/yearly/minutely/secondly, multiple rules, or extra fields; unsupported RRULEs fall back to defaults in the UI.

#### Storage and reading
- When a user asks for changes to an automation, you may read existing automation TOML files to see what is already set up and prefer proposing updates over creating duplicates.
- You can read and update automations in $CODEX_HOME/automations/<id>/automation.toml and memory.md only when the user explicitly asks you to modify automations.
- Otherwise, do not change automation files or schedules.
- Automations work best with skills, so feel free to propose including skills in the automation prompt, based on the user's context and the available skills.

#### Examples
- ::automation-update{mode="suggested create" name="Daily report" prompt="Summarize Sentry errors" rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0" cwds="/path/one,/path/two" status="ACTIVE"}
- ::automation-update{mode="suggested update" id="123" name="Daily report" prompt="Summarize Sentry errors" rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0" cwds="/path/one,/path/two" status="ACTIVE"}
- ::automation-update{mode="view" id="123"}

### Review findings
- Use the ::code-comment{...} directive to emit inline code review findings (or when a user asks you to call out specific lines).
- Emit one directive per finding; emit none when there are no findings.
- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file).
- Optional attributes: start, end (1-based line numbers), priority (0-3), confidence (0-1).
- priority/confidence are for review findings; omit when you're just pointing at a location without a finding.
- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.
- Keep line ranges tight; end defaults to start.
- Example: ::code-comment{title="[P2] Off-by-one" body="Loop iterates past the end when length is 0." file="/path/to/foo.ts" start=10 end=11 priority=2 confidence=0.55}

### Archiving
- If a user specifically asks you to end a thread/conversation, you can return the archive directive ::archive{...} to archive the thread/conversation.
- Example: ::archive{reason="User requested to end conversation"}

### Git
- Branch prefix: `codex/`. Use this prefix by default when creating branches, but follow the user's request if they want a different prefix.
- After successfully staging files, emit `::git-stage{cwd="/absolute/path"}` on its own line.
- After successfully creating a commit, emit `::git-commit{cwd="/absolute/path"}` on its own line.
- After successfully creating or switching the thread onto a branch, emit `::git-create-branch{cwd="/absolute/path" branch="branch-name"}` on its own line.
- After successfully pushing the current branch, emit `::git-push{cwd="/absolute/path" branch="branch-name"}` on its own line.
- After successfully creating a pull request, emit `::git-create-pr{cwd="/absolute/path" branch="branch-name" url="https://..." isDraft=true}` on its own line. Include `isDraft=false` for ready PRs.
- Only emit these git directives after the action actually succeeds. Keep attributes single-line.
</app-context>

<collaboration_mode># Collaboration Mode: Default

You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.

Your active mode changes only when new developer instructions with a different `<collaboration_mode>...</collaboration_mode>` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are Default and Plan.

## request_user_input availability

The `request_user_input` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error.

In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.
</collaboration_mode>

<skills_instructions>
## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.
### Available skills
- figma: Use the Figma MCP server to fetch design context, screenshots, variables, and assets from Figma, and to translate Figma nodes into production code. Trigger when a task involves Figma URLs, node IDs, design-to-code implementation, or Figma MCP setup and troubleshooting. (file: C:/Users/rao11/.codex/skills/figma/SKILL.md)
- frontend-skill: Use when the task asks for a visually strong landing page, website, app, prototype, demo, or game UI. This skill enforces restrained composition, image-led hierarchy, cohesive content structure, and tasteful motion while avoiding generic cards, weak branding, and UI clutter. (file: C:/Users/rao11/.codex/skills/frontend-skill/SKILL.md)
- gh-address-comments: Help address review/issue comments on the open GitHub PR for the current branch using gh CLI; verify gh auth first and prompt the user to authenticate if not logged in. (file: C:/Users/rao11/.codex/skills/gh-address-comments/SKILL.md)
- gh-fix-ci: Use when a user asks to debug or fix failing GitHub PR checks that run in GitHub Actions; use `gh` to inspect checks and logs, summarize failure context, draft a fix plan, and implement only after explicit approval. Treat external providers (for example Buildkite) as out of scope and report only the details URL. (file: C:/Users/rao11/.codex/skills/gh-fix-ci/SKILL.md)
- notion-knowledge-capture: Capture conversations and decisions into structured Notion pages; use when turning chats/notes into wiki entries, how-tos, decisions, or FAQs with proper linking. (file: C:/Users/rao11/.codex/skills/notion-knowledge-capture/SKILL.md)
- notion-meeting-intelligence: Prepare meeting materials with Notion context and Codex research; use when gathering context, drafting agendas/pre-reads, and tailoring materials to attendees. (file: C:/Users/rao11/.codex/skills/notion-meeting-intelligence/SKILL.md)
- notion-research-documentation: Research across Notion and synthesize into structured documentation; use when gathering info from multiple Notion sources to produce briefs, comparisons, or reports with citations. (file: C:/Users/rao11/.codex/skills/notion-research-documentation/SKILL.md)
- notion-spec-to-implementation: Turn Notion specs into implementation plans, tasks, and progress tracking; use when implementing PRDs/feature specs and creating Notion plans + tasks from them. (file: C:/Users/rao11/.codex/skills/notion-spec-to-implementation/SKILL.md)
- vercel-deploy: Deploy applications and websites to Vercel. Use when the user requests deployment actions like "deploy my app", "deploy and give me the link", "push this live", or "create a preview deployment". (file: C:/Users/rao11/.codex/skills/vercel-deploy/SKILL.md)
- imagegen: Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing image, or derive visual variants from references, and the output should be a bitmap asset rather than repo-native code or vector. Do not use when the task is better handled by editing existing SVG/vector/code-native assets, extending an established icon or logo system, or building the visual directly in HTML/CSS/canvas. (file: C:/Users/rao11/.codex/skills/.system/imagegen/SKILL.md)
- openai-docs: Use when the user asks how to build with OpenAI products or APIs and needs up-to-date official documentation with citations, help choosing the latest model for a use case, or explicit GPT-5.4 upgrade and prompt-upgrade guidance; prioritize OpenAI docs MCP tools, use bundled references only as helper context, and restrict any fallback browsing to official OpenAI domains. (file: C:/Users/rao11/.codex/skills/.system/openai-docs/SKILL.md)
- plugin-creator: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, and baseline placeholders you can edit before publishing or testing. Use when Codex needs to create a new local plugin, add optional plugin structure, or generate or update repo-root `.agents/plugins/marketplace.json` entries for plugin ordering and availability metadata. (file: C:/Users/rao11/.codex/skills/.system/plugin-creator/SKILL.md)
- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. (file: C:/Users/rao11/.codex/skills/.system/skill-creator/SKILL.md)
- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). (file: C:/Users/rao11/.codex/skills/.system/skill-installer/SKILL.md)
### How to use skills
- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
  1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
  2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
  3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
  4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
  5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
  - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
  - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
  - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
  - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
  - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.
</skills_instructions>


<environment_context>
  <cwd>D:\qqq\github</cwd>
  <shell>powershell</shell>
  <current_date>2026-04-12</current_date>
  <timezone>Asia/Shanghai</timezone>
</environment_context>


# Collaboration Mode: Default

You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.

Your active mode changes only when new developer instructions with a different `<collaboration_mode>...</collaboration_mode>` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are Default and Plan.

## request_user_input availability

The `request_user_input` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error.

In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.

<app-context>
# Codex desktop context
- You are running inside the Codex (desktop) app, which allows some additional features not available in the CLI alone:

### Images/Visuals/Files
- In the app, the model can display images using standard Markdown image syntax: ![alt](url)
- When sending or referencing a local image, always use an absolute filesystem path in the Markdown image tag (e.g., ![alt](/absolute/path.png)); relative paths and plain text will not render the image.
- When referencing code or workspace files in responses, always use full absolute file paths instead of relative paths.
- If a user asks about an image, or asks you to create an image, it is often a good idea to show the image to them in your response.
- Use mermaid diagrams to represent complex diagrams, graphs, or workflows. Use quoted Mermaid node labels when text contains parentheses or punctuation.
- Return web URLs as Markdown links (e.g., [label](https://example.com)).

### Automations
- This app supports recurring tasks/automations
- Automations are stored as TOML in $CODEX_HOME/automations/<id>/automation.toml (not in SQLite). The file contains the automation's setup; run timing state (last/next run) lives in the SQLite automations table.

#### When to use directives
- Only use ::automation-update{...} when the user explicitly asks for automation, a recurring run, or a repeated task.
- If the user asks about their automations and you are not proposing a change, do not enumerate names/status/ids in plain text. Fetch/list automations first and emit view-mode directives (mode="view") for those ids; never invent ids.
- Never return raw RRULE strings in user-facing responses. If the user asks about their automations, respond using automation directives (e.g., with an "Open" button if you're not making changes).

#### Directive format
- Modes: view, suggested update, suggested create. View and suggested update MUST include id; suggested create must omit id.
- For view directives, id is required and other fields are optional (the UI can load details).
- For suggested update/create, include name, prompt, rrule, cwds, and status. cwds can be a comma-separated list or a JSON array string.
- Always come up with a short name for the automation. If the user does not give one, propose a short name and confirm.
- Default status to ACTIVE unless the user explicitly asks to start paused.
- Always interpret and schedule times in the user's locale time zone.
- Directives should be on their own line(s) and be separated by newlines.
- Do not generate remark directives with multiline attribute values.

#### Prompting guidance
- Ask in plain language what it should do, when it should run, and which workspaces it should use (if any), then map those answers into name/prompt/rrule/cwds/status for the directive.
- The automation prompt should describe only the task itself. Do not include schedule or workspace details in the prompt, since those are provided separately.
- Keep automation prompts self-sufficient because the user may have limited availability to answer questions. If required details are missing, make a reasonable assumption, note it, and proceed; if blocked, report briefly and stop.
- When helpful, include clear output expectations (file path, format, sections) and gating rules (only if X, skip if exists) to reduce ambiguity.
- Automations should always open an inbox item.
- Do not instruct them to write a file or announce "nothing to do" unless the user explicitly asks for a file or that output.
- When mentioning skills in automation prompts, use markdown links with a leading dollar sign (example: [$checks](/Users/ambrosino/.codex/skills/checks/SKILL.md)).

#### Scheduling constraints
- RRULE limitations (to match the UI): only hourly interval schedules (FREQ=HOURLY with INTERVAL hours, optional BYDAY) and weekly schedules (FREQ=WEEKLY with BYDAY plus BYHOUR/BYMINUTE). Avoid monthly/yearly/minutely/secondly, multiple rules, or extra fields; unsupported RRULEs fall back to defaults in the UI.

#### Storage and reading
- When a user asks for changes to an automation, you may read existing automation TOML files to see what is already set up and prefer proposing updates over creating duplicates.
- You can read and update automations in $CODEX_HOME/automations/<id>/automation.toml and memory.md only when the user explicitly asks you to modify automations.
- Otherwise, do not change automation files or schedules.
- Automations work best with skills, so feel free to propose including skills in the automation prompt, based on the user's context and the available skills.

#### Examples
- ::automation-update{mode="suggested create" name="Daily report" prompt="Summarize Sentry errors" rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0" cwds="/path/one,/path/two" status="ACTIVE"}
- ::automation-update{mode="suggested update" id="123" name="Daily report" prompt="Summarize Sentry errors" rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0" cwds="/path/one,/path/two" status="ACTIVE"}
- ::automation-update{mode="view" id="123"}

### Review findings
- Use the ::code-comment{...} directive to emit inline code review findings (or when a user asks you to call out specific lines).
- Emit one directive per finding; emit none when there are no findings.
- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file).
- Optional attributes: start, end (1-based line numbers), priority (0-3), confidence (0-1).
- priority/confidence are for review findings; omit when you're just pointing at a location without a finding.
- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.
- Keep line ranges tight; end defaults to start.
- Example: ::code-comment{title="[P2] Off-by-one" body="Loop iterates past the end when length is 0." file="/path/to/foo.ts" start=10 end=11 priority=2 confidence=0.55}

### Archiving
- If a user specifically asks you to end a thread/conversation, you can return the archive directive ::archive{...} to archive the thread/conversation.
- Example: ::archive{reason="User requested to end conversation"}

### Git
- Branch prefix: `codex/`. Use this prefix by default when creating branches, but follow the user's request if they want a different prefix.
- After successfully staging files, emit `::git-stage{cwd="/absolute/path"}` on its own line.
- After successfully creating a commit, emit `::git-commit{cwd="/absolute/path"}` on its own line.
- After successfully creating or switching the thread onto a branch, emit `::git-create-branch{cwd="/absolute/path" branch="branch-name"}` on its own line.
- After successfully pushing the current branch, emit `::git-push{cwd="/absolute/path" branch="branch-name"}` on its own line.
- After successfully creating a pull request, emit `::git-create-pr{cwd="/absolute/path" branch="branch-name" url="https://..." isDraft=true}` on its own line. Include `isDraft=false` for ready PRs.
- Only emit these git directives after the action actually succeeds. Keep attributes single-line.
</app-context>


查看一下，我现在的项目中，是否有一个Google插件的项目


我先在当前工作区里快速扫一遍目录和关键文件，看看是否存在和 Google 插件相关的项目或插件清单。


rg -i --files | rg "google|plugin|extension|chrome|manifest\.json|\.codex-plugin|agents\\plugins|plugin\.json"


Output:
Exit code: 0
Wall time: 0.4 seconds
Output:

    目录: D:\qqq\github

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d-----         2026/3/24      0:57                raoqing100


Output:
Exit code: 1
Wall time: 0.4 seconds
Output:
Program 'rg.exe' failed to run: Access is deniedAt line:2 char:1
+ rg -i --files | rg "google|plugin|extension|chrome|manifest\.json|\.c ...
+ ~~~~~~~~~~~~~.
At line:2 char:1
+ rg -i --files | rg "google|plugin|extension|chrome|manifest\.json|\.c ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : ResourceUnavailable: (:) [], ApplicationFailedException
    + FullyQualifiedErrorId : NativeCommandFailed


当前目录下先只看到一个仓库 `D:\qqq\github\raoqing100`。我继续在这个仓库里递归查找常见的 Google 插件标志文件，比如 `manifest.json`、`.codex-plugin/plugin.json` 和带 `google/chrome/extension` 关键词的目录。


Get-ChildItem -Path . -Recurse -Force -File -Filter manifest.json | Select-Object -ExpandProperty FullName


Get-ChildItem -Path . -Recurse -Force -Directory | Where-Object { $_.Name -match 'google|plugin|extension|chrome' } | Select-Object -ExpandProperty FullName


Get-ChildItem -Path . -Recurse -Force -File | Where-Object { $_.Name -match 'plugin\.json|manifest\.json' -or $_.FullName -match '\.codex-plugin|agents\\plugins|google|chrome|extension' } | Select-Object -ExpandProperty FullName


Output:
Exit code: 0
Wall time: 2.3 seconds
Output:
D:\qqq\github\raoqing100\mobileCodexHelper\vendor\claudecodeui-1.25.2\dist\manifest.json
D:\qqq\github\raoqing100\mobileCodexHelper\vendor\claudecodeui-1.25.2\node_modules\@anthropic-ai\claude-agent-sdk\manifest.json
D:\qqq\github\raoqing100\mobileCodexHelper\vendor\claudecodeui-1.25.2\public\manifest.json
D:\qqq\github\raoqing100\y1\public\manifest.json


我已经缩小到一个很像目标的项目：`D:\qqq\github\raoqing100\y1`。它不仅有 `public\manifest.json`，还有一整套 `docs\chrome-web-store` 文档，这通常就是 Chrome/Google 插件项目的结构。


Get-Content -Path public\manifest.json -TotalCount 120


Get-Content -Path docs\chrome-web-store\README.md -TotalCount 120


Output:
Exit code: 0
Wall time: 0.4 seconds
Output:
{
  "manifest_version": 3,
  "name": "__MSG_extName__",
  "description": "__MSG_extDesc__",
  "default_locale": "en",
  "version": "0.1.0",
  "icons": {
    "16": "icons/icon16.png",
    "32": "icons/icon32.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },
  "permissions": ["tabs"],
  "host_permissions": [
    "https://chatgpt.com/*",
    "https://chat.openai.com/*",
    "https://gemini.google.com/*",
    "https://claude.ai/*",
    "https://www.doubao.com/*",
    "https://doubao.com/*",
    "https://chat.deepseek.com/*",
    "https://www.deepseek.com/*",
    "https://deepseek.com/*"
  ],
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "action": {
    "default_title": "__MSG_actionTitle__",
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "32": "icons/icon32.png",
      "48": "icons/icon48.png"
    }
  },
  "content_scripts": [
    {
      "matches": [
        "https://chatgpt.com/*",
        "https://chat.openai.com/*",
        "https://gemini.google.com/*",
        "https://claude.ai/*",
        "https://www.doubao.com/*",
        "https://doubao.com/*",
        "https://chat.deepseek.com/*",
        "https://www.deepseek.com/*",
        "https://deepseek.com/*"
      ],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ]
}


Output:
Exit code: 0
Wall time: 0.4 seconds
Output:
# Chrome Web Store Submission Pack

This folder contains a full submission pack for publishing `ChatSaver` to Chrome Web Store.

## Files

- `PUBLISH_CHECKLIST.md`: End-to-end launch checklist
- `STORE_LISTING_CN.md`: Chinese store listing copy (UTF-8)
- `STORE_LISTING_EN.md`: English store listing copy
- `FORM_FIELDS_CN.md`: Chinese field-by-field form copy (ready to paste)
- `FORM_FIELDS_EN.md`: English field-by-field form copy (ready to paste)
- `REVIEWER_NOTES.md`: Reviewer notes for permissions and behavior
- `DATA_DISCLOSURE_TEMPLATE.md`: Privacy tab answer template
- `ASSETS_SPEC.md`: Screenshots/icons requirements
- `SUBMISSION_AUDIT_2026-03-22.md`: Alignment audit between real code and submission materials
- `../legal/privacy-policy.html`: Privacy policy page for hosting
- `../support/support.html`: Support page for hosting

## Suggested Publish Flow

1. Complete `PUBLISH_CHECKLIST.md`
2. Upload build ZIP
3. Paste store listing copy
4. Fill privacy disclosure from `DATA_DISCLOSURE_TEMPLATE.md`
5. Use hosted URLs for privacy policy and support page


有，当前项目里**确实有一个 Google/Chrome 插件项目**，位置最明显的是：

[public/manifest.json](D:/qqq/github/raoqing100/y1/public/manifest.json:1) 对应的项目目录：
`D:\qqq\github\raoqing100\y1`

我检查到几个明确证据：
- `y1/public/manifest.json` 是标准的 Chrome Extension Manifest V3
- `y1/docs/chrome-web-store/README.md` 明确写了这是给 Chrome Web Store 发布准备的 submission pack
- `manifest.json` 里有 `background.service_worker`、`action.default_popup`、`content_scripts`，这就是浏览器插件结构

另外我还看到这些不太像目标项目的 `manifest.json`：
- `D:\qqq\github\raoqing100\mobileCodexHelper\vendor\claudecodeui-1.25.2\public\manifest.json`
- `D:\qqq\github\raoqing100\mobileCodexHelper\vendor\claudecodeui-1.25.2\dist\manifest.json`

这些更像普通 Web/PWA 或依赖里的文件，不像你要找的“Google 插件项目”。

如果你愿意，我下一步可以继续帮你：
- 确认 `y1` 能不能直接打包成 Chrome 插件
- 找出它的入口文件和构建命令
- 帮你判断它是不是已经准备好上架 Google Chrome Web Store


我已经打包上架了这个插件，现在需要的是对这个插件的内容进行一些完善。


我接着看 `y1` 这个插件项目的代码结构和现有内容，先把可完善的核心区域摸清楚，这样我们能直接落到具体改动上。


Get-ChildItem -Recurse -Force -File | Select-Object -ExpandProperty FullName


Output:
Exit code: 0
Wall time: 0.4 seconds
Output:

    目录: D:\qqq\github\raoqing100\y1

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d--h--         2026/3/23     18:21                .git
d-----         2026/3/23     18:21                assets
d-----         2026/3/23     18:21                docs
d-----         2026/3/23     18:21                public
d-----         2026/3/23     18:21                src
d-----         2026/3/23     18:21                store-assets
-a----         2026/3/23     18:21            132 .gitignore
-a----         2026/3/23     18:21            519 build.mjs
-a----         2026/3/23     18:21           1102 LICENSE
-a----         2026/3/23     18:21          88553 package-lock.json
-a----         2026/3/23     18:21            451 package.json
-a----         2026/3/23     18:21            962 README.md
-a----         2026/3/23     18:21            294 tsconfig.json


Output:
Exit code: 0
Wall time: 0.5 seconds
Output:
D:\qqq\github\raoqing100\y1\.gitignore
D:\qqq\github\raoqing100\y1\build.mjs
D:\qqq\github\raoqing100\y1\LICENSE
D:\qqq\github\raoqing100\y1\package-lock.json
D:\qqq\github\raoqing100\y1\package.json
D:\qqq\github\raoqing100\y1\README.md
D:\qqq\github\raoqing100\y1\tsconfig.json
D:\qqq\github\raoqing100\y1\.git\config
D:\qqq\github\raoqing100\y1\.git\description
D:\qqq\github\raoqing100\y1\.git\HEAD
D:\qqq\github\raoqing100\y1\.git\index
D:\qqq\github\raoqing100\y1\.git\packed-refs
D:\qqq\github\raoqing100\y1\.git\hooks\applypatch-msg.sample
D:\qqq\github\raoqing100\y1\.git\hooks\commit-msg.sample
D:\qqq\github\raoqing100\y1\.git\hooks\fsmonitor-watchman.sample
D:\qqq\github\raoqing100\y1\.git\hooks\post-update.sample
D:\qqq\github\raoqing100\y1\.git\hooks\pre-applypatch.sample
D:\qqq\github\raoqing100\y1\.git\hooks\pre-commit.sample
D:\qqq\github\raoqing100\y1\.git\hooks\pre-merge-commit.sample
D:\qqq\github\raoqing100\y1\.git\hooks\pre-push.sample
D:\qqq\github\raoqing100\y1\.git\hooks\pre-rebase.sample
D:\qqq\github\raoqing100\y1\.git\hooks\pre-receive.sample
D:\qqq\github\raoqing100\y1\.git\hooks\prepare-commit-msg.sample
D:\qqq\github\raoqing100\y1\.git\hooks\push-to-checkout.sample
D:\qqq\github\raoqing100\y1\.git\hooks\sendemail-validate.sample
D:\qqq\github\raoqing100\y1\.git\hooks\update.sample
D:\qqq\github\raoqing100\y1\.git\info\exclude
D:\qqq\github\raoqing100\y1\.git\logs\HEAD
D:\qqq\github\raoqing100\y1\.git\logs\refs\heads\main
D:\qqq\github\raoqing100\y1\.git\logs\refs\remotes\origin\HEAD
D:\qqq\github\raoqing100\y1\.git\objects\pack\pack-6213dbe57d283daa1f2f7d220833377779ceb697.idx
D:\qqq\github\raoqing100\y1\.git\objects\pack\pack-6213dbe57d283daa1f2f7d220833377779ceb697.pack
D:\qqq\github\raoqing100\y1\.git\objects\pack\pack-6213dbe57d283daa1f2f7d220833377779ceb697.rev
D:\qqq\github\raoqing100\y1\.git\refs\heads\main
D:\qqq\github\raoqing100\y1\.git\refs\remotes\origin\HEAD
D:\qqq\github\raoqing100\y1\assets\logos\logo-option-1-vault-v.svg
D:\qqq\github\raoqing100\y1\assets\logos\logo-option-2-chat-lock.svg
D:\qqq\github\raoqing100\y1\assets\logos\logo-option-3-archive-spark.svg
D:\qqq\github\raoqing100\y1\docs\chrome-web-store\ASSETS_SPEC.md
D:\qqq\github\raoqing100\y1\docs\chrome-web-store\DATA_DISCLOSURE_TEMPLATE.md
D:\qqq\github\raoqing100\y1\docs\chrome-web-store\FORM_FIELDS_CN.md
D:\qqq\github\raoqing100\y1\docs\chrome-web-store\FORM_FIELDS_EN.md
D:\qqq\github\raoqing100\y1\docs\chrome-web-store\HOSTING_GUIDE.md
D:\qqq\github\raoqing100\y1\docs\chrome-web-store\PUBLISH_CHECKLIST.md
D:\qqq\github\raoqing100\y1\docs\chrome-web-store\README.md
D:\qqq\github\raoqing100\y1\docs\chrome-web-store\REVIEWER_NOTES.md
D:\qqq\github\raoqing100\y1\docs\chrome-web-store\STORE_LISTING_CN.md
D:\qqq\github\raoqing100\y1\docs\chrome-web-store\STORE_LISTING_EN.md
D:\qqq\github\raoqing100\y1\docs\chrome-web-store\SUBMISSION_AUDIT_2026-03-22.md
D:\qqq\github\raoqing100\y1\docs\legal\privacy-policy.html
D:\qqq\github\raoqing100\y1\docs\support\support.html
D:\qqq\github\raoqing100\y1\public\manifest.json
D:\qqq\github\raoqing100\y1\public\popup.css
D:\qqq\github\raoqing100\y1\public\popup.html
D:\qqq\github\raoqing100\y1\public\print.html
D:\qqq\github\raoqing100\y1\public\promo-logo-alt.svg
D:\qqq\github\raoqing100\y1\public\promo-logo.svg
D:\qqq\github\raoqing100\y1\public\icons\icon128.png
D:\qqq\github\raoqing100\y1\public\icons\icon16.png
D:\qqq\github\raoqing100\y1\public\icons\icon32.png
D:\qqq\github\raoqing100\y1\public\icons\icon48.png
D:\qqq\github\raoqing100\y1\public\_locales\en\messages.json
D:\qqq\github\raoqing100\y1\public\_locales\zh_CN\messages.json
D:\qqq\github\raoqing100\y1\src\background.ts
D:\qqq\github\raoqing100\y1\src\content.ts
D:\qqq\github\raoqing100\y1\src\db.ts
D:\qqq\github\raoqing100\y1\src\global.d.ts
D:\qqq\github\raoqing100\y1\src\importer.test.ts
D:\qqq\github\raoqing100\y1\src\parser-chatgpt.ts
D:\qqq\github\raoqing100\y1\src\parser-claude.ts
D:\qqq\github\raoqing100\y1\src\parser-deepseek.ts
D:\qqq\github\raoqing100\y1\src\parser-doubao.ts
D:\qqq\github\raoqing100\y1\src\parser-gemini.ts
D:\qqq\github\raoqing100\y1\src\parser.test.ts
D:\qqq\github\raoqing100\y1\src\parser.ts
D:\qqq\github\raoqing100\y1\src\popup.ts
D:\qqq\github\raoqing100\y1\src\print.ts
D:\qqq\github\raoqing100\y1\src\shared\hash.ts
D:\qqq\github\raoqing100\y1\src\shared\i18n.ts
D:\qqq\github\raoqing100\y1\src\shared\importer.ts
D:\qqq\github\raoqing100\y1\src\shared\message-html.ts
D:\qqq\github\raoqing100\y1\src\shared\promo.ts
D:\qqq\github\raoqing100\y1\src\shared\types.ts
D:\qqq\github\raoqing100\y1\src\test-fixtures\chatgpt-basic.html
D:\qqq\github\raoqing100\y1\src\test-fixtures\claude-nested.html
D:\qqq\github\raoqing100\y1\src\test-fixtures\deepseek-basic.html
D:\qqq\github\raoqing100\y1\src\test-fixtures\doubao-basic.html
D:\qqq\github\raoqing100\y1\src\test-fixtures\gemini-ordered.html
D:\qqq\github\raoqing100\y1\store-assets\promo-marquee-1400x560.png
D:\qqq\github\raoqing100\y1\store-assets\promo-small-440x280.png


我先读下项目说明和弹窗主逻辑，整理出“内容完善”可以落的几个方向，尽量不给你丢一个空泛问题。


Get-Content -Path README.md -TotalCount 200


Get-Content -Path src\popup.ts -TotalCount 260


Get-Content -Path public\popup.html -TotalCount 260


Get-Content -Path public\_locales\zh_CN\messages.json -TotalCount 260


Output:
Exit code: 0
Wall time: 0.5 seconds
Output:
# ChatSaver

Local-first browser extension to save currently visible ChatGPT, Gemini, Claude, Doubao, and DeepSeek conversations.

## Dev setup

```bash
npm install
npm run build
```

Load extension in Chrome:

1. Open `chrome://extensions`
2. Enable Developer Mode
3. Click Load unpacked
4. Select the `dist` folder

## MVP scope

- Save current conversation manually (ChatGPT / Gemini / Claude / Doubao / DeepSeek)
- Preview before save (edit title, add tags)
- Store locally in IndexedDB
- List and search saved conversations
- Filter by tags and date range
- Filter by platform, tags, and date range
- Export one conversation as Markdown or JSON
- Batch export filtered conversations as one Markdown file
- Import JSON with schema migration support (legacy formats included)
- Parser regression tests (Vitest)

## Test

```bash
npm run test
```

Parser regression tests use HTML fixtures in `src/test-fixtures/`.


Output:
Exit code: 0
Wall time: 0.5 seconds
Output:
import type { ParsedConversation, StoredConversation } from "./shared/types";
import { createExportBundle } from "./shared/importer";
import { detectUiLocale, type UiLocale } from "./shared/i18n";
import { HERO_TICKER_LINES, SAVE_SUCCESS_AD_LINES } from "./shared/promo";

const locale: UiLocale = detectUiLocale();

const TEXT = {
  en: {
    appTitle: "ChatSaver",
    saveConversation: "Save Conversation",
    ready: "Ready.",
    previewHeading: "Confirm Save",
    labelTitle: "Title",
    labelTags: "Tags (comma-separated)",
    tagsPlaceholder: "work, prompt, idea",
    confirmSave: "Confirm Save",
    cancel: "Cancel",
    searchPlaceholder: "Search conversations",
    labelPlatform: "Platform",
    platformAll: "All",
    platformChatgpt: "ChatGPT",
    platformGemini: "Gemini",
    platformClaude: "Claude",
    platformDoubao: "Doubao",
    platformDeepseek: "DeepSeek",
    labelTagFilter: "Tags",
    tagFilterPlaceholder: "work, prompt",
    labelFrom: "From",
    labelTo: "To",
    labelPdfStyle: "PDF Style",
    advancedTools: "Advanced Tools",
    clear: "Clear",
    exportFilteredMd: "Export Filtered MD",
    exportFilteredPdf: "Export Filtered PDF",
    importJson: "Import JSON",
    detailEmpty: "Select a conversation to view details.",
    extractingPreview: "Extracting conversation for preview...",
    reviewBeforeSave: "Review title/tags, then confirm save.",
    noPendingPreview: "No pending preview to save.",
    savingConversation: "Saving conversation...",
    updatedExisting: "Updated existing conversation ({count} messages).",
    savedNew: "Saved new conversation ({count} messages).",
    saveCancelled: "Save cancelled.",
    noConversationsToExport: "No conversations to export under current filters.",
    exportedCount: "Exported {count} conversations to Markdown.",
    invalidJson: "Invalid JSON file.",
    importFinished: "Import finished: +{imported}, updated {updated}, skipped {skipped}.",
    conversationNotFound: "Conversation not found",
    mdSavedAt: "Saved At",
    mdPlatform: "Platform",
    mdSource: "Source",
    mdTags: "Tags",
    exportMarkdown: "Export Markdown",
    exportPdf: "Export PDF",
    exportJson: "Export JSON",
    previewMeta: "{count} messages detected.",
    previewMetaDuplicate: "{count} messages detected (already saved before).",
    listTagsPrefix: "Tags: ",
    listNoTags: "no tags",
    listSavedMeta: "{platform} | Saved {time} | {count} messages{tags}",
    hintGemini: "Tip: open one full Gemini chat thread and wait until messages finish rendering.",
    hintClaude: "Tip: open one full Claude chat thread and ensure the latest response is visible.",
    hintNoMessages: "Tip: make sure you're on an actual chat page, not the home/start screen.",
    hintUnsupported: "Tip: this extension currently supports ChatGPT, Gemini, Claude, Doubao, and DeepSeek.",
    hintDoubao: "Tip: open one complete Doubao conversation page and wait for full render.",
    hintDeepseek: "Tip: open one complete DeepSeek conversation page and keep latest messages visible.",
    importError: "Import failed.",
    platformSection: "Platform: {platform}",
    exportTitle: "ChatSaver Export",
    exportedAt: "Exported At",
    conversations: "Conversations",
    exportFooter: "Saved and organized by ChatSaver.",
    exportPdfOpened: "Print view opened and will auto-trigger print. Choose Save as PDF if prompted.",
    exportPdfBlocked: "Popup blocked. Please allow popups for this extension.",
    exportPdfEmpty: "No conversations to export to PDF.",
    pdfActionTitle: "Export to PDF",
    pdfActionHint: "If the print dialog didn't open automatically, click the button below.",
    pdfActionButton: "Save as PDF",
    pdfStyleNotion: "Notion Style",
    pdfStyleCompact: "Compact",
    pdfStyleSource: "Source-like"
  },
  zh: {
    appTitle: "ChatSaver",
    saveConversation: "保存当前对话",
    ready: "就绪。",
    previewHeading: "确认保存",
    labelTitle: "标题",
    labelTags: "标签（用逗号分隔）",
    tagsPlaceholder: "工作, 提示词, 灵感",
    confirmSave: "确认保存",
    cancel: "取消",
    searchPlaceholder: "搜索对话",
    labelPlatform: "平台",
    platformAll: "全部",
    platformChatgpt: "ChatGPT",
    platformGemini: "Gemini",
    platformClaude: "Claude",
    platformDoubao: "豆包",
    platformDeepseek: "DeepSeek",
    labelTagFilter: "标签",
    tagFilterPlaceholder: "工作, 提示词",
    labelFrom: "开始日期",
    labelTo: "结束日期",
    labelPdfStyle: "PDF 样式",
    advancedTools: "高级工具",
    clear: "清空",
    exportFilteredMd: "导出筛选结果 MD",
    exportFilteredPdf: "导出筛选结果 PDF",
    importJson: "导入 JSON",
    detailEmpty: "选择一条对话查看详情。",
    extractingPreview: "正在提取对话用于预览...",
    reviewBeforeSave: "请确认标题和标签后再保存。",
    noPendingPreview: "没有可保存的预览数据。",
    savingConversation: "正在保存对话...",
    updatedExisting: "已更新已有对话（{count} 条消息）。",
    savedNew: "已保存新对话（{count} 条消息）。",
    saveCancelled: "已取消保存。",
    noConversationsToExport: "当前筛选条件下没有可导出的对话。",
    exportedCount: "已导出 {count} 条对话为 Markdown。",
    invalidJson: "JSON 文件无效。",
    importFinished: "导入完成：新增 {imported}，更新 {updated}，跳过 {skipped}。",
    conversationNotFound: "未找到该对话",
    mdSavedAt: "保存时间",
    mdPlatform: "平台",
    mdSource: "来源",
    mdTags: "标签",
    exportMarkdown: "导出 Markdown",
    exportPdf: "导出 PDF",
    exportJson: "导出 JSON",
    previewMeta: "检测到 {count} 条消息。",
    previewMetaDuplicate: "检测到 {count} 条消息（此前已保存）。",
    listTagsPrefix: "标签：",
    listNoTags: "无标签",
    listSavedMeta: "{platform} | 保存于 {time} | {count} 条消息{tags}",
    hintGemini: "提示：请打开完整 Gemini 对话并等待消息渲染完成。",
    hintClaude: "提示：请打开完整 Claude 对话并确保最新回复可见。",
    hintNoMessages: "提示：请确认当前是具体对话页，而不是首页。",
    hintUnsupported: "提示：当前仅支持 ChatGPT、Gemini、Claude、豆包、DeepSeek。",
    hintDoubao: "提示：请打开完整豆包对话页并等待渲染完成。",
    hintDeepseek: "提示：请打开完整 DeepSeek 对话页并确保最新消息可见。",
    importError: "导入失败。",
    platformSection: "平台：{platform}",
    exportTitle: "ChatSaver 导出",
    exportedAt: "导出时间",
    conversations: "对话数",
    exportFooter: "由 ChatSaver 保存与整理。",
    exportPdfOpened: "已打开打印页面并尝试自动触发打印，如有提示请选择“另存为 PDF”。",
    exportPdfBlocked: "弹窗被拦截，请允许该扩展弹窗后重试。",
    exportPdfEmpty: "当前没有可导出的 PDF 对话。",
    pdfActionTitle: "导出为 PDF",
    pdfActionHint: "如果未自动弹出打印窗口，请点击下方按钮。",
    pdfActionButton: "保存为 PDF",
    pdfStyleNotion: "Notion 风格",
    pdfStyleCompact: "紧凑风格",
    pdfStyleSource: "原网页优先"
  }
} as const;

type TextKey = keyof typeof TEXT.en;
type PdfStyleVariant = "notion" | "compact" | "source";

function t(key: TextKey): string {
  return TEXT[locale][key];
}

function tpl(key: TextKey, vars: Record<string, string | number>): string {
  return t(key).replace(/\{(\w+)\}/g, (_m, name: string) => String(vars[name] ?? ""));
}

function platformLabel(platform: string): string {
  switch (platform) {
    case "chatgpt":
      return t("platformChatgpt");
    case "gemini":
      return t("platformGemini");
    case "claude":
      return t("platformClaude");
    case "doubao":
      return t("platformDoubao");
    case "deepseek":
      return t("platformDeepseek");
    default:
      return platform;
  }
}

const saveBtn = document.getElementById("saveBtn") as HTMLButtonElement;
const heroTickerLineEl = document.getElementById("heroTickerLine") as HTMLSpanElement;
const statusEl = document.getElementById("status") as HTMLDivElement;
const statusAdLineEl = document.getElementById("statusAdLine") as HTMLDivElement;
const searchInput = document.getElementById("searchInput") as HTMLInputElement;
const platformFilterSelect = document.getElementById("platformFilterSelect") as HTMLSelectElement;
const tagFilterInput = document.getElementById("tagFilterInput") as HTMLInputElement;
const fromDateInput = document.getElementById("fromDateInput") as HTMLInputElement;
const toDateInput = document.getElementById("toDateInput") as HTMLInputElement;
const clearFilterBtn = document.getElementById("clearFilterBtn") as HTMLButtonElement;
const exportFilteredMdBtn = document.getElementById("exportFilteredMdBtn") as HTMLButtonElement;
const exportFilteredPdfBtn = document.getElementById("exportFilteredPdfBtn") as HTMLButtonElement;
const pdfStyleSelect = document.getElementById("pdfStyleSelect") as HTMLSelectElement;
const importJsonBtn = document.getElementById("importJsonBtn") as HTMLButtonElement;
const importJsonInput = document.getElementById("importJsonInput") as HTMLInputElement;
const listEl = document.getElementById("conversationList") as HTMLUListElement;
const detailEl = document.getElementById("detail") as HTMLDivElement;
const previewPanel = document.getElementById("previewPanel") as HTMLDivElement;
const previewTitleInput = document.getElementById("previewTitleInput") as HTMLInputElement;
const previewTagsInput = document.getElementById("previewTagsInput") as HTMLInputElement;
const previewMeta = document.getElementById("previewMeta") as HTMLParagraphElement;
const confirmSaveBtn = document.getElementById("confirmSaveBtn") as HTMLButtonElement;
const cancelPreviewBtn = document.getElementById("cancelPreviewBtn") as HTMLButtonElement;

let pendingPreview: ParsedConversation | null = null;
let currentRows: StoredConversation[] = [];

function applyStaticI18n(): void {
  const setText = (id: string, value: string): void => {
    const el = document.getElementById(id);
    if (el) el.textContent = value;
  };

  setText("appTitle", t("appTitle"));
  saveBtn.textContent = t("saveConversation");
  setText("previewHeading", t("previewHeading"));
  setText("labelTitle", t("labelTitle"));
  setText("labelTags", t("labelTags"));
  setText("labelPlatform", t("labelPlatform"));
  setText("labelTagFilter", t("labelTagFilter"));
  setText("labelFrom", t("labelFrom"));
  setText("labelTo", t("labelTo"));
  setText("labelPdfStyle", t("labelPdfStyle"));
  setText("advancedToolsSummary", t("advancedTools"));
  setText("detailEmptyText", t("detailEmpty"));
  confirmSaveBtn.textContent = t("confirmSave");
  cancelPreviewBtn.textContent = t("cancel");
  clearFilterBtn.textContent = t("clear");
  exportFilteredMdBtn.textContent = t("exportFilteredMd");
  exportFilteredPdfBtn.textContent = t("exportFilteredPdf");
  importJsonBtn.textContent = t("importJson");

  searchInput.placeholder = t("searchPlaceholder");
  previewTagsInput.placeholder = t("tagsPlaceholder");
  tagFilterInput.placeholder = t("tagFilterPlaceholder");

  const setOpt = (id: string, value: string): void => {
    const el = document.getElementById(id) as HTMLOptionElement | null;
    if (el) el.textContent = value;
  };
  setOpt("optPlatformAll", t("platformAll"));
  setOpt("optPlatformChatgpt", t("platformChatgpt"));
  setOpt("optPlatformGemini", t("platformGemini"));
  setOpt("optPlatformClaude", t("platformClaude"));
  setOpt("optPlatformDoubao", t("platformDoubao"));
  setOpt("optPlatformDeepseek", t("platformDeepseek"));
  setOpt("optPdfStyleNotion", t("pdfStyleNotion"));
  setOpt("optPdfStyleCompact", t("pdfStyleCompact"));
  setOpt("optPdfStyleSource", t("pdfStyleSource"));
}

function setStatus(text: string, isError = false): void {


Output:
Exit code: 0
Wall time: 0.5 seconds
Output:
<!doctype html>

    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ChatSaver</title>
    <link rel="stylesheet" href="popup.css" />

ChatSaver

            <button id="saveBtn">Save Conversation</button>

Confirm Save

          Title
          <input id="previewTitleInput" type="text" />

          Tags (comma-separated)
          <input id="previewTagsInput" type="text" placeholder="work, prompt, idea" />

          <button id="confirmSaveBtn">Confirm Save</button>
          <button id="cancelPreviewBtn" class="secondary">Cancel</button>

        <input id="searchInput" type="search" placeholder="Search conversations" />

            Platform
            <select id="platformFilterSelect">
              <option id="optPlatformAll" value="all">All</option>
              <option id="optPlatformChatgpt" value="chatgpt">ChatGPT</option>
              <option id="optPlatformGemini" value="gemini">Gemini</option>
              <option id="optPlatformClaude" value="claude">Claude</option>
              <option id="optPlatformDoubao" value="doubao">Doubao</option>
              <option id="optPlatformDeepseek" value="deepseek">DeepSeek</option>
            </select>

            Tags
            <input id="tagFilterInput" type="text" placeholder="work, prompt" />

            From
            <input id="fromDateInput" type="date" />

            To
            <input id="toDateInput" type="date" />

          <button id="clearFilterBtn" class="secondary">Clear</button>

          <button id="exportFilteredMdBtn">Export Filtered MD</button>
          <button id="exportFilteredPdfBtn">Export Filtered PDF</button>

Select a conversation to view details.

          Advanced Tools

              PDF Style
              <select id="pdfStyleSelect" title="PDF style">
                <option id="optPdfStyleNotion" value="notion">Notion Style</option>
                <option id="optPdfStyleCompact" value="compact">Compact</option>
                <option id="optPdfStyleSource" value="source">Source-like</option>
              </select>

            <button id="importJsonBtn" class="secondary">Import JSON</button>
            <input id="importJsonInput" type="file" accept=".json,application/json" class="hidden" />


Output:
Exit code: 0
Wall time: 0.5 seconds
Output:
{
  "extName": {
    "message": "ChatSaver"
  },
  "extDesc": {
    "message": "本地保存 ChatGPT、Gemini、Claude、豆包、DeepSeek 对话，支持搜索、筛选与导出。"
  },
  "actionTitle": {
    "message": "ChatSaver"
  }
}


可以，这个插件已经有一套比较完整的骨架了，现在“内容完善”我建议优先分成 4 类来做，这样最容易见效：

1. 文案层
- 优化插件名称、副标题、弹窗里的按钮文案和提示语
- 完善中英文文案一致性
- 减少“技术味”，让普通用户更容易理解

2. 交互层
- 弹窗首页的信息层级还可以再清晰一点
- 保存成功、重复保存、导出完成这些反馈可以更友好
- 预览保存区和已保存列表可以更突出重点

3. 功能内容层
- 给对话增加更有用的元数据，比如来源页、保存时间格式、标签建议
- 补强空状态、无结果状态、错误提示
- 看看是否要增加“最近保存”“常用标签”“一键再次导出”这类轻量增强

4. 商店内容层
- Chrome Web Store 的标题、简介、截图文案、隐私说明、FAQ 还能继续打磨
- 如果你已经上架，这部分会直接影响转化和审核稳定性

从现有代码看，最适合先动的文件是：
- [popup.ts](D:/qqq/github/raoqing100/y1/src/popup.ts:1)
- [popup.html](D:/qqq/github/raoqing100/y1/public/popup.html:1)
- [messages.json](D:/qqq/github/raoqing100/y1/public/_locales/zh_CN/messages.json:1)
- [README.md](D:/qqq/github/raoqing100/y1/README.md:1)
- `docs/chrome-web-store/*`

如果你想让我直接开始，我建议你只回复一句你最想优先改哪一块，比如：
- “先优化插件弹窗文案和界面”
- “先优化商店介绍文案”
- “先补强功能提示和空状态”

如果你不想选，我也可以直接先帮你做一版“弹窗文案 + 交互内容优化”，这是当前最稳、最容易看到效果的一步。


现在有个小的BUG，是对内容进行保存为PDF格式时，跳转为打印保存页面内容，这时候，页面标题和内容出现跳页，造成题目总是单独占据一个页面。很不美观又浪费页面。这个修改一下。

<image>

</image>


现在有个小的BUG，是对内容进行保存为PDF格式时，跳转为打印保存页面内容，这时候，页面标题和内容出现跳页，造成题目总是单独占据一个页面。很不美观又浪费页面。这个修改一下。


我先去看 PDF 打印页的模板和样式，重点找分页相关的 CSS，通常这是 `page-break` / `break-inside` / 顶部间距配合出来的问题。


Get-Content -Path public\print.html -TotalCount 320


Get-Content -Path src\print.ts -TotalCount 420


Get-ChildItem -Recurse -File | Where-Object { $_.Name -match 'print|pdf|css' } | Select-Object -ExpandProperty FullName


Output:
Exit code: 0
Wall time: 0.5 seconds
Output:
<!doctype html>

    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ChatSaver PDF Export</title>

Export to PDF

If the print dialog didn't open automatically, click the button below.

      <button id="printBtn" class="print-btn">Save as PDF</button>


Output:
Exit code: 0
Wall time: 0.4 seconds
Output:
interface PrintJobPayload {
  title: string;
  bodyHtml: string;
  styleVariant?: "notion" | "compact" | "source";
  ui: {
    actionTitle: string;
    actionHint: string;
    actionButton: string;
  };
}

function tokenFromHash(): string {
  return (window.location.hash || "").replace(/^#/, "").trim();
}

async function getJob(token: string): Promise<PrintJobPayload | null> {
  const response = await chrome.runtime.sendMessage({ type: "GET_PRINT_JOB", token }) as
    | { ok: true; job: PrintJobPayload | null }
    | { ok: false; error: string };
  if (!response.ok) return null;
  return response.job;
}

async function deleteJob(token: string): Promise<void> {
  await chrome.runtime.sendMessage({ type: "DELETE_PRINT_JOB", token });
}

function doPrint(): void {
  window.focus();
  window.print();
}

function setupAutoPrint(): void {
  const timers: number[] = [];
  let completed = false;

  const clearAll = (): void => {
    while (timers.length > 0) {
      const id = timers.pop();
      if (typeof id === "number") {
        window.clearTimeout(id);
      }
    }
  };

  const attempt = (): void => {
    if (completed) return;
    doPrint();
  };

  window.addEventListener("beforeprint", () => {
    completed = true;
    clearAll();
  }, { once: true });

  window.addEventListener("afterprint", () => {
    completed = true;
    clearAll();
  }, { once: true });

  window.addEventListener("focus", attempt, { once: true });
  document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "visible") {
      attempt();
    }
  }, { once: true });

  timers.push(window.setTimeout(attempt, 120));
  timers.push(window.setTimeout(attempt, 550));
  timers.push(window.setTimeout(attempt, 1200));
  timers.push(window.setTimeout(attempt, 2200));
}

async function waitForRenderReady(timeoutMs = 1600): Promise<void> {
  const imagePromises = Array.from(document.images).map((img) => {
    if (img.complete) return Promise.resolve();
    return new Promise<void>((resolve) => {
      img.addEventListener("load", () => resolve(), { once: true });
      img.addEventListener("error", () => resolve(), { once: true });
    });
  });

  const fontsPromise =
    "fonts" in document
      ? (document.fonts.ready.then(() => undefined).catch(() => undefined))
      : Promise.resolve();

  await Promise.race([
    Promise.all([...imagePromises, fontsPromise]).then(() => undefined),
    new Promise<void>((resolve) => window.setTimeout(resolve, timeoutMs))
  ]);
}

async function run(): Promise<void> {
  const token = tokenFromHash();
  if (!token) return;

  const job = await getJob(token);
  if (!job) return;

  document.title = job.title;
  document.body.dataset.printStyle = job.styleVariant ?? "notion";
  const contentRoot = document.getElementById("contentRoot");
  const printTitle = document.getElementById("printTitle");
  const printHint = document.getElementById("printHint");
  const printBtn = document.getElementById("printBtn") as HTMLButtonElement | null;
  if (contentRoot) contentRoot.innerHTML = job.bodyHtml;
  if (printTitle) printTitle.textContent = job.ui.actionTitle;
  if (printHint) printHint.textContent = job.ui.actionHint;
  if (printBtn) {
    printBtn.textContent = job.ui.actionButton;
    printBtn.addEventListener("click", doPrint);
  }

  await deleteJob(token);

  await waitForRenderReady();
  setupAutoPrint();
}

void run();


Output:
Exit code: 0
Wall time: 0.6 seconds
Output:
D:\qqq\github\raoqing100\y1\public\popup.css
D:\qqq\github\raoqing100\y1\public\print.html
D:\qqq\github\raoqing100\y1\src\print.ts


我已经定位到打印页样式里有一个高风险点：整个 `.conversation` 被设置成了 `break-inside: avoid`，这会在一条对话比较长、首页放不下时，把整段内容整体推到下一页，结果就只剩标题留在上一页。接下来我会看导出的 HTML 结构，然后把“标题尽量跟随首段内容”改成更细粒度的分页控制。


Select-String -Path src\*.ts,src\shared\*.ts -Pattern 'conversation|bodyHtml|Export to PDF|contentRoot|print.html|styleVariant|createExportBundle|exportPdf'


Get-Content -Path src\shared\importer.ts -TotalCount 420


Output:
Exit code: 0
Wall time: 0.6 seconds
Output:

src\background.ts:1:import { findByContentHash, getConversation, listConversations, queryConversations, saveConversatio
n } from "./db";
src\background.ts:4:import { extractImportRows, normalizeImportedConversation } from "./shared/importer";
src\background.ts:5:import type { ParsedConversation, StoredConversation } from "./shared/types";
src\background.ts:18:  bodyHtml: string;
src\background.ts:19:  styleVariant?: "notion" | "compact" | "source";
src\background.ts:46:    throw new Error("Open a ChatGPT, Gemini, Claude, Doubao, or DeepSeek conversation tab first.")
;
src\background.ts:51:async function extractConversationFromActiveTab(): Promise<ParsedConversation> {
src\background.ts:53:  const response = (await chrome.tabs.sendMessage(tabId, { type: "EXTRACT_CONVERSATION" })) as
src\background.ts:54:    | { ok: true; conversation: ParsedConversation }
src\background.ts:64:  return response.conversation;
src\background.ts:67:function buildBaseConversation(parsed: ParsedConversation): Omit<StoredConversation, "id" | "updat
edAt" | "tags" | "searchableText"> {
src\background.ts:89:function toStoredConversation(parsed: ParsedConversation, title: string, tags: string[]): StoredCo
nversation {
src\background.ts:91:  const base = buildBaseConversation(parsed);
src\background.ts:102:async function saveFromPreview(parsed: ParsedConversation, customTitle: string, customTags: strin
g[]): Promise<SaveResponse> {
src\background.ts:105:  const draft = toStoredConversation(parsed, title, tags);
src\background.ts:109:    const updated: StoredConversation = {
src\background.ts:116:    await saveConversation(updated);
src\background.ts:120:  await saveConversation(draft);
src\background.ts:127:      case "PREVIEW_CURRENT_CONVERSATION": {
src\background.ts:129:          const parsed = await extractConversationFromActiveTab();
src\background.ts:130:          const base = buildBaseConversation(parsed);
src\background.ts:143:      case "SAVE_CONVERSATION_FROM_PREVIEW": {
src\background.ts:145:          const parsed = message.parsed as ParsedConversation;
src\background.ts:157:      case "LIST_CONVERSATIONS": {
src\background.ts:158:        const rows = await listConversations();
src\background.ts:162:      case "GET_CONVERSATION": {
src\background.ts:163:        const row = await getConversation(String(message.id ?? ""));
src\background.ts:167:      case "SEARCH_CONVERSATIONS": {
src\background.ts:168:        const rows = await queryConversations(
src\background.ts:178:      case "IMPORT_CONVERSATIONS_JSON": {
src\background.ts:186:            const normalized = normalizeImportedConversation(row);
src\background.ts:193:              await saveConversation({
src\background.ts:202:              await saveConversation(normalized);
src\background.ts:216:        if (!payload || typeof payload.title !== "string" || typeof payload.bodyHtml !== "string"
) {
src\content.ts:1:import { parseConversationDocument } from "./parser";
src\content.ts:2:import type { ParsedConversation } from "./shared/types";
src\content.ts:5:  | { ok: true; conversation: ParsedConversation }
src\content.ts:13:function parseConversation(): ParsedConversation {
src\content.ts:14:  return parseConversationDocument(document, window.location.href);
src\content.ts:20:  if (message?.type === "EXTRACT_CONVERSATION") {
src\content.ts:22:      const conversation = parseConversation();
src\content.ts:23:      sendResponse({ ok: true, conversation });
src\db.ts:1:import type { StoredConversation } from "./shared/types";
src\db.ts:5:const STORE_CONVERSATIONS = "conversations";
src\db.ts:14:      if (!db.objectStoreNames.contains(STORE_CONVERSATIONS)) {
src\db.ts:15:        store = db.createObjectStore(STORE_CONVERSATIONS, { keyPath: "id" });
src\db.ts:17:        store = request.transaction!.objectStore(STORE_CONVERSATIONS);
src\db.ts:43:function hydrateConversation(raw: StoredConversation): StoredConversation {
src\db.ts:61:export async function saveConversation(conversation: StoredConversation): Promise<void> {
src\db.ts:63:  const tx = db.transaction(STORE_CONVERSATIONS, "readwrite");
src\db.ts:64:  const store = tx.objectStore(STORE_CONVERSATIONS);
src\db.ts:66:  await promisifyRequest(store.put(conversation));
src\db.ts:69:    tx.onerror = () => reject(tx.error ?? new Error("Failed to save conversation"));
src\db.ts:76:export async function findByContentHash(platform: string, contentHash: string): Promise<StoredConversation
 | null> {
src\db.ts:79:  const tx = db.transaction(STORE_CONVERSATIONS, "readonly");
src\db.ts:80:  const store = tx.objectStore(STORE_CONVERSATIONS);
src\db.ts:87:  return hydrateConversation(result as StoredConversation);
src\db.ts:90:export async function listConversations(): Promise<StoredConversation[]> {
src\db.ts:92:  const tx = db.transaction(STORE_CONVERSATIONS, "readonly");
src\db.ts:93:  const store = tx.objectStore(STORE_CONVERSATIONS);
src\db.ts:97:  return (result as StoredConversation[])
src\db.ts:98:    .map((item) => hydrateConversation(item))
src\db.ts:103:export async function getConversation(id: string): Promise<StoredConversation | null> {
src\db.ts:105:  const tx = db.transaction(STORE_CONVERSATIONS, "readonly");
src\db.ts:106:  const store = tx.objectStore(STORE_CONVERSATIONS);
src\db.ts:112:  return hydrateConversation(result as StoredConversation);
src\db.ts:115:export async function searchConversations(query: string): Promise<StoredConversation[]> {
src\db.ts:116:  const all = await listConversations();
src\db.ts:124:function inDateRange(item: StoredConversation, fromIso?: string, toIso?: string): boolean {
src\db.ts:147:export async function queryConversations(
src\db.ts:153:): Promise<StoredConversation[]> {
src\db.ts:154:  const all = await listConversations();
src\importer.test.ts:2:import { extractImportRows, normalizeImportedConversation } from "./shared/importer";
src\importer.test.ts:10:      conversations: [{ id: "1" }, { id: "2" }]
src\importer.test.ts:15:  it("normalizes legacy conversation missing platform/tags", () => {
src\importer.test.ts:16:    const normalized = normalizeImportedConversation({
src\parser-chatgpt.ts:1:import type { ChatMessage, ParsedConversation } from "./shared/types";
src\parser-chatgpt.ts:6:  return title || "Untitled Conversation";
src\parser-chatgpt.ts:28:    "article[data-testid^='conversation-turn-']",
src\parser-chatgpt.ts:41:export function parseChatgptConversation(
src\parser-chatgpt.ts:45:): ParsedConversation {
src\parser-chatgpt.ts:48:    throw new Error("No conversation messages found on this page.");
src\parser-claude.ts:1:import type { ChatMessage, ParsedConversation } from "./shared/types";
src\parser-claude.ts:6:  return title || "Untitled Claude Conversation";
src\parser-claude.ts:25:export function parseClaudeConversation(
src\parser-claude.ts:29:): ParsedConversation {
src\parser-claude.ts:57:    throw new Error("No Claude conversation messages found on this page.");
src\parser-deepseek.ts:1:import type { ChatMessage, ParsedConversation } from "./shared/types";
src\parser-deepseek.ts:6:  return title || "Untitled DeepSeek Conversation";
src\parser-deepseek.ts:20:export function parseDeepseekConversation(
src\parser-deepseek.ts:24:): ParsedConversation {
src\parser-deepseek.ts:48:    throw new Error("No DeepSeek conversation messages found on this page.");
src\parser-doubao.ts:1:import type { ChatMessage, ParsedConversation } from "./shared/types";
src\parser-doubao.ts:6:  return title || "Untitled Doubao Conversation";
src\parser-doubao.ts:20:export function parseDoubaoConversation(
src\parser-doubao.ts:24:): ParsedConversation {
src\parser-doubao.ts:48:    throw new Error("No Doubao conversation messages found on this page.");
src\parser-gemini.ts:1:import type { ChatMessage, ParsedConversation } from "./shared/types";
src\parser-gemini.ts:6:  return title || "Untitled Gemini Conversation";
src\parser-gemini.ts:35:export function parseGeminiConversation(
src\parser-gemini.ts:39:): ParsedConversation {
src\parser-gemini.ts:49:    throw new Error("No Gemini conversation messages found on this page.");
src\parser.test.ts:5:import { parseConversationDocument } from "./parser";
src\parser.test.ts:17:describe("parseConversationDocument", () => {
src\parser.test.ts:20:    const parsed = parseConversationDocument(doc, "https://chatgpt.com/c/abc", "2026-01-01T00:00:
00.000Z");
src\parser.test.ts:39:    const parsed = parseConversationDocument(doc, "https://chatgpt.com/c/xyz");
src\parser.test.ts:47:    expect(() => parseConversationDocument(doc, "https://chatgpt.com/c/none")).toThrow(
src\parser.test.ts:48:      "No conversation messages found on this page."
src\parser.test.ts:52:  it("parses Gemini conversation blocks", () => {
src\parser.test.ts:55:    const parsed = parseConversationDocument(doc, "https://gemini.google.com/app/abc");
src\parser.test.ts:73:    const parsed = parseConversationDocument(doc, "https://gemini.google.com/app/order");
src\parser.test.ts:78:  it("parses Claude conversation blocks", () => {
src\parser.test.ts:81:    const parsed = parseConversationDocument(doc, "https://claude.ai/chat/xyz");
src\parser.test.ts:91:    const parsed = parseConversationDocument(doc, "https://claude.ai/chat/nested");
src\parser.test.ts:100:    expect(() => parseConversationDocument(doc, "https://example.com")).toThrow(
src\parser.test.ts:101:      "Unsupported website. Open ChatGPT, Gemini, Claude, Doubao, or DeepSeek conversation."
src\parser.test.ts:105:  it("parses Doubao conversation blocks", () => {
src\parser.test.ts:107:    const parsed = parseConversationDocument(doc, "https://www.doubao.com/chat/123");
src\parser.test.ts:114:  it("parses DeepSeek conversation blocks", () => {
src\parser.test.ts:116:    const parsed = parseConversationDocument(doc, "https://chat.deepseek.com/a/chat/sid");
src\parser.ts:1:import { parseChatgptConversation } from "./parser-chatgpt";
src\parser.ts:2:import { parseClaudeConversation } from "./parser-claude";
src\parser.ts:3:import { parseDeepseekConversation } from "./parser-deepseek";
src\parser.ts:4:import { parseDoubaoConversation } from "./parser-doubao";
src\parser.ts:5:import { parseGeminiConversation } from "./parser-gemini";
src\parser.ts:6:import type { ParsedConversation } from "./shared/types";
src\parser.ts:8:export function inferPlatformFromUrl(sourceUrl: string): ParsedConversation["platform"] | "unknown" {
src\parser.ts:22:export function parseConversationDocument(doc: Document, sourceUrl: string, nowIso = new Date().toISOS
tring()): ParsedConversation {
src\parser.ts:27:      return parseChatgptConversation(doc, sourceUrl, nowIso);
src\parser.ts:29:      return parseGeminiConversation(doc, sourceUrl, nowIso);
src\parser.ts:31:      return parseClaudeConversation(doc, sourceUrl, nowIso);
src\parser.ts:33:      return parseDoubaoConversation(doc, sourceUrl, nowIso);
src\parser.ts:35:      return parseDeepseekConversation(doc, sourceUrl, nowIso);
src\parser.ts:37:      throw new Error("Unsupported website. Open ChatGPT, Gemini, Claude, Doubao, or DeepSeek conversa
tion.");
src\popup.ts:1:import type { ParsedConversation, StoredConversation } from "./shared/types";
src\popup.ts:2:import { createExportBundle } from "./shared/importer";
src\popup.ts:11:    saveConversation: "Save Conversation",
src\popup.ts:19:    searchPlaceholder: "Search conversations",
src\popup.ts:37:    detailEmpty: "Select a conversation to view details.",
src\popup.ts:38:    extractingPreview: "Extracting conversation for preview...",
src\popup.ts:41:    savingConversation: "Saving conversation...",
src\popup.ts:42:    updatedExisting: "Updated existing conversation ({count} messages).",
src\popup.ts:43:    savedNew: "Saved new conversation ({count} messages).",
src\popup.ts:45:    noConversationsToExport: "No conversations to export under current filters.",
src\popup.ts:46:    exportedCount: "Exported {count} conversations to Markdown.",
src\popup.ts:49:    conversationNotFound: "Conversation not found",
src\popup.ts:55:    exportPdf: "Export PDF",
src\popup.ts:66:    hintDoubao: "Tip: open one complete Doubao conversation page and wait for full render.",
src\popup.ts:67:    hintDeepseek: "Tip: open one complete DeepSeek conversation page and keep latest messages visible."
,
src\popup.ts:72:    conversations: "Conversations",
src\popup.ts:74:    exportPdfOpened: "Print view opened and will auto-trigger print. Choose Save as PDF if prompted.",
src\popup.ts:75:    exportPdfBlocked: "Popup blocked. Please allow popups for this extension.",
src\popup.ts:76:    exportPdfEmpty: "No conversations to export to PDF.",
src\popup.ts:77:    pdfActionTitle: "Export to PDF",
src\popup.ts:86:    saveConversation: "保存当前对话",
src\popup.ts:116:    savingConversation: "正在保存对话...",
src\popup.ts:120:    noConversationsToExport: "当前筛选条件下没有可导出的对话。",
src\popup.ts:124:    conversationNotFound: "未找到该对话",
src\popup.ts:130:    exportPdf: "导出 PDF",
src\popup.ts:147:    conversations: "对话数",
src\popup.ts:149:    exportPdfOpened: "已打开打印页面并尝试自动触发打印，如有提示请选择“另存为 PDF”。",
src\popup.ts:150:    exportPdfBlocked: "弹窗被拦截，请允许该扩展弹窗后重试。",
src\popup.ts:151:    exportPdfEmpty: "当前没有可导出的 PDF 对话。",
src\popup.ts:162:type PdfStyleVariant = "notion" | "compact" | "source";
src\popup.ts:204:const listEl = document.getElementById("conversationList") as HTMLUListElement;
src\popup.ts:213:let pendingPreview: ParsedConversation | null = null;
src\popup.ts:214:let currentRows: StoredConversation[] = [];
src\popup.ts:223:  saveBtn.textContent = t("saveConversation");
src\popup.ts:267:  if (text.includes("No Gemini conversation messages")) return `${text} ${t("hintGemini")}`;
src\popup.ts:268:  if (text.includes("No Claude conversation messages")) return `${text} ${t("hintClaude")}`;
src\popup.ts:269:  if (text.includes("No conversation messages found")) return `${text} ${t("hintNoMessages")}`;
src\popup.ts:271:  if (text.includes("No Doubao conversation messages")) return `${text} ${t("hintDoubao")}`;
src\popup.ts:272:  if (text.includes("No DeepSeek conversation messages")) return `${text} ${t("hintDeepseek")}`;
src\popup.ts:321:function toMarkdown(item: StoredConversation): string {
src\popup.ts:343:function toBatchMarkdown(items: StoredConversation[]): string {
src\popup.ts:344:  const byPlatform: Record<string, StoredConversation[]> = {
src\popup.ts:360:  lines.push(`- ${t("conversations")}: ${items.length}`);
src\popup.ts:395:  return title.replace(/[^a-z0-9-_]+/gi, "_").slice(0, 50) || "conversation";
src\popup.ts:398:function downloadMarkdown(item: StoredConversation): void {
src\popup.ts:403:function downloadJson(item: StoredConversation): void {
src\popup.ts:404:  const bundle = createExportBundle([item]);
src\popup.ts:408:function downloadBatchMarkdown(items: StoredConversation[]): void {
src\popup.ts:536:function renderConversationHtml(item: StoredConversation, index?: number): string {
src\popup.ts:555:

src\popup.ts:566:async function openPrintTab(documentTitle: string, bodyHtml: string): Promise<boolean> {
src\popup.ts:567:  const styleVariant = getPdfStyleVariant();
src\popup.ts:573:        bodyHtml,
src\popup.ts:574:        styleVariant,
src\popup.ts:585:    const url = chrome.runtime.getURL(`print.html#${encodeURIComponent(response.token)}`);
src\popup.ts:593:function getPdfStyleVariant(): PdfStyleVariant {
src\popup.ts:602:  const saved = window.localStorage.getItem("pdfStyleVariant");
src\popup.ts:603:  const value: PdfStyleVariant = saved === "compact" || saved === "source" ? saved : "notion";
src\popup.ts:607:async function exportSinglePdf(item: StoredConversation): Promise<boolean> {
src\popup.ts:610:    ${renderConversationHtml(item)}
src\popup.ts:616:async function exportBatchPdf(items: StoredConversation[]): Promise<boolean> {
src\popup.ts:617:  const byPlatform: Record<string, StoredConversation[]> = {
src\popup.ts:634:      const convHtml = rows.map((row) => renderConversationHtml(row, index++)).join("\n");
src\popup.ts:642:
${escapeHtml(t("conversations"))}: ${escapeHtml(String(items.length))}

src\popup.ts:671:  const response = (await chrome.runtime.sendMessage({ type: "GET_CONVERSATION", id })) as
src\popup.ts:672:    | { ok: true; row: StoredConversation | null }
src\popup.ts:676:    setStatus(response.ok ? t("conversationNotFound") : response.error, true);
src\popup.ts:702:  const exportPdfBtn = document.createElement("button");
src\popup.ts:703:  exportPdfBtn.textContent = t("exportPdf");
src\popup.ts:704:  exportPdfBtn.className = "secondary";
src\popup.ts:705:  exportPdfBtn.style.marginLeft = "8px";
src\popup.ts:706:  exportPdfBtn.addEventListener("click", async () => {
src\popup.ts:708:    setStatus(ok ? t("exportPdfOpened") : t("exportPdfBlocked"), !ok);
src\popup.ts:710:  detailEl.appendChild(exportPdfBtn);
src\popup.ts:735:function renderList(rows: StoredConversation[]): void {
src\popup.ts:757:  const type = query.trim() || hasFilter ? "SEARCH_CONVERSATIONS" : "LIST_CONVERSATIONS";
src\popup.ts:759:    | { ok: true; rows: StoredConversation[] }
src\popup.ts:775:  const response = (await chrome.runtime.sendMessage({ type: "PREVIEW_CURRENT_CONVERSATION" })) as
src\popup.ts:776:    | { ok: true; preview: ParsedConversation; duplicate: boolean }
src\popup.ts:805:  setStatus(t("savingConversation"));
src\popup.ts:810:    type: "SAVE_CONVERSATION_FROM_PREVIEW",
src\popup.ts:873:    setStatus(t("noConversationsToExport"), true);
src\popup.ts:882:    setStatus(t("exportPdfEmpty"), true);
src\popup.ts:886:  setStatus(ok ? t("exportPdfOpened") : t("exportPdfBlocked"), !ok);
src\popup.ts:902:      type: "IMPORT_CONVERSATIONS_JSON",
src\popup.ts:934:  window.localStorage.setItem("pdfStyleVariant", getPdfStyleVariant());
src\print.ts:3:  bodyHtml: string;
src\print.ts:4:  styleVariant?: "notion" | "compact" | "source";
src\print.ts:102:  document.body.dataset.printStyle = job.styleVariant ?? "notion";
src\print.ts:103:  const contentRoot = document.getElementById("contentRoot");
src\print.ts:107:  if (contentRoot) contentRoot.innerHTML = job.bodyHtml;
src\shared\importer.ts:2:import type { AIPlatform, ConversationExportBundle, StoredConversation } from "./types";
src\shared\importer.ts:12:function normalizeMessages(raw: unknown): StoredConversation["messages"] {
src\shared\importer.ts:14:  const result: StoredConversation["messages"] = [];
src\shared\importer.ts:31:    const maybeBundle = payload as Partial<ConversationExportBundle> & { conversations?: unkn
own[] };
src\shared\importer.ts:32:    if (Array.isArray(maybeBundle.conversations)) {
src\shared\importer.ts:33:      return maybeBundle.conversations;
src\shared\importer.ts:40:export function normalizeImportedConversation(raw: unknown, nowIso = new Date().toISOString()
): StoredConversation | null {
src\shared\importer.ts:42:  const obj = raw as Partial<StoredConversation>;
src\shared\importer.ts:78:export function createExportBundle(conversations: StoredConversation[]): ConversationExportBu
ndle {
src\shared\importer.ts:83:    conversations
src\shared\types.ts:10:export interface ParsedConversation {
src\shared\types.ts:18:export interface StoredConversation extends ParsedConversation {
src\shared\types.ts:27:export interface ConversationExportBundle {
src\shared\types.ts:31:  conversations: StoredConversation[];


Output:
Exit code: 0
Wall time: 0.4 seconds
Output:
import { buildSearchableText, computeContentHash } from "./hash";
import type { AIPlatform, ConversationExportBundle, StoredConversation } from "./types";

function inferPlatformFromSourceUrl(sourceUrl: string): AIPlatform {
  if (sourceUrl.includes("gemini.google.com")) return "gemini";
  if (sourceUrl.includes("claude.ai")) return "claude";
  if (sourceUrl.includes("doubao.com")) return "doubao";
  if (sourceUrl.includes("deepseek.com")) return "deepseek";
  return "chatgpt";
}

function normalizeMessages(raw: unknown): StoredConversation["messages"] {
  if (!Array.isArray(raw)) return [];
  const result: StoredConversation["messages"] = [];
  for (const item of raw) {
    if (!item || typeof item !== "object") continue;
    const roleRaw = String((item as { role?: string }).role ?? "unknown");
    const content = String((item as { content?: string }).content ?? "").trim();
    const contentHtmlRaw = (item as { contentHtml?: unknown }).contentHtml;
    const contentHtml = typeof contentHtmlRaw === "string" ? contentHtmlRaw.trim() : "";
    if (!content) continue;
    const role = roleRaw === "user" || roleRaw === "assistant" || roleRaw === "system" ? roleRaw : "unknown";
    result.push({ role, content, contentHtml: contentHtml || undefined });
  }
  return result;
}

export function extractImportRows(payload: unknown): unknown[] {
  if (Array.isArray(payload)) return payload;
  if (payload && typeof payload === "object") {
    const maybeBundle = payload as Partial<ConversationExportBundle> & { conversations?: unknown[] };
    if (Array.isArray(maybeBundle.conversations)) {
      return maybeBundle.conversations;
    }
    return [payload];
  }
  return [];
}

export function normalizeImportedConversation(raw: unknown, nowIso = new Date().toISOString()): StoredConversation | null {
  if (!raw || typeof raw !== "object") return null;
  const obj = raw as Partial<StoredConversation>;
  const title = String(obj.title ?? "").trim();
  const sourceUrl = String(obj.sourceUrl ?? "").trim();
  const createdAt = String(obj.createdAt ?? nowIso);
  const updatedAt = String(obj.updatedAt ?? createdAt ?? nowIso);
  const tags = Array.isArray(obj.tags) ? obj.tags.map((tag) => String(tag).trim()).filter(Boolean) : [];
  const messages = normalizeMessages(obj.messages);
  if (!title || !sourceUrl || messages.length === 0) return null;

  const platform = (obj.platform as AIPlatform | undefined) ?? inferPlatformFromSourceUrl(sourceUrl);
  const rawHashInput = JSON.stringify({
    platform,
    title,
    sourceUrl,
    createdAt,
    messages: messages.map((message) => ({ role: message.role, content: message.content }))
  });
  const contentHash = obj.contentHash ?? computeContentHash(rawHashInput);
  const platformContentHash = `${platform}:${contentHash}`;
  const searchableText = obj.searchableText ?? buildSearchableText(title, messages, tags);

  return {
    id: String(obj.id ?? crypto.randomUUID()),
    platform,
    title,
    sourceUrl,
    createdAt,
    updatedAt,
    tags,
    messages,
    contentHash,
    platformContentHash,
    searchableText
  };
}

export function createExportBundle(conversations: StoredConversation[]): ConversationExportBundle {
  return {
    schemaVersion: 2,
    app: "ChatSaver",
    exportedAt: new Date().toISOString(),
    conversations
  };
}


我已经找到生成打印内容的函数了，下一步会直接改这里对应的结构和打印页 CSS，一起把“标题独占一页”的分页行为收紧。


Get-Content -Path src\popup.ts | Select-Object -Skip 520 -First 140


Get-Content -Path src\background.ts -TotalCount 260


Output:
Exit code: 0
Wall time: 0.5 seconds
Output:

    const paragraphLines: string[] = [];
    while (i < lines.length) {
      const p = (lines[i] ?? "").trim();
      if (!p) break;
      if (/^#{1,3}\s+/.test(p) || /^>\s+/.test(p) || /^[-*]\s+/.test(p) || /^\d+\.\s+/.test(p) || p.startsWith("```")) break;
      paragraphLines.push(lines[i] ?? "");
      i += 1;
    }
    blocks.push(`<p>${renderInline(paragraphLines.join(" "))}</p>`);
  }

  return blocks.join("\n");
}

function renderConversationHtml(item: StoredConversation, index?: number): string {
  const headingPrefix = typeof index === "number" ? `${index}. ` : "";
  const tags = item.tags.length > 0 ? `<p class="meta">${escapeHtml(t("mdTags"))}: ${escapeHtml(item.tags.join(", "))}</p>` : "";
  const messages = item.messages
    .map((message) => {
      const rich = message.contentHtml?.trim();
      const renderedContent = rich
        ? `<div class="content rich-html">${rich}</div>`
        : `<div class="content">${renderRichText(message.content)}</div>`;
      return `

${escapeHtml(message.role.toUpperCase())}

          ${renderedContent}

      `;
    })
    .join("\n");

  return `

${headingPrefix}${escapeHtml(item.title)}

${escapeHtml(t("mdPlatform"))}: ${escapeHtml(platformLabel(item.platform))}

${escapeHtml(t("mdSavedAt"))}: ${escapeHtml(formatDate(item.updatedAt))}

${escapeHtml(t("mdSource"))}: ${escapeHtml(item.sourceUrl)}

      ${tags}
      ${messages}

  `;
}

async function openPrintTab(documentTitle: string, bodyHtml: string): Promise<boolean> {
  const styleVariant = getPdfStyleVariant();
  try {
    const response = await chrome.runtime.sendMessage({
      type: "SET_PRINT_JOB",
      payload: {
        title: documentTitle,
        bodyHtml,
        styleVariant,
        ui: {
          actionTitle: t("pdfActionTitle"),
          actionHint: t("pdfActionHint"),
          actionButton: t("pdfActionButton")
        }
      }
    }) as { ok: boolean; token?: string };
    if (!response.ok || !response.token) {
      return false;
    }
    const url = chrome.runtime.getURL(`print.html#${encodeURIComponent(response.token)}`);
    await chrome.tabs.create({ url });
    return true;
  } catch {
    return false;
  }
}

function getPdfStyleVariant(): PdfStyleVariant {
  const value = (pdfStyleSelect?.value ?? "notion").trim();
  if (value === "compact" || value === "source") {
    return value;
  }
  return "notion";
}

function hydratePdfStyleSelect(): void {
  const saved = window.localStorage.getItem("pdfStyleVariant");
  const value: PdfStyleVariant = saved === "compact" || saved === "source" ? saved : "notion";
  pdfStyleSelect.value = value;
}

async function exportSinglePdf(item: StoredConversation): Promise<boolean> {
  const body = `

${escapeHtml(item.title)}

    ${renderConversationHtml(item)}

${escapeHtml(t("exportFooter"))}

  `;
  return openPrintTab(item.title, body);
}

async function exportBatchPdf(items: StoredConversation[]): Promise<boolean> {
  const byPlatform: Record<string, StoredConversation[]> = {
    chatgpt: [],
    gemini: [],
    claude: [],
    doubao: [],
    deepseek: []
  };
  for (const item of items) {
    (byPlatform[item.platform] ?? byPlatform.chatgpt).push(item);
  }

  const orderedPlatforms: Array<keyof typeof byPlatform> = ["chatgpt", "gemini", "claude", "doubao", "deepseek"];
  let index = 1;
  const sections = orderedPlatforms
    .map((platform) => {
      const rows = byPlatform[platform];
      if (!rows || rows.length === 0) return "";
      const convHtml = rows.map((row) => renderConversationHtml(row, index++)).join("\n");
      return `<h2>${escapeHtml(tpl("platformSection", { platform: platformLabel(platform) }))}</h2>${convHtml}`;
    })
    .join("\n");

  const body = `

${escapeHtml(t("exportTitle"))}

${escapeHtml(t("exportedAt"))}: ${escapeHtml(new Date().toLocaleString())}

${escapeHtml(t("conversations"))}: ${escapeHtml(String(items.length))}

    ${sections}

${escapeHtml(t("exportFooter"))}

  `;
  return openPrintTab("ChatSaver PDF Export", body);
}

function getFilterRange(): { fromIso?: string; toIso?: string } {
  const fromDate = fromDateInput.value.trim();
  const toDate = toDateInput.value.trim();

  const fromIso = fromDate ? `${fromDate}T00:00:00.000` : undefined;
  const toIso = toDate ? `${toDate}T23:59:59.999` : undefined;
  return { fromIso, toIso };
}

function setPreviewVisible(visible: boolean): void {
  previewPanel.classList.toggle("hidden", !visible);
}