@k2b/cloud 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -99,7 +99,7 @@
99
99
  "@tailwindcss/typography": "0.5.20",
100
100
  "@k2b/nessi": "0.12.1",
101
101
  "@k2b/ssr": "0.14.0",
102
- "@k2b/ui": "0.6.3",
102
+ "@k2b/ui": "0.8.0",
103
103
  "@k2b/stdlib": "0.26.0",
104
104
  "@k2b/sync": "6.5.0",
105
105
  "@nats-io/transport-node": "3.4.0",
@@ -130,6 +130,7 @@
130
130
  "@types/bun": "1.4.2",
131
131
  "babel-preset-solid": "1.9.15",
132
132
  "hono": "4.13.8",
133
+ "playwright": "1.62.0",
133
134
  "solid-js": "1.9.15",
134
135
  "typescript": "5.9.3",
135
136
  "zod": "4.6.5"
@@ -24,6 +24,7 @@ import {
24
24
  jsonPreview,
25
25
  memoryToolPresentation,
26
26
  } from "./message-utils";
27
+ import { aiChatMessages } from "./messages";
27
28
  import { AssistantMarkdownBlock } from "./primitives";
28
29
  import { AiToolActivity, AiToolDisclosureProvider, type AiToolDisclosureState, createAiToolDisclosureState } from "./tool-disclosure";
29
30
  import { groupToolBlocks, isFailedTool, summarizeToolGroup } from "./tool-groups";
@@ -57,16 +58,18 @@ function ReviewDetailValue(props: { detail: ReviewDetail }) {
57
58
  // turn streams, so every branch must re-evaluate when the store updates.
58
59
 
59
60
  function ThinkingBlockView(props: { text: string; streaming?: boolean }) {
61
+ const locale = useLocale();
62
+ const t = () => aiChatMessages(locale());
60
63
  return (
61
64
  <Show
62
65
  when={props.text.trim()}
63
66
  fallback={
64
67
  <Show when={props.streaming}>
65
- <Chat.Activity label="Thinking" icon="ti ti-sparkles" tone="ai" busy />
68
+ <Chat.Activity label={t().thinking} icon="ti ti-sparkles" tone="ai" busy />
66
69
  </Show>
67
70
  }
68
71
  >
69
- <Chat.Activity label="Show reasoning" icon="ti ti-sparkles" tone="ai" bodyInset={false}>
72
+ <Chat.Activity label={t().showReasoning} icon="ti ti-sparkles" tone="ai" bodyInset={false}>
70
73
  <pre class="max-h-52 w-full min-w-0 overflow-auto whitespace-pre-wrap rounded-md bg-zinc-100/70 p-2 text-[11px] leading-5 text-secondary [box-shadow:var(--ui-control-recess)] dark:bg-zinc-950/70">
71
74
  {props.text}
72
75
  </pre>
@@ -24,6 +24,7 @@ const SPECIALIZED_TOOL_NAMES = new Set([
24
24
  "calculate",
25
25
  "view_image",
26
26
  "markdown_to_pdf",
27
+ "html_to_pdf",
27
28
  "local_bash",
28
29
  "read_cloud_resource",
29
30
  ]);
@@ -317,6 +318,12 @@ function FileOperationView(props: { block: ToolBlock; verb: string; resultPath?:
317
318
  );
318
319
  }
319
320
 
321
+ function CreatedPdfView(props: { block: ToolBlock }) {
322
+ const locale = useLocale();
323
+ const result = () => (isRecord(props.block.result) ? props.block.result : {});
324
+ return <FileOperationView block={props.block} verb={aiChatMessages(locale()).createdPdf} resultPath={text(result().path)} />;
325
+ }
326
+
320
327
  function ReadFileView(props: { block: ToolBlock }) {
321
328
  const locale = useLocale();
322
329
  const result = () => (isRecord(props.block.result) ? props.block.result : {});
@@ -412,10 +419,9 @@ export function SpecializedBuiltinToolBlock(props: { block: ToolBlock }) {
412
419
  return <CalculateView block={props.block} />;
413
420
  case "view_image":
414
421
  return <ViewImageView block={props.block} />;
415
- case "markdown_to_pdf": {
416
- const result = isRecord(props.block.result) ? props.block.result : {};
417
- return <FileOperationView block={props.block} verb="Created PDF" resultPath={text(result.path)} />;
418
- }
422
+ case "markdown_to_pdf":
423
+ case "html_to_pdf":
424
+ return <CreatedPdfView block={props.block} />;
419
425
  case "local_bash":
420
426
  return <LocalBashView block={props.block} />;
421
427
  case "read_cloud_resource":
@@ -304,6 +304,7 @@ const BUILT_IN_TOOL_ICONS = new Map<string, string>([
304
304
  ["write_file", "ti ti-file-spark"],
305
305
  ["fetch_file", "ti ti-world-download"],
306
306
  ["markdown_to_pdf", "ti ti-file-type-pdf"],
307
+ ["html_to_pdf", "ti ti-file-type-pdf"],
307
308
  ["present", "ti ti-file-spark"],
308
309
  ["calculate", "ti ti-calculator"],
309
310
  ["web_search", "ti ti-search"],
@@ -18,6 +18,9 @@ const messages = i18n.define({
18
18
  toolIssues: "Tool issues",
19
19
  none: "None",
20
20
  read: "Read",
21
+ createdPdf: "Created PDF",
22
+ thinking: "Thinking",
23
+ showReasoning: "Show reasoning",
21
24
  byteRange: ({ start, end }: { start: string; end: string }) => `Bytes ${start}–${end}`,
22
25
  },
23
26
  de: {
@@ -36,6 +39,9 @@ const messages = i18n.define({
36
39
  toolIssues: "Werkzeugprobleme",
37
40
  none: "Keine",
38
41
  read: "Gelesen",
42
+ createdPdf: "PDF erstellt",
43
+ thinking: "Denkt nach",
44
+ showReasoning: "Denkprozess anzeigen",
39
45
  byteRange: ({ start, end }) => `Bytes ${start}–${end}`,
40
46
  },
41
47
  },
@@ -27,10 +27,15 @@ export type AiChatTimelineSession = {
27
27
 
28
28
  export { type AiChatActions, AiChatActionsProvider };
29
29
 
30
- const isWideBlock = (block: AiAssistantTimelineItem["blocks"][number]) => block.kind === "tool";
31
-
32
30
  type AssistantBlock = AiAssistantTimelineItem["blocks"][number];
33
31
 
32
+ // Activity blocks span the full message column so disclosure chevrons share one
33
+ // right edge. Tool and compaction blocks count even while busy; reasoning counts
34
+ // only once it has text, because empty reasoning renders at most a busy row and
35
+ // must not widen a prose-only reply.
36
+ const isWideBlock = (block: AssistantBlock) =>
37
+ block.kind === "tool" || block.kind === "compaction" || (block.kind === "thinking" && block.text.trim().length > 0);
38
+
34
39
  type SurveyResultBlock = Extract<AssistantBlock, { kind: "tool" }>;
35
40
  type SurveySegment = { type: "assistant"; blocks: AssistantBlock[] } | { type: "survey"; block: SurveyResultBlock };
36
41
 
@@ -48,6 +48,7 @@ export function summarizeToolGroup(tools: readonly Tool[], locale: string): stri
48
48
  return de ? "Cloud-Integration verwendet" : "Used Cloud integration";
49
49
  if (["read_file", "list_files", "view_image"].includes(tool.name)) return de ? "Dateien gelesen" : "Read files";
50
50
  if (tool.name === "write_file") return de ? "Dateien geschrieben" : "Wrote files";
51
+ if (["markdown_to_pdf", "html_to_pdf"].includes(tool.name)) return de ? "PDFs erstellt" : "Created PDFs";
51
52
  if (["load_skill", "load_tools", "search_tools"].includes(tool.name))
52
53
  return de ? "Werkzeuge und Wissen geladen" : "Loaded tools and guidance";
53
54
  if (tool.name.startsWith("web_") || tool.name === "fetch_file") return de ? "Im Web recherchiert" : "Searched the web";
@@ -4,10 +4,10 @@ import type { AiSkillTemplate } from "./skills";
4
4
 
5
5
  export const ASSISTANT_CODE_MODE_SKILL = {
6
6
  "key": "assistant:code-mode",
7
- "version": 52,
7
+ "version": 54,
8
8
  "name": "assistant-code-mode",
9
9
  "description": "Inspect and transform unfamiliar data, analyze files, compare results across Cloud apps, or build and improve interactive and agent-only Apps in Assistant Studio. Use for quick code experiments, data analysis, file generation, resource SQL queries and combining discovered Cloud capabilities. For plain arithmetic or date offsets, answer directly or use calculate.",
10
- "instructions": "# Assistant code mode\n\nChoose the smallest useful result: one-off answer, exported file, or reusable\nStudio App. Apps may expose agent actions, a display-only dashboard, or both.\nPersistence is optional. One-off scripts stay in their chat and cannot be shared. Reuse an\nexisting Cloud feature when it fits. For a\nquick reading of an uploaded PDF or Office document, `read_file` can return\nMarkdown; use code for exact cells, calculations, original PDF text or positions.\n\n## Start from the contract\n\nLoad the needed `code_*` tools individually through `load_tools` and read their\ninput schemas. They are Assistant tools, not capabilities or functions inside\ncode. Discover other Cloud operations before using `capabilities.run`.\n\nRuntime namespaces are globals: no imports or package installation are needed.\nOnly relative imports of the resource's own source files are supported. There is\nno DOM or native network access. Before using a namespace, read its reference\nbelow for signatures, options and return values. Do not invent methods or infer\nan API from a familiar library. For discovered Cloud capabilities and external\nAPIs, obtain their actual contracts separately.\n\nInspect supplied data before joining, filtering or calculating: column names,\ntypes, units, date ranges and missing values. Ask only for decisions or inputs\nthat cannot be established from available evidence. For several real steps,\nkeep a short `todo_write` plan and update it as work changes; skip ceremony for a\nsmall experiment. A failed experiment should change the next hypothesis.\n\n## First file script\n\nPass exact current-chat manifest paths as `code_run.inputPaths`, and this entry\nas `code_run.code` for a small CSV:\n\n```js\nexport default async () => {\n const [input] = await files.list();\n if (!input) throw new Error(\"Select a CSV input.\");\n const rows = await sheet.fromCsv(await files.read(input.name));\n return { rows: rows.length, columns: Object.keys(rows[0] ?? {}), sample: rows.slice(0, 3) };\n};\n```\n\n`input.name` is the full path, such as `/sales.csv`; pass it unchanged to\n`files.read`, which returns a `File`. CSV rows are objects keyed by headers:\n`rows[0]` is already data. Do not drop it. For older Excel CSVs, use\n`sheet.fromCsv(file, {encoding:\"windows-1252\"})`. Inspect actual headings first.\nFor a tiny experiment without files, `export default () => ({answer:42})` suffices.\nEach run has fresh variables. No saved resource or UI is required.\n\n## Reference routing\n\nRead only the rows relevant to the task. Each link describes its own complete\nsupported surface; links within references add related workflows when needed.\n\n| Task / API | Read |\n| --- | --- |\n| Source entry, input/output files, pickers, CSV, IDs | [Runtime and files](/skills/assistant-code-mode/references/runtime.md) |\n| Inspect PDF pages, read PDF text/positions or XLSX/ODS cells, write ODS | [Documents](/skills/assistant-code-mode/references/documents.md) |\n| Generate a PDF, embed attachments, combine invoice HTML and XML | [PDF generation](/skills/assistant-code-mode/references/pdf.md) |\n| Exact amounts, taxes, allocation, localized money | [Money](/skills/assistant-code-mode/references/money.md) |\n| Export DATEV bookings or SEPA transfers | [DATEV and SEPA](/skills/assistant-code-mode/references/finance.md) |\n| Parse a CAMT bank report | [Bank reports](/skills/assistant-code-mode/references/camt.md) |\n| Calculate, create or read electronic invoices/XML/PDF attachments | [Electronic invoices](/skills/assistant-code-mode/references/einvoice.md) |\n| Controls, layouts and dialogs | [UI and dialogs](/skills/assistant-code-mode/references/ui.md), [Analytics UI](/skills/assistant-code-mode/references/analytics.md) |\n| Chart types, series and axes | [Charts](/skills/assistant-code-mode/references/charts.md) |\n| Long processing, progress, cancellation | [Background work](/skills/assistant-code-mode/references/work.md) |\n| Persist JSON or files locally/shared | [Storage](/skills/assistant-code-mode/references/storage.md) |\n| Copy files between stores; list and download Filesv2 beside Grids documents | [File transfers](/skills/assistant-code-mode/references/files.md) |\n| Resource SQL, schema, row CRUD, imports | [Database](/skills/assistant-code-mode/references/database.md) |\n| Generate text, classify data or extract structured fields | [AI calculations](/skills/assistant-code-mode/references/ai.md) |\n| Discovered Cloud queries/actions | [Capability calls](/skills/assistant-code-mode/references/capabilities.md) |\n| External HTTPS and personal secrets | [HTTP and secrets](/skills/assistant-code-mode/references/http.md) |\n| Call a published App action; declare handlers | [App actions](/skills/assistant-code-mode/references/app-actions.md) |\n| Reuse work across chats, create or edit an App | [Source workflow](/skills/assistant-code-mode/references/source-workflow.md) |\n| Publish, restore, copy | [Publishing](/skills/assistant-code-mode/references/publishing.md) |\n| Find recipients or change App/Skill sharing | [Access](/skills/assistant-code-mode/references/access.md) |\n| Inspect, export, clear server data, or delete an App | [Management](/skills/assistant-code-mode/references/management.md) |\n| Execute, inspect, interact, export, stop, diagnose errors | [Run and debug](/skills/assistant-code-mode/references/debugging.md) |\n| Unfamiliar inputs or cross-app investigation | [Investigation](/skills/assistant-code-mode/references/investigation.md) |\n| Complete app starters | [Examples](/skills/assistant-code-mode/references/examples.md) |\n\nFor a new app, read Source workflow and the closest complete example before\nwriting source, plus only the API references it uses. For analytical reports or\ndashboards, also load `assistant-data-analysis` for metrics and source validation.\n\n## Choose the delivery\n\nFor a one-off chart, calculator, or interactive analysis in this conversation,\nuse `code_run({code,inputPaths})`, test the controls, then\n`code_present({runId,title})`. Read [Chat visualizations](/skills/assistant-code-mode/references/chat.md).\nA successful run is visible to the agent only; present it before saying the\nuser can see it. No saved App or chat file is necessary.\n\nUse a Studio App when the user needs an independently accessible, reusable\napplication. Use `files.save`, `code_export`, and `present` when the requested\nresult is a file. These are separate delivery choices.\n\n## Verify and deliver\n\nRun the actual source (the saved revision for Apps) and test relevant controls with IDs returned by\n`code_run`/`code_interact`, including invalid inputs and picker fixtures. Creating,\ncompiling or saving source does not verify behavior. If `work.status` is\n`running`, wait with `code_inspect({runId,waitMs:30000})`; do not restart the job.\nInspect only when the returned snapshot needs more detail. Errors and\n`outputTruncated` are not successful complete results.\n\nFor a CSV, call `await files.save(sheet.toCsv(rows), \"result.csv\")` inside code;\nfor a spreadsheet, `await files.save(await sheet.toOds(sheets), \"result.ods\")`.\nThen call the **tool** `code_export` with the returned `runId` and captured file\nname, and `present` its returned chat path. `files.save` returns no path.\nReuse exported data via its path/version rather than retyping truncated output.\nReconcile row counts, exclusions and totals before reporting findings.\n\nOpen GUI apps with `code_open`. Saving or testing does\nnot replace a user's already-running app. Stop runs no longer needed that retain\nUI, jobs or output files. Never claim an unexecuted result is verified.\n\nAgent execution runs independently of the user's tab. Agent local storage is\ntemporary; shared storage, database writes and external actions are real, even\nin tests. Cancellation and source restore do not undo them. Apps select local\nfiles explicitly; they never gain implicit access to chat attachments. Use\n`code_secret` for credentials, never chat or app controls. Honor normal access\nand approval decisions; availability is not authorization for unrelated actions.",
10
+ "instructions": "# Assistant code mode\n\nChoose the smallest useful result: one-off answer, exported file, or reusable\nStudio App. Apps may expose agent actions, a display-only dashboard, or both.\nPersistence is optional. One-off scripts stay in their chat and cannot be shared. Reuse an\nexisting Cloud feature when it fits. For a\nquick reading of an uploaded PDF or Office document, `read_file` can return\nMarkdown; use code for exact cells, calculations, original PDF text or positions.\n\n## Start from the contract\n\nLoad the needed `code_*` tools individually through `load_tools` and read their\ninput schemas. They are Assistant tools, not capabilities or functions inside\ncode. Discover other Cloud operations before using `capabilities.run`.\n\nRuntime namespaces are globals: no imports or package installation are needed.\nOnly relative imports of the resource's own source files are supported. There is\nno DOM or native network access. Before using a namespace, read its reference\nbelow for signatures, options and return values. Do not invent methods or infer\nan API from a familiar library. For discovered Cloud capabilities and external\nAPIs, obtain their actual contracts separately.\n\nInspect supplied data before joining, filtering or calculating: column names,\ntypes, units, date ranges and missing values. Ask only for decisions or inputs\nthat cannot be established from available evidence. For several real steps,\nkeep a short `todo_write` plan and update it as work changes; skip ceremony for a\nsmall experiment. A failed experiment should change the next hypothesis.\n\n## First file script\n\nPass exact current-chat manifest paths as `code_run.inputPaths`, and this entry\nas `code_run.code` for a small CSV:\n\n```js\nexport default async () => {\n const [input] = await files.list();\n if (!input) throw new Error(\"Select a CSV input.\");\n const rows = await sheet.fromCsv(await files.read(input.name));\n return { rows: rows.length, columns: Object.keys(rows[0] ?? {}), sample: rows.slice(0, 3) };\n};\n```\n\n`input.name` is the full path, such as `/sales.csv`; pass it unchanged to\n`files.read`, which returns a `File`. CSV rows are objects keyed by headers:\n`rows[0]` is already data. Do not drop it. For older Excel CSVs, use\n`sheet.fromCsv(file, {encoding:\"windows-1252\"})`. Inspect actual headings first.\nFor a tiny experiment without files, `export default () => ({answer:42})` suffices.\nEach run has fresh variables. No saved resource or UI is required.\n\n## Reference routing\n\nRead only the rows relevant to the task. Each link describes its own complete\nsupported surface; links within references add related workflows when needed.\n\n| Task / API | Read |\n| --- | --- |\n| Source entry, input/output files, pickers, CSV, IDs | [Runtime and files](/skills/assistant-code-mode/references/runtime.md) |\n| Inspect PDF pages, read PDF text/positions or XLSX/ODS cells, write ODS | [Documents](/skills/assistant-code-mode/references/documents.md) |\n| Generate a PDF, save one in Files, embed attachments, combine invoice HTML and XML | [PDF generation](/skills/assistant-code-mode/references/pdf.md) |\n| Exact amounts, taxes, allocation, localized money | [Money](/skills/assistant-code-mode/references/money.md) |\n| Export DATEV bookings or SEPA transfers | [DATEV and SEPA](/skills/assistant-code-mode/references/finance.md) |\n| Parse a CAMT bank report | [Bank reports](/skills/assistant-code-mode/references/camt.md) |\n| Calculate, create or read electronic invoices/XML/PDF attachments | [Electronic invoices](/skills/assistant-code-mode/references/einvoice.md) |\n| Controls, layouts and dialogs | [UI and dialogs](/skills/assistant-code-mode/references/ui.md), [Analytics UI](/skills/assistant-code-mode/references/analytics.md) |\n| Chart types, series and axes | [Charts](/skills/assistant-code-mode/references/charts.md) |\n| Long processing, progress, cancellation | [Background work](/skills/assistant-code-mode/references/work.md) |\n| Persist JSON or files locally/shared | [Storage](/skills/assistant-code-mode/references/storage.md) |\n| Copy files between stores; list and download Filesv2 beside Grids documents | [File transfers](/skills/assistant-code-mode/references/files.md) |\n| Resource SQL, schema, row CRUD, imports | [Database](/skills/assistant-code-mode/references/database.md) |\n| Generate text, classify data or extract structured fields | [AI calculations](/skills/assistant-code-mode/references/ai.md) |\n| Discovered Cloud queries/actions | [Capability calls](/skills/assistant-code-mode/references/capabilities.md) |\n| External HTTPS and personal secrets | [HTTP and secrets](/skills/assistant-code-mode/references/http.md) |\n| Call a published App action; declare handlers | [App actions](/skills/assistant-code-mode/references/app-actions.md) |\n| Reuse work across chats, create or edit an App | [Source workflow](/skills/assistant-code-mode/references/source-workflow.md) |\n| Publish, restore, copy | [Publishing](/skills/assistant-code-mode/references/publishing.md) |\n| Find recipients or change App/Skill sharing | [Access](/skills/assistant-code-mode/references/access.md) |\n| Inspect, export, clear server data, or delete an App | [Management](/skills/assistant-code-mode/references/management.md) |\n| Execute, inspect, interact, export, stop, diagnose errors | [Run and debug](/skills/assistant-code-mode/references/debugging.md) |\n| Unfamiliar inputs or cross-app investigation | [Investigation](/skills/assistant-code-mode/references/investigation.md) |\n| Complete app starters | [Examples](/skills/assistant-code-mode/references/examples.md) |\n\nFor a new app, read Source workflow and the closest complete example before\nwriting source, plus only the API references it uses. For analytical reports or\ndashboards, also load `assistant-data-analysis` for metrics and source validation.\n\n## Choose the delivery\n\nFor a one-off chart, calculator, or interactive analysis in this conversation,\nuse `code_run({code,inputPaths})`, test the controls, then\n`code_present({runId,title})`. Read [Chat visualizations](/skills/assistant-code-mode/references/chat.md).\nA successful run is visible to the agent only; present it before saying the\nuser can see it. No saved App or chat file is necessary.\n\nUse a Studio App when the user needs an independently accessible, reusable\napplication. Use `files.save`, `code_export`, and `present` when the requested\nresult is a file. These are separate delivery choices.\n\n## Verify and deliver\n\nRun the actual source (the saved revision for Apps) and test relevant controls with IDs returned by\n`code_run`/`code_interact`, including invalid inputs and picker fixtures. Creating,\ncompiling or saving source does not verify behavior. If `work.status` is\n`running`, wait with `code_inspect({runId,waitMs:30000})`; do not restart the job.\nInspect only when the returned snapshot needs more detail. Errors and\n`outputTruncated` are not successful complete results.\n\nFor a CSV, call `await files.save(sheet.toCsv(rows), \"result.csv\")` inside code;\nfor a spreadsheet, `await files.save(await sheet.toOds(sheets), \"result.ods\")`.\nThen call the **tool** `code_export` with the returned `runId` and captured file\nname, and `present` its returned chat path. `files.save` returns no path.\nReuse exported data via its path/version rather than retyping truncated output.\nReconcile row counts, exclusions and totals before reporting findings.\n\nOpen GUI apps with `code_open`. Saving or testing does\nnot replace a user's already-running app. Stop runs no longer needed that retain\nUI, jobs or output files. Never claim an unexecuted result is verified.\n\nAgent execution runs independently of the user's tab. Agent local storage is\ntemporary; shared storage, database writes and external actions are real, even\nin tests. Cancellation and source restore do not undo them. Apps select local\nfiles explicitly; they never gain implicit access to chat attachments. Use\n`code_secret` for credentials, never chat or app controls. Honor normal access\nand approval decisions; availability is not authorization for unrelated actions.",
11
11
  "extraFrontmatter": {},
12
12
  "references": [
13
13
  {
@@ -88,7 +88,7 @@ export const ASSISTANT_CODE_MODE_SKILL = {
88
88
  },
89
89
  {
90
90
  "path": "references/pdf.md",
91
- "content": "# Generate PDFs\n\n`pdf.render`, `pdf.attach` and `pdf.facturX` are asynchronous and return a PDF\n`Blob`. They use the instance's configured PDF service. `pdf.open` is\nthe local text reader described in [Documents](documents.md).\n\n## HTML and CSS\n\n```js\nconst document = await pdf.render({\n html: `<!doctype html><html><head><style>\n body { font-family: sans-serif; }\n h1 { color: #087f70; }\n tr { break-inside: avoid; }\n </style></head><body><h1>Stock report</h1><img src=\"logo.png\"></body></html>`,\n assets: [{ name: \"logo.png\", data: logoFile }],\n page: { format: \"A4\", landscape: false, margin: { top: 15, right: 15, bottom: 15, left: 15 } },\n tagged: true,\n});\nawait files.save(document, \"stock-report.pdf\");\n```\n\n`html` is required. `assets` defaults to an empty array and accepts named `Blob`\nvalues for local images, fonts and CSS. Use plain filenames, no directories;\nreference the exact filename from HTML or CSS. Duplicate names and the reserved\nnames `index.html`, `header.html`, `footer.html`, `factur-x.xml` fail.\n`headerHtml` and `footerHtml` are optional independent HTML strings with their own\nCSS. Page markers such as `<span class=\"pageNumber\"></span>` work in\nthose templates. Background colors are printed.\n\n`page.format` defaults to `A4`; alternatives are `A3`, `A5`, `Letter`, and `Legal`.\n`landscape` defaults to false. Each margin is a nonnegative millimeter number,\ndefaulting to 15. Use `page` for paper dimensions and margins; avoid conflicting\nCSS `@page` rules. `tagged` defaults to true, which requests a tagged PDF but does\nnot certify accessibility.\n\nStudio styles are not inherited. Scripts, redirects, frames and outbound\nresources are blocked. Supply local assets or data URLs; this is not a URL-to-PDF\nbrowser or a JavaScript rendering environment.\n\n## Attach files\n\n```js\nconst result = await pdf.attach({\n document,\n attachments: [{\n name: \"details.xml\",\n data: new Blob([xml], { type: \"application/xml\" }),\n relationship: \"Data\",\n }],\n});\nawait files.shared.write(\"reports/with-details.pdf\", result);\n```\n\nThe source PDF and attachments are ordinary `Blob`s. Their origin does not\nmatter: explicit picker selections, authorized chat inputs, or app storage use\nthe same API. `relationship` defaults to `Unspecified`; alternatives are\n`Source`, `Data`, `Alternative`, and `Supplement`. MIME type comes from the Blob\nand defaults to `application/octet-stream` if empty. Provide at least one attachment. Names within the request\nmust be unique. All asset/attachment names are 1–180 characters, with no slash,\nbackslash or control characters, and cannot be `.` or `..`. Embedding an XML file alone does not create a compliant invoice.\n\n## Factur-X / ZUGFeRD\n\n```js\nconst checked = einvoice.validate(invoice);\nif (!checked.ok) throw new Error(JSON.stringify(checked.error));\nconst xml = einvoice.serialize(checked.data, { format: \"zugferd-2.5-en16931\" });\nif (!xml.ok) throw new Error(JSON.stringify(xml.error));\nconst document = await pdf.facturX({\n html: invoiceHtml,\n xml: xml.data.xml,\n profile: \"EN 16931\",\n});\nawait files.save(document, \"invoice.pdf\");\n```\n\n`facturX` accepts the same render options plus required `xml` and `profile`.\nProfiles: `MINIMUM`, `BASIC WL`, `BASIC`, `EN 16931`, `EXTENDED`. Use `EN 16931`\nwith the bundled `einvoice.serialize` output; that serializer does not support\nthe other profiles. The service embeds `factur-x.xml`, sets Factur-X 1.0 invoice\nmetadata and requests PDF/A-3b. The app must supply matching HTML and XML.\nNeither rendering nor parsing certifies XSD, Schematron, tax or invoice validity.\n\n## Cancellation, access and limits\n\nAll three methods accept a second `{ signal }` argument, for example the signal\nfrom a `work.run` job. Abort rejects with `AbortError`. Stopping the execution\nhost also cancels pending PDF requests. Rendering creates no stored file until\ncode explicitly saves it; do not automatically retry failed calls.\n\nSaved resources need Use access, not Manage. One-off scripts need an accessible,\nunrestricted current chat. The server checks access before reading the body.\nNo service URL, credentials, shell flags or arbitrary conversion route are\nexposed to app code. This is an internal conversion, not `http.fetch`; there is\nno external API approval prompt.\n\nConfigured service input, output and timeout limits apply. HTML, its headers,\nfooters, assets and invoice XML share the HTML input budget. PDF attachments\nand the source PDF share the PDF input budget. All transfers also have a 64 MiB\nceiling; multipart framing has a separate bounded overhead. Shared storage and\nchat export budgets remain independent. Errors include `PDF_NOT_CONFIGURED`,\n`PDF_LIMIT`, `PDF_TIMEOUT`, `PDF_FAILED`, `INVALID_INPUT`, and `ACCESS_DENIED`.\n"
91
+ "content": "# Generate PDFs\n\n`pdf.render`, `pdf.attach` and `pdf.facturX` are asynchronous and return a PDF\n`Blob`. They use the instance's configured PDF service. `pdf.open` is\nthe local text reader described in [Documents](documents.md).\n\n## Choose the path\n\nA document written in the chat needs no code. Use the chat tool\n`markdown_to_pdf` for text-first documents; it applies A4 presets and custom CSS\nand turns images into links. Use `html_to_pdf` for a chat `.html` file whose\nlayout needs HTML and CSS, images, or fonts. It takes optional CSS (file or\ninline), header and footer files, chat files as named assets, and the `page`\noptions below, then writes a sibling `.pdf` for `present`. Use `pdf.render` when\ncode builds the document from data, for Factur-X or attachments, and in Studio\nApps.\n\n## HTML and CSS\n\n```js\nconst document = await pdf.render({\n html: `<!doctype html><html><head><style>\n body { font-family: sans-serif; }\n h1 { color: #087f70; }\n tr { break-inside: avoid; }\n </style></head><body><h1>Stock report</h1><img src=\"logo.png\"></body></html>`,\n assets: [{ name: \"logo.png\", data: logoFile }],\n page: { format: \"A4\", landscape: false, margin: { top: 15, right: 15, bottom: 15, left: 15 } },\n tagged: true,\n});\nawait files.save(document, \"stock-report.pdf\");\n```\n\n`html` is required. `assets` defaults to an empty array and accepts named `Blob`\nvalues for local images, fonts and CSS. Use plain filenames, no directories;\nreference the exact filename from HTML or CSS. Duplicate names and the reserved\nnames `index.html`, `header.html`, `footer.html`, `factur-x.xml` fail.\n`headerHtml` and `footerHtml` are optional independent HTML strings with their own\nCSS. They load no assets; use `data:` URLs for images there. Page markers such as\n`<span class=\"pageNumber\"></span>` work in those templates, and the page margin\nmust leave room for them. Background colors are printed.\n\n`page.format` defaults to `A4`; alternatives are `A3`, `A5`, `Letter`, and `Legal`.\n`landscape` defaults to false. Each margin is a nonnegative millimeter number,\ndefaulting to 15. Use `page` for paper dimensions and margins; avoid conflicting\nCSS `@page` rules. `tagged` defaults to true, which requests a tagged PDF but does\nnot certify accessibility.\n\nStudio styles are not inherited. Scripts, redirects, frames and outbound\nresources are blocked. MathML (`math`) and the SVG elements `foreignObject` and\n`desc` are removed; write formulas and labels as HTML and CSS or as SVG text.\nSupply local assets or data URLs; this is not a URL-to-PDF browser or a\nJavaScript rendering environment.\n\n## Attach files\n\n```js\nconst result = await pdf.attach({\n document,\n attachments: [{\n name: \"details.xml\",\n data: new Blob([xml], { type: \"application/xml\" }),\n relationship: \"Data\",\n }],\n});\nawait files.shared.write(\"reports/with-details.pdf\", result);\n```\n\nThe source PDF and attachments are ordinary `Blob`s. Their origin does not\nmatter: explicit picker selections, authorized chat inputs, or app storage use\nthe same API. `relationship` defaults to `Unspecified`; alternatives are\n`Source`, `Data`, `Alternative`, and `Supplement`. MIME type comes from the Blob\nand defaults to `application/octet-stream` if empty. Provide at least one attachment. Names within the request\nmust be unique. All asset/attachment names are 1–180 characters, with no slash,\nbackslash or control characters, and cannot be `.` or `..`. Embedding an XML file alone does not create a compliant invoice.\n\n## Factur-X / ZUGFeRD\n\n```js\nconst checked = einvoice.validate(invoice);\nif (!checked.ok) throw new Error(JSON.stringify(checked.error));\nconst xml = einvoice.serialize(checked.data, { format: \"zugferd-2.5-en16931\" });\nif (!xml.ok) throw new Error(JSON.stringify(xml.error));\nconst document = await pdf.facturX({\n html: invoiceHtml,\n xml: xml.data.xml,\n profile: \"EN 16931\",\n});\nawait files.save(document, \"invoice.pdf\");\n```\n\n`facturX` accepts the same render options plus required `xml` and `profile`.\nProfiles: `MINIMUM`, `BASIC WL`, `BASIC`, `EN 16931`, `EXTENDED`. Use `EN 16931`\nwith the bundled `einvoice.serialize` output; that serializer does not support\nthe other profiles. The service embeds `factur-x.xml`, sets Factur-X 1.0 invoice\nmetadata and requests PDF/A-3b. The app must supply matching HTML and XML.\nNeither rendering nor parsing certifies XSD, Schematron, tax or invoice validity.\n\n## Save a PDF in Files\n\nA chat PDF stays in the chat until code writes it elsewhere. To save it in the\nuser's Files, pass its chat path in `code_run.inputPaths` and write it through\nthe discovered `filesv2.content.create` action:\n\n```js\nexport default async () => {\n const document = await files.read(\"/offer.pdf\");\n const target = await capabilities.run(\"filesv2.content.create\", {\n baseId: \"<exact ID from filesv2.bases.list>\",\n path: \"Offers/offer.pdf\",\n size: document.size,\n mediaType: \"application/pdf\",\n });\n return capabilities.streams.write(target.stream, document);\n};\n```\n\nAsk for the storage base and folder when the request does not name them. The user\nreviews the write. It creates a new file and fails when the path exists;\nreplacing requires the current `expectedRevision`. A `pdf.render` result can be\nwritten the same way without saving it to the chat first. See\n[Capability calls](capabilities.md) for stream limits and interrupted writes.\n\n## Cancellation, access and limits\n\nAll three methods accept a second `{ signal }` argument, for example the signal\nfrom a `work.run` job. Abort rejects with `AbortError`. Stopping the execution\nhost also cancels pending PDF requests. Rendering creates no stored file until\ncode explicitly saves it; do not automatically retry failed calls.\n\nSaved resources need Use access, not Manage. One-off scripts need an accessible,\nunrestricted current chat. The server checks access before reading the body.\nNo service URL, credentials, shell flags or arbitrary conversion route are\nexposed to app code. This is an internal conversion, not `http.fetch`; there is\nno external API approval prompt.\n\nConfigured service input, output and timeout limits apply. HTML, its headers,\nfooters, assets and invoice XML share the HTML input budget. PDF attachments\nand the source PDF share the PDF input budget. All transfers also have a 64 MiB\nceiling; multipart framing has a separate bounded overhead. Shared storage and\nchat export budgets remain independent. Errors include `PDF_NOT_CONFIGURED`,\n`PDF_LIMIT`, `PDF_TIMEOUT`, `PDF_FAILED`, `INVALID_INPUT`, and `ACCESS_DENIED`.\n"
92
92
  },
93
93
  {
94
94
  "path": "references/publishing.md",
@@ -34,6 +34,7 @@ import {
34
34
  createCloudAiWriteFileTool,
35
35
  } from "./file-tools";
36
36
  import { createCloudAiWebExtractTool, createCloudAiWebSearchTool, isCloudAiFirecrawlConfigured } from "./firecrawl-tools";
37
+ import { createCloudAiHtmlToPdfTool } from "./html-pdf-tool";
37
38
  import { createCloudAiMarkdownToPdfTool } from "./markdown-pdf-tool";
38
39
  import { createAiTodoTool } from "./todo-tool";
39
40
  import { defineAiTool } from "./tools";
@@ -190,6 +191,7 @@ export const CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES = new Set<string>([
190
191
  "list_files",
191
192
  "write_file",
192
193
  "markdown_to_pdf",
194
+ "html_to_pdf",
193
195
  "present",
194
196
  "calculate",
195
197
  "read_cloud_resource",
@@ -208,6 +210,7 @@ export const createConfiguredDefaultCloudAiTools = async (config?: {
208
210
  createCloudAiFetchFileTool(),
209
211
  createCloudAiWriteFileTool(),
210
212
  createCloudAiMarkdownToPdfTool(),
213
+ createCloudAiHtmlToPdfTool(),
211
214
  createCloudAiPresentTool(),
212
215
  createCloudAiCalculateTool(),
213
216
  createCloudAiViewImageTool(),
@@ -0,0 +1,180 @@
1
+ import { z } from "zod";
2
+ import {
3
+ type GotenbergConfig,
4
+ getGotenbergConfig,
5
+ MARKDOWN_PDF_MAX_CUSTOM_CSS_BYTES,
6
+ type RenderHtmlToPdfInput,
7
+ type RenderHtmlToPdfOptions,
8
+ type RenderHtmlToPdfResult,
9
+ renderHtmlToPdfWithConfig,
10
+ } from "../services/pdf";
11
+ import { aiProjectFilePathFromMount, aiSkillFilePathFromMount } from "./file-mount";
12
+ import { type AiFileContent, type AiFileStat, aiFileStore, normalizeAiFilePath } from "./files-store";
13
+ import { withAiPdfConversionSlot } from "./pdf-conversions";
14
+ import { defineAiTool } from "./tools";
15
+
16
+ /** The same bound as the chat input files of one code_run. */
17
+ export const HTML_PDF_MAX_ASSETS = 64;
18
+
19
+ const FilePath = z.string().trim().min(1);
20
+ const Margin = z.number().finite().nonnegative();
21
+
22
+ export const CloudAiHtmlToPdfInputSchema = z
23
+ .object({
24
+ path: FilePath.describe("Absolute path to an assistant-created .html file in this conversation."),
25
+ cssPath: FilePath.optional().describe("Optional conversation CSS file, applied after the document's own styles."),
26
+ customCss: z.string().max(MARKDOWN_PDF_MAX_CUSTOM_CSS_BYTES).optional().describe("Optional inline CSS, applied last."),
27
+ headerPath: FilePath.optional().describe("Optional conversation HTML file printed at the top of every page."),
28
+ footerPath: FilePath.optional().describe("Optional conversation HTML file printed at the bottom of every page."),
29
+ assets: z
30
+ .array(FilePath)
31
+ .max(HTML_PDF_MAX_ASSETS)
32
+ .optional()
33
+ .describe("Conversation image or font files. The HTML and CSS reference each by its file name only, such as logo.png."),
34
+ page: z
35
+ .object({
36
+ format: z.enum(["A4", "A3", "A5", "Letter", "Legal"]).optional().describe("Paper size. Default A4."),
37
+ landscape: z.boolean().optional().describe("Landscape orientation. Default false."),
38
+ margin: z
39
+ .object({ top: Margin.optional(), right: Margin.optional(), bottom: Margin.optional(), left: Margin.optional() })
40
+ .strict()
41
+ .optional()
42
+ .describe("Page margins in millimeters. Each defaults to 15."),
43
+ })
44
+ .strict()
45
+ .optional(),
46
+ })
47
+ .strict();
48
+
49
+ export const CloudAiHtmlToPdfOutputSchema = z.object({
50
+ sourcePath: z.string(),
51
+ path: z.string(),
52
+ size: z.number().int().nonnegative(),
53
+ mediaType: z.literal("application/pdf"),
54
+ });
55
+
56
+ type HtmlPdfToolDependencies = {
57
+ stat?: (input: { conversationId: string; path: string }) => Promise<AiFileStat | null>;
58
+ read?: (input: { conversationId: string; path: string }) => Promise<AiFileContent | null>;
59
+ write?: (input: { conversationId: string; path: string; bytes: Uint8Array; mediaType: string; origin: "assistant" }) => Promise<void>;
60
+ config?: () => Promise<GotenbergConfig>;
61
+ render?: (input: RenderHtmlToPdfInput, config: GotenbergConfig, options: RenderHtmlToPdfOptions) => Promise<RenderHtmlToPdfResult>;
62
+ };
63
+
64
+ const HTML_EXTENSION = /\.html?$/i;
65
+
66
+ const conversationPath = (value: string, role: "source" | "input"): string => {
67
+ const path = normalizeAiFilePath(value.startsWith("/") ? value : `/${value}`);
68
+ if (!path) throw new Error(`Use an absolute conversation file path instead of ${value}.`);
69
+ if (aiProjectFilePathFromMount(path) !== null || aiSkillFilePathFromMount(path) !== null) {
70
+ throw new Error(
71
+ role === "source"
72
+ ? "html_to_pdf converts only conversation files; copy the HTML into a conversation file with write_file first."
73
+ : `html_to_pdf reads only files of this conversation, not ${path}.`,
74
+ );
75
+ }
76
+ if (role === "source" && !HTML_EXTENSION.test(path)) throw new Error("html_to_pdf requires a .html file written with write_file.");
77
+ return path;
78
+ };
79
+
80
+ const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
81
+
82
+ const utf8 = (file: AiFileContent): string => {
83
+ try {
84
+ return new TextDecoder("utf-8", { fatal: true }).decode(file.bytes);
85
+ } catch {
86
+ throw new Error(`File ${file.path} is not valid UTF-8 text.`);
87
+ }
88
+ };
89
+
90
+ const cancelled = (signal: AbortSignal): Error =>
91
+ signal.reason instanceof Error ? signal.reason : new Error("PDF conversion was cancelled.");
92
+
93
+ export const createCloudAiHtmlToPdfTool = (dependencies: HtmlPdfToolDependencies = {}) => {
94
+ const stat = dependencies.stat ?? aiFileStore.stat;
95
+ const read = dependencies.read ?? aiFileStore.read;
96
+ const write = dependencies.write ?? aiFileStore.write;
97
+ const loadConfig = dependencies.config ?? getGotenbergConfig;
98
+ const render = dependencies.render ?? renderHtmlToPdfWithConfig;
99
+
100
+ return defineAiTool({
101
+ name: "html_to_pdf",
102
+ description:
103
+ 'Convert one assistant-created conversation HTML file to a sibling PDF; the output path replaces .html with .pdf. Use it when the layout needs HTML and CSS, such as columns, exact tables, letterheads, invoices, certificates, images, or custom fonts; use markdown_to_pdf for text-first documents. Write the .html source with write_file first, appending long files in parts; CSS, header, footer, and asset files can be any conversation files, including uploads. Rendering is offline without JavaScript: scripts, frames, and remote URLs are removed or blocked, and MathML and the SVG foreignObject and desc elements are removed, so write formulas and labels as HTML and CSS or as SVG text. Embed images and fonts as data: URLs, or list conversation files in assets and reference each by its file name only, for example <img src="logo.png"> or url("brand.woff2"); percent-encode #, ?, %, and : in a name, such as logo%232.png for logo#2.png. cssPath and customCss apply after the document\'s own styles. page sets paper size (default A4), orientation, and millimeter margins (default 15); CSS @page sizes are ignored. Header and footer files are small separate HTML documents with their own inline styles; they load no assets, so use data: URLs for images there. <span class="pageNumber"></span> and <span class="totalPages"></span> print page numbers, and the top or bottom margin must leave room for them. All input files share the configured PDF input budget. Call present with the returned path. To also save the PDF in Files, follow the PDF reference of the assistant-code-mode skill.',
104
+ inputSchema: CloudAiHtmlToPdfInputSchema,
105
+ outputSchema: CloudAiHtmlToPdfOutputSchema,
106
+ approval: "never",
107
+ timeoutMs: 125_000,
108
+ promptHint:
109
+ "for PDFs whose layout needs HTML and CSS, images, or fonts: write an assistant-owned .html file with write_file, convert it with html_to_pdf, then call present with the returned PDF path.",
110
+ }).server(async (input, ctx) => {
111
+ const conversationId = ctx.conversationId;
112
+ if (!conversationId) throw new Error("The html_to_pdf tool needs a conversation context.");
113
+ const sourcePath = conversationPath(input.path, "source");
114
+ const cssPath = input.cssPath === undefined ? undefined : conversationPath(input.cssPath, "input");
115
+ const headerPath = input.headerPath === undefined ? undefined : conversationPath(input.headerPath, "input");
116
+ const footerPath = input.footerPath === undefined ? undefined : conversationPath(input.footerPath, "input");
117
+ const assetPaths = (input.assets ?? []).map((path) => conversationPath(path, "input"));
118
+ const config = await loadConfig();
119
+
120
+ // Check every file and the shared input budget before loading any bytes.
121
+ let total = new TextEncoder().encode(input.customCss ?? "").byteLength;
122
+ for (const path of [sourcePath, cssPath, headerPath, footerPath, ...assetPaths]) {
123
+ if (path === undefined) continue;
124
+ const file = await stat({ conversationId, path });
125
+ if (!file) throw new Error(`No such file: ${path}`);
126
+ if (path === sourcePath && file.origin !== "assistant") {
127
+ throw new Error(`Create an assistant-owned HTML file with write_file before converting ${sourcePath}.`);
128
+ }
129
+ total += file.size;
130
+ if (total > config.maxHtmlBytes) {
131
+ throw new Error(`The HTML, CSS, header, footer, and assets exceed the ${config.maxHtmlBytes}-byte PDF input budget.`);
132
+ }
133
+ }
134
+
135
+ const load = async (path: string): Promise<AiFileContent> => {
136
+ const file = await read({ conversationId, path });
137
+ if (!file) throw new Error(`No such file: ${path}`);
138
+ return file;
139
+ };
140
+ const text = async (path: string | undefined): Promise<string | undefined> => (path === undefined ? undefined : utf8(await load(path)));
141
+
142
+ const source = utf8(await load(sourcePath));
143
+ const css = [await text(cssPath), input.customCss].filter((value) => value?.trim()).join("\n");
144
+ if (/<\/style/i.test(css)) throw new Error("CSS cannot contain </style.");
145
+ const headerHtml = await text(headerPath);
146
+ const footerHtml = await text(footerPath);
147
+ const assets: RenderHtmlToPdfInput["assets"] = [];
148
+ for (const path of assetPaths) {
149
+ const file = await load(path);
150
+ assets.push({ name: basename(path), data: new Blob([new Uint8Array(file.bytes)], { type: file.mediaType }) });
151
+ }
152
+
153
+ let rendered: RenderHtmlToPdfResult;
154
+ try {
155
+ rendered = await withAiPdfConversionSlot(() =>
156
+ render(
157
+ {
158
+ // Like Code Mode, the document renders in standards mode. Separate CSS
159
+ // follows the document, so it wins over the document's own styles.
160
+ html: `<!doctype html>${source}${css ? `\n<style>\n${css}\n</style>\n` : ""}`,
161
+ headerHtml,
162
+ footerHtml,
163
+ assets,
164
+ page: { format: input.page?.format ?? "A4", landscape: input.page?.landscape ?? false, margin: input.page?.margin },
165
+ tagged: true,
166
+ },
167
+ config,
168
+ { signal: ctx.signal },
169
+ ),
170
+ );
171
+ } catch (error) {
172
+ throw ctx.signal.aborted ? cancelled(ctx.signal) : error;
173
+ }
174
+ if (ctx.signal.aborted) throw cancelled(ctx.signal);
175
+
176
+ const path = sourcePath.replace(HTML_EXTENSION, ".pdf");
177
+ await write({ conversationId, path, bytes: rendered.pdf, mediaType: "application/pdf", origin: "assistant" });
178
+ return { sourcePath, path, size: rendered.pdf.byteLength, mediaType: "application/pdf" as const };
179
+ });
180
+ };
@@ -4,10 +4,12 @@ import {
4
4
  MARKDOWN_PDF_MAX_MARKDOWN_BYTES,
5
5
  MARKDOWN_PDF_TEMPLATE_IDS,
6
6
  type RenderMarkdownToPdfInput,
7
+ type RenderMarkdownToPdfOptions,
7
8
  renderMarkdownToPdf,
8
9
  } from "../services/pdf";
9
10
  import { aiProjectFilePathFromMount } from "./file-mount";
10
11
  import { type AiFileContent, aiFileStore, normalizeAiFilePath } from "./files-store";
12
+ import { withAiPdfConversionSlot } from "./pdf-conversions";
11
13
  import { defineAiTool } from "./tools";
12
14
 
13
15
  export const CloudAiMarkdownToPdfInputSchema = z
@@ -28,7 +30,7 @@ export const CloudAiMarkdownToPdfOutputSchema = z.object({
28
30
  type MarkdownPdfToolDependencies = {
29
31
  read?: (input: { conversationId: string; path: string }) => Promise<AiFileContent | null>;
30
32
  write?: (input: { conversationId: string; path: string; bytes: Uint8Array; mediaType: string; origin: "assistant" }) => Promise<void>;
31
- render?: (input: RenderMarkdownToPdfInput) => Promise<{ pdf: Uint8Array; contentType: string }>;
33
+ render?: (input: RenderMarkdownToPdfInput, options: RenderMarkdownToPdfOptions) => Promise<{ pdf: Uint8Array; contentType: string }>;
32
34
  };
33
35
 
34
36
  const conversationPath = (value: string): string => {
@@ -52,13 +54,13 @@ export const createCloudAiMarkdownToPdfTool = (dependencies: MarkdownPdfToolDepe
52
54
  return defineAiTool({
53
55
  name: "markdown_to_pdf",
54
56
  description:
55
- "Convert one assistant-created conversation Markdown file to a sibling PDF. Write or edit the .md source with write_file first. A named A4 template may be combined with custom CSS; custom CSS without a template is used as the complete stylesheet. The output path replaces .md with .pdf.",
57
+ "Convert one assistant-created conversation Markdown file to a sibling PDF. Use it for text-first documents; use html_to_pdf when the layout needs HTML, images, or fonts. Write or edit the .md source with write_file first. A named A4 template may be combined with custom CSS; custom CSS without a template is used as the complete stylesheet. Images become links and are not embedded. The output path replaces .md with .pdf.",
56
58
  inputSchema: CloudAiMarkdownToPdfInputSchema,
57
59
  outputSchema: CloudAiMarkdownToPdfOutputSchema,
58
60
  approval: "never",
59
61
  timeoutMs: 125_000,
60
62
  promptHint:
61
- "write or edit an assistant-owned .md file with write_file before converting it with markdown_to_pdf; call present with the returned PDF path afterwards.",
63
+ "for text-first PDFs: write or edit an assistant-owned .md file with write_file, convert it with markdown_to_pdf, then call present with the returned PDF path.",
62
64
  }).server(async (input, ctx) => {
63
65
  if (!ctx.conversationId) throw new Error("The markdown_to_pdf tool needs a conversation context.");
64
66
  const sourcePath = conversationPath(input.path);
@@ -78,10 +80,16 @@ export const createCloudAiMarkdownToPdfTool = (dependencies: MarkdownPdfToolDepe
78
80
  throw new Error(`File ${sourcePath} is not valid UTF-8 Markdown.`);
79
81
  }
80
82
 
81
- const rendered = await render({ markdown, templateId: input.template, customCss: input.customCss });
82
- if (ctx.signal.aborted) {
83
- throw ctx.signal.reason instanceof Error ? ctx.signal.reason : new Error("PDF conversion was cancelled.");
83
+ const cancelled = () => (ctx.signal.reason instanceof Error ? ctx.signal.reason : new Error("PDF conversion was cancelled."));
84
+ let rendered: { pdf: Uint8Array; contentType: string };
85
+ try {
86
+ rendered = await withAiPdfConversionSlot(() =>
87
+ render({ markdown, templateId: input.template, customCss: input.customCss }, { signal: ctx.signal }),
88
+ );
89
+ } catch (error) {
90
+ throw ctx.signal.aborted ? cancelled() : error;
84
91
  }
92
+ if (ctx.signal.aborted) throw cancelled();
85
93
  const path = pdfPath(sourcePath);
86
94
  await write({
87
95
  conversationId: ctx.conversationId,
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Chat PDF tools share Gotenberg with every other Cloud renderer. Like the
3
+ * Tools app's Markdown route, one process renders at most two chat PDFs at a
4
+ * time and rejects further conversions instead of queueing them until the
5
+ * render timeout.
6
+ */
7
+ export const AI_PDF_MAX_ACTIVE_CONVERSIONS = 2;
8
+
9
+ let activeConversions = 0;
10
+
11
+ export const withAiPdfConversionSlot = async <T>(render: () => Promise<T>): Promise<T> => {
12
+ if (activeConversions >= AI_PDF_MAX_ACTIVE_CONVERSIONS) {
13
+ throw new Error("The PDF renderer is busy with other conversions. Try again after they finish.");
14
+ }
15
+ activeConversions += 1;
16
+ try {
17
+ return await render();
18
+ } finally {
19
+ activeConversions -= 1;
20
+ }
21
+ };
package/src/ai/routes.ts CHANGED
@@ -200,6 +200,7 @@ const MemoryUpdateSchema = MemoryCreateSchema.partial().refine((value) => Object
200
200
  const MemoryIdSchema = z.string().regex(AI_SHORT_ID_PATTERN);
201
201
 
202
202
  const notFound = (c: Context<AuthContext>) => respond(c, fail(err.notFound("Conversation")));
203
+ const fileNotFound = (c: Context<AuthContext>) => respond(c, fail(err.notFound("File")));
203
204
 
204
205
  const publicConversation = (conversation: AiConversation, projectId: string | null = null) => ({
205
206
  ...conversation,
@@ -1294,9 +1295,9 @@ export const aiRoutes = (() => {
1294
1295
  const conversation = await loadConversation(c, ctx);
1295
1296
  if (!conversation) return notFound(c);
1296
1297
  const path = normalizeAiFilePath(c.req.valid("query").path);
1297
- if (!path) return notFound(c);
1298
+ if (!path) return fileNotFound(c);
1298
1299
  const stored = await aiFileStore.read({ conversationId: conversation.id, path });
1299
- if (!stored) return notFound(c);
1300
+ if (!stored) return fileNotFound(c);
1300
1301
  const filename = path.slice(path.lastIndexOf("/") + 1).replaceAll('"', "");
1301
1302
  return c.body(stored.bytes as unknown as ArrayBuffer, 200, {
1302
1303
  "Content-Type": stored.mediaType || "application/octet-stream",
@@ -1311,9 +1312,9 @@ export const aiRoutes = (() => {
1311
1312
  const conversation = await loadConversation(c, ctx);
1312
1313
  if (!conversation) return notFound(c);
1313
1314
  const path = normalizeAiFilePath(c.req.valid("query").path);
1314
- if (!path) return notFound(c);
1315
+ if (!path) return fileNotFound(c);
1315
1316
  const removed = await aiFileStore.remove({ conversationId: conversation.id, path, recursive: false });
1316
- if (removed === 0) return notFound(c);
1317
+ if (removed === 0) return fileNotFound(c);
1317
1318
  return respond(c, ok({ deleted: true }));
1318
1319
  })
1319
1320
  .put("/conversations/:conversationId/files/content", v("json", FileWriteSchema), async (c) => {
@@ -33,6 +33,7 @@ export {
33
33
  evaluateAiDate,
34
34
  evaluateAiMath,
35
35
  } from "./file-tools";
36
+ export { CloudAiHtmlToPdfInputSchema, CloudAiHtmlToPdfOutputSchema, createCloudAiHtmlToPdfTool } from "./html-pdf-tool";
36
37
  export {
37
38
  CloudAiMarkdownToPdfInputSchema,
38
39
  CloudAiMarkdownToPdfOutputSchema,
package/src/api/auth.ts CHANGED
@@ -189,24 +189,19 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
189
189
  tags: ["Auth"],
190
190
  summary: "Request magic link login",
191
191
  description:
192
- "Request a magic link token for local account sign-in. The `email` field accepts the email address or the username of the account; the link is always sent to the account's email address.",
192
+ "Request a magic link token for local account sign-in. The `email` field accepts the email address or the username of the account; the link is always sent to the account's email address. The response is the same, and returns equally fast, for existing, unknown and mail-less accounts.",
193
193
  responses: {
194
194
  200: jsonResponse(MessageResponseSchema, "Request accepted"),
195
- 400: jsonResponse(ErrorResponseSchema, "Email sign-in not available"),
196
195
  },
197
196
  }),
198
197
  v("json", EmailLoginSchema),
199
198
  async (c) => {
200
199
  const { email, redirectTo, category } = c.req.valid("json");
201
200
 
202
- const requestResult = await authFlows.magicLink.request({ email, redirectTo, category, locale: getLocale(c) }, notificationSender);
203
- if (!requestResult.ok) {
204
- return c.json({ message: requestResult.message }, requestResult.status);
205
- }
206
-
201
+ authFlows.magicLink.request({ email, redirectTo, category, locale: getLocale(c) }, notificationSender);
207
202
  log.info("Magic link requested", { identifier: email });
208
203
  return c.json({
209
- message: "If this email can sign in with a login code, a code has been sent.",
204
+ message: "If this account can sign in by email, a sign-in code has been sent. If no message arrives, contact an administrator.",
210
205
  });
211
206
  },
212
207
  )
@@ -251,7 +246,7 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
251
246
  tags: ["Auth"],
252
247
  summary: "Request password reset",
253
248
  description:
254
- "Request a one-time password reset email for an IPA-backed account. The response is always generic to avoid account enumeration.",
249
+ "Request a one-time password reset email for an IPA-backed account. The response is the same, and returns equally fast, for every address to avoid account enumeration.",
255
250
  responses: {
256
251
  200: jsonResponse(MessageResponseSchema, "Request accepted"),
257
252
  },
@@ -260,7 +255,7 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
260
255
  async (c) => {
261
256
  const { email, redirectTo } = c.req.valid("json");
262
257
 
263
- const result = await authFlows.passwordReset.request({ email, redirectTo, locale: getLocale(c) }, notificationSender);
258
+ const result = authFlows.passwordReset.request({ email, redirectTo, locale: getLocale(c) }, notificationSender);
264
259
  return c.json({ message: result.message });
265
260
  },
266
261
  )
@@ -215,6 +215,12 @@ const connect = async (options: {
215
215
  throw new AppApprovalClientError("INVALID_RESPONSE");
216
216
  return result;
217
217
  },
218
+ /** Reads the account this device signs in and the device's own record. Older Clouds answer HTTP 400. */
219
+ account: async (device: AppApprovalDevice, signal?: AbortSignal) => {
220
+ const result = await command(device, { operation: "account" }, signal);
221
+ if (!("account" in result)) throw new AppApprovalClientError("INVALID_RESPONSE");
222
+ return result;
223
+ },
218
224
  /** Hands this device's push token to the Cloud. Older Clouds answer HTTP 400. */
219
225
  push: async (device: AppApprovalDevice, token: string, signal?: AbortSignal) => {
220
226
  const result = await command(device, { operation: "push", token }, signal);